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:
Scarriffle
2026-09-05 21:05:13 +02:00
co-authored by Claude Opus 5
parent ecb39fbe5f
commit c05ccb8ae1
12 changed files with 1081 additions and 104 deletions
@@ -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)
}
}
}