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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<CGFloat> = 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()
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
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)
|
||||
|
||||
@@ -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<CGFloat, CGFloat> {
|
||||
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<Content: View>: View {
|
||||
public struct NotchContainer<Leading: View, Trailing: View,
|
||||
Ambient: View, Content: View>: 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<Content: View>: 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.
|
||||
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<Content: View>: 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<Content: View>: NSHostingView<Content> {
|
||||
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<Content: View>: NSHostingView<Content> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 [] }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user