Schließen entruckeln: Material fest, Maske animiert, Zeiger abgefragt

Drei Ursachen, davon zwei echte Fehler.

Die NSVisualEffectView steckte in einem Rahmen, dessen Größe animiert wurde.
Ein Weichzeichner muss dann in jedem Einzelbild neu in anderer Größe gerechnet
werden — am teuersten genau beim Schließen, wo die Fläche am schnellsten
schrumpft. Das Material behält jetzt feste Größe, animiert wird nur die Maske
davor. Tint, Bänderung und Kante sind reine SwiftUI-Formen und dürfen weiter in
der Größe animieren. Auch die Schattendeckkraft ist jetzt konstant statt
animiert; sie aufzublenden kostete pro Bild eine neue Weichzeichnung.

Onyx bekam Mausbewegungen über dem eigenen Panel praktisch nicht mit. Der
globale Monitor feuert nur für Ereignisse an fremde Programme, der lokale nur
wenn Onyx aktiv ist — was es als .accessory-App nie ist. Ausgerechnet beim
Verlassen des Panels kam das Ereignis also verspätet oder gar nicht. Solange
ein Panel offen ist, wird die Zeigerposition jetzt mit 60 Hz abgefragt; der
Timer läuft ausschließlich in dieser Zeit.

Öffnen und Schließen benutzten dieselbe Kurve. Schließen ist jetzt kürzer und
ohne Nachschwingen: beim Öffnen sieht man zu, beim Schließen ist man schon
woanders.

Nebenbefund beim Prüfen: es liefen zwei Onyx-Instanzen aus verschiedenen Builds
gleichzeitig, jede mit eigener Zustandsmaschine auf derselben Notch. Das dürfte
einen guten Teil der Hakeligkeit erklärt haben.
This commit is contained in:
Guido Schmit
2026-08-10 17:52:42 +02:00
parent 65f9fbfa4e
commit 0297b3ad0d
5 changed files with 143 additions and 45 deletions

View File

@@ -45,9 +45,16 @@ public enum Onyx {
// MARK: - Bewegung // MARK: - Bewegung
public enum Motion { public enum Motion {
/// Ein einziger Spring für alles Ausfahren. Mehrere Kurven im selben /// Ausfahren: ein Spring mit einem Hauch Nachschwingen. Das gibt dem
/// Panel wirken unruhig, egal wie gut jede für sich ist. /// Panel Masse, statt es nur größer werden zu lassen.
public static let expand = Animation.spring(response: 0.34, dampingFraction: 0.86) public static let expand = Animation.spring(response: 0.34, dampingFraction: 0.86)
/// Einfahren: kürzer und ohne Nachschwingen.
///
/// Schließen muss schneller sein als Öffnen. Beim Öffnen sieht man zu,
/// beim Schließen ist man schon woanders dieselbe Kurve rückwärts
/// fühlt sich dort zäh an, und das Nachschwingen liest sich als Ruckler.
public static let collapse = Animation.spring(response: 0.24, dampingFraction: 1.0)
/// Für Messwerte: sichtbar, aber ohne Pulsieren. /// Für Messwerte: sichtbar, aber ohne Pulsieren.
public static let value = Animation.easeOut(duration: 0.25) public static let value = Animation.easeOut(duration: 0.25)
} }

View File

@@ -3,11 +3,17 @@ import AppKit
/// Hält für jeden in Frage kommenden Bildschirm einen Controller und versorgt /// Hält für jeden in Frage kommenden Bildschirm einen Controller und versorgt
/// alle mit der Zeigerposition. /// alle mit der Zeigerposition.
/// ///
/// Die Zeigerposition kommt aus einem globalen Ereignismonitor statt aus einem /// Die Zeigerposition kommt aus zwei Quellen, und beide werden gebraucht:
/// unsichtbaren Fenster über der Notch. Ein solches Fenster müsste Mausereignisse ///
/// annehmen, um sie zu bemerken und würde damit die Menüleiste und die /// **Ereignismonitor**, solange das Panel zu ist. Ein unsichtbares Fenster über
/// Fensterknöpfe darunter unbenutzbar machen. Mausbewegungen global zu beobachten /// der Notch wäre die naheliegende Alternative, müsste dafür aber Mausereignisse
/// verlangt anders als bei Tastatureingaben keine Bedienungshilfen-Berechtigung. /// annehmen und würde die Menüleiste und die Fensterknöpfe darunter
/// unbenutzbar machen. Mausbewegungen global zu beobachten verlangt anders als
/// bei Tastatureingaben keine Bedienungshilfen-Berechtigung.
///
/// **Abfrage**, solange es offen ist. Über dem eigenen Panel schweigen beide
/// Monitore: der globale feuert nur für fremde Programme, der lokale nur wenn
/// Onyx aktiv ist was es als `.accessory`-App nie ist. Siehe `updatePolling()`.
@MainActor @MainActor
public final class NotchCoordinator { public final class NotchCoordinator {
@@ -22,6 +28,8 @@ public final class NotchCoordinator {
private var controllers: [CGDirectDisplayID: NotchScreenController] = [:] private var controllers: [CGDirectDisplayID: NotchScreenController] = [:]
private var monitors: [Any] = [] private var monitors: [Any] = []
private var suppressionTimer: Timer? private var suppressionTimer: Timer?
/// Läuft nur, solange ein Panel offen ist. Siehe `startPolling()`.
private var pointerPollTimer: Timer?
/// Baut den Inhalt für einen Bildschirm. Ab Phase 2 liefert das die Widget-Ebene. /// Baut den Inhalt für einen Bildschirm. Ab Phase 2 liefert das die Widget-Ebene.
/// Die `NotchPresentation` kommt mit, weil der Inhalt in den Animationsrahmen /// Die `NotchPresentation` kommt mit, weil der Inhalt in den Animationsrahmen
@@ -58,6 +66,8 @@ public final class NotchCoordinator {
monitors.removeAll() monitors.removeAll()
suppressionTimer?.invalidate() suppressionTimer?.invalidate()
suppressionTimer = nil suppressionTimer = nil
pointerPollTimer?.invalidate()
pointerPollTimer = nil
NotificationCenter.default.removeObserver(self) NotificationCenter.default.removeObserver(self)
controllers.values.forEach { $0.tearDown() } controllers.values.forEach { $0.tearDown() }
controllers.removeAll() controllers.removeAll()
@@ -83,10 +93,14 @@ public final class NotchCoordinator {
existing.update(metrics: metrics, virtualSize: virtualNotchSize) existing.update(metrics: metrics, virtualSize: virtualNotchSize)
} else { } else {
let id = metrics.displayID let id = metrics.displayID
controllers[id] = NotchScreenController( let controller = NotchScreenController(
metrics: metrics, metrics: metrics,
virtualSize: virtualNotchSize, virtualSize: virtualNotchSize,
makeContent: { presentation in makeContent(id, presentation) }) makeContent: { presentation in makeContent(id, presentation) })
controller.onVisibilityChanged = { [weak self] _ in
self?.updatePolling()
}
controllers[id] = controller
} }
} }
refreshSuppression() refreshSuppression()
@@ -136,6 +150,35 @@ public final class NotchCoordinator {
} }
} }
/// Fragt die Zeigerposition ab, solange ein Panel offen ist.
///
/// Über dem eigenen Panel bekommt Onyx sonst gar nichts mit: der globale
/// Monitor feuert nur für Ereignisse, die an **fremde** Programme gehen, und
/// der lokale nur, wenn Onyx das aktive Programm ist was es als
/// `.accessory`-App nie ist. Genau in dem Moment, in dem der Zeiger das
/// Panel wieder verlässt, käme das Ereignis also verspätet oder gar nicht,
/// und das Schließen wirkt hakelig.
///
/// Abfragen ist hier das ehrlichere Mittel als noch mehr Monitore: es läuft
/// nur, während wirklich etwas offen ist, und kostet dabei einen
/// Rechteckvergleich pro Bild.
private func updatePolling() {
let anyVisible = controllers.values.contains { $0.isVisible }
if anyVisible, pointerPollTimer == nil {
let timer = Timer(timeInterval: 1.0 / 60.0, repeats: true) { _ in
MainActor.assumeIsolated { [weak self] in self?.pointerMoved() }
}
// `.common` statt der Standard-Schleife: sonst steht die Abfrage,
// während ein Menü offen ist oder gescrollt wird.
RunLoop.main.add(timer, forMode: .common)
pointerPollTimer = timer
} else if !anyVisible {
pointerPollTimer?.invalidate()
pointerPollTimer = nil
}
}
private func refreshSuppression() { private func refreshSuppression() {
for screen in NSScreen.screens { for screen in NSScreen.screens {
controllers[screen.displayID]?.setSuppressed(screen.isShowingFullScreenWindow) controllers[screen.displayID]?.setSuppressed(screen.isShowingFullScreenWindow)

View File

@@ -37,7 +37,10 @@ public final class NotchPanel: NSPanel {
hasShadow = false hasShadow = false
isMovable = false isMovable = false
isMovableByWindowBackground = false isMovableByWindowBackground = false
// Kein Systemeinblenden: das Panel bringt seine eigene Animation mit,
// und beide übereinander sehen aus wie ein Ruckler.
animationBehavior = .none animationBehavior = .none
acceptsMouseMovedEvents = true
// Nicht in Bildschirmaufnahmen und Fensterlisten auftauchen. // Nicht in Bildschirmaufnahmen und Fensterlisten auftauchen.
isExcludedFromWindowsMenu = true isExcludedFromWindowsMenu = true

View File

@@ -29,8 +29,9 @@ public final class NotchPresentation {
} }
/// Wie lange das Einfahren braucht, bis das Fenster gefahrlos verschwinden darf. /// Wie lange das Einfahren braucht, bis das Fenster gefahrlos verschwinden darf.
/// Der Spring schwingt nach; zu früh entfernt reißt das Panel sichtbar weg. /// Muss zu `Onyx.Motion.collapse` passen: zu früh entfernt reißt das Panel
public static let collapseDuration: TimeInterval = 0.42 /// sichtbar weg, zu spät bleibt ein unsichtbares Fenster im Weg stehen.
public static let collapseDuration: TimeInterval = 0.30
} }
/// Der Rahmen, der aus der Notch herauswächst. /// Der Rahmen, der aus der Notch herauswächst.
@@ -60,40 +61,72 @@ public struct NotchContainer<Content: View>: View {
presentation.isExpanded ? Onyx.Metric.panelCornerRadius : 10 presentation.isExpanded ? Onyx.Metric.panelCornerRadius : 10
} }
private var shapeAnimation: Animation {
presentation.isExpanded ? Onyx.Motion.expand : Onyx.Motion.collapse
}
public var body: some View { public var body: some View {
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
ZStack(alignment: .top) { ZStack(alignment: .top) {
OnyxSurface(cornerRadius: cornerRadius) // Das Weichzeichner-Material behält **feste** Größe; animiert wird
// nur die Maske davor.
//
// Eine NSVisualEffectView in einem animierten Rahmen muss ihren
// 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.
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.strokeBorder(Onyx.Color.hairline, lineWidth: 1) }
.frame(width: size.width, height: size.height) .frame(width: size.width, height: size.height)
.shadow(color: .black.opacity(presentation.isExpanded ? 0.45 : 0), // Konstante Deckkraft: einen Schatten aufzublenden kostet in
radius: 22, y: 10) // jedem Einzelbild eine neue Weichzeichnung. Er schrumpft
.overlay(alignment: .top) { // ohnehin mit der Form mit.
.shadow(color: .black.opacity(0.45), radius: 22, y: 10)
content content
.frame(width: presentation.expandedSize.width, .frame(width: presentation.expandedSize.width,
height: presentation.expandedSize.height height: presentation.expandedSize.height
- presentation.contentTopInset, - presentation.contentTopInset,
alignment: .top) alignment: .top)
// Freiraum für die Notch. Padding statt Offset, damit // Freiraum für die Notch. Padding statt Offset, damit der
// der Inhalt auch beim Messen die richtige Höhe hat. // Inhalt auch beim Messen die richtige Höhe hat.
.padding(.top, presentation.contentTopInset) .padding(.top, presentation.contentTopInset)
.opacity(presentation.isExpanded ? 1 : 0) .opacity(presentation.isExpanded ? 1 : 0)
// Ein Hauch von oben herangleiten genug, um Richtung zu // Ein Hauch von oben herangleiten genug, um Richtung zu geben,
// geben, zu wenig, um als Bewegung aufzufallen. // zu wenig, um als Bewegung aufzufallen.
.offset(y: presentation.isExpanded ? 0 : -8) .offset(y: presentation.isExpanded ? 0 : -8)
.allowsHitTesting(presentation.isExpanded) .allowsHitTesting(presentation.isExpanded)
.clipped() .clipped()
} // Eigene Kurve: beim Öffnen verzögert, damit die Fläche vorweg
.animation(Onyx.Motion.expand, value: presentation.isExpanded) // läuft; beim Schließen sofort weg, damit beim Einfahren nichts
// gestaucht wird.
.animation(presentation.isExpanded
? .easeOut(duration: 0.20).delay(0.08)
: .easeIn(duration: 0.08),
value: presentation.isExpanded)
} }
.frame(width: presentation.expandedSize.width, .frame(width: presentation.expandedSize.width,
height: presentation.expandedSize.height, height: presentation.expandedSize.height,
alignment: .top) alignment: .top)
// Der Inhalt hat eine eigene Kurve: beim Öffnen verzögert, damit die .animation(shapeAnimation, value: presentation.isExpanded)
// Fläche vorweg läuft; beim Schließen sofort, damit nichts beim
// Einfahren gestaucht wird.
.animation(presentation.isExpanded
? .easeOut(duration: 0.22).delay(0.09)
: .easeIn(duration: 0.10),
value: presentation.isExpanded)
} }
} }

View File

@@ -56,6 +56,10 @@ public final class NotchScreenController {
public var phase: NotchPhase { machine.phase } public var phase: NotchPhase { machine.phase }
public var isVisible: Bool { machine.isVisible } public var isVisible: Bool { machine.isVisible }
/// Meldet, wenn das Panel sichtbar wird oder verschwindet. Der Koordinator
/// schaltet daran die Zeigerabfrage an und aus.
public var onVisibilityChanged: ((Bool) -> Void)?
// MARK: - Eingaben // MARK: - Eingaben
/// Zeigerposition in globalen Bildschirmkoordinaten. /// Zeigerposition in globalen Bildschirmkoordinaten.
@@ -66,12 +70,12 @@ public final class NotchScreenController {
// Fläche das Panel offen. // Fläche das Panel offen.
let inside = geometry.triggerRect.contains(point) let inside = geometry.triggerRect.contains(point)
|| (presentation.isExpanded && panel.frame.contains(point)) || (presentation.isExpanded && panel.frame.contains(point))
apply(machine.handle(inside ? .pointerEntered : .pointerExited)) send(inside ? .pointerEntered : .pointerExited)
} }
public func click() { apply(machine.handle(.click)) } public func click() { send(.click) }
public func escape() { apply(machine.handle(.escape)) } public func escape() { send(.escape) }
public func setSuppressed(_ suppressed: Bool) { apply(machine.handle(.suppressed(suppressed))) } public func setSuppressed(_ suppressed: Bool) { send(.suppressed(suppressed)) }
/// Nach einem Wechsel von Auflösung, Anordnung oder Skalierung. /// Nach einem Wechsel von Auflösung, Anordnung oder Skalierung.
public func update(metrics: ScreenMetrics, virtualSize: CGSize) { public func update(metrics: ScreenMetrics, virtualSize: CGSize) {
@@ -93,14 +97,23 @@ public final class NotchScreenController {
// MARK: - Wirkungen // MARK: - Wirkungen
/// Der einzige Weg, die Maschine zu bewegen.
///
/// 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
apply(machine.handle(input))
if machine.isVisible != wasVisible { onVisibilityChanged?(machine.isVisible) }
}
private func apply(_ effects: [NotchEffect]) { private func apply(_ effects: [NotchEffect]) {
for effect in effects { for effect in effects {
switch effect { switch effect {
case .startArmTimer(let delay): case .startArmTimer(let delay):
armTimer?.cancel() armTimer?.cancel()
armTimer = schedule(after: delay) { [weak self] in armTimer = schedule(after: delay) { [weak self] in
guard let self else { return } self?.send(.armTimerFired)
apply(machine.handle(.armTimerFired))
} }
case .cancelArmTimer: case .cancelArmTimer:
@@ -110,8 +123,7 @@ public final class NotchScreenController {
case .startCloseTimer(let delay): case .startCloseTimer(let delay):
closeTimer?.cancel() closeTimer?.cancel()
closeTimer = schedule(after: delay) { [weak self] in closeTimer = schedule(after: delay) { [weak self] in
guard let self else { return } self?.send(.closeTimerFired)
apply(machine.handle(.closeTimerFired))
} }
case .cancelCloseTimer: case .cancelCloseTimer: