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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user