import CoreGraphics /// Die Bildschirmwerte, aus denen sich die Notch ableiten lässt — als eigener /// Typ, damit die Geometrie ohne echten `NSScreen` prüfbar bleibt. public struct ScreenMetrics: Equatable, Sendable, Identifiable { public let frame: CGRect public let safeAreaTop: CGFloat 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 public var id: UInt32 { displayID } public init(frame: CGRect, safeAreaTop: CGFloat, 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 } } /// Auf welchen Displays Onyx überhaupt erscheint. 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 public func screens(from available: [ScreenMetrics]) -> [ScreenMetrics] { 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. // Diese Einstellung beim Wort zu nehmen hieße, Onyx unerreichbar zu // machen — deshalb ein einzelnes Ersatzdisplay statt gar keinem. guard builtIn.isEmpty else { return builtIn } return Array(available.prefix(1)) } } } /// 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 /// links und rechts davon berechnet. Auf allen anderen Displays tritt ein /// virtueller Ersatz an dieselbe Stelle, damit Onyx dort überhaupt erreichbar ist. public struct NotchGeometry: Equatable, Sendable { public enum Kind: Equatable, Sendable { /// Aus `auxiliaryTopLeftArea` / `auxiliaryTopRightArea` abgeleitet. case physical /// Frei gesetzter Balken oben mittig. case virtual } public let kind: Kind /// Die Notch selbst, in Bildschirmkoordinaten (Ursprung unten links). 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 /// Nur noch für Bestandscode: die frühere feste Zugabe. public static let triggerInsetX: CGFloat = 16 /// Nach unten deutlich großzügiger als nach oben: von oben kommt niemand, /// dort ist der Bildschirmrand. Vier Punkte waren zu wenig — eine Hand /// hält den Zeiger nicht auf vier Punkte genau. public static let triggerInsetY: CGFloat = 14 func with(trigger: TriggerArea) -> NotchGeometry { var copy = self copy.trigger = trigger return copy } 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, 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 - clearance - height, width: width, height: height), screenFrame: metrics.frame, isDetached: detached, topMargin: clearance).with(trigger: trigger) } /// Die Fläche, deren Betreten das Panel scharf schaltet. public var triggerRect: CGRect { trigger.rect(notch: rect, screen: 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 { let width = min(size.width, screenFrame.width) let height = min(size.height, screenFrame.height) return CGRect(x: rect.midX - width / 2, y: screenFrame.maxY - topMargin - height, width: width, height: height) .clamped(to: screenFrame) } } extension CGRect { /// Schiebt das Rechteck in die Grenzen hinein, statt es abzuschneiden. /// Ein zurechtgestutztes Panel wäre halb unsichtbar; ein verschobenes ist /// vollständig da. func clamped(to bounds: CGRect) -> CGRect { let width = Swift.min(width, bounds.width) let height = Swift.min(height, bounds.height) let x = Swift.min(Swift.max(minX, bounds.minX), bounds.maxX - width) let y = Swift.min(Swift.max(minY, bounds.minY), bounds.maxY - height) return CGRect(x: x, y: y, width: width, height: height) } }