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:
Guido Schmit
2026-08-10 17:17:16 +02:00
parent 41ee3cc174
commit fbe13fd1cb
24 changed files with 2204 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
// swift-tools-version: 6.2
import PackageDescription
let package = Package(
name: "OnyxKit",
platforms: [.macOS(.v26)],
products: [
.library(name: "OnyxDesign", targets: ["OnyxDesign"]),
.library(name: "OnyxNotch", targets: ["OnyxNotch"]),
.library(name: "OnyxWidgetKit", targets: ["OnyxWidgetKit"]),
.library(name: "OnyxMenuBar", targets: ["OnyxMenuBar"]),
],
targets: [
.target(name: "OnyxDesign"),
.target(name: "OnyxNotch", dependencies: ["OnyxDesign"]),
.testTarget(name: "OnyxNotchTests", dependencies: ["OnyxNotch"]),
.target(name: "OnyxWidgetKit", dependencies: ["OnyxDesign"]),
.testTarget(name: "OnyxWidgetKitTests", dependencies: ["OnyxWidgetKit"]),
.target(name: "OnyxMenuBar", dependencies: ["OnyxDesign", "OnyxWidgetKit"]),
.testTarget(name: "OnyxMenuBarTests", dependencies: ["OnyxMenuBar"]),
]
)

View 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))
}
}

View 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()
}
}

View 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)
}
}

View 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, 110.
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)
}
}

View 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
}
}

View 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()
}
}

View 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)
}
}

View 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
}
}

View 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)
}
}

View 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
}
}

View File

@@ -0,0 +1 @@
// OnyxWidgetKit

View File

@@ -0,0 +1 @@
// OnyxMenuBarTests

View File

@@ -0,0 +1,67 @@
import Testing
import CoreGraphics
@testable import OnyxNotch
@Suite("Anzeigen-Auswahl")
struct DisplayPolicyTests {
static let builtIn = ScreenMetrics(
frame: CGRect(x: 0, y: 0, width: 1800, height: 1169),
safeAreaTop: 38,
auxiliaryTopLeft: CGRect(x: 0, y: 1131, width: 790, height: 38),
auxiliaryTopRight: CGRect(x: 1010, y: 1131, width: 790, height: 38),
isBuiltIn: true,
displayID: 1
)
static let external = ScreenMetrics(
frame: CGRect(x: 1800, y: 0, width: 2560, height: 1440),
safeAreaTop: 0, auxiliaryTopLeft: nil, auxiliaryTopRight: nil,
isBuiltIn: false, displayID: 2
)
static let secondExternal = ScreenMetrics(
frame: CGRect(x: -2560, y: 0, width: 2560, height: 1440),
safeAreaTop: 0, auxiliaryTopLeft: nil, auxiliaryTopRight: nil,
isBuiltIn: false, displayID: 3
)
@Test("Nur intern: externe Displays bekommen kein Panel")
func builtInOnlySkipsExternal() {
let chosen = DisplayPolicy.builtInOnly.screens(from: [Self.builtIn, Self.external])
#expect(chosen.map(\.displayID) == [1])
}
@Test("Alle Displays: jedes bekommt eins")
func allDisplaysCoversEverything() {
let chosen = DisplayPolicy.allDisplays
.screens(from: [Self.builtIn, Self.external, Self.secondExternal])
#expect(Set(chosen.map(\.displayID)) == [1, 2, 3])
}
@Test("Nur intern und der Deckel ist zu: Onyx wäre unerreichbar, also fällt es auf extern zurück")
func builtInOnlyFallsBackWhenLidClosed() {
// Deckel zu am Dock: das eingebaute Display verschwindet aus der Liste.
// Ohne Rückfall hätte man gar keinen Zugang mehr zur App.
let chosen = DisplayPolicy.builtInOnly.screens(from: [Self.external])
#expect(chosen.map(\.displayID) == [2])
}
@Test("Rückfall wählt genau ein Display, nicht alle")
func fallbackPicksOnlyOne() {
let chosen = DisplayPolicy.builtInOnly.screens(from: [Self.external, Self.secondExternal])
#expect(chosen.count == 1)
}
@Test("Ohne Displays kein Panel — und kein Absturz")
func noScreensIsSafe() {
#expect(DisplayPolicy.builtInOnly.screens(from: []).isEmpty)
#expect(DisplayPolicy.allDisplays.screens(from: []).isEmpty)
}
@Test("Mehrere eingebaute Displays gibt es nicht, aber der Code darf daran nicht scheitern")
func handlesImplausibleInput() {
let chosen = DisplayPolicy.builtInOnly.screens(from: [Self.builtIn, Self.builtIn])
#expect(chosen.count == 2)
}
}

View File

@@ -0,0 +1,142 @@
import Testing
import CoreGraphics
@testable import OnyxNotch
// Bildschirmkoordinaten auf macOS haben ihren Ursprung unten links, y wächst nach
// oben. Die Notch sitzt also bei y = frame.maxY höhe, nicht bei y = 0.
@Suite("Notch-Geometrie")
struct NotchGeometryTests {
/// Gemessen auf dem Zielgerät (MacBook Pro Mac17,9, M5 Pro) am 10.08.2026.
/// Dient als Referenzfall wenn dieser Test bricht, hat Apple die
/// Notch-Metrik geändert und nicht wir.
static let builtIn = ScreenMetrics(
frame: CGRect(x: 0, y: 0, width: 1800, height: 1169),
safeAreaTop: 38,
auxiliaryTopLeft: CGRect(x: 0, y: 1131, width: 790, height: 38),
auxiliaryTopRight: CGRect(x: 1010, y: 1131, width: 790, height: 38)
)
/// Typischer externer Monitor, rechts neben dem eingebauten angeordnet.
static let external = ScreenMetrics(
frame: CGRect(x: 1800, y: 0, width: 2560, height: 1440),
safeAreaTop: 0,
auxiliaryTopLeft: nil,
auxiliaryTopRight: nil
)
static let virtualSize = CGSize(width: 220, height: 32)
// MARK: - Erkennung
@Test("Eingebautes Display: echte Notch aus den Hilfsflächen berechnet")
func physicalNotchFromRealDevice() {
let g = NotchGeometry.resolve(Self.builtIn, virtualSize: Self.virtualSize)
#expect(g.kind == .physical)
#expect(g.rect == CGRect(x: 790, y: 1131, width: 220, height: 38))
}
@Test("Externes Display ohne Notch: virtueller Ersatz, oben mittig")
func virtualNotchOnExternal() {
let g = NotchGeometry.resolve(Self.external, virtualSize: Self.virtualSize)
#expect(g.kind == .virtual)
// Mittig: 1800 + (2560 220)/2 = 2970
#expect(g.rect == CGRect(x: 2970, y: 1408, width: 220, height: 32))
}
@Test("safeAreaTop ohne Hilfsflächen gilt nicht als Notch")
func safeAreaWithoutAuxIsVirtual() {
// Kommt bei manchen Spiegelungs- und Fernanzeige-Konfigurationen vor:
// ein Inset ist gesetzt, aber es gibt keine Notch, um die man herumlegen
// könnte. Ein Panel an dieser Stelle säße im Nichts.
let odd = ScreenMetrics(
frame: CGRect(x: 0, y: 0, width: 1800, height: 1169),
safeAreaTop: 38,
auxiliaryTopLeft: nil,
auxiliaryTopRight: nil
)
#expect(NotchGeometry.resolve(odd, virtualSize: Self.virtualSize).kind == .virtual)
}
@Test("Notch-Mitte liegt auf der Bildschirmmitte")
func notchIsCentred() {
let g = NotchGeometry.resolve(Self.builtIn, virtualSize: Self.virtualSize)
#expect(g.rect.midX == Self.builtIn.frame.midX)
}
// MARK: - Auslösefläche
@Test("Auslösefläche ist breiter und tiefer als die Notch")
func triggerIsLargerThanNotch() {
let g = NotchGeometry.resolve(Self.builtIn, virtualSize: Self.virtualSize)
let t = g.triggerRect
#expect(t.width > g.rect.width)
#expect(t.height > g.rect.height)
#expect(t.midX == g.rect.midX)
}
@Test("Auslösefläche ragt nie über den oberen Bildschirmrand hinaus")
func triggerStaysOnScreen() {
let g = NotchGeometry.resolve(Self.builtIn, virtualSize: Self.virtualSize)
#expect(g.triggerRect.maxY <= Self.builtIn.frame.maxY)
#expect(g.triggerRect.minX >= Self.builtIn.frame.minX)
#expect(g.triggerRect.maxX <= Self.builtIn.frame.maxX)
}
@Test("Auslösefläche eines schmalen Bildschirms bleibt innerhalb")
func triggerClampsOnNarrowScreen() {
let narrow = ScreenMetrics(
frame: CGRect(x: 0, y: 0, width: 240, height: 400),
safeAreaTop: 0, auxiliaryTopLeft: nil, auxiliaryTopRight: nil
)
let g = NotchGeometry.resolve(narrow, virtualSize: Self.virtualSize)
#expect(g.triggerRect.minX >= 0)
#expect(g.triggerRect.maxX <= 240)
}
// MARK: - Panelfläche
@Test("Panel hängt unter der Notch und ist auf sie zentriert")
func panelHangsBelowNotch() {
let g = NotchGeometry.resolve(Self.builtIn, virtualSize: Self.virtualSize)
let panel = g.panelRect(for: CGSize(width: 640, height: 320))
#expect(panel.midX == g.rect.midX)
// Oberkante bündig mit dem Bildschirmrand das Panel wächst aus der
// Notch heraus, es schwebt nicht darunter.
#expect(panel.maxY == Self.builtIn.frame.maxY)
#expect(panel.height == 320)
}
@Test("Panel breiter als der Bildschirm wird eingepasst statt abgeschnitten")
func panelClampsToScreen() {
let g = NotchGeometry.resolve(Self.builtIn, virtualSize: Self.virtualSize)
let panel = g.panelRect(for: CGSize(width: 5000, height: 320))
#expect(panel.minX >= Self.builtIn.frame.minX)
#expect(panel.maxX <= Self.builtIn.frame.maxX)
}
@Test("Panel auf dem externen Display liegt in dessen Koordinaten, nicht bei null")
func panelUsesScreenOrigin() {
let g = NotchGeometry.resolve(Self.external, virtualSize: Self.virtualSize)
let panel = g.panelRect(for: CGSize(width: 640, height: 320))
#expect(panel.minX >= 1800)
#expect(panel.maxY == 1440)
}
@Test("Zu hohes Panel wird auf die Bildschirmhöhe begrenzt")
func panelClampsHeight() {
let g = NotchGeometry.resolve(Self.builtIn, virtualSize: Self.virtualSize)
let panel = g.panelRect(for: CGSize(width: 640, height: 99_999))
#expect(panel.height <= Self.builtIn.frame.height)
#expect(panel.minY >= Self.builtIn.frame.minY)
}
}

View File

@@ -0,0 +1,250 @@
import Testing
@testable import OnyxNotch
// Die Zustandsmaschine ist die Spezifikation des Panelverhaltens. Sie ist bewusst
// frei von AppKit: keine Fenster, keine Timer, keine Uhr. Zeit kommt ausschließlich
// als Eingabe herein (`.armTimerFired` / `.closeTimerFired`), damit jeder Übergang
// ohne Warten prüfbar ist.
@Suite("Notch-Zustandsmaschine")
struct NotchStateMachineTests {
// MARK: - Öffnen
@Test("Zeiger betritt die Notch → Entprellung startet, Panel bleibt zu")
func pointerEntersStartsArming() {
var machine = NotchStateMachine()
let effects = machine.handle(.pointerEntered)
#expect(machine.phase == .arming)
#expect(effects == [.startArmTimer(NotchStateMachine.armDelay)])
}
@Test("Entprellzeit abgelaufen → Panel öffnet")
func armTimerOpens() {
var machine = NotchStateMachine()
_ = machine.handle(.pointerEntered)
let effects = machine.handle(.armTimerFired)
#expect(machine.phase == .open)
#expect(effects == [.show])
}
@Test("Zeiger verlässt die Notch vor Ablauf → kein Öffnen")
func leavingDuringArmingCancels() {
var machine = NotchStateMachine()
_ = machine.handle(.pointerEntered)
let effects = machine.handle(.pointerExited)
#expect(machine.phase == .idle)
#expect(effects == [.cancelArmTimer])
}
// MARK: - Schließen
@Test("Zeiger verlässt geöffnetes Panel → Nachlauffrist, noch kein Schließen")
func leavingOpenStartsGrace() {
var machine = NotchStateMachine()
machine.forcePhase(.open)
let effects = machine.handle(.pointerExited)
#expect(machine.phase == .closing)
#expect(effects == [.startCloseTimer(NotchStateMachine.closeDelay)])
}
@Test("Rückkehr innerhalb der Nachlauffrist → Panel bleibt offen")
func returningDuringGraceKeepsOpen() {
var machine = NotchStateMachine()
machine.forcePhase(.open)
_ = machine.handle(.pointerExited)
let effects = machine.handle(.pointerEntered)
#expect(machine.phase == .open)
#expect(effects == [.cancelCloseTimer])
}
@Test("Nachlauffrist abgelaufen → Panel schließt")
func graceExpiryCloses() {
var machine = NotchStateMachine()
machine.forcePhase(.open)
_ = machine.handle(.pointerExited)
let effects = machine.handle(.closeTimerFired)
#expect(machine.phase == .idle)
#expect(effects == [.hide])
}
// MARK: - Fixieren
@Test("Klick fixiert das offene Panel")
func clickPins() {
var machine = NotchStateMachine()
machine.forcePhase(.open)
let effects = machine.handle(.click)
#expect(machine.phase == .pinned)
#expect(effects == [.cancelCloseTimer])
}
@Test("Fixiertes Panel bleibt offen, wenn der Zeiger weggeht — das ist der Zweck")
func pinnedIgnoresPointerExit() {
var machine = NotchStateMachine()
machine.forcePhase(.pinned)
let effects = machine.handle(.pointerExited)
#expect(machine.phase == .pinned)
#expect(effects.isEmpty)
}
@Test("Zweiter Klick löst die Fixierung, Panel bleibt offen solange der Zeiger drin ist")
func secondClickUnpins() {
var machine = NotchStateMachine()
machine.forcePhase(.pinned)
let effects = machine.handle(.click)
#expect(machine.phase == .open)
#expect(effects.isEmpty)
}
@Test("esc löst die Fixierung und schließt")
func escapeClosesPinned() {
var machine = NotchStateMachine()
machine.forcePhase(.pinned)
let effects = machine.handle(.escape)
#expect(machine.phase == .idle)
#expect(effects == [.hide])
}
@Test("esc schließt auch ein nur geöffnetes Panel")
func escapeClosesOpen() {
var machine = NotchStateMachine()
machine.forcePhase(.open)
let effects = machine.handle(.escape)
#expect(machine.phase == .idle)
#expect(effects == [.hide])
}
@Test("esc im Ruhezustand tut nichts")
func escapeIdleIsNoop() {
var machine = NotchStateMachine()
let effects = machine.handle(.escape)
#expect(machine.phase == .idle)
#expect(effects.isEmpty)
}
// MARK: - Unterdrückung (Vollbildvideo, Nicht stören")
@Test("Unterdrückung schließt ein offenes Panel und räumt die Timer ab")
func suppressionClosesOpen() {
var machine = NotchStateMachine()
machine.forcePhase(.open)
let effects = machine.handle(.suppressed(true))
#expect(machine.phase == .idle)
#expect(effects == [.cancelArmTimer, .cancelCloseTimer, .hide])
}
@Test("Unterdrückung löst auch die Fixierung — sonst klebt das Panel im Vollbild")
func suppressionBeatsPinning() {
var machine = NotchStateMachine()
machine.forcePhase(.pinned)
_ = machine.handle(.suppressed(true))
#expect(machine.phase == .idle)
}
@Test("Während der Unterdrückung öffnet Hovern nicht")
func suppressedIgnoresHover() {
var machine = NotchStateMachine()
_ = machine.handle(.suppressed(true))
let effects = machine.handle(.pointerEntered)
#expect(machine.phase == .idle)
#expect(effects.isEmpty)
}
@Test("Nach dem Ende der Unterdrückung öffnet Hovern wieder")
func unsuppressingRestoresHover() {
var machine = NotchStateMachine()
_ = machine.handle(.suppressed(true))
_ = machine.handle(.pointerEntered)
_ = machine.handle(.suppressed(false))
_ = machine.handle(.pointerEntered)
#expect(machine.phase == .arming)
}
@Test("Ende der Unterdrückung öffnet nicht von selbst, auch wenn der Zeiger dort steht")
func unsuppressingDoesNotAutoOpen() {
var machine = NotchStateMachine()
_ = machine.handle(.suppressed(true))
let effects = machine.handle(.suppressed(false))
#expect(machine.phase == .idle)
#expect(effects.isEmpty)
}
// MARK: - Robustheit gegen doppelte Ereignisse
@Test("Zweimal betreten ohne zwischenzeitliches Verlassen startet keinen zweiten Timer")
func repeatedEnterIsIdempotent() {
var machine = NotchStateMachine()
_ = machine.handle(.pointerEntered)
let effects = machine.handle(.pointerEntered)
#expect(machine.phase == .arming)
#expect(effects.isEmpty)
}
@Test("Verlassen im Ruhezustand tut nichts")
func exitWhileIdleIsNoop() {
var machine = NotchStateMachine()
let effects = machine.handle(.pointerExited)
#expect(machine.phase == .idle)
#expect(effects.isEmpty)
}
@Test("Verspäteter Timer nach dem Schließen öffnet nicht mehr")
func staleArmTimerDoesNotOpen() {
var machine = NotchStateMachine()
_ = machine.handle(.pointerEntered)
_ = machine.handle(.pointerExited) // Timer wurde abbestellt
let effects = machine.handle(.armTimerFired) // feuert aber trotzdem noch
#expect(machine.phase == .idle)
#expect(effects.isEmpty)
}
@Test("Verspäteter Schließtimer schließt kein wieder geöffnetes Panel")
func staleCloseTimerDoesNotHide() {
var machine = NotchStateMachine()
machine.forcePhase(.open)
_ = machine.handle(.pointerExited)
_ = machine.handle(.pointerEntered) // zurück, Timer abbestellt
let effects = machine.handle(.closeTimerFired) // feuert trotzdem
#expect(machine.phase == .open)
#expect(effects.isEmpty)
}
@Test("Klick im Ruhezustand öffnet nicht — sonst öffnet jeder Klick oben am Rand")
func clickWhileIdleIsNoop() {
var machine = NotchStateMachine()
let effects = machine.handle(.click)
#expect(machine.phase == .idle)
#expect(effects.isEmpty)
}
// MARK: - Zeitkonstanten
@Test("Entprellung und Nachlauf entsprechen dem Plan")
func timingConstants() {
#expect(NotchStateMachine.armDelay == 0.22)
#expect(NotchStateMachine.closeDelay == 0.30)
}
}

View File

@@ -0,0 +1 @@
// OnyxWidgetKitTests