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
@@ -44,6 +44,9 @@ public final class SystemAudio {
public private(set) var inputIsAdjustable = false
private var listeners: [PropertyListener] = []
/// Zuhörer, die am gewählten Gerät hängen und beim Wechsel neu gesetzt
/// werden müssen.
private var deviceListeners: [PropertyListener] = []
/// Die Pegel von Ausgabe und Eingabe.
///
@@ -59,6 +62,9 @@ public final class SystemAudio {
// MARK: - Lesen
public func refresh() {
let previousOutput = outputDeviceID
let previousInput = inputDeviceID
outputDevices = Self.devices().filter(\.isOutput)
inputDevices = Self.devices().filter(\.isInput)
@@ -79,6 +85,13 @@ public final class SystemAudio {
alertVolume = alertDevice == outputDeviceID
? nil // Dasselbe Gerät, derselbe Regler — keine zweite Zeile dafür.
: Self.volume(alertDevice, scope: kAudioDevicePropertyScopeOutput)
// Nur bei einem echten Wechsel neu anmelden. Ein Abmelden mitten im
// Rückruf, der gerade läuft, ist eine Einladung zum Absturz.
if outputDeviceID != previousOutput || inputDeviceID != previousInput
|| deviceListeners.isEmpty {
observeCurrentDevices()
}
}
// MARK: - Setzen
@@ -135,6 +148,48 @@ public final class SystemAudio {
}
}
/// Zuhörer an Lautstärke und Stummschaltung der **gerade gewählten** Geräte.
///
/// **Das fehlte.** Beobachtet wurden nur Gerätewechsel; wer die Lautstärke
/// per Tastatur änderte, bekam davon nichts mit — der Regler im Mixer stand
/// dann auf einem Wert, den es nicht mehr gab, bis irgendetwas anderes ein
/// `refresh()` auslöste.
///
/// Die Zuhörer hängen am Gerät, nicht am Systemobjekt, und müssen deshalb
/// bei jedem Gerätewechsel neu gesetzt werden.
private func observeCurrentDevices() {
deviceListeners = []
for (device, scope) in [(outputDeviceID, kAudioDevicePropertyScopeOutput),
(inputDeviceID, kAudioDevicePropertyScopeInput)] {
guard device != AudioObjectID(kAudioObjectUnknown) else { continue }
for selector in [kAudioDevicePropertyVolumeScalar, kAudioDevicePropertyMute] {
// Manche Geräte führen keine Hauptlautstärke, sondern nur
// Kanäle. Dort scheitert das Anmelden — das ist kein Fehler,
// dort gibt es schlicht nichts zu hören.
if let listener = PropertyListener(object: device,
selector: selector,
scope: scope,
handler: { [weak self] in
Task { @MainActor in self?.readLevels() }
}) {
deviceListeners.append(listener)
}
}
}
}
/// Nur die Pegel und Schalter neu lesen — ohne Geräteliste.
///
/// Getrennt von `refresh()`, weil dieser Weg aus einem Rückruf kommt, der
/// **während** einer Änderung feuert: dort auch noch die Geräte neu
/// aufzuzählen wäre Arbeit für nichts.
private func readLevels() {
outputVolume = Self.volume(outputDeviceID, scope: kAudioDevicePropertyScopeOutput) ?? 0
outputMuted = Self.muted(outputDeviceID, scope: kAudioDevicePropertyScopeOutput)
inputVolume = Self.volume(inputDeviceID, scope: kAudioDevicePropertyScopeInput) ?? 0
inputMuted = Self.muted(inputDeviceID, scope: kAudioDevicePropertyScopeInput)
}
private static func devices() -> [AudioDevice] {
var address = AudioObjectPropertyAddress(
mSelector: kAudioHardwarePropertyDevices,
@@ -325,14 +380,23 @@ public final class SystemAudio {
/// geschrieben.
private final class PropertyListener: @unchecked Sendable {
private let object = AudioObjectID(kAudioObjectSystemObject)
private let object: AudioObjectID
private var address: AudioObjectPropertyAddress
private let block: AudioObjectPropertyListenerBlock
init?(selector: AudioObjectPropertySelector, handler: @escaping @Sendable () -> Void) {
/// - Parameters:
/// - object: Standardmäßig das Systemobjekt. Für Lautstärke und
/// Stummschaltung muss es das **Gerät** sein — die sind keine
/// Eigenschaften der Hardware im Allgemeinen, sondern dieser Kiste.
/// - scope: Ein- oder Ausgabe. Global genügt nur bei Geräteeigenschaften.
init?(object: AudioObjectID = AudioObjectID(kAudioObjectSystemObject),
selector: AudioObjectPropertySelector,
scope: AudioObjectPropertyScope = kAudioObjectPropertyScopeGlobal,
handler: @escaping @Sendable () -> Void) {
self.object = object
address = AudioObjectPropertyAddress(
mSelector: selector,
mScope: kAudioObjectPropertyScopeGlobal,
mScope: scope,
mElement: kAudioObjectPropertyElementMain)
block = { _, _ in handler() }
guard AudioObjectAddPropertyListenerBlock(object, &address, .main, block) == noErr