diff --git a/Packages/OnyxKit/Sources/OnyxDesign/OnyxTokens.swift b/Packages/OnyxKit/Sources/OnyxDesign/OnyxTokens.swift index 1439936..9625a6f 100644 --- a/Packages/OnyxKit/Sources/OnyxDesign/OnyxTokens.swift +++ b/Packages/OnyxKit/Sources/OnyxDesign/OnyxTokens.swift @@ -45,9 +45,16 @@ public enum Onyx { // MARK: - Bewegung public enum Motion { - /// Ein einziger Spring für alles Ausfahren. Mehrere Kurven im selben - /// Panel wirken unruhig, egal wie gut jede für sich ist. + /// Ausfahren: ein Spring mit einem Hauch Nachschwingen. Das gibt dem + /// Panel Masse, statt es nur größer werden zu lassen. 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. public static let value = Animation.easeOut(duration: 0.25) } diff --git a/Packages/OnyxKit/Sources/OnyxNotch/NotchCoordinator.swift b/Packages/OnyxKit/Sources/OnyxNotch/NotchCoordinator.swift index 817363c..dfa6ee0 100644 --- a/Packages/OnyxKit/Sources/OnyxNotch/NotchCoordinator.swift +++ b/Packages/OnyxKit/Sources/OnyxNotch/NotchCoordinator.swift @@ -3,11 +3,17 @@ import AppKit /// Hält für jeden in Frage kommenden Bildschirm einen Controller und versorgt /// alle mit der Zeigerposition. /// -/// Die Zeigerposition kommt aus einem globalen Ereignismonitor statt aus einem -/// 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 -/// Fensterknöpfe darunter unbenutzbar machen. Mausbewegungen global zu beobachten -/// verlangt anders als bei Tastatureingaben keine Bedienungshilfen-Berechtigung. +/// Die Zeigerposition kommt aus zwei Quellen, und beide werden gebraucht: +/// +/// **Ereignismonitor**, solange das Panel zu ist. Ein unsichtbares Fenster über +/// der Notch wäre die naheliegende Alternative, müsste dafür aber Mausereignisse +/// 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 public final class NotchCoordinator { @@ -22,6 +28,8 @@ public final class NotchCoordinator { private var controllers: [CGDirectDisplayID: NotchScreenController] = [:] private var monitors: [Any] = [] 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. /// Die `NotchPresentation` kommt mit, weil der Inhalt in den Animationsrahmen @@ -58,6 +66,8 @@ public final class NotchCoordinator { monitors.removeAll() suppressionTimer?.invalidate() suppressionTimer = nil + pointerPollTimer?.invalidate() + pointerPollTimer = nil NotificationCenter.default.removeObserver(self) controllers.values.forEach { $0.tearDown() } controllers.removeAll() @@ -83,10 +93,14 @@ public final class NotchCoordinator { existing.update(metrics: metrics, virtualSize: virtualNotchSize) } else { let id = metrics.displayID - controllers[id] = NotchScreenController( + let controller = NotchScreenController( metrics: metrics, virtualSize: virtualNotchSize, makeContent: { presentation in makeContent(id, presentation) }) + controller.onVisibilityChanged = { [weak self] _ in + self?.updatePolling() + } + controllers[id] = controller } } 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() { for screen in NSScreen.screens { controllers[screen.displayID]?.setSuppressed(screen.isShowingFullScreenWindow) diff --git a/Packages/OnyxKit/Sources/OnyxNotch/NotchPanel.swift b/Packages/OnyxKit/Sources/OnyxNotch/NotchPanel.swift index 023e41a..3a0db40 100644 --- a/Packages/OnyxKit/Sources/OnyxNotch/NotchPanel.swift +++ b/Packages/OnyxKit/Sources/OnyxNotch/NotchPanel.swift @@ -37,7 +37,10 @@ public final class NotchPanel: NSPanel { hasShadow = false isMovable = false isMovableByWindowBackground = false + // Kein Systemeinblenden: das Panel bringt seine eigene Animation mit, + // und beide übereinander sehen aus wie ein Ruckler. animationBehavior = .none + acceptsMouseMovedEvents = true // Nicht in Bildschirmaufnahmen und Fensterlisten auftauchen. isExcludedFromWindowsMenu = true diff --git a/Packages/OnyxKit/Sources/OnyxNotch/NotchPresentation.swift b/Packages/OnyxKit/Sources/OnyxNotch/NotchPresentation.swift index a562039..594abaa 100644 --- a/Packages/OnyxKit/Sources/OnyxNotch/NotchPresentation.swift +++ b/Packages/OnyxKit/Sources/OnyxNotch/NotchPresentation.swift @@ -29,8 +29,9 @@ public final class NotchPresentation { } /// 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. - public static let collapseDuration: TimeInterval = 0.42 + /// 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 } /// Der Rahmen, der aus der Notch herauswächst. @@ -60,40 +61,72 @@ public struct NotchContainer: View { presentation.isExpanded ? Onyx.Metric.panelCornerRadius : 10 } + private var shapeAnimation: Animation { + presentation.isExpanded ? Onyx.Motion.expand : Onyx.Motion.collapse + } + public var body: some View { + let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + ZStack(alignment: .top) { - OnyxSurface(cornerRadius: cornerRadius) - .frame(width: size.width, height: size.height) - .shadow(color: .black.opacity(presentation.isExpanded ? 0.45 : 0), - radius: 22, y: 10) - .overlay(alignment: .top) { - content - .frame(width: presentation.expandedSize.width, - height: presentation.expandedSize.height - - presentation.contentTopInset, - alignment: .top) - // Freiraum für die Notch. Padding statt Offset, damit - // der Inhalt auch beim Messen die richtige Höhe hat. - .padding(.top, presentation.contentTopInset) - .opacity(presentation.isExpanded ? 1 : 0) - // Ein Hauch von oben herangleiten — genug, um Richtung zu - // geben, zu wenig, um als Bewegung aufzufallen. - .offset(y: presentation.isExpanded ? 0 : -8) - .allowsHitTesting(presentation.isExpanded) - .clipped() + // 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) } - .animation(Onyx.Motion.expand, value: presentation.isExpanded) + + // 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) + // 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) + + content + .frame(width: presentation.expandedSize.width, + height: presentation.expandedSize.height + - presentation.contentTopInset, + alignment: .top) + // Freiraum für die Notch. Padding statt Offset, damit der + // Inhalt auch beim Messen die richtige Höhe hat. + .padding(.top, presentation.contentTopInset) + .opacity(presentation.isExpanded ? 1 : 0) + // Ein Hauch von oben herangleiten — genug, um Richtung zu geben, + // zu wenig, um als Bewegung aufzufallen. + .offset(y: presentation.isExpanded ? 0 : -8) + .allowsHitTesting(presentation.isExpanded) + .clipped() + // Eigene Kurve: beim Öffnen verzögert, damit die Fläche vorweg + // 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, height: presentation.expandedSize.height, alignment: .top) - // Der Inhalt hat eine eigene Kurve: beim Öffnen verzögert, damit die - // 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) + .animation(shapeAnimation, value: presentation.isExpanded) } } diff --git a/Packages/OnyxKit/Sources/OnyxNotch/NotchScreenController.swift b/Packages/OnyxKit/Sources/OnyxNotch/NotchScreenController.swift index b1ce1f4..dc3853f 100644 --- a/Packages/OnyxKit/Sources/OnyxNotch/NotchScreenController.swift +++ b/Packages/OnyxKit/Sources/OnyxNotch/NotchScreenController.swift @@ -56,6 +56,10 @@ public final class NotchScreenController { public var phase: NotchPhase { machine.phase } 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 /// Zeigerposition in globalen Bildschirmkoordinaten. @@ -66,12 +70,12 @@ public final class NotchScreenController { // Fläche das Panel offen. let inside = geometry.triggerRect.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 escape() { apply(machine.handle(.escape)) } - public func setSuppressed(_ suppressed: Bool) { apply(machine.handle(.suppressed(suppressed))) } + public func click() { send(.click) } + public func escape() { send(.escape) } + public func setSuppressed(_ suppressed: Bool) { send(.suppressed(suppressed)) } /// Nach einem Wechsel von Auflösung, Anordnung oder Skalierung. public func update(metrics: ScreenMetrics, virtualSize: CGSize) { @@ -93,14 +97,23 @@ public final class NotchScreenController { // 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]) { for effect in effects { switch effect { case .startArmTimer(let delay): armTimer?.cancel() armTimer = schedule(after: delay) { [weak self] in - guard let self else { return } - apply(machine.handle(.armTimerFired)) + self?.send(.armTimerFired) } case .cancelArmTimer: @@ -110,8 +123,7 @@ public final class NotchScreenController { case .startCloseTimer(let delay): closeTimer?.cancel() closeTimer = schedule(after: delay) { [weak self] in - guard let self else { return } - apply(machine.handle(.closeTimerFired)) + self?.send(.closeTimerFired) } case .cancelCloseTimer: