Die Silhouette bestand aus zwei überlappenden Teilpfaden. Gefüllt stimmte die Vereinigung, aber die Kontur zeichnete jeden Teilpfad vollständig nach — auch dort, wo er innen lag. Dazu kippte die Form ab einer Breite in einen anderen Zweig, und die beiden Zweige waren nicht dieselbe Geometrie. Zusammen war das das Flackern an den Rundungen. Jetzt ein durchgehender Umriss, dessen Zweige ineinander übergehen statt zu springen. Neu ist die Wahl zwischen verschmolzen und schwebend. Eine echte Notch **muss** an der Kante kleben und oben eckig sein, sonst klafft ein Spalt zum Gehäuse; eine 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. Ihre Höhe rät Onyx nicht mehr, sondern misst die Menüleiste: 24 Punkte minus Luft oben und unten. Vorher hing ein festes Maß unten heraus. Dazu „nur der Hauptbildschirm" als dritte Anzeigewahl — gemeint ist der mit der Menüleiste, nicht `NSScreen.main`, das mit dem aktiven Fenster mitwandert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
309 lines
13 KiB
Swift
309 lines
13 KiB
Swift
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)
|
|
}
|
|
}
|