Kacheln und Fenster sprechen dieselbe Sprache

Alle Kacheln, Popovers und Fenster auf die Designschicht umgestellt: rund
hundert gesetzte Schriftgrößen und 159 gesetzte Abstände und Radien sind jetzt
Token. Übrig bleibt `spacing: 0` — das ist eine Aussage, keine geratene Zahl.

Die Einstellungen bekommen eine Seitenleiste statt sieben Reitern, deren
deutsche Beschriftungen zusammen 880 Punkte brauchten. Panel-Token stehen nicht
mehr in Fenstern: dort gilt Systemsemantik, damit der Hellmodus nicht schwarz
auf schwarz endet.

Neu ist ein Schaufenster, das alle Bauteile nebeneinander zeigt — ohne das
lässt sich eine Änderung an einem Token nur prüfen, indem man die App bedient,
bis jede Kachel einmal zu sehen war.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-05 21:05:51 +02:00
co-authored by Claude Opus 5
parent 9b600de9a9
commit edb327cabe
32 changed files with 3677 additions and 968 deletions
+264 -7
View File
@@ -12,6 +12,9 @@ import MetricsProvider
import NetworkProvider
import AudioProvider
import ShelfProvider
import ClipboardProvider
import NotesProvider
import ActivityProvider
import OSLog
private let log = Logger(subsystem: "com.scarriffleservices.onyx", category: "App")
@@ -42,18 +45,58 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private var weatherModel: WeatherModel?
private var mediaModel: MediaModel?
private var metricsModel: MetricsModel?
/// Woran erkannt wird, dass sich etwas **geändert** hat. Ohne dieses
/// Gedächtnis meldete jede Messung dasselbe noch einmal.
/// Entscheidet, ob ein Messwert überhaupt ein Ereignis ist. Geprüft in
/// `PowerWatcherTests`.
private var powerWatcher = PowerWatcher()
/// Woran ein Titelwechsel erkannt wird — Titel und Interpret, nicht der
/// ganze Zustand: die Abspielposition wandert im Sekundentakt weiter, und
/// das ist kein Ereignis.
private var lastTrack: String?
/// Die Helligkeit des eingebauten Bildschirms. Auf Rechnern ohne lesbare
/// Helligkeit bleibt sie leer und meldet nie etwas.
private let brightness = DisplayBrightness()
/// Der zuletzt gemeldete Stand — der erste meldet nichts, sonst begrüßte
/// Onyx jeden Start mit der Helligkeit, die ohnehin schon eingestellt war.
private var lastBrightness: Double?
private var networkModel: NetworkModel?
private var shelfStore: ShelfStore?
private var clipboardStore: ClipboardStore?
private var notesStore: NotesStore?
private let settingsWindow = SettingsWindowController()
private let fanControl = FanControl()
private let audioMixer = AudioMixer()
private let systemAudio = SystemAudio()
private let launchAtLogin = LaunchAtLogin()
private let onboarding = OnboardingWindowController()
private let galleryWindow = GalleryWindowController()
private let activities = ActivityPresenter()
func applicationDidFinishLaunching(_ notification: Notification) {
NSApp.setActivationPolicy(.accessory)
// Werkstattschalter: die Erscheinung **nur dieser App** umstellen, um
// Fenster hell und dunkel nebeneinander prüfen zu können. Die
// Systemeinstellung dafür zu drehen wäre ein Eingriff in die Umgebung
// des Nutzers — für einen Blick auf ein Fenster zu viel.
//
// open Onyx.app --args -onyx.appearance light
//
// **Nur im Debug.** Der Schalter liegt im flüchtigen Argument-Bereich
// und überlebt keinen Neustart — aber eine ausgelieferte App, die sich
// über die Erscheinungswahl des Systems hinwegsetzen *kann*, ist eine
// Falle, die irgendwann jemand auslöst.
#if DEBUG
switch UserDefaults.standard.string(forKey: "onyx.appearance") {
case "light": NSApp.appearance = NSAppearance(named: .aqua)
case "dark": NSApp.appearance = NSAppearance(named: .darkAqua)
default: break
}
#endif
// Vor allem anderen: sicherstellen, dass wir allein sind. Zwei Instanzen
// bringen zwei Zustandsmaschinen und zwei Animationen auf dieselbe
// Notch — das sieht aus wie ein Ruckler und ist keiner.
@@ -74,14 +117,48 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
let coordinator = NotchCoordinator(policy: model.displayPolicy,
trigger: model.triggerArea) { displayID, presentation in
NotchHostingView(
rootView: NotchContainer(presentation: presentation) {
NotchPanelContent(model: model, displayID: displayID)
},
rootView: NotchContainer(
presentation: presentation,
leading: { [weak self] in
NotchBandSlot(presentation: presentation) {
BandSlotView(model: model, edge: .leading,
weather: self?.weatherModel,
metrics: self?.metricsModel)
}
},
trailing: { [weak self] in
NotchBandSlot(presentation: presentation) {
BandSlotView(model: model, edge: .trailing,
weather: self?.weatherModel,
metrics: self?.metricsModel)
}
},
ambient: { [weak self] in
if let self { NotchAmbientSlot(presenter: activities) }
},
content: { NotchPanelContent(model: model, displayID: displayID) }),
presentation: presentation)
}
coordinator.showsIdleIsland = model.showsIdleIsland
coordinator.style = model.notchStyle
coordinator.virtualWidthOverrides = model.notchWidths
coordinator.start()
self.coordinator = coordinator
// Die Live Activities: der Presenter entscheidet, die Controller
// führen aus. Beides je Bildschirm, weil jede Notch ihr eigenes
// Fenster hat.
activities.onChange = { [weak self] activity in
guard let self, let coordinator = self.coordinator else { return }
for controller in coordinator.activeControllers {
controller.setAmbient(activity == nil ? nil : self.activities.contentSize)
}
}
observeVolume()
observePower()
observeMedia()
observeBrightness()
NotificationCenter.default.addObserver(
self, selector: #selector(screensChanged),
name: NSApplication.didChangeScreenParametersNotification, object: nil)
@@ -97,6 +174,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
self?.coordinator?.policy = policy
self?.applyContentSize()
}
model.onShowsIdleIslandChanged = { [weak self] shows in
self?.coordinator?.showsIdleIsland = shows
}
model.onNotchStyleChanged = { [weak self] style in
self?.coordinator?.style = style
}
model.onNotchWidthsChanged = { [weak self] widths in
self?.coordinator?.virtualWidthOverrides = widths
}
let menuBar = MenuBarController()
if let metricsModel {
@@ -133,6 +219,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
installOnyxStatusItem()
observeMenuBarPreference()
requestPermissions()
if GalleryWindowController.opensAtLaunch {
galleryWindow.show(model: model)
}
// Derselbe Werkstattgriff wie beim Schaufenster: bauen, starten,
// hinsehen — ohne den Umweg übers Statusmenü.
//
// open Onyx.app --args -onyx.settings.open YES \
// -onyx.settings.section appearance
if UserDefaults.standard.bool(forKey: "onyx.settings.open") {
let name = UserDefaults.standard.string(forKey: "onyx.settings.section") ?? ""
showSettings(section: SettingsSection(rawValue: name) ?? .general)
}
}
/// Berechtigungen beim Start anfragen, nicht erst wenn ein Widget sichtbar
@@ -178,7 +278,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// Deshalb der ehrliche Weg: ein echtes Fenster im Vordergrund. Die
// Einrichtung lief schon einmal, hier fehlt nur noch etwas — dann
// genügt der Berechtigungsbereich der Einstellungen.
showSettings(tab: .permissions)
showSettings(section: .permissions)
}
/// Die Einrichtung noch einmal zeigen — aus den Einstellungen heraus.
@@ -246,6 +346,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
// AirDrop steckt in der Ablage-Kachel — zwei eigene Kacheln für je
// eine leere Ablegefläche waren ein Drittel des Panels.
WidgetRegistry.shared.register(ShelfWidget(store: shelfStore))
let clipboardStore = ClipboardStore()
self.clipboardStore = clipboardStore
WidgetRegistry.shared.register(ClipboardWidget(store: clipboardStore))
let notesStore = NotesStore()
self.notesStore = notesStore
WidgetRegistry.shared.register(NotesWidget(store: notesStore))
}
/// Je Bildschirm eine eigene Größe.
@@ -292,6 +400,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
action: #selector(openSettings), keyEquivalent: ",").target = self
menu.addItem(withTitle: String(localized: "settings.onboarding.show"),
action: #selector(openOnboarding), keyEquivalent: "").target = self
// Werkzeug, kein Programmteil: unlokalisiert, und im Release nur, wenn
// jemand den Schlüssel ausdrücklich setzt. Siehe
// `GalleryWindowController.isAvailable`.
if GalleryWindowController.isAvailable {
menu.addItem(withTitle: "Schaufenster",
action: #selector(openGallery), keyEquivalent: "").target = self
}
menu.addItem(.separator())
menu.addItem(withTitle: String(localized: "menu.quit"),
action: #selector(quit), keyEquivalent: "q").target = self
@@ -302,6 +417,148 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
@objc private func openOnboarding() { showOnboarding() }
/// Meldet jede Änderung der Systemlautstärke als Live Activity an.
///
/// Über `withObservationTracking` und nicht über einen eigenen Listener:
/// `SystemAudio` hängt bereits mit CoreAudio-Listenern an Lautstärke und
/// Stummschaltung. Ein zweiter Weg dorthin wäre eine zweite Wahrheit.
///
/// Die Meldung kommt **vor** dem Schreiben; der neue Wert steht erst
/// danach. Deshalb wird er in einer Aufgabe gelesen, nicht im Rückruf.
private func observeVolume() {
withObservationTracking {
_ = systemAudio.outputVolume
_ = systemAudio.outputMuted
} onChange: {
Task { @MainActor [weak self] in
guard let self else { return }
activities.raise(VolumeActivity(level: systemAudio.outputVolume,
isMuted: systemAudio.outputMuted))
observeVolume()
}
}
}
/// Meldet jeden **Wechsel** am Strom als Live Activity an.
///
/// Nicht jeden Messwert: der Ladestand ändert sich ständig, und eine Notch,
/// die das begleitet, wäre eine Leuchtreklame. Angemeldet wird nur, wenn
/// sich der Zustand ändert — und der Zustand ist das, was ``ChargeActivity``
/// aus Laden, Netz, Ladestand und Limit ableitet.
///
/// Der erste gelesene Zustand meldet **nichts**. Sonst begrüßte Onyx jeden
/// Start mit „am Netz" — einer Nachricht über nichts, das gerade geschehen
/// ist.
private func observePower() {
guard let metricsModel else { return }
withObservationTracking {
_ = metricsModel.snapshot.battery
} onChange: {
Task { @MainActor [weak self] in
guard let self else { return }
defer { observePower() }
guard let battery = self.metricsModel?.snapshot.battery else { return }
guard let event = self.powerWatcher.observe(
isCharging: battery.isCharging,
isPluggedIn: battery.isPluggedIn,
charge: battery.charge,
limit: self.metricsModel?.chargeControl?.chargeLimit)
else { return }
let kind: PowerActivity.Kind = switch event {
case .charging: .charging
case .plugged: .plugged
case .unplugged: .unplugged
case .atLimit(let limit): .atLimit(limit)
case .low: .low
}
self.activities.raise(PowerActivity(kind: kind,
charge: battery.charge,
isCharging: battery.isCharging,
isPluggedIn: battery.isPluggedIn))
}
}
}
/// Meldet jede Helligkeitsänderung als Live Activity an.
///
/// Nur, wenn dieser Rechner überhaupt eine lesbare Helligkeit hat — auf
/// einem Mini am Monitor gibt es keine, und dann soll auch nichts
/// erscheinen. Siehe ``DisplayBrightness``.
private func observeBrightness() {
guard brightness.isAvailable else { return }
withObservationTracking {
_ = brightness.level
} onChange: {
Task { @MainActor [weak self] in
guard let self else { return }
defer { observeBrightness() }
guard let level = self.brightness.level else { return }
guard let previous = self.lastBrightness else {
self.lastBrightness = level
return
}
self.lastBrightness = level
// Ein Hauch Unterschied ist kein Ereignis: die Meldung kommt
// auch bei automatischer Anpassung an das Umgebungslicht, und
// die kriecht in winzigen Schritten.
guard abs(level - previous) >= Self.brightnessStep else { return }
self.activities.raise(BrightnessActivity(level: level))
}
}
}
/// Ab welchem Sprung eine Helligkeitsänderung als Handgriff gilt.
///
/// Ein Tastendruck ändert sie um rund ein Sechzehntel; das Umgebungslicht
/// kriecht in viel kleineren Schritten.
private static let brightnessStep = 0.02
/// Meldet jeden **Titelwechsel** als Live Activity an.
///
/// Nicht jede Änderung an `nowPlaying`: dort wandert die Abspielposition
/// im Sekundentakt weiter, und das ist kein Ereignis. Verglichen wird
/// deshalb Titel und Interpret, nicht der ganze Zustand.
///
/// Auch hier meldet der erste gelesene Titel nichts — beim Start läuft
/// vielleicht seit einer Stunde dasselbe Stück.
private func observeMedia() {
guard let mediaModel else { return }
withObservationTracking {
_ = mediaModel.nowPlaying
} onChange: {
Task { @MainActor [weak self] in
guard let self else { return }
defer { observeMedia() }
guard let playing = self.mediaModel?.nowPlaying, !playing.title.isEmpty else {
self.lastTrack = nil
return
}
let track = "\(playing.title)\u{1}\(playing.artist)"
guard let previous = self.lastTrack else {
self.lastTrack = track
return
}
guard track != previous else { return }
self.lastTrack = track
// Ein pausierter Titel wechselt nicht von selbst — was hier
// ankommt, während nichts läuft, ist ein Nachtrag der Quelle
// und kein Ereignis.
guard playing.isPlaying else { return }
self.activities.raise(MediaActivity(title: playing.title,
artist: playing.artist,
artwork: playing.artwork))
}
}
}
@objc private func openGallery() {
guard let model else { return }
galleryWindow.show(model: model)
}
/// Das Glyph aus dem App-Icon, auf Menüleistengröße gebracht.
///
/// Nicht das vollständige App-Icon: das bringt die abgerundete Kachel samt
@@ -362,16 +619,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
@objc private func togglePanel() { coordinator?.togglePanelUnderPointer() }
@objc private func openSettings() { showSettings(tab: .widgets) }
@objc private func openSettings() { showSettings(section: .general) }
private func showSettings(tab: SettingsTab) {
private func showSettings(section: SettingsSection) {
guard let model, let calendarModel, let weatherModel else { return }
settingsWindow.show(model: model, calendarModel: calendarModel,
weatherModel: weatherModel, metricsModel: metricsModel,
shelfStore: shelfStore, fanControl: fanControl,
launchAtLogin: launchAtLogin,
showOnboarding: { [weak self] in self?.showOnboarding() },
tab: tab)
section: section)
}
@objc private func quit() { NSApp.terminate(nil) }