diff --git a/Packages/OnyxKit/Sources/OnyxNotch/NSScreen+Metrics.swift b/Packages/OnyxKit/Sources/OnyxNotch/NSScreen+Metrics.swift index eacef20..4f5affe 100644 --- a/Packages/OnyxKit/Sources/OnyxNotch/NSScreen+Metrics.swift +++ b/Packages/OnyxKit/Sources/OnyxNotch/NSScreen+Metrics.swift @@ -12,6 +12,14 @@ public extension NSScreen { CGDisplayIsBuiltin(displayID) != 0 } + /// Der Bildschirm mit der Menüleiste. + /// + /// Das ist der **erste** in `NSScreen.screens`, nicht `NSScreen.main` — + /// letzteres ist der mit dem aktiven Fenster und wandert beim Klicken. + var isMainDisplay: Bool { + NSScreen.screens.first?.displayID == displayID + } + /// Reduziert den Bildschirm auf genau die Werte, die die Geometrie braucht. /// Alles Weitere bleibt draußen, damit `NotchGeometry` ohne Fenstersystem /// prüfbar bleibt. @@ -21,9 +29,21 @@ public extension NSScreen { auxiliaryTopLeft: auxiliaryTopLeftArea, auxiliaryTopRight: auxiliaryTopRightArea, isBuiltIn: isBuiltInDisplay, + menuBarHeight: onyxMenuBarHeight, + isMain: isMainDisplay, displayID: displayID) } + /// Die Höhe der Menüleiste auf diesem Bildschirm. + /// + /// `visibleFrame` lässt sie oben aus — die Differenz *ist* die Leiste. + /// Bei einem Vollbildfenster ist die Differenz null; dann taugt sie nicht + /// als Maß, und die Systemdicke tritt an ihre Stelle. + var onyxMenuBarHeight: CGFloat { + let measured = frame.maxY - visibleFrame.maxY + return measured > 1 ? measured : NSStatusBar.system.thickness + } + /// Verdeckt gerade ein Vollbildfenster die Menüleiste? /// /// Es gibt keine öffentliche API, die „hier läuft ein Vollbildvideo" meldet. diff --git a/Packages/OnyxKit/Sources/OnyxNotch/NotchBand.swift b/Packages/OnyxKit/Sources/OnyxNotch/NotchBand.swift new file mode 100644 index 0000000..aeee81a --- /dev/null +++ b/Packages/OnyxKit/Sources/OnyxNotch/NotchBand.swift @@ -0,0 +1,59 @@ +import CoreGraphics + +/// Wie viel Platz links und rechts der Notch bleibt — auf Menüleistenhöhe. +/// +/// Der oberste Streifen des Panels ist so hoch wie die Notch und so breit wie +/// das Panel. In der Mitte sitzt die Hardware; links und rechts davon lag +/// bisher durchsichtiges, ungenutztes Fenster. Bei fünf Kacheln sind das je +/// 376 Punkte. +public struct NotchBand: Equatable, Sendable { + + /// Breite links der Notch. + public let leading: CGFloat + /// Breite rechts der Notch. + public let trailing: CGFloat + + public init(leading: CGFloat, trailing: CGFloat) { + self.leading = leading + self.trailing = trailing + } + + public static let none = NotchBand(leading: 0, trailing: 0) + + public var isEmpty: Bool { leading <= 0 && trailing <= 0 } + + /// Die Gesamtbreite des gezeichneten Streifens, Notch eingeschlossen. + public func totalWidth(notchWidth: CGFloat) -> CGFloat { + leading + notchWidth + trailing + } +} + +/// Rechnet aus, wie breit die Arme neben der Notch sein dürfen. +/// +/// Reine Rechnung ohne AppKit, damit sie ohne Bildschirm prüfbar ist — wie +/// `NotchGeometry` und `TriggerArea`. +public enum NotchBandLayout { + + /// Ab wann ein Arm überhaupt etwas tragen kann. + /// + /// Ein Symbol, ein zweistelliger Wert und Luft an beiden Seiten. Schmaler + /// wird der Arm zu einem Streifen, in dem nichts steht — und ein leerer + /// Streifen neben der Notch sieht nicht nach Zurückhaltung aus, sondern + /// nach einem Zeichenfehler. + public static let minimumUsableWidth: CGFloat = 56 + + /// - Parameters: + /// - panelWidth: Die Breite des ausgefahrenen Panels. + /// - notchWidth: Die Breite der Notch bzw. ihres virtuellen Ersatzes. + /// + /// **Symmetrisch oder gar nicht.** Reicht es nur auf einer Seite, bleiben + /// beide leer: ein Arm links ohne Gegenstück rechts liest sich als + /// verrutschtes Panel, nicht als Absicht. + public static func resolve(panelWidth: CGFloat, + notchWidth: CGFloat, + minimumUsableWidth: CGFloat = minimumUsableWidth) -> NotchBand { + let side = (panelWidth - notchWidth) / 2 + guard side >= minimumUsableWidth else { return .none } + return NotchBand(leading: side, trailing: side) + } +} diff --git a/Packages/OnyxKit/Sources/OnyxNotch/NotchCoordinator.swift b/Packages/OnyxKit/Sources/OnyxNotch/NotchCoordinator.swift index d839842..8a48477 100644 --- a/Packages/OnyxKit/Sources/OnyxNotch/NotchCoordinator.swift +++ b/Packages/OnyxKit/Sources/OnyxNotch/NotchCoordinator.swift @@ -36,6 +36,61 @@ public final class NotchCoordinator { didSet { guard virtualNotchSize != oldValue else { return }; rebuild() } } + /// Das Maß einer Insel, wenn nichts anderes eingestellt ist. + /// + /// Die **Höhe** ist hier nur ein Rückfall: auf einem Bildschirm mit + /// Menüleiste gibt deren Höhe das Maß vor, damit die Insel darin liegt + /// statt darunter herauszuhängen. + /// + /// Die Breite bestimmt, wie rund die Form *wirkt* — der Radius ist mit der + /// halben Höhe ohnehin am Maximum. Bei 150 zu 26 war es ein Balken mit + /// runden Enden; 72 zu rund 18 sind etwa 4:1, nah an der Insel am iPhone. + /// Inhalt braucht die leere Insel keinen: für jede Live Activity wächst sie + /// ohnehin auf deren Maß. + public static let defaultVirtualSize = CGSize(width: 72, height: 26) + + /// Was eine Inselbreite mindestens und höchstens sein darf. + /// + /// Nach unten, damit die Insel nicht zum Punkt wird, den niemand mehr + /// trifft; nach oben, weil sie sonst die halbe Menüleiste verdeckt. + public static let widthRange: ClosedRange = 48...320 + + /// Abweichende Inselbreiten, je Bildschirm. + /// + /// **Nur die Breite.** Die Höhe ist nicht mehr frei: bei verschmolzener + /// Notch gibt sie die Hardware vor, bei schwebender Insel die Menüleiste, + /// damit die Insel darin liegt statt darunter herauszuhängen. Ein + /// Höhenregler daneben wäre ein Bedienelement, das nichts tut. + /// + /// Je Bildschirm, weil ein 27-Zoll-Monitor und ein Laptopdeckel dieselbe + /// Breite verschieden groß aussehen lassen. + public var virtualWidthOverrides: [CGDirectDisplayID: CGFloat] = [:] { + didSet { guard virtualWidthOverrides != oldValue else { return }; rebuild() } + } + + /// Die Inselgröße, die für diesen Bildschirm gilt. + func virtualSize(for displayID: CGDirectDisplayID) -> CGSize { + guard let width = virtualWidthOverrides[displayID] else { return virtualNotchSize } + return CGSize(width: width, height: virtualNotchSize.height) + } + + /// Notch oder Insel. Siehe ``NotchStyle``. + public var style: NotchStyle = .automatic { + didSet { + guard style != oldValue else { return } + controllers.values.forEach { $0.style = style } + } + } + + /// Ob die eingeklappte Insel auf jedem Bildschirm stehen bleibt. + /// Siehe ``NotchScreenController/showsIdleIsland``. + public var showsIdleIsland = true { + didSet { + guard showsIdleIsland != oldValue else { return } + controllers.values.forEach { $0.showsIdleIsland = showsIdleIsland } + } + } + private var controllers: [CGDirectDisplayID: NotchScreenController] = [:] private var monitors: [Any] = [] private var suppressionTimer: Timer? @@ -50,7 +105,7 @@ public final class NotchCoordinator { public init(policy: DisplayPolicy = .builtInOnly, trigger: TriggerArea = .standard, - virtualNotchSize: CGSize = CGSize(width: 220, height: 32), + virtualNotchSize: CGSize = NotchCoordinator.defaultVirtualSize, makeContent: @escaping (CGDirectDisplayID, NotchPresentation) -> NSView) { self.policy = policy self.trigger = trigger @@ -108,14 +163,17 @@ public final class NotchCoordinator { if let existing = controllers[metrics.displayID] { // Bestehenden Controller aktualisieren statt neu zu bauen: // ein Auflösungswechsel darf ein offenes Panel nicht wegreißen. - existing.update(metrics: metrics, virtualSize: virtualNotchSize) + existing.update(metrics: metrics, + virtualSize: virtualSize(for: metrics.displayID)) } else { let id = metrics.displayID let controller = NotchScreenController( metrics: metrics, - virtualSize: virtualNotchSize, + virtualSize: virtualSize(for: id), trigger: trigger, + style: style, makeContent: { presentation in makeContent(id, presentation) }) + controller.showsIdleIsland = showsIdleIsland controller.onVisibilityChanged = { [weak self] _ in self?.updatePolling() } diff --git a/Packages/OnyxKit/Sources/OnyxNotch/NotchGeometry.swift b/Packages/OnyxKit/Sources/OnyxNotch/NotchGeometry.swift index 2f149fa..9b2539f 100644 --- a/Packages/OnyxKit/Sources/OnyxNotch/NotchGeometry.swift +++ b/Packages/OnyxKit/Sources/OnyxNotch/NotchGeometry.swift @@ -8,6 +8,18 @@ public struct ScreenMetrics: Equatable, Sendable, Identifiable { public let auxiliaryTopLeft: CGRect? public let auxiliaryTopRight: CGRect? public let isBuiltIn: Bool + /// Wie hoch die Menüleiste auf diesem Bildschirm ist. + /// + /// Bestimmt die Höhe einer schwebenden Insel: sie soll **in** der + /// Menüleiste liegen, nicht unter ihr heraushängen. Null heißt „unbekannt", + /// dann bleibt es beim vorgegebenen Maß. + public let menuBarHeight: CGFloat + /// Trägt dieser Bildschirm die Menüleiste? + /// + /// Nicht `NSScreen.main`: das ist der Bildschirm mit dem aktiven Fenster + /// und wechselt, sobald man woanders hinklickt. Gemeint ist der, den man + /// in den Systemeinstellungen zum Hauptbildschirm erklärt hat. + public let isMain: Bool /// `CGDirectDisplayID` — stabil über Anordnungswechsel hinweg, anders als /// der Index in `NSScreen.screens`. public let displayID: UInt32 @@ -19,12 +31,16 @@ public struct ScreenMetrics: Equatable, Sendable, Identifiable { auxiliaryTopLeft: CGRect?, auxiliaryTopRight: CGRect?, isBuiltIn: Bool = false, + menuBarHeight: CGFloat = 0, + isMain: Bool = false, displayID: UInt32 = 0) { self.frame = frame self.safeAreaTop = safeAreaTop self.auxiliaryTopLeft = auxiliaryTopLeft self.auxiliaryTopRight = auxiliaryTopRight self.isBuiltIn = isBuiltIn + self.menuBarHeight = menuBarHeight + self.isMain = isMain self.displayID = displayID } } @@ -33,6 +49,14 @@ public struct ScreenMetrics: Equatable, Sendable, Identifiable { public enum DisplayPolicy: String, Codable, Sendable, CaseIterable { /// Nur das eingebaute Display. case builtInOnly + /// Nur der Bildschirm mit der Menüleiste — egal ob eingebaut oder nicht. + /// + /// Die dritte Antwort auf „wo soll das Ding sein": nicht „am Gerät" und + /// nicht „überall", sondern **dort, wo ich ohnehin hinschaue**. Wer den + /// Laptop zugeklappt am Monitor betreibt, meint mit „mein Bildschirm" + /// diesen — und musste bisher zwischen „nur eingebaut" (dann ein + /// Ersatzdisplay) und „alle" (dann vier) wählen. + case mainDisplay /// Jedes angeschlossene Display, externe mit virtueller Notch. case allDisplays @@ -40,6 +64,12 @@ public enum DisplayPolicy: String, Codable, Sendable, CaseIterable { switch self { case .allDisplays: return available + case .mainDisplay: + // Meldet niemand die Menüleiste — etwa in Tests —, gilt der erste. + guard let main = available.first(where: \.isMain) else { + return Array(available.prefix(1)) + } + return [main] case .builtInOnly: let builtIn = available.filter(\.isBuiltIn) // Am Dock mit geschlossenem Deckel gibt es kein eingebautes Display. @@ -51,6 +81,43 @@ public enum DisplayPolicy: String, Codable, Sendable, CaseIterable { } } +/// Welche Form Onyx oben annimmt. +/// +/// Die Frage ist nicht kosmetisch: eine verschmolzene Notch **muss** an der +/// Kante kleben und oben eckig sein, sonst klafft ein Spalt zwischen Gehäuse +/// und Fläche. Eine schwebende Insel **muss** Abstand halten und rundum +/// gerundet sein, sonst sieht sie aus wie ein abgeschnittenes Rechteck. Beides +/// zu mischen geht nicht — deshalb eine Wahl und keine zwei Schalter. +public enum NotchStyle: String, Codable, Sendable, CaseIterable { + + /// Verschmolzen, wo es Hardware gibt; schwebend, wo nicht. + case automatic + + /// Immer an der Kante. Auf Bildschirmen ohne Notch eine nachgemalte. + case notch + + /// Immer schwebend — auch auf dem eingebauten Bildschirm, wo die Insel + /// dann unter der echten Notch hängt. + case island + + /// Ob die Form mit der Hardware verschmilzt. + public func merges(withHardware hasHardware: Bool) -> Bool { + switch self { + case .automatic, .notch: hasHardware + case .island: false + } + } + + /// Ob die Form frei schwebt. + public func isDetached(withHardware hasHardware: Bool) -> Bool { + switch self { + case .automatic: !hasHardware + case .notch: false + case .island: true + } + } +} + /// Wo die Notch sitzt und wo das Panel dazu hingehört. /// /// Auf dem eingebauten Display wird die echte Notch aus den beiden Hilfsflächen @@ -70,6 +137,30 @@ public struct NotchGeometry: Equatable, Sendable { public let rect: CGRect public let screenFrame: CGRect + /// Wie weit die Fläche unter der Bildschirmkante beginnt. + /// + /// Bei einer verschmolzenen Notch null: sie **ist** die Kante, und jeder + /// Abstand wäre ein sichtbarer Spalt zwischen Gehäuse und Fläche. Bei einer + /// schwebenden Insel ist der Abstand genau das, was sie zur Insel macht + /// statt zu einer nachgemalten Notch — und auf einem Gerät mit echter Notch + /// kommt deren Höhe hinzu, damit die Insel unter der Hardware hängt und + /// nicht in ihr verschwindet. + public let topMargin: CGFloat + + /// Frei schwebend statt mit der Kante verschmolzen — und damit rundum + /// gerundet. + public let isDetached: Bool + + /// Die Luft über und unter einer schwebenden Insel. + /// + /// Klein, weil sie zweimal vorkommt: die Insel soll in die Menüleiste + /// passen, und von deren 24 Punkten bleiben bei 3 Punkten oben und unten + /// noch 18 für die Insel selbst. Mehr Luft hieße weniger Insel. + public static let detachedMargin: CGFloat = 3 + + /// Darunter wird die Insel zum Strich. + public static let minimumIslandHeight: CGFloat = 14 + /// Wie weit die Auslösefläche über die Notch hinausreicht — einstellbar, /// weil es an Hand und Zeigegerät hängt. Siehe ``TriggerArea``. public var trigger: TriggerArea = .standard @@ -87,40 +178,85 @@ public struct NotchGeometry: Equatable, Sendable { return copy } - public init(kind: Kind, rect: CGRect, screenFrame: CGRect) { + public init(kind: Kind, rect: CGRect, screenFrame: CGRect, + isDetached: Bool = false, topMargin: CGFloat = 0) { self.kind = kind self.rect = rect self.screenFrame = screenFrame + self.isDetached = isDetached + self.topMargin = topMargin + } + + /// Die echte Notch dieses Bildschirms, falls es eine gibt. + /// + /// **Öffentlich, weil die Einstellungen dieselbe Frage stellen.** Dort ist + /// zu entscheiden, ob eine Breite überhaupt einstellbar ist — bei einer + /// echten Notch gibt die Hardware sie vor. Ein zweiter Weg zu dieser + /// Antwort wäre eine zweite Wahrheit, die irgendwann von dieser abweicht. + /// + /// Erkannt an den beiden Flächen daneben, nicht am Inset allein: bei + /// Spiegelung und Fernanzeige ist `safeAreaTop` gesetzt, obwohl es nichts + /// gibt, um das man herumlegen könnte. + public static func hardwareRect(_ metrics: ScreenMetrics) -> CGRect? { + guard let left = metrics.auxiliaryTopLeft, + let right = metrics.auxiliaryTopRight, + metrics.safeAreaTop > 0 else { return nil } + let width = metrics.frame.width - left.width - right.width + guard width > 0 else { return nil } + return CGRect(x: left.maxX, + y: metrics.frame.maxY - metrics.safeAreaTop, + width: width, + height: metrics.safeAreaTop) } public static func resolve(_ metrics: ScreenMetrics, virtualSize: CGSize, - trigger: TriggerArea = .standard) -> NotchGeometry { - // Eine echte Notch erkennt man an den beiden Flächen daneben, nicht am - // Inset allein: bei Spiegelung und Fernanzeige ist `safeAreaTop` gesetzt, - // obwohl es nichts gibt, um das man herumlegen könnte. - if let left = metrics.auxiliaryTopLeft, - let right = metrics.auxiliaryTopRight, - metrics.safeAreaTop > 0 { - let width = metrics.frame.width - left.width - right.width - if width > 0 { - return NotchGeometry( - kind: .physical, - rect: CGRect(x: left.maxX, - y: metrics.frame.maxY - metrics.safeAreaTop, - width: width, - height: metrics.safeAreaTop), - screenFrame: metrics.frame).with(trigger: trigger) - } + trigger: TriggerArea = .standard, + style: NotchStyle = .automatic) -> NotchGeometry { + let hardware = hardwareRect(metrics) + + // Verschmelzen kann nur, was etwas zum Verschmelzen hat. + if style.merges(withHardware: hardware != nil), let hardware { + return NotchGeometry(kind: .physical, rect: hardware, + screenFrame: metrics.frame).with(trigger: trigger) } let width = min(virtualSize.width, metrics.frame.width) + let detached = style.isDetached(withHardware: hardware != nil) + + // Wie hoch die Insel wird und wie weit unter der Kante sie beginnt. + // + // Ohne Hardware liegt sie **in** der Menüleiste: deren Höhe minus Luft + // oben und unten. Vorher stand hier ein festes Maß, und das hing dann + // unter der Leiste heraus — die Insel grenzte unten an nichts mehr an. + // + // Mit Hardware (Stil „immer schwebend") hängt sie unter der Notch, denn + // in ihr wäre sie zur Hälfte schwarz auf schwarzem Gehäuse. + let height: CGFloat + let clearance: CGFloat + switch (detached, hardware) { + case (false, _): + height = virtualSize.height + clearance = 0 + case (true, .some(let notch)): + height = virtualSize.height + clearance = notch.height + detachedMargin + case (true, .none): + let bar = metrics.menuBarHeight + height = bar > 0 ? max(bar - 2 * detachedMargin, minimumIslandHeight) + : virtualSize.height + // Mittig in der Leiste: der Rest der Höhe, halbiert. + clearance = bar > 0 ? max((bar - height) / 2, 0) : detachedMargin + } + return NotchGeometry( kind: .virtual, rect: CGRect(x: metrics.frame.midX - width / 2, - y: metrics.frame.maxY - virtualSize.height, + y: metrics.frame.maxY - clearance - height, width: width, - height: virtualSize.height), - screenFrame: metrics.frame).with(trigger: trigger) + height: height), + screenFrame: metrics.frame, + isDetached: detached, + topMargin: clearance).with(trigger: trigger) } /// Die Fläche, deren Betreten das Panel scharf schaltet. @@ -151,7 +287,7 @@ public struct NotchGeometry: Equatable, Sendable { let height = min(size.height, screenFrame.height) return CGRect(x: rect.midX - width / 2, - y: screenFrame.maxY - height, + y: screenFrame.maxY - topMargin - height, width: width, height: height) .clamped(to: screenFrame) diff --git a/Packages/OnyxKit/Sources/OnyxNotch/NotchPresentation.swift b/Packages/OnyxKit/Sources/OnyxNotch/NotchPresentation.swift index 3452270..3f97c1e 100644 --- a/Packages/OnyxKit/Sources/OnyxNotch/NotchPresentation.swift +++ b/Packages/OnyxKit/Sources/OnyxNotch/NotchPresentation.swift @@ -28,16 +28,64 @@ public final class NotchPresentation { self.contentTopInset = contentTopInset } - /// Die Fläche, die das Panel oben einnehmen darf, ohne die Menüleiste - /// zuzudecken — also die Breite der Notch. - /// - /// Darunter darf es beliebig breit werden; dort ist keine Menüleiste mehr. + /// Die Breite der Notch bzw. ihres virtuellen Ersatzes. public var notchWidth: CGFloat { collapsedSize.width } + /// Was eine Live Activity **unter** der Notch aufspannt. `nil` = keine. + /// + /// Wird vom Presenter gesetzt. + public var ambientContentSize: CGSize? + + /// Ob die Fläche frei schwebt statt mit der Bildschirmkante zu verschmelzen. + /// + /// Vom Controller aus der Geometrie gesetzt. Eine echte Notch verschmilzt + /// mit dem Gehäuse und ist oben eckig; ohne Hardware gibt es nichts zu + /// verschmelzen, und eine oben eckige Fläche mit Luft darüber sähe aus wie + /// ein abgeschnittenes Rechteck. + public var isDetached = false + + /// Die Arme links und rechts der Notch, auf Menüleistenhöhe. + /// + /// Nur ausgefahren: eingeklappt hängt die Live Activity **unter** der + /// Notch, nicht neben ihr. Neben ihr müssten beide Arme gleich breit sein — + /// die Silhouette zeichnet den Steg mittig um die Hardware —, und das hieße + /// bei einem Symbol links und einem Balken rechts über hundert Punkte + /// Schwarz für das Symbol. + public var band: NotchBand { + isExpanded + ? NotchBandLayout.resolve(panelWidth: expandedSize.width, notchWidth: notchWidth) + : .none + } + + /// Die Größe der **eingeklappten** Fläche. + /// + /// Ohne Activity die Notch allein. Mit einer wächst sie nach unten — und + /// mindestens auf Notchbreite, sonst hinge unter dem Steg ein schmalerer + /// Zipfel. + public var collapsedDisplaySize: CGSize { + guard let ambientContentSize else { return collapsedSize } + return CGSize(width: max(ambientContentSize.width, collapsedSize.width), + height: collapsedSize.height + ambientContentSize.height) + } + + /// Wie breit der oberste Streifen gerade gezeichnet wird. + /// + /// **Der Steg wächst, es kommen nicht Arme dazu.** Das ist derselbe Wert, + /// den die Silhouette animiert: zusammengezogen die Notch, ausgefahren die + /// Notch samt Armen. So wächst das Band aus der Hardware heraus, statt + /// daneben aufzublenden — und die Animation bleibt die eine, die es schon + /// gab. + public var topStripWidth: CGFloat { + band.totalWidth(notchWidth: notchWidth) + } + /// Wie lange das Einfahren braucht, bis das Fenster gefahrlos verschwinden darf. + /// /// Muss zu `Onyx.Motion.collapse` passen: zu früh entfernt reißt das Panel - /// sichtbar weg, zu spät bleibt ein unsichtbares Fenster im Weg stehen. - public static let collapseDuration: TimeInterval = 0.30 + /// sichtbar weg, zu spät bleibt ein unsichtbares Fenster im Weg stehen. Seit + /// die Kurve am Bewegungsprofil hängt, hängt die Dauer mit — eine feste Zahl + /// wäre bei „calm" um ein Drittel zu kurz gewesen. + public static var collapseDuration: TimeInterval { OnyxTheme.shared.motion.collapseDuration } } /// Die Silhouette des Panels: oben nur so breit wie die Notch, darunter voll. @@ -48,17 +96,31 @@ public final class NotchPresentation { /// und rechts davon aber die Menüleiste zu — bei sechs Kacheln die ganze, /// samt aller Statuselemente. /// -/// Also ein T: oben ein Steg in Notch-Breite, ab Unterkante der Menüleiste die -/// volle Fläche. Die Notch bleibt verschmolzen, die Menüleiste bleibt frei. +/// Also ein T: oben ein Steg, ab Unterkante der Menüleiste die volle Fläche. +/// Die Notch bleibt verschmolzen, die Menüleiste daneben bleibt frei. +/// +/// Der Steg ist **nicht** zwingend so breit wie die Notch: er darf bis zur +/// Panelbreite wachsen und trägt dann links und rechts der Hardware das Band. +/// Welche Breite gilt, entscheidet `NotchPresentation.topStripWidth`. struct NotchShape: Shape, InsettableShape { var cornerRadius: CGFloat var topBandHeight: CGFloat - var notchWidth: CGFloat + /// Die Breite des oberen Stegs — Notch, oder Notch samt Band. + var topWidth: CGFloat + /// Frei schwebend: dann ist auch die Oberkante gerundet. + /// + /// Kein eigener Radius, sondern derselbe wie unten — sonst hätte dieselbe + /// Form zwei Rundungen, und das liest sich als Fehler. Weil er von + /// `cornerRadius` abgeleitet ist, wächst er beim Ausfahren mit, ohne + /// eigens animiert werden zu müssen. + var isDetached: Bool = false var inset: CGFloat = 0 + private var topRadius: CGFloat { isDetached ? cornerRadius : 0 } + var animatableData: AnimatablePair { - get { .init(cornerRadius, notchWidth) } - set { cornerRadius = newValue.first; notchWidth = newValue.second } + get { .init(cornerRadius, topWidth) } + set { cornerRadius = newValue.first; topWidth = newValue.second } } func inset(by amount: CGFloat) -> NotchShape { @@ -69,30 +131,83 @@ struct NotchShape: Shape, InsettableShape { func path(in rect: CGRect) -> Path { let rect = rect.insetBy(dx: inset, dy: inset) - // Ist das Panel nicht breiter als die Notch, ist der Steg die ganze - // Form — dann bleibt es die schlichte abgerundete Fläche. - let band = min(max(notchWidth - 2 * inset, 0), rect.width) + let band = min(max(topWidth - 2 * inset, 0), rect.width) let radius = min(cornerRadius, rect.width / 2, rect.height / 2) + let bandBottom = rect.minY + max(topBandHeight, 0) + // **Ein durchgehender Umriss, nicht zwei überlappende Rechtecke.** + // + // Gefüllt sahen zwei Teilpfade richtig aus — die Vereinigung stimmt. + // Die Kontur aber zeichnet jeden Teilpfad vollständig nach, auch dort, + // wo er *innen* liegt: quer durch die Fläche lief die Oberkante des + // Körpers und die eckige Unterkante des Stegs. Dazu sprang die Form ab + // einer Breite in einen anderen Zweig. Zusammen war das das Flackern. var path = Path() + + // Die Schulter: der Übergang vom Steg in den Körper, nach innen + // gewölbt. Sie ist es, die aus einem T eine Insel macht — ohne sie + // sitzt der Steg wie aufgeklebt auf einer Platte. + let shoulder = min(radius, max((rect.width - band) / 2, 0), max(topBandHeight, 0)) + guard topBandHeight > 0, band < rect.width - 0.5 else { - path.addRoundedRect(in: rect, - cornerSize: CGSize(width: radius, height: radius), - style: .continuous) + // Nur der Steg. Verschmolzen ist er oben eckig — dort ist die + // Bildschirmkante, und eine Rundung wäre ein Spalt zum Gehäuse. + // Schwebend ist er rundum gerundet, sonst sähe er aus wie ein + // abgeschnittenes Rechteck. + let top = min(topRadius, rect.width / 2, rect.height / 2) + path.move(to: CGPoint(x: rect.minX, y: rect.minY + top)) + path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY - radius)) + path.addQuadCurve(to: CGPoint(x: rect.minX + radius, y: rect.maxY), + control: CGPoint(x: rect.minX, y: rect.maxY)) + path.addLine(to: CGPoint(x: rect.maxX - radius, y: rect.maxY)) + path.addQuadCurve(to: CGPoint(x: rect.maxX, y: rect.maxY - radius), + control: CGPoint(x: rect.maxX, y: rect.maxY)) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY + top)) + path.addQuadCurve(to: CGPoint(x: rect.maxX - top, y: rect.minY), + control: CGPoint(x: rect.maxX, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.minX + top, y: rect.minY)) + path.addQuadCurve(to: CGPoint(x: rect.minX, y: rect.minY + top), + control: CGPoint(x: rect.minX, y: rect.minY)) + path.closeSubpath() return path } - let bandRect = CGRect(x: rect.midX - band / 2, y: rect.minY, - width: band, height: topBandHeight + radius) - let body = CGRect(x: rect.minX, y: rect.minY + topBandHeight, - width: rect.width, height: rect.height - topBandHeight) + let bandMinX = rect.midX - band / 2 + let bandMaxX = rect.midX + band / 2 + let top = min(topRadius, band / 2, max(topBandHeight, 0)) - // Der Steg bekommt keine Rundung nach oben: dort sitzt die Notch, und - // eine Rundung an der Bildschirmkante wäre ohnehin nicht zu sehen. - path.addRect(bandRect) - path.addRoundedRect(in: body, - cornerSize: CGSize(width: radius, height: radius), - style: .continuous) + path.move(to: CGPoint(x: bandMinX, y: rect.minY + top)) + // Linke Stegkante hinunter, dann nach außen in den Körper. + path.addLine(to: CGPoint(x: bandMinX, y: bandBottom - shoulder)) + path.addQuadCurve(to: CGPoint(x: bandMinX - shoulder, y: bandBottom), + control: CGPoint(x: bandMinX, y: bandBottom)) + // Oberkante des Körpers nach links, obere linke Ecke. + path.addLine(to: CGPoint(x: rect.minX + radius, y: bandBottom)) + path.addQuadCurve(to: CGPoint(x: rect.minX, y: bandBottom + radius), + control: CGPoint(x: rect.minX, y: bandBottom)) + // Linke Flanke, untere Ecken, rechte Flanke. + path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY - radius)) + path.addQuadCurve(to: CGPoint(x: rect.minX + radius, y: rect.maxY), + control: CGPoint(x: rect.minX, y: rect.maxY)) + path.addLine(to: CGPoint(x: rect.maxX - radius, y: rect.maxY)) + path.addQuadCurve(to: CGPoint(x: rect.maxX, y: rect.maxY - radius), + control: CGPoint(x: rect.maxX, y: rect.maxY)) + path.addLine(to: CGPoint(x: rect.maxX, y: bandBottom + radius)) + path.addQuadCurve(to: CGPoint(x: rect.maxX - radius, y: bandBottom), + control: CGPoint(x: rect.maxX, y: bandBottom)) + // Zurück zur rechten Stegkante, wieder über die Schulter. + path.addLine(to: CGPoint(x: bandMaxX + shoulder, y: bandBottom)) + path.addQuadCurve(to: CGPoint(x: bandMaxX, y: bandBottom - shoulder), + control: CGPoint(x: bandMaxX, y: bandBottom)) + // Rechte Stegkante hinauf und über die Oberkante zurück. Verschmolzen + // ist `top` null, dann sind es schlicht zwei Geraden. + path.addLine(to: CGPoint(x: bandMaxX, y: rect.minY + top)) + path.addQuadCurve(to: CGPoint(x: bandMaxX - top, y: rect.minY), + control: CGPoint(x: bandMaxX, y: rect.minY)) + path.addLine(to: CGPoint(x: bandMinX + top, y: rect.minY)) + path.addQuadCurve(to: CGPoint(x: bandMinX, y: rect.minY + top), + control: CGPoint(x: bandMinX, y: rect.minY)) + path.closeSubpath() return path } } @@ -104,34 +219,73 @@ struct NotchShape: Shape, InsettableShape { /// Hardware darüber, sodass beides als ein Körper wirkt. Eingeblendet wird nur der /// Inhalt, und zwar erst, wenn die Fläche fast ihre Endgröße hat: Text, der sich /// mitdehnt, verrät die Animation als Skalierung und sieht billig aus. -public struct NotchContainer: View { +public struct NotchContainer: View { private let presentation: NotchPresentation + private let leading: Leading + private let trailing: Trailing + private let ambient: Ambient private let content: Content - public init(presentation: NotchPresentation, @ViewBuilder content: () -> Content) { + /// - Parameters: + /// - leading: Was links neben der Notch steht, wenn ausgefahren. + /// - trailing: Was rechts daneben steht. + /// - ambient: Die Live Activity, eingeklappt unter der Notch. + /// - content: Die Kacheln, unterhalb der Menüleiste. + public init(presentation: NotchPresentation, + @ViewBuilder leading: () -> Leading, + @ViewBuilder trailing: () -> Trailing, + @ViewBuilder ambient: () -> Ambient, + @ViewBuilder content: () -> Content) { self.presentation = presentation + self.leading = leading() + self.trailing = trailing() + self.ambient = ambient() self.content = content() } private var size: CGSize { - presentation.isExpanded ? presentation.expandedSize : presentation.collapsedSize + presentation.isExpanded ? presentation.expandedSize : presentation.collapsedDisplaySize } private var cornerRadius: CGFloat { - // Zusammengezogen die Rundung der Notch-Unterkante, ausgefahren die des - // Panels. Ein fester Radius lässt die kleine Form aufgeblasen aussehen. - presentation.isExpanded ? Onyx.Metric.panelCornerRadius : 10 + // Ausgefahren die Rundung des Panels. Ein fester Radius ließe die + // kleine Form aufgeblasen aussehen. + if presentation.isExpanded { return Onyx.Radius.panel } + // Eingeklappt und verschmolzen: die Rundung der Notch-Unterkante. + guard presentation.isDetached else { return Onyx.Radius.notch } + // Eingeklappt und schwebend: die **halbe Höhe**, also eine Pille. + // + // Eine Insel mit dem Radius einer Notch-Unterkante ist keine Insel, + // sondern eine abgerundete Kachel — die Rundung muss aus der Form + // selbst kommen und nicht aus einer geerbten Zahl. Bei wachsender + // Live Activity bleibt es bei der halben *Insel*höhe, sonst würde die + // Fläche mit dem Inhalt immer runder. + return presentation.collapsedSize.height / 2 } - private var shapeAnimation: Animation { - presentation.isExpanded ? Onyx.Motion.expand : Onyx.Motion.collapse + /// Die Kurve für **jede** Größenänderung der Fläche. + /// + /// Eine, nicht zwei. Zwei gestapelte `.animation(_:value:)` sehen aus wie + /// zwei Zuständigkeiten, sind aber keine: der innere Modifikator + /// beansprucht den Teilbaum, und der äußere kommt nicht mehr an die Form + /// heran. Weil eine Live Activity `isExpanded` unberührt lässt, hatte der + /// innere nichts zu animieren — und die Fläche sprang auf, während die + /// zweite Kurve daneben wirkungslos stand. + private var sizeAnimation: Animation { + // Ausfahren und Wachsen sind dieselbe Richtung; nur das Einfahren + // ist kürzer und schwingt nicht nach. + presentation.isExpanded || presentation.ambientContentSize != nil + ? Onyx.Motion.expand + : Onyx.Motion.collapse } public var body: some View { let shape = NotchShape(cornerRadius: cornerRadius, topBandHeight: presentation.contentTopInset, - notchWidth: presentation.notchWidth) + topWidth: presentation.topStripWidth, + isDetached: presentation.isDetached) ZStack(alignment: .top) { // Das Weichzeichner-Material behält **feste** Größe; animiert wird @@ -141,31 +295,68 @@ public struct NotchContainer: View { // Weichzeichner in jedem Einzelbild neu in anderer Größe berechnen — // das ruckelt sichtbar, und zwar genau beim Schließen, wo die Fläche // am schnellsten schrumpft. Eine Maske kostet dagegen nichts. - OnyxVisualEffect() - .frame(width: presentation.expandedSize.width, - height: presentation.expandedSize.height) - .mask(alignment: .top) { - shape.frame(width: size.width, height: size.height) - } + if OnyxTheme.shared.material.showsVibrancy { + OnyxVisualEffect() + .frame(width: presentation.expandedSize.width, + height: presentation.expandedSize.height) + .mask(alignment: .top) { + shape.frame(width: size.width, height: size.height) + } + } // Tint, Bänderung und Kante sind reine SwiftUI-Formen. Die dürfen // in der Größe animieren, das kostet nichts. - shape - .fill(Onyx.Color.surface.opacity(Onyx.Color.surfaceOpacity)) - .overlay { - shape.fill( - LinearGradient( - stops: [.init(color: .white.opacity(0.02), location: 0), - .init(color: .clear, location: 0.4)], - startPoint: .top, endPoint: .bottom)) - } - .overlay { shape.stroke(Onyx.Color.hairline, lineWidth: 1) } + // + // Dasselbe Rezept wie in ``OnyxSurface`` — und zwar buchstäblich + // dasselbe, nicht eine zweite Fassung davon. + OnyxStone(shape) .frame(width: size.width, height: size.height) // Konstante Deckkraft: einen Schatten aufzublenden kostet in // jedem Einzelbild eine neue Weichzeichnung. Er schrumpft // ohnehin mit der Form mit. .shadow(color: .black.opacity(0.45), radius: 22, y: 10) + // Die Live Activity, unter der Notch. + ambient + .frame(width: presentation.collapsedDisplaySize.width, + height: presentation.collapsedDisplaySize.height + - presentation.contentTopInset) + .padding(.top, presentation.contentTopInset) + // Beim Einfahren schneidet die schrumpfende Fläche den Inhalt + // weg — das ist die Bewegung. Das Ausblenden kommt nur dazu, + // damit die letzte Zeile nicht als Strich stehen bleibt. + .opacity(presentation.isExpanded || presentation.ambientContentSize == nil + ? 0 : 1) + .allowsHitTesting(false) + .clipped() + + // Das Band: links und rechts der Notch, auf Menüleistenhöhe. + // + // Die Notchbreite in der Mitte bleibt leer und **muss** es: dort + // sitzt undurchsichtige Hardware, alles darunter ist schlicht nicht + // zu sehen. + HStack(spacing: 0) { + leading + .frame(width: presentation.band.leading, alignment: .leading) + Color.clear + .frame(width: presentation.notchWidth) + trailing + .frame(width: presentation.band.trailing, alignment: .trailing) + } + .frame(height: presentation.contentTopInset) + .clipped() + // Eingeklappt ist das Band die Live Activity und muss stehen; + // ausgefahren blendet es mit dem übrigen Inhalt ein. + .opacity(presentation.band.isEmpty ? 0 : 1) + .allowsHitTesting(false) + // Dieselbe Kurve wie beim Kachelinhalt: die Fläche läuft vorweg, + // der Inhalt kommt nach. Ein Band, das mit der Fläche mitwächst, + // verrät die Animation als Skalierung. + .animation(presentation.isExpanded + ? .easeOut(duration: 0.20).delay(0.08) + : .easeIn(duration: 0.08), + value: presentation.isExpanded) + content .frame(width: presentation.expandedSize.width, height: presentation.expandedSize.height @@ -191,7 +382,9 @@ public struct NotchContainer: View { .frame(width: presentation.expandedSize.width, height: presentation.expandedSize.height, alignment: .top) - .animation(shapeAnimation, value: presentation.isExpanded) + // Der Anlass ist die gezeichnete Größe selbst — nicht der Umweg über + // `isExpanded`, der die Live Activity nicht mitbekommt. + .animation(sizeAnimation, value: size) } } @@ -216,7 +409,8 @@ public final class NotchHostingView: NSHostingView { required init?(coder: NSCoder) { fatalError() } public override func hitTest(_ point: NSPoint) -> NSView? { - let size = presentation.isExpanded ? presentation.expandedSize : presentation.collapsedSize + let size = presentation.isExpanded + ? presentation.expandedSize : presentation.collapsedDisplaySize // Bildschirmkoordinaten wachsen nach oben, View-Koordinaten nach unten: // die sichtbare Fläche hängt oben mittig im Fenster. let visible = CGRect(x: (bounds.width - size.width) / 2, @@ -225,16 +419,34 @@ public final class NotchHostingView: NSHostingView { height: size.height) guard visible.contains(point) else { return nil } - // Links und rechts neben der Notch gehört der oberste Streifen der - // Menüleiste. Dort ist das Panel durchsichtig — und muss auch - // durchlässig sein, sonst schluckt es Klicks auf fremde - // Statuselemente, ohne dass man sieht, warum. + // Außerhalb des Stegs gehört der oberste Streifen der Menüleiste. Dort + // ist das Panel durchsichtig — und muss auch durchlässig sein, sonst + // schluckt es Klicks auf fremde Statuselemente, ohne dass man sieht, + // warum. + // + // **Die Prüfung folgt dem, was gezeichnet wird**, nicht der Notchbreite: + // sonst wäre das Band zwar zu sehen, aber tot für Klicks — und ein + // Knopf, der aussieht wie einer und keiner ist, ist schlimmer als + // keiner. if presentation.isExpanded, point.y < presentation.contentTopInset { - let band = CGRect(x: (bounds.width - presentation.notchWidth) / 2, y: 0, - width: presentation.notchWidth, + let strip = presentation.topStripWidth + let band = CGRect(x: (bounds.width - strip) / 2, y: 0, + width: strip, height: presentation.contentTopInset) guard band.contains(point) else { return nil } } return super.hitTest(point) } } + +public extension NotchContainer +where Leading == EmptyView, Trailing == EmptyView, Ambient == EmptyView { + /// Ohne Band und ohne Live Activity — die Fassung, die es vorher gab. + init(presentation: NotchPresentation, @ViewBuilder content: () -> Content) { + self.init(presentation: presentation, + leading: { EmptyView() }, + trailing: { EmptyView() }, + ambient: { EmptyView() }, + content: content) + } +} diff --git a/Packages/OnyxKit/Sources/OnyxNotch/NotchScreenController.swift b/Packages/OnyxKit/Sources/OnyxNotch/NotchScreenController.swift index aa0a9f8..8815a7d 100644 --- a/Packages/OnyxKit/Sources/OnyxNotch/NotchScreenController.swift +++ b/Packages/OnyxKit/Sources/OnyxNotch/NotchScreenController.swift @@ -46,15 +46,33 @@ public final class NotchScreenController { } } + /// Notch oder Insel. Ändern baut die Geometrie neu — die Form hängt daran, + /// aber auch die Lage des Fensters. + public var style: NotchStyle { + didSet { + guard style != oldValue, let metrics = lastMetrics else { return } + update(metrics: metrics, virtualSize: lastVirtualSize) + } + } + + /// Was zuletzt gemessen wurde — damit ein Stilwechsel die Geometrie ohne + /// Umweg über den Koordinator neu rechnen kann. + private var lastMetrics: ScreenMetrics? + private var lastVirtualSize: CGSize + public init(metrics: ScreenMetrics, virtualSize: CGSize, trigger: TriggerArea = .standard, + style: NotchStyle = .automatic, contentSize: CGSize = CGSize(width: 640, height: 320), makeContent: (NotchPresentation) -> NSView) { self.displayID = metrics.displayID self.trigger = trigger + self.style = style + self.lastMetrics = metrics + self.lastVirtualSize = virtualSize let geometry = NotchGeometry.resolve(metrics, virtualSize: virtualSize, - trigger: trigger) + trigger: trigger, style: style) self.geometry = geometry self.contentSize = contentSize self.presentation = NotchPresentation( @@ -62,13 +80,18 @@ public final class NotchScreenController { expandedSize: CGSize(width: contentSize.width, height: contentSize.height + geometry.rect.height), contentTopInset: geometry.rect.height) + presentation.isDetached = geometry.isDetached self.panel = NotchPanel(contentRect: .zero) panel.contentView = makeContent(presentation) layoutPanel() + applyIdleIsland() } public var phase: NotchPhase { machine.phase } - public var isVisible: Bool { machine.isVisible } + /// Ausgefahren. **Nicht** „auf dem Bildschirm": eine eingeklappte Live + /// Activity soll den Koordinator nicht dazu bringen, den Zeiger zu + /// verfolgen, als stünde ein Panel offen. + public var isVisible: Bool { machine.isExpanded } /// Meldet, wenn das Panel sichtbar wird oder verschwindet. Der Koordinator /// schaltet daran die Zeigerabfrage an und aus. @@ -99,29 +122,99 @@ public final class NotchScreenController { /// und ist links und rechts der Notch dort durchsichtig — dieser Streifen /// gehört der Menüleiste. Wer den Fensterrahmen befragt, hält einen Klick /// auf ein fremdes Statuselement für einen Klick ins Panel. + /// + /// Der oberste Streifen zählt genau so weit, wie er auch **gezeichnet** + /// wird — sonst gilt der Zeiger auf dem eigenen Band als „außerhalb", und + /// das Panel fährt unter dem Finger ein, während man auf sein Akkusymbol + /// zeigt. public func containsVisiblePoint(_ point: CGPoint) -> Bool { guard presentation.isExpanded, panel.frame.contains(point) else { return false } let menuBarBand = panel.frame.maxY - geometry.rect.height guard point.y > menuBarBand else { return true } - return abs(point.x - panel.frame.midX) <= geometry.rect.width / 2 + return abs(point.x - panel.frame.midX) <= presentation.topStripWidth / 2 } public func escape() { send(.escape) } + + /// Was eine Live Activity gerade unter der Notch beansprucht. `nil` = keine. + /// + /// Meldet der Presenter, wenn sich seine Warteschlange ändert. Das Panel + /// fährt davon nicht aus — es bleibt oder kommt nur eingeklappt auf den + /// Schirm. + /// + /// **Die Reihenfolge ist der ganze Trick.** Erst das Fenster zeigen, dann + /// im nächsten Durchlauf wachsen lassen: steht die Endgröße schon im ersten + /// Bild, gibt es nichts zu animieren, und die Fläche springt auf. Beim + /// Ausfahren macht `show()` seit jeher genau das — für die Activity fehlte + /// es. + public func setAmbient(_ size: CGSize?) { + guard let size else { + // Umgekehrt beim Verschwinden: erst schrumpfen, dann fortnehmen. + // Das Fenster geht ohnehin erst nach der Einfahrfrist. + presentation.ambientContentSize = nil + send(.ambient(false)) + return + } + send(.ambient(true)) + DispatchQueue.main.async { [weak self] in + self?.presentation.ambientContentSize = size + } + } + /// Ob die eingeklappte Fläche stehen bleibt, wenn nichts anliegt. + /// + /// **Damit das Panel aus etwas herauswächst.** Auf dem eingebauten Display + /// wächst es aus der Notch — die ist immer da. Auf einem externen gab es + /// bisher nichts: das Fenster kam erst mit dem Inhalt auf den Schirm, und + /// eine schwarze Fläche, die im Nichts aufgeht, liest sich als Fehler und + /// nicht als Bewegung. Bleibt die eingeklappte Insel stehen, ist der + /// Ausgangspunkt sichtbar — und die Bewegung hat einen Ursprung. + /// + /// Auf dem eingebauten Display kostet das nichts: dort liegt die Fläche + /// deckungsgleich unter der schwarzen Hardware. + public var showsIdleIsland = true { + didSet { + guard showsIdleIsland != oldValue else { return } + applyIdleIsland() + } + } + + /// Unterdrückung schlägt die Insel: bei einem Vollbildvideo hat auch sie + /// zu schweigen. + private var mayShowIdleIsland: Bool { showsIdleIsland && !machine.isSuppressed } + + /// Bringt das Fenster in den Zustand, den die Insel gerade verlangt. + /// + /// Rührt nichts an, solange etwas anderes das Fenster braucht — ein + /// ausgefahrenes Panel oder eine Live Activity haben Vorrang. + private func applyIdleIsland() { + guard !presentation.isExpanded, !machine.hasAmbient else { return } + if mayShowIdleIsland { + panel.orderFrontRegardless() + } else { + panel.orderOut(nil) + } + } + public func setSuppressed(_ suppressed: Bool) { guard suppressed != machine.isSuppressed else { return } log.notice("Notch \(self.displayID, privacy: .public): Unterdrückung \(suppressed ? "an" : "aus", privacy: .public)") send(.suppressed(suppressed)) + applyIdleIsland() } /// Nach einem Wechsel von Auflösung, Anordnung oder Skalierung. public func update(metrics: ScreenMetrics, virtualSize: CGSize) { + lastMetrics = metrics + lastVirtualSize = virtualSize geometry = NotchGeometry.resolve(metrics, virtualSize: virtualSize, - trigger: trigger) + trigger: trigger, style: style) + presentation.isDetached = geometry.isDetached 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() + applyIdleIsland() } public func tearDown() { @@ -138,7 +231,7 @@ public final class NotchScreenController { /// Sichtbarkeit wird hier verglichen und nicht in `apply`: `handle` hat den /// Zustand dort bereits geändert, ein Vergleich käme also immer negativ zurück. private func send(_ input: NotchInput) { - let wasVisible = machine.isVisible + let wasVisible = machine.isExpanded let before = machine.phase apply(machine.handle(input)) // Jeden Zustandswechsel protokollieren — außer den beiden, die @@ -155,7 +248,7 @@ public final class NotchScreenController { log.notice(" Zeiger \(Int(self.lastPoint.x), privacy: .public)/\(Int(self.lastPoint.y), privacy: .public), Auslösefläche \(String(describing: self.geometry.triggerRect), privacy: .public), Fenster \(String(describing: self.panel.frame), privacy: .public), ausgefahren \(self.presentation.isExpanded, privacy: .public)") } } - if machine.isVisible != wasVisible { onVisibilityChanged?(machine.isVisible) } + if machine.isExpanded != wasVisible { onVisibilityChanged?(machine.isExpanded) } } private func apply(_ effects: [NotchEffect]) { @@ -186,6 +279,12 @@ public final class NotchScreenController { case .hide: hide() + + case .showAmbient: + showAmbient() + + case .hideAmbient: + hideAmbient() } } } @@ -213,6 +312,10 @@ public final class NotchScreenController { teardownTimer?.cancel() teardownTimer = schedule(after: NotchPresentation.collapseDuration) { [weak self] in guard let self, !presentation.isExpanded else { return } + // Liegt eine Live Activity an, bleibt das Fenster stehen — nur + // eingeklappt. Es fortzunehmen hieße, sie mit dem Panel zu + // beerdigen, obwohl sie mit ihm nichts zu tun hat. + guard !machine.hasAmbient, !mayShowIdleIsland else { return } panel.orderOut(nil) } } @@ -231,3 +334,27 @@ public final class NotchScreenController { panel.setFrame(geometry.panelRect(for: expandedSize), display: presentation.isExpanded) } } + +// MARK: - Live Activities + +private extension NotchScreenController { + + /// Fenster auf den Schirm, ohne auszufahren. + func showAmbient() { + teardownTimer?.cancel() + teardownTimer = nil + panel.orderFrontRegardless() + presentation.isExpanded = false + } + + /// Nichts mehr zu zeigen — Fenster fort, sofern es nicht ausgefahren ist. + func hideAmbient() { + guard !presentation.isExpanded else { return } + teardownTimer?.cancel() + teardownTimer = schedule(after: NotchPresentation.collapseDuration) { [weak self] in + guard let self, !presentation.isExpanded, + !machine.hasAmbient, !mayShowIdleIsland else { return } + panel.orderOut(nil) + } + } +} diff --git a/Packages/OnyxKit/Sources/OnyxNotch/NotchStateMachine.swift b/Packages/OnyxKit/Sources/OnyxNotch/NotchStateMachine.swift index 65ea670..baa0915 100644 --- a/Packages/OnyxKit/Sources/OnyxNotch/NotchStateMachine.swift +++ b/Packages/OnyxKit/Sources/OnyxNotch/NotchStateMachine.swift @@ -31,6 +31,12 @@ public enum NotchInput: Equatable, Sendable { case closeTimerFired /// Vollbildvideo, „Nicht stören" oder ein anderer Grund, das Panel wegzuhalten. case suppressed(Bool) + /// Ob gerade eine Live Activity zu zeigen ist. + /// + /// Sie hält das Panel **eingeklappt sichtbar**: Fenster auf dem Schirm, + /// Fläche in Notchgröße, Band daneben belegt. Ausgefahren wird davon + /// nichts — das bleibt dem Zeiger vorbehalten. + case ambient(Bool) } /// Was der Aufrufer nach einem Übergang zu tun hat. Die Maschine selbst @@ -42,6 +48,10 @@ public enum NotchEffect: Equatable, Sendable { case cancelCloseTimer case show case hide + /// Fenster einblenden, ohne auszufahren. + case showAmbient + /// Den eingeklappten Inhalt wieder wegnehmen. + case hideAmbient } /// Das Öffnungs- und Schließverhalten des Notch-Panels, frei von AppKit. @@ -75,16 +85,29 @@ public struct NotchStateMachine: Equatable, Sendable { /// Kommt er zurück, wird ohne weiteres Warten geöffnet. private var debounceElapsed = false + /// Ob eine Live Activity anliegt. + public private(set) var hasAmbient = false + public init() {} - /// Sichtbar, egal aus welchem Grund. - public var isVisible: Bool { + /// Ausgefahren — die Fläche zeigt Kacheln. + public var isExpanded: Bool { switch phase { case .open, .pinned, .closing: true case .idle, .arming: false } } + /// Auf dem Bildschirm, aus welchem Grund auch immer. + /// + /// **Getrennt von `isExpanded`, und das ist der ganze Punkt dieser + /// Erweiterung.** Bis hierher hieß „nicht ausgefahren" auch „nicht da": + /// `idle` war unsichtbar. Eine Live Activity ist aber eingeklappt *und* + /// sichtbar — sonst gäbe es keinen Ort, an dem sie stehen könnte. + public var isOnScreen: Bool { + !isSuppressed && (isExpanded || hasAmbient) + } + @discardableResult public mutating func handle(_ input: NotchInput) -> [NotchEffect] { // Unterdrückung schlägt jeden anderen Zustand, auch die Fixierung. @@ -94,12 +117,28 @@ public struct NotchStateMachine: Equatable, Sendable { guard active != isSuppressed else { return [] } isSuppressed = active - guard active else { return [] } - let wasVisible = isVisible + guard active else { + // Unterdrückung vorbei: was eingeklappt anliegt, darf zurück. + return hasAmbient ? [.showAmbient] : [] + } + let wasExpanded = isExpanded phase = .idle - return wasVisible - ? [.cancelArmTimer, .cancelCloseTimer, .hide] - : [.cancelArmTimer, .cancelCloseTimer] + var effects: [NotchEffect] = [.cancelArmTimer, .cancelCloseTimer] + if wasExpanded { effects.append(.hide) } + if hasAmbient { effects.append(.hideAmbient) } + return effects + } + + // Live Activities kommen und gehen unabhängig vom Zeiger. Sie ändern + // die Phase **nicht**: ein eingeblendeter Titel darf nicht dazu führen, + // dass ein Klick plötzlich fixiert oder eine Frist läuft. + if case .ambient(let present) = input { + guard present != hasAmbient else { return [] } + hasAmbient = present + // Ausgefahren ist ohnehin alles zu sehen, und unterdrückt gar + // nichts. In beiden Fällen bleibt es beim Merken. + guard !isSuppressed, !isExpanded else { return [] } + return present ? [.showAmbient] : [.hideAmbient] } guard !isSuppressed else { return [] } diff --git a/Packages/OnyxKit/Sources/OnyxNotch/TriggerAreaSettings.swift b/Packages/OnyxKit/Sources/OnyxNotch/TriggerAreaSettings.swift index 756534a..85d9ef4 100644 --- a/Packages/OnyxKit/Sources/OnyxNotch/TriggerAreaSettings.swift +++ b/Packages/OnyxKit/Sources/OnyxNotch/TriggerAreaSettings.swift @@ -17,7 +17,7 @@ public struct TriggerAreaSettings: View { } public var body: some View { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: Onyx.Space.lg) { Text("trigger.hint", bundle: .module) .font(.callout) .foregroundStyle(.secondary) @@ -120,9 +120,9 @@ private struct TriggerPreview: View { .frame(width: width, height: height) // Die Rundung zuletzt: sie schneidet Hintergrund, Menüleiste und // Fläche gemeinsam ab, sonst ragt etwas über die Ecken hinaus. - .clipShape(.rect(cornerRadius: 8, style: .continuous)) + .clipShape(.rect(cornerRadius: Onyx.Radius.control, style: .continuous)) .overlay { - RoundedRectangle(cornerRadius: 8, style: .continuous) + RoundedRectangle(cornerRadius: Onyx.Radius.control, style: .continuous) .strokeBorder(Onyx.Color.hairline, lineWidth: 1) } .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/Packages/OnyxKit/Tests/OnyxNotchTests/AmbientStateTests.swift b/Packages/OnyxKit/Tests/OnyxNotchTests/AmbientStateTests.swift new file mode 100644 index 0000000..473403d --- /dev/null +++ b/Packages/OnyxKit/Tests/OnyxNotchTests/AmbientStateTests.swift @@ -0,0 +1,107 @@ +import Testing +@testable import OnyxNotch + +@Suite("Eingeklappt und trotzdem da") +struct AmbientStateTests { + + /// Öffnet das Panel auf dem üblichen Weg: hinein, Entprellzeit ab. + private func opened() -> NotchStateMachine { + var machine = NotchStateMachine() + machine.handle(.pointerEntered) + machine.handle(.armTimerFired) + return machine + } + + @Test("Ohne Live Activity ist eingeklappt gleich unsichtbar") + func idleIsOffScreen() { + let machine = NotchStateMachine() + #expect(!machine.isExpanded) + #expect(!machine.isOnScreen) + } + + /// Der ganze Zweck: Fenster auf dem Schirm, Fläche in Notchgröße. + @Test("Eine Live Activity holt das Fenster, ohne auszufahren") + func ambientShowsWithoutExpanding() { + var machine = NotchStateMachine() + let effects = machine.handle(.ambient(true)) + + #expect(effects == [.showAmbient]) + #expect(machine.isOnScreen) + #expect(!machine.isExpanded) + } + + @Test("Fällt sie weg, geht das Fenster wieder fort") + func ambientVanishing() { + var machine = NotchStateMachine() + machine.handle(.ambient(true)) + #expect(machine.handle(.ambient(false)) == [.hideAmbient]) + #expect(!machine.isOnScreen) + } + + /// Zweimal dieselbe Meldung ist keine Änderung. Ohne diese Sperre blendet + /// jeder Lautstärkeschritt das Fenster neu ein. + @Test("Dieselbe Meldung zweimal bewirkt nichts") + func repeatedIsQuiet() { + var machine = NotchStateMachine() + machine.handle(.ambient(true)) + #expect(machine.handle(.ambient(true)).isEmpty) + } + + /// Ausgefahren ist ohnehin alles zu sehen — dort darf eine Activity das + /// Fenster nicht ein zweites Mal einblenden. + @Test("Ausgefahren bleibt eine Live Activity ohne Wirkung") + func ambientWhileExpanded() { + var machine = opened() + #expect(machine.handle(.ambient(true)).isEmpty) + #expect(machine.isExpanded) + #expect(machine.isOnScreen) + } + + /// Der eigentliche Grund für die Trennung: nach dem Einfahren darf das + /// Fenster nicht fortgeräumt werden, wenn noch etwas anliegt. + @Test("Nach dem Einfahren bleibt das Fenster, solange etwas anliegt") + func staysAfterCollapse() { + var machine = opened() + machine.handle(.ambient(true)) + machine.handle(.pointerExited) + machine.handle(.closeTimerFired) + + #expect(!machine.isExpanded) + #expect(machine.isOnScreen) + #expect(machine.hasAmbient) + } + + @Test("Unterdrückung nimmt auch die Live Activity weg") + func suppressionHidesAmbient() { + var machine = NotchStateMachine() + machine.handle(.ambient(true)) + let effects = machine.handle(.suppressed(true)) + + #expect(effects.contains(.hideAmbient)) + #expect(!machine.isOnScreen) + // Gemerkt bleibt sie trotzdem — sonst käme sie nach dem Vollbildvideo + // nicht von selbst zurück. + #expect(machine.hasAmbient) + } + + @Test("Nach der Unterdrückung kommt sie von selbst zurück") + func ambientReturnsAfterSuppression() { + var machine = NotchStateMachine() + machine.handle(.ambient(true)) + machine.handle(.suppressed(true)) + + #expect(machine.handle(.suppressed(false)) == [.showAmbient]) + #expect(machine.isOnScreen) + } + + /// Eine Activity darf die Phase nicht anfassen: sonst gälte ein Klick + /// plötzlich als Fixieren, oder eine Frist liefe, die niemand gestartet hat. + @Test("Eine Live Activity ändert die Phase nicht") + func ambientLeavesPhaseAlone() { + var machine = NotchStateMachine() + machine.handle(.pointerEntered) + let before = machine.phase + machine.handle(.ambient(true)) + #expect(machine.phase == before) + } +} diff --git a/Packages/OnyxKit/Tests/OnyxNotchTests/DisplayPolicyTests.swift b/Packages/OnyxKit/Tests/OnyxNotchTests/DisplayPolicyTests.swift index 326d183..a55c27c 100644 --- a/Packages/OnyxKit/Tests/OnyxNotchTests/DisplayPolicyTests.swift +++ b/Packages/OnyxKit/Tests/OnyxNotchTests/DisplayPolicyTests.swift @@ -53,10 +53,44 @@ struct DisplayPolicyTests { #expect(chosen.count == 1) } + // MARK: - Hauptbildschirm + + /// Ein externer Bildschirm, der die Menüleiste trägt — der übliche Fall am + /// Schreibtisch, wenn der Laptop danebensteht. + static let externalMain = ScreenMetrics( + frame: CGRect(x: 1800, y: 0, width: 2560, height: 1440), + safeAreaTop: 0, auxiliaryTopLeft: nil, auxiliaryTopRight: nil, + isBuiltIn: false, isMain: true, displayID: 2 + ) + + @Test("Hauptbildschirm: der mit der Menüleiste, auch wenn er extern ist") + func mainDisplayPicksTheMenuBarScreen() { + let chosen = DisplayPolicy.mainDisplay + .screens(from: [Self.builtIn, Self.externalMain, Self.secondExternal]) + #expect(chosen.map(\.displayID) == [2]) + } + + @Test("Hauptbildschirm: genau einer, nie mehrere") + func mainDisplayPicksExactlyOne() { + let chosen = DisplayPolicy.mainDisplay + .screens(from: [Self.builtIn, Self.externalMain, Self.secondExternal]) + #expect(chosen.count == 1) + } + + @Test("Hauptbildschirm: meldet ihn niemand, bleibt Onyx trotzdem erreichbar") + func mainDisplayFallsBackWhenNobodyClaimsIt() { + // Kann im Betrieb nicht vorkommen — die Menüleiste ist immer irgendwo. + // Ein leeres Ergebnis hieße aber: keine Notch, kein Zugang, kein Weg + // zurück in die Einstellungen. + let chosen = DisplayPolicy.mainDisplay.screens(from: [Self.external, Self.secondExternal]) + #expect(chosen.count == 1) + } + @Test("Ohne Displays kein Panel — und kein Absturz") func noScreensIsSafe() { #expect(DisplayPolicy.builtInOnly.screens(from: []).isEmpty) #expect(DisplayPolicy.allDisplays.screens(from: []).isEmpty) + #expect(DisplayPolicy.mainDisplay.screens(from: []).isEmpty) } @Test("Mehrere eingebaute Displays gibt es nicht, aber der Code darf daran nicht scheitern") diff --git a/Packages/OnyxKit/Tests/OnyxNotchTests/NotchBandTests.swift b/Packages/OnyxKit/Tests/OnyxNotchTests/NotchBandTests.swift new file mode 100644 index 0000000..e659537 --- /dev/null +++ b/Packages/OnyxKit/Tests/OnyxNotchTests/NotchBandTests.swift @@ -0,0 +1,65 @@ +import Testing +import CoreGraphics +@testable import OnyxNotch + +@Suite("Band neben der Notch") +struct NotchBandTests { + + // Die nachgemessenen Werte des Zielgeräts: 1800 Punkte breiter Bildschirm, + // Notch 220 breit, bei fünf Kacheln ein 972 Punkte breites Panel. + private let notchWidth: CGFloat = 220 + private let panelWidth: CGFloat = 972 + + @Test("Bei fünf Kacheln bleiben je 376 Punkte") + func referenceMachine() { + let band = NotchBandLayout.resolve(panelWidth: panelWidth, notchWidth: notchWidth) + #expect(band.leading == 376) + #expect(band.trailing == 376) + #expect(!band.isEmpty) + } + + @Test("Der gezeichnete Streifen ist wieder so breit wie das Panel") + func totalMatchesPanel() { + let band = NotchBandLayout.resolve(panelWidth: panelWidth, notchWidth: notchWidth) + #expect(band.totalWidth(notchWidth: notchWidth) == panelWidth) + } + + /// Zusammengezogen ist das Panel genau die Notch. Dann gibt es keine Arme — + /// und erst recht keine negativen. + @Test("Ist das Panel so breit wie die Notch, gibt es kein Band") + func collapsedHasNoBand() { + let band = NotchBandLayout.resolve(panelWidth: notchWidth, notchWidth: notchWidth) + #expect(band == .none) + #expect(band.isEmpty) + } + + /// Kommt auf sehr schmalen Bildschirmen vor, wenn das Panel geklemmt wird. + /// Eine negative Breite wäre in einer `frame`-Angabe ein Absturz. + @Test("Ein Panel schmaler als die Notch ergibt keine negative Breite") + func narrowerThanNotchStaysZero() { + let band = NotchBandLayout.resolve(panelWidth: 120, notchWidth: notchWidth) + #expect(band == .none) + } + + @Test("Knapp unter dem Mindestmaß bleibt der Streifen leer") + func belowMinimumIsEmpty() { + let width = notchWidth + 2 * (NotchBandLayout.minimumUsableWidth - 1) + #expect(NotchBandLayout.resolve(panelWidth: width, notchWidth: notchWidth) == .none) + } + + @Test("Genau am Mindestmaß trägt er") + func atMinimumIsUsed() { + let width = notchWidth + 2 * NotchBandLayout.minimumUsableWidth + let band = NotchBandLayout.resolve(panelWidth: width, notchWidth: notchWidth) + #expect(band.leading == NotchBandLayout.minimumUsableWidth) + } + + /// Ein Arm links ohne Gegenstück rechts liest sich als verrutschtes Panel. + @Test("Beide Seiten sind immer gleich breit") + func bothSidesMatch() { + for width in stride(from: CGFloat(200), through: 2000, by: 37) { + let band = NotchBandLayout.resolve(panelWidth: width, notchWidth: notchWidth) + #expect(band.leading == band.trailing) + } + } +} diff --git a/Packages/OnyxKit/Tests/OnyxNotchTests/NotchGeometryTests.swift b/Packages/OnyxKit/Tests/OnyxNotchTests/NotchGeometryTests.swift index 0431712..abe1415 100644 --- a/Packages/OnyxKit/Tests/OnyxNotchTests/NotchGeometryTests.swift +++ b/Packages/OnyxKit/Tests/OnyxNotchTests/NotchGeometryTests.swift @@ -43,8 +43,71 @@ struct NotchGeometryTests { let g = NotchGeometry.resolve(Self.external, virtualSize: Self.virtualSize) #expect(g.kind == .virtual) - // Mittig: 1800 + (2560 − 220)/2 = 2970 - #expect(g.rect == CGRect(x: 2970, y: 1408, width: 220, height: 32)) + #expect(g.isDetached) + // Mittig: 1800 + (2560 − 220)/2 = 2970. + // Und **nicht** an der Oberkante: ohne Hardware gibt es nichts zum + // Verschmelzen, also schwebt die Insel. Dieser Bildschirm meldet keine + // Menüleistenhöhe, deshalb bleibt es beim vorgegebenen Maß und der + // kleinen Luft: 1440 − 3 − 32 = 1405. + #expect(g.rect == CGRect(x: 2970, y: 1405, width: 220, height: 32)) + } + + @Test("Die Insel liegt in der Menüleiste, nicht darunter") + func islandFitsIntoTheMenuBar() { + // Ein Bildschirm, der seine Menüleiste meldet — der Normalfall. + let screen = ScreenMetrics( + frame: CGRect(x: 1800, y: 0, width: 2560, height: 1440), + safeAreaTop: 0, auxiliaryTopLeft: nil, auxiliaryTopRight: nil, + isBuiltIn: false, menuBarHeight: 24, displayID: 2) + + let g = NotchGeometry.resolve(screen, virtualSize: CGSize(width: 72, height: 26)) + + #expect(g.isDetached) + // 24 minus je 3 Punkt Luft: die vorgegebenen 26 wären zu hoch gewesen + // und hätten unten aus der Leiste herausgeragt. + #expect(g.rect.height == 18) + // Oben und unten gleich viel Luft. + #expect(screen.frame.maxY - g.rect.maxY == 3) + #expect(g.rect.minY - (screen.frame.maxY - screen.menuBarHeight) == 3) + } + + @Test("Eine sehr flache Menüleiste drückt die Insel nicht zum Strich") + func islandKeepsAMinimumHeight() { + let screen = ScreenMetrics( + frame: CGRect(x: 0, y: 0, width: 2560, height: 1440), + safeAreaTop: 0, auxiliaryTopLeft: nil, auxiliaryTopRight: nil, + isBuiltIn: false, menuBarHeight: 12, displayID: 9) + + let g = NotchGeometry.resolve(screen, virtualSize: CGSize(width: 72, height: 26)) + #expect(g.rect.height == NotchGeometry.minimumIslandHeight) + } + + @Test("Echte Notch verschmilzt und schwebt nicht") + func physicalNotchStaysMerged() { + let g = NotchGeometry.resolve(Self.builtIn, virtualSize: Self.virtualSize) + #expect(g.kind == .physical) + #expect(!g.isDetached) + // Kein Spalt: die Fläche *ist* die Kante. + #expect(g.topMargin == 0) + } + + @Test("Stil „immer schwebend\": auch mit Notch hängt die Insel darunter") + func islandStyleDetachesEvenWithHardware() { + let g = NotchGeometry.resolve(Self.builtIn, virtualSize: Self.virtualSize, + style: .island) + #expect(g.isDetached) + // Unter der Hardware, nicht in ihr: sonst wäre die Insel zur Hälfte + // schwarz auf schwarzem Gehäuse. + #expect(g.topMargin > Self.builtIn.safeAreaTop) + } + + @Test("Stil „immer verschmolzen\": ohne Hardware klebt die Fläche an der Kante") + func notchStyleStaysAttachedWithoutHardware() { + let g = NotchGeometry.resolve(Self.external, virtualSize: Self.virtualSize, + style: .notch) + #expect(!g.isDetached) + #expect(g.topMargin == 0) + #expect(g.rect.maxY == Self.external.frame.maxY) } @Test("safeAreaTop ohne Hilfsflächen gilt nicht als Notch") @@ -128,7 +191,10 @@ struct NotchGeometryTests { let panel = g.panelRect(for: CGSize(width: 640, height: 320)) #expect(panel.minX >= 1800) - #expect(panel.maxY == 1440) + // Die Oberkante des Fensters folgt der schwebenden Insel, nicht der + // Bildschirmkante — sonst klebte das Panel oben fest, während die + // Insel darunter hängt. + #expect(panel.maxY == 1440 - NotchGeometry.detachedMargin) } // MARK: - Sichtbarer Inhaltsbereich @@ -181,3 +247,57 @@ struct NotchGeometryTests { #expect(panel.minY >= Self.builtIn.frame.minY) } } + +@Suite("Hardware-Erkennung") +struct HardwareNotchTests { + + @Test("Ein Bildschirm mit Hilfsflächen hat eine echte Notch") + func builtInHasHardware() { + let metrics = ScreenMetrics( + frame: CGRect(x: 0, y: 0, width: 1800, height: 1169), + safeAreaTop: 38, + auxiliaryTopLeft: CGRect(x: 0, y: 1131, width: 790, height: 38), + auxiliaryTopRight: CGRect(x: 1010, y: 1131, width: 790, height: 38), + isBuiltIn: true, displayID: 1) + #expect(NotchGeometry.hardwareRect(metrics) != nil) + } + + @Test("Ein externer Bildschirm hat keine") + func externalHasNone() { + let metrics = ScreenMetrics( + frame: CGRect(x: 1800, y: 0, width: 2560, height: 1440), + safeAreaTop: 0, auxiliaryTopLeft: nil, auxiliaryTopRight: nil, + isBuiltIn: false, displayID: 2) + #expect(NotchGeometry.hardwareRect(metrics) == nil) + } + + @Test("safeAreaTop allein genügt nicht") + func insetAloneIsNotANotch() { + // Bei Spiegelung und Fernanzeige ist das Inset gesetzt, obwohl es + // nichts gibt, um das man herumlegen könnte. + let metrics = ScreenMetrics( + frame: CGRect(x: 0, y: 0, width: 1800, height: 1169), + safeAreaTop: 38, auxiliaryTopLeft: nil, auxiliaryTopRight: nil, + isBuiltIn: true, displayID: 3) + #expect(NotchGeometry.hardwareRect(metrics) == nil) + } + + @Test("Die Einstellungen und die Geometrie kommen zum selben Schluss") + func settingsAgreeWithGeometry() { + // Genau dafür ist `hardwareRect` öffentlich: würden beide die Frage + // getrennt beantworten, zeigten die Einstellungen irgendwann einen + // Regler für eine Breite, die gar nicht gilt. + let builtIn = ScreenMetrics( + frame: CGRect(x: 0, y: 0, width: 1800, height: 1169), + safeAreaTop: 38, + auxiliaryTopLeft: CGRect(x: 0, y: 1131, width: 790, height: 38), + auxiliaryTopRight: CGRect(x: 1010, y: 1131, width: 790, height: 38), + isBuiltIn: true, displayID: 1) + + let hasHardware = NotchGeometry.hardwareRect(builtIn) != nil + let geometry = NotchGeometry.resolve(builtIn, virtualSize: CGSize(width: 72, height: 26)) + + #expect(NotchStyle.automatic.merges(withHardware: hasHardware)) + #expect(geometry.kind == .physical) + } +}