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