Files
onyx/Onyx/AppModel.swift
T
ScarriffleandClaude Opus 5 edb327cabe 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>
2026-09-05 21:05:51 +02:00

273 lines
11 KiB
Swift

import SwiftUI
import AppKit
import OnyxNotch
import OnyxWidgetKit
import OnyxMenuBar
/// Der geteilte Zustand von Onyx: was im Panel liegt und auf welchen Displays.
///
/// Änderungen wirken sofort. Eine Einstellung, die erst nach einem Neustart
/// greift, ist in einer App ohne Fenster praktisch unauffindbar — man sieht ja
/// nicht, dass etwas passiert ist.
@MainActor
@Observable
final class AppModel {
private let store: LayoutStore
private let defaults: UserDefaults
/// Wird gerufen, wenn sich die Panelgröße geändert haben könnte.
var onLayoutChanged: (([WidgetPlacement]) -> Void)?
/// Wird gerufen, wenn Panels auf anderen Displays erscheinen sollen.
var onDisplayPolicyChanged: ((DisplayPolicy) -> Void)?
var onShowsIdleIslandChanged: ((Bool) -> Void)?
var onNotchStyleChanged: ((NotchStyle) -> Void)?
var onNotchWidthsChanged: (([CGDirectDisplayID: CGFloat]) -> Void)?
var layout: [WidgetPlacement] {
didSet {
guard layout != oldValue else { return }
persistLayout()
onLayoutChanged?(layout)
}
}
var displayPolicy: DisplayPolicy {
didSet {
guard displayPolicy != oldValue else { return }
defaults.set(displayPolicy.rawValue, forKey: Keys.displayPolicy)
onDisplayPolicyChanged?(displayPolicy)
}
}
/// Abweichende Inselbreiten, je Bildschirm — leer heißt „Standardbreite".
///
/// Als Wörterbuch über die `CGDirectDisplayID` und nicht als Liste über den
/// Index in `NSScreen.screens`: der Index ändert sich beim Umstecken, die
/// Kennung nicht. Sonst gälte die Einstellung des einen Monitors plötzlich
/// für den anderen.
private(set) var notchWidths: [CGDirectDisplayID: CGFloat] = [:]
/// Die eingestellte Breite dieses Bildschirms, falls es eine gibt.
func notchWidth(for displayID: CGDirectDisplayID) -> CGFloat? {
notchWidths[displayID]
}
/// Setzt eine abweichende Breite — oder nimmt sie mit `nil` zurück.
func setNotchWidth(_ width: CGFloat?, for displayID: CGDirectDisplayID) {
if let width { notchWidths[displayID] = width } else { notchWidths[displayID] = nil }
// Als Zeichenkette gespeichert, weil `UserDefaults` keine Wörterbücher
// mit Zahlenschlüsseln kann.
let raw = notchWidths.reduce(into: [String: Double]()) { $0[String($1.key)] = Double($1.value) }
defaults.set(raw, forKey: Keys.notchWidths)
onNotchWidthsChanged?(notchWidths)
}
/// Was links neben der Notch steht.
var bandLeading: BandAccessory {
didSet {
guard bandLeading != oldValue else { return }
defaults.set(bandLeading.rawValue, forKey: Keys.bandLeading)
}
}
/// Was rechts daneben steht.
var bandTrailing: BandAccessory {
didSet {
guard bandTrailing != oldValue else { return }
defaults.set(bandTrailing.rawValue, forKey: Keys.bandTrailing)
}
}
/// Notch oder Insel — die Form, die Onyx oben annimmt.
var notchStyle: NotchStyle {
didSet {
guard notchStyle != oldValue else { return }
defaults.set(notchStyle.rawValue, forKey: Keys.notchStyle)
onNotchStyleChanged?(notchStyle)
}
}
/// Ob die eingeklappte Insel sichtbar stehen bleibt.
///
/// Standardmäßig an: ohne sie wächst das Panel auf externen Bildschirmen
/// aus dem Nichts. Abschaltbar, weil ein schwarzer Balken auf vier
/// Monitoren eine Geschmacksfrage ist und keine Voreinstellung sein darf,
/// von der man nicht wegkommt.
var showsIdleIsland: Bool {
didSet {
guard showsIdleIsland != oldValue else { return }
defaults.set(showsIdleIsland, forKey: Keys.showsIdleIsland)
onShowsIdleIslandChanged?(showsIdleIsland)
}
}
var showsMenuBarIcon: Bool {
didSet { defaults.set(showsMenuBarIcon, forKey: Keys.showsMenuBarIcon) }
}
/// Ob Onyx im Dock auftaucht.
///
/// Standardmäßig nicht: Onyx ist ein Werkzeug, das in der Notch und in der
/// Menüleiste lebt, hat kein Hauptfenster und wäre im Dock ein Symbol, das
/// beim Anklicken nichts öffnet. Es taucht dann auch im Programmumschalter
/// auf und steht dort zwischen den Programmen, mit denen man arbeitet.
var showsDockIcon: Bool {
didSet {
guard showsDockIcon != oldValue else { return }
defaults.set(showsDockIcon, forKey: Keys.showsDockIcon)
NSApp.setActivationPolicy(showsDockIcon ? .regular : .accessory)
}
}
/// Der Menüleisten-Controller. Wird nach dem Aufbau gesetzt, damit
/// Änderungen aus den Einstellungen sofort durchschlagen.
weak var menuBar: MenuBarController?
/// Die gespeicherten Einstellungen eines Menüleisten-Moduls.
///
/// Standardmäßig ist **kein** Modul an. Sechs Symbole beim ersten Start
/// wären eine Zumutung — wer sie will, schaltet sie einzeln ein.
func menuBarSettings(for moduleID: String) -> MenuBarModuleSettings {
guard let data = defaults.data(forKey: Keys.menuBarModule(moduleID)),
let decoded = try? JSONDecoder().decode(MenuBarModuleSettings.self, from: data)
else { return MenuBarModuleSettings() }
return decoded
}
func setMenuBarSettings(_ settings: MenuBarModuleSettings, for moduleID: String) {
if let data = try? JSONEncoder().encode(settings) {
defaults.set(data, forKey: Keys.menuBarModule(moduleID))
}
menuBar?.setEnabled(settings.isEnabled, forModuleID: moduleID)
menuBar?.setPresentation(settings.presentation, forModuleID: moduleID)
menuBarRevision += 1
}
/// Zählt hoch, wenn sich ein Modul ändert — die Einstellungen zeichnen sich
/// daran neu, ohne dass jedes Modul einzeln beobachtet werden müsste.
private(set) var menuBarRevision = 0
private enum Keys {
static func menuBarModule(_ id: String) -> String { "onyx.menubar.module.\(id)" }
static let displayPolicy = "onyx.displayPolicy"
static let showsIdleIsland = "onyx.showsIdleIsland"
static let notchStyle = "onyx.notchStyle"
static let notchWidths = "onyx.notchWidths"
static let bandLeading = "onyx.band.leading"
static let bandTrailing = "onyx.band.trailing"
static let showsMenuBarIcon = "onyx.showsMenuBarIcon"
static let showsDockIcon = "onyx.showsDockIcon"
static let triggerArea = "onyx.triggerArea"
}
init(store: LayoutStore = .standard(), defaults: UserDefaults = .standard) {
self.store = store
self.defaults = defaults
let known = Set(WidgetRegistry.shared.all.map(\.id))
self.layout = store.load(knownWidgetIDs: known)
self.displayPolicy = defaults.string(forKey: Keys.displayPolicy)
.flatMap(DisplayPolicy.init(rawValue:)) ?? .builtInOnly
self.showsIdleIsland = defaults.object(forKey: Keys.showsIdleIsland) as? Bool ?? true
self.notchStyle = defaults.string(forKey: Keys.notchStyle)
.flatMap(NotchStyle.init(rawValue:)) ?? .automatic
// Die bisherige feste Verdrahtung als Voreinstellung: wer nichts
// einstellt, sieht, was vorher auch dastand.
self.bandLeading = defaults.string(forKey: Keys.bandLeading)
.flatMap(BandAccessory.init(rawValue:)) ?? .weather
self.bandTrailing = defaults.string(forKey: Keys.bandTrailing)
.flatMap(BandAccessory.init(rawValue:)) ?? .battery
self.notchWidths = (defaults.dictionary(forKey: Keys.notchWidths) as? [String: Double] ?? [:])
.reduce(into: [CGDirectDisplayID: CGFloat]()) { result, entry in
guard let id = CGDirectDisplayID(entry.key) else { return }
result[id] = CGFloat(entry.value)
}
// Standardmäßig sichtbar: das Menüleistensymbol ist der einzige Weg
// zurück, wenn die Notch einmal nicht reagiert.
self.showsMenuBarIcon = defaults.object(forKey: Keys.showsMenuBarIcon) as? Bool ?? true
self.showsDockIcon = defaults.object(forKey: Keys.showsDockIcon) as? Bool ?? false
self.triggerArea = (defaults.data(forKey: Keys.triggerArea)
.flatMap { try? JSONDecoder().decode(TriggerArea.self, from: $0) }) ?? .standard
}
// MARK: - Widgets
/// Die Auslösefläche der Notch. Wirkt sofort.
var triggerArea: TriggerArea {
didSet {
guard triggerArea != oldValue else { return }
if let data = try? JSONEncoder().encode(triggerArea) {
defaults.set(data, forKey: Keys.triggerArea)
}
onTriggerAreaChanged?(triggerArea)
}
}
var onTriggerAreaChanged: ((TriggerArea) -> Void)?
/// Zählt Bildschirmwechsel mit.
///
/// Nur dafür da, beobachtbar zu sein: die Breite kommt aus `NSScreen`, und
/// davon erfährt SwiftUI von sich aus nichts. Ohne diesen Zähler bliebe ein
/// Panel, das einmal für einen schmalen Fernseher gerechnet wurde, für
/// immer schmal — auch nach dem Abstecken.
private(set) var screenGeneration = 0
func noteScreensChanged() { screenGeneration += 1 }
/// Wie breit das Panel auf **diesem** Bildschirm werden darf.
///
/// Je Bildschirm und nicht für alle zusammen. Vorher gab der schmalste
/// aktive Bildschirm den Ausschlag — mit der Begründung, dasselbe Panel
/// erscheine überall. Der Fall, der das widerlegt: ein Fernseher mit 1280
/// Punkten Breite drückte das Panel auch auf dem Notebook zusammen, die
/// Kacheln brachen um, und es wurde hoch statt breit. Ein Panel, das auf
/// dem eigenen Bildschirm nicht mehr passt, weil woanders ein Gerät hängt,
/// ist der schlechtere Kompromiss.
///
/// Der Abzug lässt Luft zum Rand — ein Panel, das exakt bis zur Kante
/// geht, wirkt eingeklemmt.
func availableWidth(forDisplay id: CGDirectDisplayID) -> CGFloat {
_ = screenGeneration
let width = NSScreen.screens.first { $0.displayID == id }?.frame.width
?? NSScreen.screens.map(\.frame.width).min()
?? 1440
return width - 120
}
func panelContentSize(forDisplay id: CGDirectDisplayID) -> CGSize {
WidgetGrid.panelSize(for: layout, availableWidth: availableWidth(forDisplay: id))
}
func isInPanel(_ widgetID: String) -> Bool {
layout.contains { $0.widgetID == widgetID }
}
func add(_ widget: any OnyxWidget) {
guard !isInPanel(widget.id) else { return }
layout.append(WidgetPlacement(widgetID: widget.id))
}
func remove(_ widgetID: String) {
layout.removeAll { $0.widgetID == widgetID }
}
func move(fromOffsets source: IndexSet, toOffset destination: Int) {
layout.move(fromOffsets: source, toOffset: destination)
}
private func persistLayout() {
do {
try store.save(layout)
} catch {
// Ein fehlgeschlagener Schreibvorgang darf die App nicht anhalten;
// beim nächsten Start greift dann das Standardlayout.
NSLog("Onyx: Layout konnte nicht gespeichert werden — \(error.localizedDescription)")
}
}
}