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:
co-authored by
Claude Opus 5
parent
9b600de9a9
commit
edb327cabe
+264
-7
@@ -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) }
|
||||
|
||||
Reference in New Issue
Block a user