Phase 1: Notch-Shell, Design-System, Menüleisten-Infrastruktur
Zustandsmaschine und Geometrie sind testgetrieben entstanden und tragen 40 Tests — sie sind frei von AppKit, damit jeder Übergang ohne Fenster und ohne Warten prüfbar ist. Zeit kommt nur als Ereignis herein. Zwei Entscheidungen, die im Code begründet sind: Der Zeiger wird über einen globalen Ereignismonitor verfolgt, nicht über ein unsichtbares Fenster auf der Notch. Ein solches Fenster müsste Mausereignisse annehmen, um sie zu bemerken, und würde damit Menüleiste und Fensterknöpfe darunter unbenutzbar machen. "Nur internes Display" fällt auf ein externes zurück, wenn kein eingebautes da ist. Am Dock mit geschlossenem Deckel hieße die Einstellung wörtlich genommen, dass Onyx unerreichbar wird. Design: NSVisualEffectView mit eigenem Tint statt Liquid Glass — Onyx ist Stein, kein Glas. Alle Farben und Maße liegen als Tokens in OnyxDesign. Menüleiste: jedes Modul bekommt ein eigenes NSStatusItem. Elemente, denen macOS mangels Platz keine Breite gibt, werden erkannt und gemeldet, statt still zu verschwinden. App-Target über XcodeGen, damit die Projektdefinition im Diff lesbar bleibt. Nicht sandboxed, mit App-Group-Entitlement — baut, startet, signiert mit PP34X97WS3.
This commit is contained in:
72
Packages/OnyxKit/Sources/OnyxDesign/OnyxSurface.swift
Normal file
72
Packages/OnyxKit/Sources/OnyxDesign/OnyxSurface.swift
Normal file
@@ -0,0 +1,72 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
/// Die Vibrancy-Ebene hinter dem Panel.
|
||||
///
|
||||
/// `.hudWindow` mit `behindWindow` lässt den Hintergrund durchscheinen, ohne ihn
|
||||
/// lesbar zu lassen. Der Tint darüber macht daraus schwarzen Stein statt grauem Glas.
|
||||
public struct OnyxVisualEffect: NSViewRepresentable {
|
||||
public init() {}
|
||||
|
||||
public func makeNSView(context: Context) -> NSVisualEffectView {
|
||||
let view = NSVisualEffectView()
|
||||
view.material = .hudWindow
|
||||
view.blendingMode = .behindWindow
|
||||
view.state = .active
|
||||
view.appearance = NSAppearance(named: .darkAqua)
|
||||
return view
|
||||
}
|
||||
|
||||
public func updateNSView(_ view: NSVisualEffectView, context: Context) {}
|
||||
}
|
||||
|
||||
/// Der Hintergrund des Panels: Vibrancy, Tint, ein sehr flacher Glanz von oben
|
||||
/// und eine Haarlinie als Innenkante.
|
||||
public struct OnyxSurface: View {
|
||||
private let cornerRadius: CGFloat
|
||||
|
||||
public init(cornerRadius: CGFloat = Onyx.Metric.panelCornerRadius) {
|
||||
self.cornerRadius = cornerRadius
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
|
||||
shape
|
||||
.fill(Onyx.Color.surface.opacity(Onyx.Color.surfaceOpacity))
|
||||
.background(OnyxVisualEffect().clipShape(shape))
|
||||
.overlay {
|
||||
// Die Bänderung des Steins: oben ein Hauch heller, nach 40 %
|
||||
// vollständig verlaufen. Mehr wirkt sofort billig.
|
||||
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: Onyx.Metric.hairlineWidth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Eine Kachel auf der Panelfläche.
|
||||
public struct OnyxTile<Content: View>: View {
|
||||
private let content: Content
|
||||
|
||||
public init(@ViewBuilder content: () -> Content) {
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
let shape = RoundedRectangle(cornerRadius: Onyx.Metric.tileCornerRadius, style: .continuous)
|
||||
|
||||
content
|
||||
.padding(12)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.background(shape.fill(Onyx.Color.elevated))
|
||||
.overlay(shape.strokeBorder(Onyx.Color.hairline, lineWidth: Onyx.Metric.hairlineWidth))
|
||||
}
|
||||
}
|
||||
67
Packages/OnyxKit/Sources/OnyxDesign/OnyxTokens.swift
Normal file
67
Packages/OnyxKit/Sources/OnyxDesign/OnyxTokens.swift
Normal file
@@ -0,0 +1,67 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Die einzige Quelle für Farben, Maße und Bewegung in Onyx.
|
||||
///
|
||||
/// Onyx ist ein tiefschwarzer Stein mit feiner Bänderung und mattem Glanz — kein
|
||||
/// Glas. Das Panel benutzt deshalb bewusst **nicht** die Liquid-Glass-APIs von
|
||||
/// macOS 26, sondern eine dunkle Vibrancy-Ebene mit eigenem Tint darüber.
|
||||
public enum Onyx {
|
||||
|
||||
// MARK: - Farben
|
||||
|
||||
public enum Color {
|
||||
/// Grundfläche des Panels.
|
||||
public static let surface = SwiftUI.Color(red: 0.043, green: 0.043, blue: 0.051)
|
||||
/// Kacheln auf der Grundfläche.
|
||||
public static let elevated = SwiftUI.Color(red: 0.078, green: 0.078, blue: 0.090)
|
||||
/// Innenkante — der halbe „edle" Effekt steckt in dieser einen Linie.
|
||||
public static let hairline = SwiftUI.Color.white.opacity(0.08)
|
||||
|
||||
public static let textPrimary = SwiftUI.Color(red: 0.949, green: 0.949, blue: 0.957)
|
||||
public static let textSecondary = SwiftUI.Color.white.opacity(0.58)
|
||||
public static let textTertiary = SwiftUI.Color.white.opacity(0.34)
|
||||
|
||||
/// Kühl und entsättigt. Farbe ist in Onyx ein Signal, keine Dekoration.
|
||||
public static let accent = SwiftUI.Color(red: 0.498, green: 0.659, blue: 1.0)
|
||||
public static let positive = SwiftUI.Color(red: 0.435, green: 0.812, blue: 0.592)
|
||||
public static let warning = SwiftUI.Color(red: 0.910, green: 0.706, blue: 0.290)
|
||||
public static let critical = SwiftUI.Color(red: 0.898, green: 0.404, blue: 0.361)
|
||||
|
||||
/// Deckkraft des Tints über der Vibrancy-Ebene. Darunter liest man den
|
||||
/// Hintergrund durch, darüber wirkt das Panel wie eine schwarze Platte.
|
||||
public static let surfaceOpacity: Double = 0.74
|
||||
}
|
||||
|
||||
// MARK: - Maße
|
||||
|
||||
public enum Metric {
|
||||
public static let panelCornerRadius: CGFloat = 26
|
||||
public static let tileCornerRadius: CGFloat = 16
|
||||
public static let panelPadding: CGFloat = 16
|
||||
public static let tileSpacing: CGFloat = 10
|
||||
public static let hairlineWidth: CGFloat = 1
|
||||
}
|
||||
|
||||
// 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.
|
||||
public static let expand = Animation.spring(response: 0.34, dampingFraction: 0.86)
|
||||
/// Für Messwerte: sichtbar, aber ohne Pulsieren.
|
||||
public static let value = Animation.easeOut(duration: 0.25)
|
||||
}
|
||||
|
||||
// MARK: - Typografie
|
||||
|
||||
public enum Font {
|
||||
public static let title = SwiftUI.Font.system(size: 13, weight: .semibold)
|
||||
public static let body = SwiftUI.Font.system(size: 12, weight: .regular)
|
||||
public static let caption = SwiftUI.Font.system(size: 10, weight: .medium)
|
||||
/// Für alle Messwerte — ohne feste Ziffernbreite zappeln die Zahlen.
|
||||
public static let metric = SwiftUI.Font.system(size: 20, weight: .medium)
|
||||
.monospacedDigit()
|
||||
public static let metricSmall = SwiftUI.Font.system(size: 11, weight: .medium)
|
||||
.monospacedDigit()
|
||||
}
|
||||
}
|
||||
138
Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarController.swift
Normal file
138
Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarController.swift
Normal file
@@ -0,0 +1,138 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// Verwaltet beliebig viele Menüleisten-Elemente, je eines pro aktiviertem Modul.
|
||||
///
|
||||
/// Die Reihenfolge merkt sich macOS selbst über `autosaveName`; Onyx speichert sie
|
||||
/// nicht doppelt. Was Onyx speichert, ist welche Module an sind und wie sie
|
||||
/// aussehen sollen.
|
||||
@MainActor
|
||||
public final class MenuBarController {
|
||||
|
||||
/// Ein Modul, dessen Element keine Breite bekommen hat.
|
||||
///
|
||||
/// Die Menüleiste ist endlich, und auf einem Notch-Display ist die Mitte
|
||||
/// zusätzlich blockiert. Ein Element, das dort nicht mehr hinpasst, wird von
|
||||
/// macOS kommentarlos weggelassen — für den Nutzer sieht das aus, als wäre
|
||||
/// die Einstellung wirkungslos. Deshalb wird der Fall erkannt und gemeldet.
|
||||
public private(set) var hiddenModuleIDs: Set<String> = []
|
||||
|
||||
/// Wird gerufen, wenn sich `hiddenModuleIDs` ändert — die Einstellungen
|
||||
/// zeigen daraufhin einen Hinweis am betroffenen Modul.
|
||||
public var onVisibilityChanged: ((Set<String>) -> Void)?
|
||||
|
||||
private struct Entry {
|
||||
let module: any MenuBarModule
|
||||
let item: NSStatusItem
|
||||
let popover: NSPopover
|
||||
var settings: MenuBarModuleSettings
|
||||
}
|
||||
|
||||
private var entries: [String: Entry] = [:]
|
||||
private var visibilityTimer: Timer?
|
||||
|
||||
public init() {}
|
||||
|
||||
public func start() {
|
||||
// Ob ein Element sichtbar ist, meldet AppKit nicht. Es bleibt nur,
|
||||
// gelegentlich nachzusehen.
|
||||
visibilityTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: true) { _ in
|
||||
MainActor.assumeIsolated { [weak self] in self?.checkVisibility() }
|
||||
}
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
visibilityTimer?.invalidate()
|
||||
visibilityTimer = nil
|
||||
for id in entries.keys { setEnabled(false, forModuleID: id) }
|
||||
}
|
||||
|
||||
// MARK: - Module
|
||||
|
||||
public func register(_ module: any MenuBarModule, settings: MenuBarModuleSettings) {
|
||||
guard entries[module.id] == nil else { return }
|
||||
|
||||
let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||
item.autosaveName = "onyx.menubar.\(module.id)"
|
||||
item.isVisible = false
|
||||
|
||||
let popover = NSPopover()
|
||||
popover.behavior = .transient
|
||||
popover.animates = true
|
||||
|
||||
entries[module.id] = Entry(module: module, item: item, popover: popover, settings: settings)
|
||||
apply(settings, toModuleID: module.id)
|
||||
}
|
||||
|
||||
public func setEnabled(_ enabled: Bool, forModuleID id: String) {
|
||||
guard var entry = entries[id] else { return }
|
||||
entry.settings.isEnabled = enabled
|
||||
entries[id] = entry
|
||||
apply(entry.settings, toModuleID: id)
|
||||
}
|
||||
|
||||
public func setPresentation(_ presentation: MenuBarPresentation, forModuleID id: String) {
|
||||
guard var entry = entries[id] else { return }
|
||||
entry.settings.presentation = presentation
|
||||
entries[id] = entry
|
||||
apply(entry.settings, toModuleID: id)
|
||||
}
|
||||
|
||||
public func settings(forModuleID id: String) -> MenuBarModuleSettings? {
|
||||
entries[id]?.settings
|
||||
}
|
||||
|
||||
private func apply(_ settings: MenuBarModuleSettings, toModuleID id: String) {
|
||||
guard let entry = entries[id] else { return }
|
||||
|
||||
entry.item.isVisible = settings.isEnabled
|
||||
|
||||
if settings.isEnabled {
|
||||
let view = entry.module.makeStatusView(presentation: settings.presentation)
|
||||
if let button = entry.item.button {
|
||||
button.subviews.forEach { $0.removeFromSuperview() }
|
||||
view.frame = button.bounds
|
||||
view.autoresizingMask = [.width, .height]
|
||||
button.addSubview(view)
|
||||
button.target = self
|
||||
button.action = #selector(statusItemClicked(_:))
|
||||
// Beide Tasten annehmen: rechts öffnet später das Kontextmenü.
|
||||
button.sendAction(on: [.leftMouseUp, .rightMouseUp])
|
||||
button.identifier = NSUserInterfaceItemIdentifier(id)
|
||||
}
|
||||
entry.item.length = view.fittingSize.width > 0 ? view.fittingSize.width : NSStatusItem.variableLength
|
||||
entry.module.activate()
|
||||
} else {
|
||||
entry.module.deactivate()
|
||||
}
|
||||
checkVisibility()
|
||||
}
|
||||
|
||||
// MARK: - Interaktion
|
||||
|
||||
@objc private func statusItemClicked(_ sender: NSStatusBarButton) {
|
||||
guard let id = sender.identifier?.rawValue, let entry = entries[id] else { return }
|
||||
|
||||
if entry.popover.isShown {
|
||||
entry.popover.performClose(nil)
|
||||
return
|
||||
}
|
||||
entry.popover.contentViewController = NSHostingController(
|
||||
rootView: entry.module.makePopoverView())
|
||||
entry.popover.show(relativeTo: sender.bounds, of: sender, preferredEdge: .minY)
|
||||
// Ohne das bleibt das Popover hinter dem aktiven Fenster.
|
||||
entry.popover.contentViewController?.view.window?.makeKey()
|
||||
}
|
||||
|
||||
private func checkVisibility() {
|
||||
var hidden = Set<String>()
|
||||
for (id, entry) in entries where entry.settings.isEnabled {
|
||||
// `isVisible` bleibt true, auch wenn das Element mangels Platz nicht
|
||||
// gezeichnet wird. Der verlässliche Hinweis ist ein Knopf ohne Fenster.
|
||||
if entry.item.button?.window == nil { hidden.insert(id) }
|
||||
}
|
||||
guard hidden != hiddenModuleIDs else { return }
|
||||
hiddenModuleIDs = hidden
|
||||
onVisibilityChanged?(hidden)
|
||||
}
|
||||
}
|
||||
64
Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarModule.swift
Normal file
64
Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarModule.swift
Normal file
@@ -0,0 +1,64 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// Wie ein Modul seinen Messwert in der Menüleiste zeichnet.
|
||||
public enum MenuBarPresentation: String, Codable, Sendable, CaseIterable, Identifiable {
|
||||
/// Nur die Zahl, z. B. `34 %` oder `↓ 2,4 MB/s`.
|
||||
case value
|
||||
/// Schmales Verlaufsdiagramm über die letzten Messungen.
|
||||
case graph
|
||||
/// Ein Balken je Cluster bzw. je Richtung.
|
||||
case bars
|
||||
case valueAndGraph
|
||||
/// Nur ein Glyph, nach Grenzwert eingefärbt.
|
||||
case symbol
|
||||
|
||||
public var id: String { rawValue }
|
||||
}
|
||||
|
||||
/// Die Menüleisten-Entsprechung zu einem Widget.
|
||||
///
|
||||
/// Ein Modul liefert nur Darstellung. Die Daten kommen aus demselben Provider,
|
||||
/// den auch das Panel-Widget benutzt — es gibt keine zweite Messschleife.
|
||||
@MainActor
|
||||
public protocol MenuBarModule: AnyObject {
|
||||
var id: String { get }
|
||||
var displayName: String { get }
|
||||
|
||||
/// Zeichnet den kompakten Zustand. Muss eine feste Breite je Darstellungsart
|
||||
/// liefern, sonst springt die ganze Menüleiste bei jedem Messwert.
|
||||
func makeStatusView(presentation: MenuBarPresentation) -> NSView
|
||||
|
||||
/// Der Inhalt des Popovers beim Klick.
|
||||
func makePopoverView() -> AnyView
|
||||
|
||||
/// Wird gerufen, wenn das Modul sichtbar wird bzw. verschwindet — hier
|
||||
/// meldet sich das Modul beim Provider an und wieder ab.
|
||||
func activate()
|
||||
func deactivate()
|
||||
}
|
||||
|
||||
/// Was der Nutzer je Modul eingestellt hat.
|
||||
public struct MenuBarModuleSettings: Codable, Sendable, Equatable {
|
||||
public var isEnabled: Bool
|
||||
public var presentation: MenuBarPresentation
|
||||
/// Abtastintervall in Sekunden, 1–10.
|
||||
public var refreshInterval: TimeInterval
|
||||
public var usesThresholdColors: Bool
|
||||
|
||||
public init(isEnabled: Bool = false,
|
||||
presentation: MenuBarPresentation = .value,
|
||||
refreshInterval: TimeInterval = 2,
|
||||
usesThresholdColors: Bool = true) {
|
||||
self.isEnabled = isEnabled
|
||||
self.presentation = presentation
|
||||
self.refreshInterval = refreshInterval.clamped(to: 1...10)
|
||||
self.usesThresholdColors = usesThresholdColors
|
||||
}
|
||||
}
|
||||
|
||||
extension TimeInterval {
|
||||
func clamped(to range: ClosedRange<TimeInterval>) -> TimeInterval {
|
||||
Swift.min(Swift.max(self, range.lowerBound), range.upperBound)
|
||||
}
|
||||
}
|
||||
35
Packages/OnyxKit/Sources/OnyxNotch/NSScreen+Metrics.swift
Normal file
35
Packages/OnyxKit/Sources/OnyxNotch/NSScreen+Metrics.swift
Normal file
@@ -0,0 +1,35 @@
|
||||
import AppKit
|
||||
|
||||
public extension NSScreen {
|
||||
|
||||
/// Die `CGDirectDisplayID` dieses Bildschirms. Anders als der Index in
|
||||
/// `NSScreen.screens` überlebt sie ein Umstecken oder Umsortieren.
|
||||
var displayID: CGDirectDisplayID {
|
||||
deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? CGDirectDisplayID ?? 0
|
||||
}
|
||||
|
||||
var isBuiltInDisplay: Bool {
|
||||
CGDisplayIsBuiltin(displayID) != 0
|
||||
}
|
||||
|
||||
/// Reduziert den Bildschirm auf genau die Werte, die die Geometrie braucht.
|
||||
/// Alles Weitere bleibt draußen, damit `NotchGeometry` ohne Fenstersystem
|
||||
/// prüfbar bleibt.
|
||||
var onyxMetrics: ScreenMetrics {
|
||||
ScreenMetrics(frame: frame,
|
||||
safeAreaTop: safeAreaInsets.top,
|
||||
auxiliaryTopLeft: auxiliaryTopLeftArea,
|
||||
auxiliaryTopRight: auxiliaryTopRightArea,
|
||||
isBuiltIn: isBuiltInDisplay,
|
||||
displayID: displayID)
|
||||
}
|
||||
|
||||
/// Verdeckt gerade ein Vollbildfenster die Menüleiste?
|
||||
///
|
||||
/// Es gibt keine öffentliche API, die „hier läuft ein Vollbildvideo" meldet.
|
||||
/// Der verlässliche Hinweis ist die verschwundene Menüleiste: bei echtem
|
||||
/// Vollbild reicht `visibleFrame` bis an die Oberkante von `frame`.
|
||||
var isShowingFullScreenWindow: Bool {
|
||||
frame.maxY - visibleFrame.maxY < 1
|
||||
}
|
||||
}
|
||||
159
Packages/OnyxKit/Sources/OnyxNotch/NotchCoordinator.swift
Normal file
159
Packages/OnyxKit/Sources/OnyxNotch/NotchCoordinator.swift
Normal file
@@ -0,0 +1,159 @@
|
||||
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.
|
||||
@MainActor
|
||||
public final class NotchCoordinator {
|
||||
|
||||
public var policy: DisplayPolicy {
|
||||
didSet { guard policy != oldValue else { return }; rebuild() }
|
||||
}
|
||||
|
||||
public var virtualNotchSize: CGSize {
|
||||
didSet { guard virtualNotchSize != oldValue else { return }; rebuild() }
|
||||
}
|
||||
|
||||
private var controllers: [CGDirectDisplayID: NotchScreenController] = [:]
|
||||
private var monitors: [Any] = []
|
||||
private var suppressionTimer: Timer?
|
||||
|
||||
/// Baut den Inhalt für einen Bildschirm. Ab Phase 2 liefert das die Widget-Ebene.
|
||||
private let makeContent: (CGDirectDisplayID) -> NSView
|
||||
|
||||
public init(policy: DisplayPolicy = .builtInOnly,
|
||||
virtualNotchSize: CGSize = CGSize(width: 220, height: 32),
|
||||
makeContent: @escaping (CGDirectDisplayID) -> NSView) {
|
||||
self.policy = policy
|
||||
self.virtualNotchSize = virtualNotchSize
|
||||
self.makeContent = makeContent
|
||||
}
|
||||
|
||||
// MARK: - Lebenszyklus
|
||||
|
||||
public func start() {
|
||||
rebuild()
|
||||
installMonitors()
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(screensChanged),
|
||||
name: NSApplication.didChangeScreenParametersNotification, object: nil)
|
||||
|
||||
// Vollbild lässt sich nicht über eine Benachrichtigung erfragen; es
|
||||
// bleibt nur, gelegentlich nachzusehen. Zwei Sekunden reichen — das
|
||||
// Panel ist ohnehin zu, solange niemand die Notch berührt.
|
||||
suppressionTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in
|
||||
MainActor.assumeIsolated { [weak self] in self?.refreshSuppression() }
|
||||
}
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
monitors.forEach(NSEvent.removeMonitor)
|
||||
monitors.removeAll()
|
||||
suppressionTimer?.invalidate()
|
||||
suppressionTimer = nil
|
||||
NotificationCenter.default.removeObserver(self)
|
||||
controllers.values.forEach { $0.tearDown() }
|
||||
controllers.removeAll()
|
||||
}
|
||||
|
||||
// MARK: - Bildschirme
|
||||
|
||||
@objc private func screensChanged() { rebuild() }
|
||||
|
||||
private func rebuild() {
|
||||
let wanted = policy.screens(from: NSScreen.screens.map(\.onyxMetrics))
|
||||
let wantedIDs = Set(wanted.map(\.displayID))
|
||||
|
||||
for (id, controller) in controllers where !wantedIDs.contains(id) {
|
||||
controller.tearDown()
|
||||
controllers[id] = nil
|
||||
}
|
||||
|
||||
for metrics in wanted {
|
||||
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)
|
||||
} else {
|
||||
controllers[metrics.displayID] = NotchScreenController(
|
||||
metrics: metrics,
|
||||
virtualSize: virtualNotchSize,
|
||||
content: makeContent(metrics.displayID))
|
||||
}
|
||||
}
|
||||
refreshSuppression()
|
||||
}
|
||||
|
||||
// MARK: - Ereignisse
|
||||
|
||||
private func installMonitors() {
|
||||
let moved: NSEvent.EventTypeMask = [.mouseMoved, .leftMouseDragged, .rightMouseDragged]
|
||||
|
||||
if let global = NSEvent.addGlobalMonitorForEvents(matching: moved, handler: { _ in
|
||||
MainActor.assumeIsolated { [weak self] in self?.pointerMoved() }
|
||||
}) { monitors.append(global) }
|
||||
|
||||
// Der globale Monitor schweigt, solange Onyx selbst aktiv ist — dann
|
||||
// greift dieser hier, sonst schließt das Panel unter dem eigenen Zeiger.
|
||||
if let local = NSEvent.addLocalMonitorForEvents(matching: moved, handler: { event in
|
||||
MainActor.assumeIsolated { [weak self] in self?.pointerMoved() }
|
||||
return event
|
||||
}) { monitors.append(local) }
|
||||
|
||||
if let clicks = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown], handler: { event in
|
||||
MainActor.assumeIsolated { [weak self] in
|
||||
guard let self else { return }
|
||||
let point = NSEvent.mouseLocation
|
||||
for controller in controllers.values
|
||||
where controller.isVisible && controller.geometry.triggerRect.contains(point) {
|
||||
controller.click()
|
||||
}
|
||||
}
|
||||
return event
|
||||
}) { monitors.append(clicks) }
|
||||
|
||||
if let keys = NSEvent.addLocalMonitorForEvents(matching: [.keyDown], handler: { event in
|
||||
guard event.keyCode == 53 else { return event } // esc
|
||||
MainActor.assumeIsolated { [weak self] in
|
||||
self?.controllers.values.forEach { $0.escape() }
|
||||
}
|
||||
return nil
|
||||
}) { monitors.append(keys) }
|
||||
}
|
||||
|
||||
private func pointerMoved() {
|
||||
let point = NSEvent.mouseLocation
|
||||
for controller in controllers.values {
|
||||
controller.pointerMoved(to: point)
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshSuppression() {
|
||||
for screen in NSScreen.screens {
|
||||
controllers[screen.displayID]?.setSuppressed(screen.isShowingFullScreenWindow)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Zugriff für die App
|
||||
|
||||
public var activeControllers: [NotchScreenController] { Array(controllers.values) }
|
||||
|
||||
public func controller(for displayID: CGDirectDisplayID) -> NotchScreenController? {
|
||||
controllers[displayID]
|
||||
}
|
||||
|
||||
/// Öffnet das Panel auf dem Bildschirm, auf dem gerade der Zeiger steht —
|
||||
/// der Weg, über den das Menüleisten-Icon das Panel aufruft.
|
||||
public func togglePanelUnderPointer() {
|
||||
let point = NSEvent.mouseLocation
|
||||
guard let screen = NSScreen.screens.first(where: { $0.frame.contains(point) })
|
||||
?? NSScreen.main,
|
||||
let controller = controllers[screen.displayID] else { return }
|
||||
controller.click()
|
||||
}
|
||||
}
|
||||
146
Packages/OnyxKit/Sources/OnyxNotch/NotchGeometry.swift
Normal file
146
Packages/OnyxKit/Sources/OnyxNotch/NotchGeometry.swift
Normal file
@@ -0,0 +1,146 @@
|
||||
import CoreGraphics
|
||||
|
||||
/// Die Bildschirmwerte, aus denen sich die Notch ableiten lässt — als eigener
|
||||
/// Typ, damit die Geometrie ohne echten `NSScreen` prüfbar bleibt.
|
||||
public struct ScreenMetrics: Equatable, Sendable, Identifiable {
|
||||
public let frame: CGRect
|
||||
public let safeAreaTop: CGFloat
|
||||
public let auxiliaryTopLeft: CGRect?
|
||||
public let auxiliaryTopRight: CGRect?
|
||||
public let isBuiltIn: Bool
|
||||
/// `CGDirectDisplayID` — stabil über Anordnungswechsel hinweg, anders als
|
||||
/// der Index in `NSScreen.screens`.
|
||||
public let displayID: UInt32
|
||||
|
||||
public var id: UInt32 { displayID }
|
||||
|
||||
public init(frame: CGRect,
|
||||
safeAreaTop: CGFloat,
|
||||
auxiliaryTopLeft: CGRect?,
|
||||
auxiliaryTopRight: CGRect?,
|
||||
isBuiltIn: Bool = false,
|
||||
displayID: UInt32 = 0) {
|
||||
self.frame = frame
|
||||
self.safeAreaTop = safeAreaTop
|
||||
self.auxiliaryTopLeft = auxiliaryTopLeft
|
||||
self.auxiliaryTopRight = auxiliaryTopRight
|
||||
self.isBuiltIn = isBuiltIn
|
||||
self.displayID = displayID
|
||||
}
|
||||
}
|
||||
|
||||
/// Auf welchen Displays Onyx überhaupt erscheint.
|
||||
public enum DisplayPolicy: String, Codable, Sendable, CaseIterable {
|
||||
/// Nur das eingebaute Display.
|
||||
case builtInOnly
|
||||
/// Jedes angeschlossene Display, externe mit virtueller Notch.
|
||||
case allDisplays
|
||||
|
||||
public func screens(from available: [ScreenMetrics]) -> [ScreenMetrics] {
|
||||
switch self {
|
||||
case .allDisplays:
|
||||
return available
|
||||
case .builtInOnly:
|
||||
let builtIn = available.filter(\.isBuiltIn)
|
||||
// Am Dock mit geschlossenem Deckel gibt es kein eingebautes Display.
|
||||
// Diese Einstellung beim Wort zu nehmen hieße, Onyx unerreichbar zu
|
||||
// machen — deshalb ein einzelnes Ersatzdisplay statt gar keinem.
|
||||
guard builtIn.isEmpty else { return builtIn }
|
||||
return Array(available.prefix(1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wo die Notch sitzt und wo das Panel dazu hingehört.
|
||||
///
|
||||
/// Auf dem eingebauten Display wird die echte Notch aus den beiden Hilfsflächen
|
||||
/// links und rechts davon berechnet. Auf allen anderen Displays tritt ein
|
||||
/// virtueller Ersatz an dieselbe Stelle, damit Onyx dort überhaupt erreichbar ist.
|
||||
public struct NotchGeometry: Equatable, Sendable {
|
||||
|
||||
public enum Kind: Equatable, Sendable {
|
||||
/// Aus `auxiliaryTopLeftArea` / `auxiliaryTopRightArea` abgeleitet.
|
||||
case physical
|
||||
/// Frei gesetzter Balken oben mittig.
|
||||
case virtual
|
||||
}
|
||||
|
||||
public let kind: Kind
|
||||
/// Die Notch selbst, in Bildschirmkoordinaten (Ursprung unten links).
|
||||
public let rect: CGRect
|
||||
public let screenFrame: CGRect
|
||||
|
||||
/// Wie weit die Auslösefläche über die Notch hinausreicht. Ohne diesen Rand
|
||||
/// muss man die Notch pixelgenau treffen, und die unteren Bildpunkte der
|
||||
/// Notch selbst nimmt die Menüleiste bereits für sich in Anspruch.
|
||||
public static let triggerInsetX: CGFloat = 16
|
||||
public static let triggerInsetY: CGFloat = 4
|
||||
|
||||
public init(kind: Kind, rect: CGRect, screenFrame: CGRect) {
|
||||
self.kind = kind
|
||||
self.rect = rect
|
||||
self.screenFrame = screenFrame
|
||||
}
|
||||
|
||||
public static func resolve(_ metrics: ScreenMetrics, virtualSize: CGSize) -> 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),
|
||||
screenFrame: metrics.frame)
|
||||
}
|
||||
}
|
||||
|
||||
let width = min(virtualSize.width, metrics.frame.width)
|
||||
return NotchGeometry(
|
||||
kind: .virtual,
|
||||
rect: CGRect(x: metrics.frame.midX - width / 2,
|
||||
y: metrics.frame.maxY - virtualSize.height,
|
||||
width: width,
|
||||
height: virtualSize.height),
|
||||
screenFrame: metrics.frame)
|
||||
}
|
||||
|
||||
/// Die Fläche, deren Betreten das Panel scharf schaltet.
|
||||
public var triggerRect: CGRect {
|
||||
rect
|
||||
.insetBy(dx: -Self.triggerInsetX, dy: -Self.triggerInsetY)
|
||||
.clamped(to: screenFrame)
|
||||
}
|
||||
|
||||
/// Wohin das Panel gehört: auf die Notch zentriert, oben bündig mit dem
|
||||
/// Bildschirmrand — es wächst aus der Notch heraus, statt darunter zu schweben.
|
||||
public func panelRect(for size: CGSize) -> CGRect {
|
||||
let width = min(size.width, screenFrame.width)
|
||||
let height = min(size.height, screenFrame.height)
|
||||
|
||||
return CGRect(x: rect.midX - width / 2,
|
||||
y: screenFrame.maxY - height,
|
||||
width: width,
|
||||
height: height)
|
||||
.clamped(to: screenFrame)
|
||||
}
|
||||
}
|
||||
|
||||
extension CGRect {
|
||||
/// Schiebt das Rechteck in die Grenzen hinein, statt es abzuschneiden.
|
||||
/// Ein zurechtgestutztes Panel wäre halb unsichtbar; ein verschobenes ist
|
||||
/// vollständig da.
|
||||
func clamped(to bounds: CGRect) -> CGRect {
|
||||
let width = Swift.min(width, bounds.width)
|
||||
let height = Swift.min(height, bounds.height)
|
||||
let x = Swift.min(Swift.max(minX, bounds.minX), bounds.maxX - width)
|
||||
let y = Swift.min(Swift.max(minY, bounds.minY), bounds.maxY - height)
|
||||
return CGRect(x: x, y: y, width: width, height: height)
|
||||
}
|
||||
}
|
||||
68
Packages/OnyxKit/Sources/OnyxNotch/NotchPanel.swift
Normal file
68
Packages/OnyxKit/Sources/OnyxNotch/NotchPanel.swift
Normal file
@@ -0,0 +1,68 @@
|
||||
import AppKit
|
||||
|
||||
/// Das Fenster, in dem das Notch-Panel lebt.
|
||||
///
|
||||
/// Zwei Anforderungen kollidieren hier: es muss über allem liegen, auch über der
|
||||
/// Menüleiste — und es darf der aktiven App niemals den Fokus wegnehmen. Wer beim
|
||||
/// Blick auf den Kalender den Cursor aus seinem Editor verliert, benutzt das
|
||||
/// Panel kein zweites Mal.
|
||||
public final class NotchPanel: NSPanel {
|
||||
|
||||
public init(contentRect: NSRect) {
|
||||
super.init(contentRect: contentRect,
|
||||
styleMask: [.borderless, .nonactivatingPanel],
|
||||
backing: .buffered,
|
||||
defer: false)
|
||||
|
||||
isFloatingPanel = true
|
||||
// Ohne das wird das Panel beim ersten Klick zum Schlüsselfenster und
|
||||
// entzieht der aktiven App den Fokus.
|
||||
becomesKeyOnlyIfNeeded = true
|
||||
hidesOnDeactivate = false
|
||||
|
||||
// Über der Menüleiste, aber unter Bildschirmschoner und Anmeldefenster.
|
||||
level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.statusWindow)) + 1)
|
||||
|
||||
// Auf allen Schreibtischen sichtbar und auch neben Vollbildfenstern,
|
||||
// ohne beim Umschalten mitzuwandern.
|
||||
collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary,
|
||||
.stationary, .ignoresCycle]
|
||||
|
||||
backgroundColor = .clear
|
||||
isOpaque = false
|
||||
hasShadow = true
|
||||
isMovable = false
|
||||
isMovableByWindowBackground = false
|
||||
animationBehavior = .none
|
||||
|
||||
// Nicht in Bildschirmaufnahmen und Fensterlisten auftauchen.
|
||||
isExcludedFromWindowsMenu = true
|
||||
}
|
||||
|
||||
/// Randlose Fenster gelten sonst als nicht fokussierbar — das bräuchten wir
|
||||
/// aber, damit `esc` im fixierten Zustand ankommt.
|
||||
public override var canBecomeKey: Bool { true }
|
||||
public override var canBecomeMain: Bool { false }
|
||||
}
|
||||
|
||||
/// Der unsichtbare Fühler über der Notch.
|
||||
///
|
||||
/// Er zeichnet nichts und nimmt keine Klicks an — `ignoresMouseEvents` bleibt an,
|
||||
/// sonst würden die Menüleiste und die Fensterknöpfe darunter unerreichbar.
|
||||
/// Die Zeigerposition kommt aus einem globalen Monitor, nicht aus diesem Fenster;
|
||||
/// das Fenster dient nur der Sichtprüfung beim Entwickeln.
|
||||
public final class NotchDebugOverlay: NSPanel {
|
||||
|
||||
public init(frame: NSRect) {
|
||||
super.init(contentRect: frame,
|
||||
styleMask: [.borderless, .nonactivatingPanel],
|
||||
backing: .buffered,
|
||||
defer: false)
|
||||
level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.statusWindow)) + 2)
|
||||
collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
|
||||
backgroundColor = NSColor.systemPink.withAlphaComponent(0.25)
|
||||
isOpaque = false
|
||||
hasShadow = false
|
||||
ignoresMouseEvents = true
|
||||
}
|
||||
}
|
||||
106
Packages/OnyxKit/Sources/OnyxNotch/NotchScreenController.swift
Normal file
106
Packages/OnyxKit/Sources/OnyxNotch/NotchScreenController.swift
Normal file
@@ -0,0 +1,106 @@
|
||||
import AppKit
|
||||
|
||||
/// Führt für genau einen Bildschirm Geometrie, Zustandsmaschine und Fenster zusammen.
|
||||
///
|
||||
/// Die Entscheidungen trifft `NotchStateMachine`; diese Klasse übersetzt sie nur
|
||||
/// in Timer und Fensteraufrufe. Deshalb steht hier auch kein `if phase == …`.
|
||||
@MainActor
|
||||
public final class NotchScreenController {
|
||||
|
||||
public private(set) var geometry: NotchGeometry
|
||||
public let displayID: CGDirectDisplayID
|
||||
|
||||
private var machine = NotchStateMachine()
|
||||
private let panel: NotchPanel
|
||||
private var armTimer: DispatchWorkItem?
|
||||
private var closeTimer: DispatchWorkItem?
|
||||
|
||||
/// Größe des ausgefahrenen Panels. Ab Phase 2 liefert das die Layout-Engine.
|
||||
public var expandedSize = CGSize(width: 640, height: 320) {
|
||||
didSet { if machine.isVisible { layoutPanel() } }
|
||||
}
|
||||
|
||||
public init(metrics: ScreenMetrics, virtualSize: CGSize, content: NSView) {
|
||||
self.displayID = metrics.displayID
|
||||
self.geometry = NotchGeometry.resolve(metrics, virtualSize: virtualSize)
|
||||
self.panel = NotchPanel(contentRect: .zero)
|
||||
panel.contentView = content
|
||||
layoutPanel()
|
||||
}
|
||||
|
||||
public var phase: NotchPhase { machine.phase }
|
||||
public var isVisible: Bool { machine.isVisible }
|
||||
|
||||
// MARK: - Eingaben
|
||||
|
||||
/// Zeigerposition in globalen Bildschirmkoordinaten.
|
||||
public func pointerMoved(to point: CGPoint) {
|
||||
let inside = geometry.triggerRect.contains(point)
|
||||
|| (machine.isVisible && panel.frame.contains(point))
|
||||
apply(machine.handle(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))) }
|
||||
|
||||
/// Nach einem Wechsel von Auflösung, Anordnung oder Skalierung.
|
||||
public func update(metrics: ScreenMetrics, virtualSize: CGSize) {
|
||||
geometry = NotchGeometry.resolve(metrics, virtualSize: virtualSize)
|
||||
layoutPanel()
|
||||
}
|
||||
|
||||
public func tearDown() {
|
||||
armTimer?.cancel()
|
||||
closeTimer?.cancel()
|
||||
panel.orderOut(nil)
|
||||
}
|
||||
|
||||
// MARK: - Wirkungen
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
case .cancelArmTimer:
|
||||
armTimer?.cancel()
|
||||
armTimer = nil
|
||||
|
||||
case .startCloseTimer(let delay):
|
||||
closeTimer?.cancel()
|
||||
closeTimer = schedule(after: delay) { [weak self] in
|
||||
guard let self else { return }
|
||||
apply(machine.handle(.closeTimerFired))
|
||||
}
|
||||
|
||||
case .cancelCloseTimer:
|
||||
closeTimer?.cancel()
|
||||
closeTimer = nil
|
||||
|
||||
case .show:
|
||||
layoutPanel()
|
||||
panel.orderFrontRegardless()
|
||||
|
||||
case .hide:
|
||||
panel.orderOut(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func schedule(after delay: TimeInterval, _ body: @escaping @MainActor () -> Void)
|
||||
-> DispatchWorkItem {
|
||||
let item = DispatchWorkItem { MainActor.assumeIsolated { body() } }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: item)
|
||||
return item
|
||||
}
|
||||
|
||||
private func layoutPanel() {
|
||||
panel.setFrame(geometry.panelRect(for: expandedSize), display: machine.isVisible)
|
||||
}
|
||||
}
|
||||
154
Packages/OnyxKit/Sources/OnyxNotch/NotchStateMachine.swift
Normal file
154
Packages/OnyxKit/Sources/OnyxNotch/NotchStateMachine.swift
Normal file
@@ -0,0 +1,154 @@
|
||||
import Foundation
|
||||
|
||||
/// Sichtbarer Zustand des Notch-Panels.
|
||||
public enum NotchPhase: Equatable, Sendable {
|
||||
/// Panel unsichtbar, nichts läuft.
|
||||
case idle
|
||||
/// Zeiger ist in der Notch, die Entprellzeit läuft. Panel noch unsichtbar.
|
||||
case arming
|
||||
/// Panel sichtbar, folgt dem Zeiger.
|
||||
case open
|
||||
/// Panel sichtbar und festgehalten — der Zeiger darf weg.
|
||||
case pinned
|
||||
/// Zeiger ist draußen, die Nachlauffrist läuft. Panel noch sichtbar.
|
||||
case closing
|
||||
}
|
||||
|
||||
/// Alles, was die Maschine bewegen kann. Zeit kommt nur als Ereignis herein,
|
||||
/// damit sich jeder Übergang ohne Warten prüfen lässt.
|
||||
public enum NotchInput: Equatable, Sendable {
|
||||
case pointerEntered
|
||||
case pointerExited
|
||||
case click
|
||||
case escape
|
||||
case armTimerFired
|
||||
case closeTimerFired
|
||||
/// Vollbildvideo, „Nicht stören" oder ein anderer Grund, das Panel wegzuhalten.
|
||||
case suppressed(Bool)
|
||||
}
|
||||
|
||||
/// Was der Aufrufer nach einem Übergang zu tun hat. Die Maschine selbst
|
||||
/// berührt weder Fenster noch Timer.
|
||||
public enum NotchEffect: Equatable, Sendable {
|
||||
case startArmTimer(TimeInterval)
|
||||
case cancelArmTimer
|
||||
case startCloseTimer(TimeInterval)
|
||||
case cancelCloseTimer
|
||||
case show
|
||||
case hide
|
||||
}
|
||||
|
||||
/// Das Öffnungs- und Schließverhalten des Notch-Panels, frei von AppKit.
|
||||
///
|
||||
/// Zwei Eigenschaften sind wichtiger als sie aussehen:
|
||||
///
|
||||
/// **Entprellung.** Der Zeiger streift die Notch ständig beiläufig — auf dem Weg
|
||||
/// zur Menüleiste, zu den Fensterknöpfen. Ohne Wartezeit klappt das Panel dabei
|
||||
/// dauernd auf. Deshalb `arming` als eigener Zustand.
|
||||
///
|
||||
/// **Nachlauffrist.** Wer vom oberen Rand diagonal zu einem Widget zieht, verlässt
|
||||
/// die Notch-Zone kurz. Ohne Frist schließt das Panel genau in dem Moment, in dem
|
||||
/// man es benutzen will. Deshalb `closing` als eigener Zustand.
|
||||
public struct NotchStateMachine: Equatable, Sendable {
|
||||
|
||||
/// Wartezeit, bevor ein Hover als Absicht gilt.
|
||||
public static let armDelay: TimeInterval = 0.22
|
||||
/// Nachlauffrist, bevor ein verlassenes Panel schließt.
|
||||
public static let closeDelay: TimeInterval = 0.30
|
||||
|
||||
public private(set) var phase: NotchPhase = .idle
|
||||
public private(set) var isSuppressed: Bool = false
|
||||
|
||||
public init() {}
|
||||
|
||||
/// Sichtbar, egal aus welchem Grund.
|
||||
public var isVisible: Bool {
|
||||
switch phase {
|
||||
case .open, .pinned, .closing: true
|
||||
case .idle, .arming: false
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public mutating func handle(_ input: NotchInput) -> [NotchEffect] {
|
||||
// Unterdrückung schlägt jeden anderen Zustand, auch die Fixierung.
|
||||
// Ein angeheftetes Panel über einem Vollbildvideo wäre sonst nicht
|
||||
// mehr wegzubekommen, ohne das Video zu verlassen.
|
||||
if case .suppressed(let active) = input {
|
||||
guard active != isSuppressed else { return [] }
|
||||
isSuppressed = active
|
||||
|
||||
guard active else { return [] }
|
||||
let wasVisible = isVisible
|
||||
phase = .idle
|
||||
return wasVisible
|
||||
? [.cancelArmTimer, .cancelCloseTimer, .hide]
|
||||
: [.cancelArmTimer, .cancelCloseTimer]
|
||||
}
|
||||
|
||||
guard !isSuppressed else { return [] }
|
||||
|
||||
switch (phase, input) {
|
||||
|
||||
// Öffnen
|
||||
case (.idle, .pointerEntered):
|
||||
phase = .arming
|
||||
return [.startArmTimer(Self.armDelay)]
|
||||
|
||||
case (.arming, .armTimerFired):
|
||||
phase = .open
|
||||
return [.show]
|
||||
|
||||
case (.arming, .pointerExited):
|
||||
phase = .idle
|
||||
return [.cancelArmTimer]
|
||||
|
||||
// Schließen
|
||||
case (.open, .pointerExited):
|
||||
phase = .closing
|
||||
return [.startCloseTimer(Self.closeDelay)]
|
||||
|
||||
case (.closing, .pointerEntered):
|
||||
phase = .open
|
||||
return [.cancelCloseTimer]
|
||||
|
||||
case (.closing, .closeTimerFired):
|
||||
phase = .idle
|
||||
return [.hide]
|
||||
|
||||
// Fixieren
|
||||
case (.open, .click), (.closing, .click):
|
||||
phase = .pinned
|
||||
return [.cancelCloseTimer]
|
||||
|
||||
case (.pinned, .click):
|
||||
// Lösen, aber offen lassen: der Zeiger ist im Panel, sonst wäre der
|
||||
// Klick nicht angekommen. Von hier gelten wieder die normalen Regeln.
|
||||
phase = .open
|
||||
return []
|
||||
|
||||
// Beenden
|
||||
case (.open, .escape), (.pinned, .escape), (.closing, .escape):
|
||||
phase = .idle
|
||||
return [.hide]
|
||||
|
||||
case (.arming, .escape):
|
||||
phase = .idle
|
||||
return [.cancelArmTimer]
|
||||
|
||||
// Alles andere ist bewusst folgenlos: doppelte Ereignisse aus
|
||||
// überlappenden Tracking-Areas und verspätete Timer, die nach dem
|
||||
// Abbestellen noch feuern.
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension NotchStateMachine {
|
||||
/// Nur für Tests: setzt die Phase direkt, statt sie über eine Ereigniskette
|
||||
/// aufzubauen. Hält die Tests bei dem Übergang, den sie prüfen.
|
||||
mutating func forcePhase(_ phase: NotchPhase) {
|
||||
self.phase = phase
|
||||
}
|
||||
}
|
||||
1
Packages/OnyxKit/Sources/OnyxWidgetKit/Placeholder.swift
Normal file
1
Packages/OnyxKit/Sources/OnyxWidgetKit/Placeholder.swift
Normal file
@@ -0,0 +1 @@
|
||||
// OnyxWidgetKit
|
||||
Reference in New Issue
Block a user