Die Notch wird zur Insel, wo es keine Hardware gibt
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
ecb39fbe5f
commit
c05ccb8ae1
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user