diff --git a/Onyx/OnyxApp.swift b/Onyx/OnyxApp.swift index 371e1cb..1b1cb25 100644 --- a/Onyx/OnyxApp.swift +++ b/Onyx/OnyxApp.swift @@ -44,7 +44,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // Die Panelgröße folgt dem Layout, nicht umgekehrt: wer ein Widget // hinzufügt, soll nicht auch noch die Fenstergröße nachziehen müssen. let size = WidgetGrid.panelSize(for: layout) - coordinator.activeControllers.forEach { $0.expandedSize = size } + coordinator.activeControllers.forEach { $0.contentSize = size } self.coordinator = coordinator let menuBar = MenuBarController() diff --git a/Packages/OnyxKit/Sources/OnyxNotch/NotchGeometry.swift b/Packages/OnyxKit/Sources/OnyxNotch/NotchGeometry.swift index 67fd955..abe6e29 100644 --- a/Packages/OnyxKit/Sources/OnyxNotch/NotchGeometry.swift +++ b/Packages/OnyxKit/Sources/OnyxNotch/NotchGeometry.swift @@ -118,6 +118,22 @@ public struct NotchGeometry: Equatable, Sendable { .clamped(to: screenFrame) } + /// Der Bereich, in dem Inhalt tatsächlich zu sehen ist. + /// + /// Das Panel reicht bis an die Bildschirmoberkante, damit seine Fläche mit + /// der schwarzen Notch zu einem Körper verschmilzt. Die Notch ist aber + /// undurchsichtig — was dort steht, ist weg. Auch links und rechts daneben + /// liegt in diesem Streifen die Menüleiste. Der nutzbare Bereich beginnt + /// deshalb vollständig unterhalb. + public func contentRect(for contentSize: CGSize) -> CGRect { + let full = panelRect(for: CGSize(width: contentSize.width, + height: contentSize.height + rect.height)) + return CGRect(x: full.minX, + y: full.minY, + width: full.width, + height: max(full.height - rect.height, 0)) + } + /// Wohin das Panel gehört: auf die Notch zentriert, oben bündig mit dem /// Bildschirmrand — es wächst aus der Notch heraus, statt darunter zu schweben. public func panelRect(for size: CGSize) -> CGRect { diff --git a/Packages/OnyxKit/Sources/OnyxNotch/NotchPresentation.swift b/Packages/OnyxKit/Sources/OnyxNotch/NotchPresentation.swift index de1c704..a562039 100644 --- a/Packages/OnyxKit/Sources/OnyxNotch/NotchPresentation.swift +++ b/Packages/OnyxKit/Sources/OnyxNotch/NotchPresentation.swift @@ -14,9 +14,18 @@ public final class NotchPresentation { public var collapsedSize: CGSize public var expandedSize: CGSize - public init(collapsedSize: CGSize, expandedSize: CGSize) { + /// Wie weit der Inhalt von oben wegbleiben muss. + /// + /// Die Fläche reicht bis an die Bildschirmoberkante, damit sie mit der + /// schwarzen Notch-Hardware zu einem Körper verschmilzt. Die Notch ist aber + /// undurchsichtig: alles, was in diesen obersten Punkten steht, ist schlicht + /// nicht zu sehen. Der Inhalt beginnt deshalb darunter. + public var contentTopInset: CGFloat + + public init(collapsedSize: CGSize, expandedSize: CGSize, contentTopInset: CGFloat) { self.collapsedSize = collapsedSize self.expandedSize = expandedSize + self.contentTopInset = contentTopInset } /// Wie lange das Einfahren braucht, bis das Fenster gefahrlos verschwinden darf. @@ -60,7 +69,12 @@ public struct NotchContainer: View { .overlay(alignment: .top) { content .frame(width: presentation.expandedSize.width, - height: presentation.expandedSize.height) + height: presentation.expandedSize.height + - presentation.contentTopInset, + alignment: .top) + // Freiraum für die Notch. Padding statt Offset, damit + // der Inhalt auch beim Messen die richtige Höhe hat. + .padding(.top, presentation.contentTopInset) .opacity(presentation.isExpanded ? 1 : 0) // Ein Hauch von oben herangleiten — genug, um Richtung zu // geben, zu wenig, um als Bewegung aufzufallen. diff --git a/Packages/OnyxKit/Sources/OnyxNotch/NotchScreenController.swift b/Packages/OnyxKit/Sources/OnyxNotch/NotchScreenController.swift index 9646029..b1ce1f4 100644 --- a/Packages/OnyxKit/Sources/OnyxNotch/NotchScreenController.swift +++ b/Packages/OnyxKit/Sources/OnyxNotch/NotchScreenController.swift @@ -19,24 +19,35 @@ public final class NotchScreenController { /// Entfernt das Fenster erst, wenn das Einfahren zu Ende ist. private var teardownTimer: DispatchWorkItem? - /// Größe des ausgefahrenen Panels. Ab Phase 2 liefert das die Layout-Engine. - public var expandedSize: CGSize { + /// Größe des **Inhalts**, nicht des Fensters. Die Layout-Engine liefert sie; + /// den Freiraum für die Notch schlägt der Controller selbst auf, damit keine + /// Aufrufstelle daran denken muss. + public var contentSize: CGSize { didSet { - guard expandedSize != oldValue else { return } + guard contentSize != oldValue else { return } presentation.expandedSize = expandedSize layoutPanel() } } + /// Fenstergröße: Inhalt plus der von der Notch verdeckte Streifen. + private var expandedSize: CGSize { + CGSize(width: contentSize.width, height: contentSize.height + geometry.rect.height) + } + public init(metrics: ScreenMetrics, virtualSize: CGSize, - expandedSize: CGSize = CGSize(width: 640, height: 320), + contentSize: CGSize = CGSize(width: 640, height: 320), makeContent: (NotchPresentation) -> NSView) { self.displayID = metrics.displayID - self.geometry = NotchGeometry.resolve(metrics, virtualSize: virtualSize) - self.expandedSize = expandedSize - self.presentation = NotchPresentation(collapsedSize: geometry.rect.size, - expandedSize: expandedSize) + let geometry = NotchGeometry.resolve(metrics, virtualSize: virtualSize) + self.geometry = geometry + self.contentSize = contentSize + self.presentation = NotchPresentation( + collapsedSize: geometry.rect.size, + expandedSize: CGSize(width: contentSize.width, + height: contentSize.height + geometry.rect.height), + contentTopInset: geometry.rect.height) self.panel = NotchPanel(contentRect: .zero) panel.contentView = makeContent(presentation) layoutPanel() @@ -66,6 +77,10 @@ public final class NotchScreenController { public func update(metrics: ScreenMetrics, virtualSize: CGSize) { geometry = NotchGeometry.resolve(metrics, virtualSize: virtualSize) presentation.collapsedSize = geometry.rect.size + // Beim Wechsel zwischen eingebautem Display (38 pt Notch) und externem + // (virtueller Balken) ändert sich der Freiraum mit. + presentation.contentTopInset = geometry.rect.height + presentation.expandedSize = expandedSize layoutPanel() } diff --git a/Packages/OnyxKit/Tests/OnyxNotchTests/NotchGeometryTests.swift b/Packages/OnyxKit/Tests/OnyxNotchTests/NotchGeometryTests.swift index a8ac95a..0431712 100644 --- a/Packages/OnyxKit/Tests/OnyxNotchTests/NotchGeometryTests.swift +++ b/Packages/OnyxKit/Tests/OnyxNotchTests/NotchGeometryTests.swift @@ -131,6 +131,47 @@ struct NotchGeometryTests { #expect(panel.maxY == 1440) } + // MARK: - Sichtbarer Inhaltsbereich + // + // Die Notch ist undurchsichtig. Ein Panel, dessen Oberkante am Bildschirmrand + // liegt, verliert dort alles, was gezeichnet wird — genau der Fehler, der in + // der ersten lauffähigen Fassung sichtbar wurde. + + @Test("Der Inhaltsbereich liegt vollständig unterhalb der Notch") + func contentClearsTheNotch() { + let g = NotchGeometry.resolve(Self.builtIn, virtualSize: Self.virtualSize) + let content = g.contentRect(for: CGSize(width: 446, height: 340)) + + #expect(!content.intersects(g.rect)) + #expect(content.maxY <= g.rect.minY) + } + + @Test("Der Inhaltsbereich behält die angeforderte Höhe — nichts geht verloren") + func contentKeepsRequestedHeight() { + let g = NotchGeometry.resolve(Self.builtIn, virtualSize: Self.virtualSize) + let content = g.contentRect(for: CGSize(width: 446, height: 340)) + + #expect(content.height == 340) + #expect(content.width == 446) + } + + @Test("Das Fenster darüber reicht bis an die Bildschirmkante, damit es mit der Notch verschmilzt") + func panelStillReachesScreenTop() { + let g = NotchGeometry.resolve(Self.builtIn, virtualSize: Self.virtualSize) + let panel = g.panelRect(for: CGSize(width: 446, height: 340 + g.rect.height)) + + #expect(panel.maxY == Self.builtIn.frame.maxY) + } + + @Test("Auf einem externen Display gilt dasselbe für den virtuellen Balken") + func contentClearsVirtualNotch() { + let g = NotchGeometry.resolve(Self.external, virtualSize: Self.virtualSize) + let content = g.contentRect(for: CGSize(width: 446, height: 340)) + + #expect(!content.intersects(g.rect)) + #expect(content.height == 340) + } + @Test("Zu hohes Panel wird auf die Bildschirmhöhe begrenzt") func panelClampsHeight() { let g = NotchGeometry.resolve(Self.builtIn, virtualSize: Self.virtualSize)