From edb327cabeba2c50d7f02479a8aed2da9a2e9ffe Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sat, 5 Sep 2026 21:05:51 +0200 Subject: [PATCH] Kacheln und Fenster sprechen dieselbe Sprache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Onyx/AppModel.swift | 85 ++ Onyx/AppearanceSettingsView.swift | 198 +++ Onyx/FanSettingsView.swift | 84 +- Onyx/FanWidget.swift | 78 +- Onyx/GalleryView.swift | 568 ++++++++ Onyx/GalleryWindowController.swift | 100 ++ Onyx/Localizable.xcstrings | 1168 +++++++++++++++++ Onyx/MixerMenuBarModule.swift | 2 +- Onyx/OnboardingView.swift | 45 +- Onyx/OnboardingWindowController.swift | 5 +- Onyx/OnyxApp.swift | 271 +++- Onyx/SettingsSection.swift | 69 + Onyx/SettingsView.swift | 656 +++++---- Onyx/SettingsWindowController.swift | 196 ++- Onyx/WidgetOptionsView.swift | 233 ++-- Onyx/WindowPlacement.swift | 26 + .../Sources/AudioProvider/MixerView.swift | 256 +--- .../Sources/AudioProvider/ProcessTap.swift | 124 +- .../Sources/AudioProvider/SystemAudio.swift | 70 +- .../CalendarSourceSettings.swift | 4 +- .../CalendarProvider/CalendarWidget.swift | 72 +- .../Sources/MediaProvider/MediaWidget.swift | 18 +- .../MetricsProvider/BatteryGlyph.swift | 7 +- .../MetricsProvider/ChargeLimitView.swift | 4 +- .../MetricsProvider/MetricPopovers.swift | 65 +- .../NetworkProvider/NetworkPopover.swift | 33 +- .../NetworkProvider/NetworkWidget.swift | 22 +- .../Sources/OnyxWidgetKit/WidgetGrid.swift | 2 +- .../Sources/ShelfProvider/AirDropWidget.swift | 19 +- .../Sources/ShelfProvider/ShelfWidget.swift | 41 +- .../WeatherProvider/WeatherWidget.swift | 53 +- .../TapBufferLayoutTests.swift | 71 + 32 files changed, 3677 insertions(+), 968 deletions(-) create mode 100644 Onyx/AppearanceSettingsView.swift create mode 100644 Onyx/GalleryView.swift create mode 100644 Onyx/GalleryWindowController.swift create mode 100644 Onyx/SettingsSection.swift create mode 100644 Onyx/WindowPlacement.swift create mode 100644 Packages/OnyxKit/Tests/AudioProviderTests/TapBufferLayoutTests.swift diff --git a/Onyx/AppModel.swift b/Onyx/AppModel.swift index 8065df4..e06b0ed 100644 --- a/Onyx/AppModel.swift +++ b/Onyx/AppModel.swift @@ -20,6 +20,9 @@ final class AppModel { 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 { @@ -37,6 +40,68 @@ final class AppModel { } } + /// 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) } } @@ -86,6 +151,11 @@ final class AppModel { 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" @@ -101,6 +171,21 @@ final class AppModel { 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 diff --git a/Onyx/AppearanceSettingsView.swift b/Onyx/AppearanceSettingsView.swift new file mode 100644 index 0000000..c0f1fe3 --- /dev/null +++ b/Onyx/AppearanceSettingsView.swift @@ -0,0 +1,198 @@ +import SwiftUI +import OnyxDesign + +/// Was am Aussehen einstellbar ist: die Farben und die Bewegung. +/// +/// Drei Farben, nicht zehn: die Akzentfarbe, mit der Onyx sich selbst zeichnet, +/// und das Paar für Herunter- und Hochladen. Die Signalfarben stehen bewusst +/// nicht zur Wahl — Gelb heißt Warnung, Rot heißt kritisch, und wer das +/// umstellen kann, kann die Bedeutung zerstören. +/// +/// Die Bewegung kommt als **Profil**, nicht als Federkonstanten. Ausfahren und +/// Einfahren müssen zueinander passen; diese Beziehung wählt man, nicht vier +/// Zahlen. +struct AppearanceSettingsView: View { + + @Bindable var theme: OnyxTheme + + /// Ein paar Vorschläge, damit man nicht im Farbwähler anfangen muss. + /// + /// Alle gedämpft: Onyx ist eine dunkle Fläche, und eine volle Sättigung + /// darauf sticht, statt zu leiten. + private static let suggestions: [(name: LocalizedStringKey, hex: String)] = [ + ("color.suggestion.onyx", "#7FA8FF"), + ("color.suggestion.mint", "#6FCF97"), + ("color.suggestion.amber", "#E8B44A"), + ("color.suggestion.coral", "#E5675C"), + ("color.suggestion.lilac", "#B79CFF"), + ("color.suggestion.slate", "#9AA6B2"), + ] + + var body: some View { + SettingsPage { + accent + network + material + motion + fixed + } + } + + private var accent: some View { + OnyxFormCard(String(localized: "color.section.accent")) { + OnyxFormRow(String(localized: "color.accent"), + hint: String(localized: "color.accent.hint")) { + ColorPicker("", selection: $theme.accent, supportsOpacity: false) + .labelsHidden() + } + + OnyxFormRow(String(localized: "color.suggestions")) { + HStack(spacing: Onyx.Space.row) { + ForEach(Self.suggestions, id: \.hex) { suggestion in + Swatch(hex: suggestion.hex, + name: suggestion.name, + isSelected: theme.accent.onyxHex == suggestion.hex) { + theme.accent = Color(onyxHex: suggestion.hex) ?? theme.accent + } + } + } + } + + // Mit dem Farbfleck daneben: welche Farbe das System gerade führt, + // weiß man nicht auswendig — und dann wirkt das Ergebnis wie ein + // Fehler statt wie eine Übernahme. + OnyxFormRow(String(localized: "color.adoptSystem")) { + Button { theme.adoptSystemAccent() } label: { + HStack(spacing: Onyx.Space.sm) { + Circle() + .fill(Color(nsColor: .controlAccentColor)) + .frame(width: 11, height: 11) + Text("settings.action.adopt") + } + } + } + } + } + + private var network: some View { + OnyxFormCard(String(localized: "color.section.network")) { + OnyxFormRow(String(localized: "color.download"), + hint: String(localized: "color.network.hint")) { + ColorPicker("", selection: $theme.download, supportsOpacity: false) + .labelsHidden() + } + + OnyxFormRow(String(localized: "color.upload")) { + ColorPicker("", selection: $theme.upload, supportsOpacity: false) + .labelsHidden() + } + + OnyxFormRow(String(localized: "color.swap")) { + Button("settings.action.swap") { + let previous = theme.download + theme.download = theme.upload + theme.upload = previous + } + } + } + } + + private var material: some View { + OnyxFormCard(String(localized: "settings.appearance.material"), + hint: String(localized: "settings.appearance.material.hint")) { + OnyxFormRow(String(localized: "settings.appearance.surface")) { + Picker("", selection: $theme.material) { + ForEach(NotchMaterial.allCases, id: \.self) { material in + Text(material.localizedName).tag(material) + } + } + .labelsHidden() + .pickerStyle(.segmented) + .frame(width: 240) + } + } + } + + private var motion: some View { + OnyxFormCard(String(localized: "settings.appearance.motion"), + hint: String(localized: "settings.appearance.motion.hint")) { + OnyxFormRow(String(localized: "settings.appearance.profile")) { + Picker("", selection: $theme.motion) { + ForEach(MotionProfile.allCases, id: \.self) { profile in + Text(profile.localizedName).tag(profile) + } + } + .labelsHidden() + .pickerStyle(.segmented) + .frame(width: 240) + } + } + } + + private var fixed: some View { + OnyxFormCard(String(localized: "color.section.fixed"), + hint: String(localized: "color.fixed.hint")) { + // Sichtbar, aber nicht bedienbar: sonst sucht man die Warnfarbe + // hier und findet sie nirgends. + OnyxFormRow(String(localized: "color.fixed.positive")) { + Circle().fill(Onyx.Color.positive).frame(width: 14, height: 14) + } + OnyxFormRow(String(localized: "color.fixed.warning")) { + Circle().fill(Onyx.Color.warning).frame(width: 14, height: 14) + } + OnyxFormRow(String(localized: "color.fixed.critical")) { + Circle().fill(Onyx.Color.critical).frame(width: 14, height: 14) + } + OnyxFormRow(String(localized: "color.reset")) { + Button("settings.action.reset") { theme.reset() } + .disabled(theme.isDefault) + } + } + } +} + +extension NotchMaterial { + var localizedName: LocalizedStringKey { + switch self { + case .stone: "settings.material.stone" + case .blur: "settings.material.blur" + case .glass: "settings.material.glass" + } + } +} + +extension MotionProfile { + var localizedName: LocalizedStringKey { + switch self { + case .snappy: "settings.motion.snappy" + case .bouncy: "settings.motion.bouncy" + case .calm: "settings.motion.calm" + } + } +} + +/// Ein Farbfeld zum Anklicken. +private struct Swatch: View { + let hex: String + let name: LocalizedStringKey + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + Circle() + .fill(Color(onyxHex: hex) ?? .gray) + .frame(width: 20, height: 20) + .overlay { + // Der Ring statt eines Hakens: ein Haken auf einer dunklen + // Farbe ist nicht zu sehen, auf einer hellen nicht auf + // einer dunklen Fläche. + Circle() + .strokeBorder(.primary, lineWidth: isSelected ? 2 : 0) + .padding(-3) + } + } + .buttonStyle(.plain) + .help(Text(name)) + } +} diff --git a/Onyx/FanSettingsView.swift b/Onyx/FanSettingsView.swift index e6a94cc..2daa339 100644 --- a/Onyx/FanSettingsView.swift +++ b/Onyx/FanSettingsView.swift @@ -12,7 +12,7 @@ struct FanSettingsView: View { @Bindable var control: FanControl var body: some View { - Form { + SettingsPage { switch control.installState { case .notInstalled, .failed: installSection @@ -23,16 +23,13 @@ struct FanSettingsView: View { // Das Ladelimit hängt am selben Helfer wie die Lüfter — beides // ist Hardwaresteuerung über den SMC, und beides ist erst // erreichbar, wenn der Helfer läuft. - Section("charge.section") { + OnyxFormCard(String(localized: "charge.section")) { ChargeLimitControl(control: control, roomy: true) + .padding(.horizontal, OnyxForm.rowPadding) + .padding(.bottom, OnyxForm.rowPadding) } } } - .formStyle(.grouped) - // Der eigene Untergrund des Formulars weicht ein, zwei Prozent von dem - // des Fensters ab. In Dunkelgrau sieht man genau das als Kante. - .scrollContentBackground(.hidden) - .padding() .onAppear { control.refreshInstallState() control.start() @@ -43,37 +40,33 @@ struct FanSettingsView: View { // MARK: - Einrichtung private var installSection: some View { - Section { - Text("fans.intro") - .font(.callout) - .fixedSize(horizontal: false, vertical: true) - + OnyxFormCard(String(localized: "fans.group.setup"), + hint: String(localized: "fans.intro")) { // Das Risiko benennen, bevor jemand zustimmt — nicht danach. - Label("fans.warning", systemImage: "exclamationmark.triangle.fill") - .font(.callout) - .foregroundStyle(Onyx.Color.warning) - .fixedSize(horizontal: false, vertical: true) - - Text("fans.safety") - .font(.callout) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) + OnyxFormRow(String(localized: "fans.warning"), + hint: String(localized: "fans.safety"), + symbol: "exclamationmark.triangle.fill", + tone: .warning) if case .failed(let message) = control.installState { - Text(message).font(.callout).foregroundStyle(Onyx.Color.critical) + OnyxFormRow(message, symbol: "xmark.octagon", tone: .critical) } - Button("fans.install") { control.install() } + OnyxFormRow(String(localized: "fans.install")) { + Button("fans.install") { control.install() } + } } } private var approvalSection: some View { - Section { - Label("fans.approval.needed", systemImage: "hand.raised") - .font(.callout) - .fixedSize(horizontal: false, vertical: true) - Button("fans.approval.open") { control.openApprovalSettings() } - Button("fans.approval.recheck") { control.refreshInstallState() } + OnyxFormCard(String(localized: "fans.group.setup")) { + OnyxFormRow(String(localized: "fans.approval.needed"), + symbol: "hand.raised") { + Button("fans.approval.open") { control.openApprovalSettings() } + } + OnyxFormRow(String(localized: "fans.action.recheck")) { + Button("fans.action.recheck") { control.refreshInstallState() } + } } } @@ -82,14 +75,13 @@ struct FanSettingsView: View { @ViewBuilder private var controlSection: some View { if let report = control.report { - Section { + OnyxFormCard(String(localized: "fans.group.control")) { if let hottest = report.hottestCelsius { - HStack { - Label("fans.hottest", systemImage: "thermometer") - Spacer() + OnyxFormRow(String(localized: "fans.hottest"), symbol: "thermometer") { Text("\(Int(hottest))°").monospacedDigit() .foregroundStyle(hottest >= FanSafety.criticalCelsius - ? Onyx.Color.critical : .secondary) + ? AnyShapeStyle(Onyx.Color.critical) + : AnyShapeStyle(.secondary)) } } ForEach(report.fans, id: \.index) { fan in @@ -97,22 +89,21 @@ struct FanSettingsView: View { } } - Section { - Text("fans.safety.active") - .font(.callout).foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - Button("fans.uninstall", role: .destructive) { - Task { await control.uninstall() } + OnyxFormCard(String(localized: "fans.group.danger"), + hint: String(localized: "fans.safety.active")) { + OnyxFormRow(String(localized: "fans.uninstall")) { + Button("settings.action.remove", role: .destructive) { + Task { await control.uninstall() } + } } } } else { - Section { - HStack { + OnyxFormCard(String(localized: "fans.group.control")) { + OnyxFormRow(String(localized: "fans.connecting")) { ProgressView().controlSize(.small) - Text("fans.connecting").foregroundStyle(.secondary) } if let error = control.lastError { - Text(error).font(.callout).foregroundStyle(Onyx.Color.critical) + OnyxFormRow(error, symbol: "xmark.octagon", tone: .critical) } } } @@ -127,7 +118,7 @@ private struct FanRow: View { @State private var editing = false var body: some View { - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: Onyx.Space.sm) { HStack { Label(name, systemImage: "fan") Spacer() @@ -173,6 +164,9 @@ private struct FanRow: View { .fixedSize(horizontal: false, vertical: true) } } + // Die Karte polstert nur `OnyxFormRow`; dieser Block ist ein eigener. + .padding(.horizontal, OnyxForm.rowPadding) + .padding(.vertical, Onyx.Space.row) .onAppear { if !editing { target = max(fan.targetRPM, fan.limits.minimum) } } .onChange(of: fan.targetRPM) { _, new in if !editing { target = new } } } diff --git a/Onyx/FanWidget.swift b/Onyx/FanWidget.swift index fd3a4fe..97f8894 100644 --- a/Onyx/FanWidget.swift +++ b/Onyx/FanWidget.swift @@ -16,7 +16,7 @@ struct FanView: View { let roomy: Bool var body: some View { - VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: Onyx.Space.row) { switch control.installState { case .installed: if let report = control.report, !report.fans.isEmpty { @@ -24,10 +24,10 @@ struct FanView: View { FanRow(control: control, index: index, fan: fan, roomy: roomy) } if let hottest = report.hottestCelsius { - Divider().overlay(Onyx.Color.hairline) - HStack(spacing: 5) { + OnyxHairline() + HStack(spacing: Onyx.Space.sm) { Image(systemName: "thermometer.medium") - .font(.system(size: 10)) + .font(.system(size: Onyx.Symbol.small)) Text("\(Int(hottest.rounded())) °C") .font(Onyx.Font.metricSmall).monospacedDigit() Spacer(minLength: 0) @@ -35,14 +35,24 @@ struct FanView: View { .foregroundStyle(Onyx.Color.textTertiary) } } else { - notice("fans.noData", symbol: "fan") + OnyxNotice(symbol: "fan", + text: String(localized: "fans.noData"), + placement: .leading) } case .requiresApproval: - notice("fans.approval.needed", symbol: "hand.raised") { - control.openApprovalSettings() + OnyxNotice(symbol: "hand.raised", + text: String(localized: "fans.approval.needed"), + placement: .leading) { + OnyxPillButton(String(localized: "fans.install")) { + control.openApprovalSettings() + } } case .notInstalled: - notice("fans.notInstalled", symbol: "fan") { control.install() } + OnyxNotice(symbol: "fan", + text: String(localized: "fans.notInstalled"), + placement: .leading) { + OnyxPillButton(String(localized: "fans.install")) { control.install() } + } case .failed(let reason): Text(reason) .font(Onyx.Font.caption) @@ -54,29 +64,6 @@ struct FanView: View { .onAppear { control.refreshInstallState() } } - @ViewBuilder - private func notice(_ key: LocalizedStringKey, symbol: String, - action: (() -> Void)? = nil) -> some View { - VStack(alignment: .leading, spacing: 6) { - Image(systemName: symbol) - .font(.system(size: 15, weight: .light)) - .foregroundStyle(Onyx.Color.textTertiary) - Text(key) - .font(Onyx.Font.caption) - .foregroundStyle(Onyx.Color.textSecondary) - .fixedSize(horizontal: false, vertical: true) - if let action { - Button(action: action) { - Text("fans.install") - .font(Onyx.Font.caption) - .foregroundStyle(Onyx.Color.surface) - .padding(.horizontal, 10).padding(.vertical, 4) - .background(Onyx.Color.accent, in: .capsule) - } - .buttonStyle(.plain) - } - } - } } private struct FanRow: View { @@ -86,8 +73,8 @@ private struct FanRow: View { let roomy: Bool var body: some View { - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 6) { + VStack(alignment: .leading, spacing: Onyx.Space.xs) { + HStack(spacing: Onyx.Space.sm) { Text("fans.fan \(fan.index + 1)") .font(Onyx.Font.caption) .foregroundStyle(Onyx.Color.textSecondary) @@ -98,29 +85,16 @@ private struct FanRow: View { .foregroundStyle(Onyx.Color.textPrimary) } - HStack(spacing: 8) { + HStack(spacing: Onyx.Space.md) { // Automatik ist der Ruhezustand und bleibt einen Klick entfernt: // wer manuell geregelt hat, muss ohne Nachdenken zurückkönnen. // Als gefüllte Pille, wenn sie greift — ein blauer Text sieht // aus wie ein Link und nicht wie ein Zustand. - Button { control.setAutomatic(index: index) } label: { - Text("fans.auto") - .font(Onyx.Font.metricSmall) - .foregroundStyle(fan.isManual ? Onyx.Color.textTertiary - : Onyx.Color.surface) - .padding(.horizontal, 7) - .padding(.vertical, 2) - .background { - Capsule() - .fill(fan.isManual ? Color.clear : Onyx.Color.accent) - .overlay { - Capsule().strokeBorder( - fan.isManual ? Onyx.Color.hairline : .clear, - lineWidth: 1) - } - } + OnyxPillButton(String(localized: "fans.auto"), + isFilled: !fan.isManual, + size: .compact) { + control.setAutomatic(index: index) } - .buttonStyle(.plain) .disabled(!fan.isManual) // Ein Regler über einen leeren Bereich stürzt ab. Meldet der @@ -184,7 +158,7 @@ final class FanMenuBarModule: MenuBarModule { func makePopoverView() -> AnyView { AnyView(FanView(control: control, roomy: true) - .padding(14) + .padding(Onyx.Space.xl) .frame(width: 260)) } diff --git a/Onyx/GalleryView.swift b/Onyx/GalleryView.swift new file mode 100644 index 0000000..24668fc --- /dev/null +++ b/Onyx/GalleryView.swift @@ -0,0 +1,568 @@ +import SwiftUI +import OnyxDesign +import OnyxWidgetKit + +/// Das Schaufenster: jede Kachel, das ganze Panel und alle Token — ohne die +/// Notch anzufahren. +/// +/// **Warum ein Fenster und keine `#Preview`.** Der Baum setzt +/// `ENABLE_DEBUG_DYLIB = NO`, weil Xcode den Programmcode sonst in eine eigene +/// dylib auslagert und TCC die Berechtigungsanfragen dem verbleibenden Rumpf +/// zuordnet, der weder Info.plist noch Entitlements trägt. Genau diese +/// Auslagerung brauchen SwiftUIs Vorschauen aber. Die Vorschau ist hier also +/// nicht vergessen worden, sie ist der Preis einer Entscheidung, die vorher +/// fiel. Ein Fenster kostet ihn nicht — und zeigt zudem die echten Modelle +/// statt erfundener. +/// +/// **Nicht lokalisiert, und das mit Absicht.** Das Schaufenster ist Werkzeug, +/// keine Programmoberfläche. Seine Beschriftungen im Sprachkatalog hätten +/// dieselbe Sichtbarkeit wie die der Einstellungen und wären doch nie zu +/// übersetzen. +struct GalleryView: View { + + let model: AppModel + + /// Bleibt stehen, wo man zuletzt war. + /// + /// Nicht nur bequem: `UserDefaults` liest Startargumente mit, also führt + /// `open Onyx.app --args -onyx.gallery.section tokens` direkt in den + /// Bereich, an dem man gerade arbeitet. Bauen, starten, hinsehen — ohne + /// Zwischenklick. + @AppStorage("onyx.gallery.section") private var section: GallerySection = .tiles + + var body: some View { + VStack(spacing: 0) { + Picker("", selection: $section) { + ForEach(GallerySection.allCases) { Text($0.title).tag($0) } + } + .pickerStyle(.segmented) + .labelsHidden() + .padding(.horizontal, Onyx.Space.window) + .padding(.top, Onyx.Space.row) + .padding(.bottom, Onyx.Space.xl) + + Divider() + + ScrollView { + switch section { + case .tiles: TileGallery() + case .panel: PanelGallery(model: model) + case .components: ComponentGallery() + case .tokens: TokenGallery() + } + } + .background(Color(nsColor: .windowBackgroundColor)) + } + .frame(minWidth: 760, minHeight: 520) + // Im Rumpf gelesen, damit ein Farbwechsel ankommt — wie im Panel. + .tint(Onyx.Color.accent) + .labelStyle(.onyx) + } +} + +enum GallerySection: String, CaseIterable, Identifiable { + case tiles, panel, components, tokens + + var id: Self { self } + + var title: String { + switch self { + case .tiles: "Kacheln" + case .panel: "Panel" + case .components: "Bauteile" + case .tokens: "Token" + } + } +} + +// MARK: - Kacheln + +/// Jede angemeldete Kachel in echter Größe auf echter Paneloberfläche. +/// +/// Echte Größe und echte Fläche sind der ganze Zweck: eine Kachel, die man in +/// einem beliebig breiten Fenster beurteilt, sieht dort immer gut aus. 180 × 200 +/// auf schwarzem Stein ist der Ort, an dem sie später steht. +private struct TileGallery: View { + + private struct Entry: Identifiable { + let id: String + let widget: any OnyxWidget + } + + var body: some View { + let entries = WidgetRegistry.shared.all.map { Entry(id: $0.id, widget: $0) } + + VStack(alignment: .leading, spacing: Onyx.Space.lg) { + if entries.isEmpty { + Text("Keine Kacheln angemeldet.") + .font(.callout) + .foregroundStyle(.secondary) + } else { + LazyVGrid( + columns: [GridItem(.adaptive(minimum: LayoutEngine.tileSize.width, + maximum: LayoutEngine.tileSize.width), + spacing: Onyx.Metric.tileSpacing)], + alignment: .leading, + spacing: Onyx.Space.xxl + ) { + ForEach(entries) { entry in + VStack(spacing: Onyx.Space.sm) { + // Dieselbe Reihenfolge wie in `WidgetGrid`: Kachel, + // dann Rahmen, dann beschneiden. Wer erst + // beschneidet, sieht Überlauf nicht, den das Panel + // später zeigt. + OnyxTile { entry.widget.makeView() } + .frame(width: LayoutEngine.tileSize.width, + height: LayoutEngine.tileSize.height) + .clipped() + + Text(entry.id) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + .padding(Onyx.Metric.panelPadding) + .background(OnyxSurface()) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(Onyx.Space.window) + } +} + +// MARK: - Panel + +/// Das ganze Panel, so breit, wie man es einstellt. +/// +/// Die Breite ist regelbar, weil die Spaltenzahl an ihr hängt: `LayoutEngine` +/// rechnet aus der verfügbaren Breite, wie viele Kacheln nebeneinander passen, +/// und ab wann eine zweite Reihe entsteht. Genau diese Sprünge will man sehen, +/// ohne dafür einen Bildschirm anzustecken. +private struct PanelGallery: View { + + let model: AppModel + + @State private var availableWidth: Double = 1680 + + var body: some View { + let width = CGFloat(availableWidth) + let size = WidgetGrid.panelSize(for: model.layout, availableWidth: width) + + VStack(alignment: .leading, spacing: Onyx.Space.xl) { + HStack(spacing: Onyx.Space.lg) { + Text("Verfügbare Breite") + .font(.callout) + Slider(value: $availableWidth, in: 400...3000) + .frame(maxWidth: 320) + Text("\(Int(availableWidth)) pt") + .font(.callout.monospacedDigit()) + .foregroundStyle(.secondary) + } + + Text("Panel \(Int(size.width)) × \(Int(size.height)) pt · " + + "\(LayoutEngine.columns(availableWidth: width)) Spalten · " + + "\(model.layout.count) Kacheln") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + + WidgetGrid(placements: model.layout, availableWidth: width) + .background(OnyxSurface()) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(Onyx.Space.window) + } +} + +// MARK: - Bauteile + +/// Jedes Bauteil in jedem Zustand. +/// +/// Das ist der Teil, den es bei den Kacheln noch nicht gibt: deren Modelle +/// führen ihren Zustand als `private(set)`, „leer" oder „Fehler" lässt sich von +/// außen nicht setzen. Bei den Bauteilen ist der Zustand ein Argument — hier +/// kann man ihn also einfach hinschreiben. +private struct ComponentGallery: View { + + @State private var volume = 0.62 + @State private var overdrive = 1.8 + @State private var targeted = true + + var body: some View { + VStack(alignment: .leading, spacing: Onyx.Space.xxl) { + group("OnyxCard mit OnyxHairline und OnyxSectionTitle") { + VStack(alignment: .leading, spacing: Onyx.Space.md) { + OnyxSectionTitle("System") + OnyxCard { + VStack(spacing: 0) { + row("MacBook Pro-Lautsprecher", "41 %") + OnyxHairline() + row("MacBook Pro-Mikrofon", "27 %") + } + } + } + .frame(width: 260) + } + + group("OnyxNotice") { + HStack(alignment: .top, spacing: Onyx.Space.lg) { + tile { OnyxNotice(symbol: "wifi.slash", text: "Kein Netz erreichbar.") } + tile { + OnyxNotice(symbol: "hand.raised", + text: "Der Helfer wartet auf deine Freigabe.") { + OnyxPillButton("Einstellungen") {} + } + } + tile { + OnyxNotice(symbol: "fan", + text: "Noch keine Lüfterdaten.", + placement: .leading) + } + tile { + OnyxNotice(symbol: "fan", + text: "Der Helfer ist nicht installiert.", + placement: .leading) { + OnyxPillButton("Installieren") {} + } + } + } + } + + group("OnyxPillButton") { + HStack(spacing: Onyx.Space.lg) { + OnyxPillButton("Einschalten") {} + OnyxPillButton("Automatik", isFilled: false) {} + OnyxPillButton("Auto", size: .compact) {} + OnyxPillButton("Auto", isFilled: false, size: .compact) {} + } + } + + group("OnyxSlider") { + VStack(alignment: .leading, spacing: Onyx.Space.lg) { + sliderRow("normal") { + OnyxSlider(value: volume) { volume = $0 } + } + sliderRow("mit Pegel") { + OnyxSlider(value: volume, level: 0.35) { volume = $0 } + } + sliderRow("stumm") { + OnyxSlider(value: volume, isActive: false) { volume = $0 } + } + sliderRow("Overdrive") { + OnyxSlider(value: overdrive, maximum: 4, level: 0.5) { overdrive = $0 } + } + } + .frame(width: 320) + } + + group("OnyxDropZone") { + VStack(alignment: .leading, spacing: Onyx.Space.lg) { + Toggle("Zeiger schwebt darüber", isOn: $targeted) + .font(Onyx.Font.body) + .foregroundStyle(Onyx.Color.textSecondary) + HStack(spacing: Onyx.Space.lg) { + tile { + OnyxNotice(symbol: "paperplane", text: "Zum Senden ablegen") + .onyxDropZone(isTargeted: targeted, style: .always) + } + tile { + OnyxNotice(symbol: "tray", text: "Nimmt nebenbei Dateien an") + .onyxDropZone(isTargeted: targeted, style: .whenTargeted) + } + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(Onyx.Metric.panelPadding) + .background(OnyxSurface()) + .padding(Onyx.Space.window) + } + + private func row(_ name: String, _ value: String) -> some View { + HStack { + Text(name) + .font(Onyx.Font.caption) + .foregroundStyle(Onyx.Color.textSecondary) + Spacer(minLength: Onyx.Space.md) + Text(value) + .font(Onyx.Font.metricSmall) + .foregroundStyle(Onyx.Color.textTertiary) + } + .padding(.horizontal, Onyx.Space.md) + .padding(.vertical, Onyx.Space.md) + } + + /// Bauteile in einer echten Kachel zeigen, nicht frei auf der Fläche: ein + /// Leerzustand, der sich mittig ausrichtet, braucht etwas, worin er das tun + /// kann. + private func tile(@ViewBuilder content: () -> some View) -> some View { + OnyxTile { content() } + .frame(width: LayoutEngine.tileSize.width, height: 130) + } + + private func sliderRow(_ name: String, + @ViewBuilder content: () -> some View) -> some View { + HStack(spacing: Onyx.Space.lg) { + Text(name) + .font(Onyx.Font.micro) + .foregroundStyle(Onyx.Color.textTertiary) + .frame(width: 72, alignment: .leading) + content() + } + } + + private func group(_ title: String, + @ViewBuilder content: () -> some View) -> some View { + VStack(alignment: .leading, spacing: Onyx.Space.lg) { + Text(title) + .font(Onyx.Font.title) + .foregroundStyle(Onyx.Color.textPrimary) + content() + } + } +} + +// MARK: - Token + +/// Alle Token an einem Ort — auf der Fläche, für die sie gemacht sind. +/// +/// Farben auf hellem Fensterhintergrund zu zeigen wäre irreführend: `hairline` +/// ist Weiß mit 8 % Deckkraft und auf Weiß schlicht nicht da. +private struct TokenGallery: View { + + @State private var moved = false + + var body: some View { + VStack(alignment: .leading, spacing: Onyx.Space.xxl) { + colors + spaces + radii + fonts + metrics + motion + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(Onyx.Metric.panelPadding) + .background(OnyxSurface()) + .padding(Onyx.Space.window) + } + + private var colors: some View { + let swatches: [(String, Color)] = [ + ("surface", Onyx.Color.surface), + ("elevated", Onyx.Color.elevated), + ("hairline", Onyx.Color.hairline), + ("textPrimary", Onyx.Color.textPrimary), + ("textSecondary", Onyx.Color.textSecondary), + ("textTertiary", Onyx.Color.textTertiary), + ("accent", Onyx.Color.accent), + ("download", Onyx.Color.download), + ("upload", Onyx.Color.upload), + ("positive", Onyx.Color.positive), + ("warning", Onyx.Color.warning), + ("critical", Onyx.Color.critical), + ] + + return section("Farben") { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 120, maximum: 160), spacing: Onyx.Space.row)], + alignment: .leading, spacing: Onyx.Space.row) { + ForEach(swatches, id: \.0) { name, color in + HStack(spacing: Onyx.Space.md) { + RoundedRectangle(cornerRadius: Onyx.Radius.thumbnail, style: .continuous) + .fill(color) + .overlay { + RoundedRectangle(cornerRadius: Onyx.Radius.thumbnail, style: .continuous) + .strokeBorder(Onyx.Color.hairline, + lineWidth: Onyx.Metric.hairlineWidth) + } + .frame(width: 28, height: 28) + Text(name) + .font(Onyx.Font.caption) + .foregroundStyle(Onyx.Color.textSecondary) + } + } + } + } + } + + private var spaces: some View { + let steps: [(String, CGFloat)] = [ + ("xxs", Onyx.Space.xxs), + ("xs", Onyx.Space.xs), + ("sm", Onyx.Space.sm), + ("md", Onyx.Space.md), + ("lg", Onyx.Space.lg), + ("xl", Onyx.Space.xl), + ("xxl", Onyx.Space.xxl), + ] + + return section("Abstände") { + VStack(alignment: .leading, spacing: Onyx.Space.md) { + ForEach(steps, id: \.0) { name, value in + HStack(spacing: Onyx.Space.lg) { + Text(name) + .font(Onyx.Font.micro) + .foregroundStyle(Onyx.Color.textTertiary) + .frame(width: 40, alignment: .leading) + Rectangle() + .fill(Onyx.Color.accent) + .frame(width: value, height: 12) + Text("\(Int(value))") + .font(Onyx.Font.metricMicro) + .foregroundStyle(Onyx.Color.textSecondary) + } + } + } + } + } + + private var radii: some View { + let steps: [(String, CGFloat)] = [ + ("chip", Onyx.Radius.chip), + ("control", Onyx.Radius.control), + ("card", Onyx.Radius.card), + ("tile", Onyx.Radius.tile), + ("notch", Onyx.Radius.notch), + ("panel", Onyx.Radius.panel), + ] + + return section("Rundungen") { + HStack(alignment: .top, spacing: Onyx.Space.lg) { + ForEach(steps, id: \.0) { name, value in + VStack(spacing: Onyx.Space.sm) { + RoundedRectangle(cornerRadius: value, style: .continuous) + .fill(Onyx.Color.elevated) + .overlay { + RoundedRectangle(cornerRadius: value, style: .continuous) + .strokeBorder(Onyx.Color.hairline, + lineWidth: Onyx.Metric.hairlineWidth) + } + .frame(width: 64, height: 64) + Text("\(name) \(Int(value))") + .font(Onyx.Font.micro) + .foregroundStyle(Onyx.Color.textTertiary) + } + } + } + } + } + + private var fonts: some View { + let specimens: [(String, Font)] = [ + ("micro", Onyx.Font.micro), + ("caption", Onyx.Font.caption), + ("footnote", Onyx.Font.footnote), + ("body", Onyx.Font.body), + ("title", Onyx.Font.title), + ("headline", Onyx.Font.headline), + ("display", Onyx.Font.display), + ("displayLarge", Onyx.Font.displayLarge), + ("metricMicro", Onyx.Font.metricMicro), + ("metricSmall", Onyx.Font.metricSmall), + ("metric", Onyx.Font.metric), + ("metricLarge", Onyx.Font.metricLarge), + ("symbolHero", Onyx.Font.symbolHero), + ] + + return section("Schrift") { + VStack(alignment: .leading, spacing: Onyx.Space.md) { + ForEach(specimens, id: \.0) { name, font in + HStack(alignment: .firstTextBaseline, spacing: Onyx.Space.lg) { + Text(name) + .font(Onyx.Font.micro) + .foregroundStyle(Onyx.Color.textTertiary) + .frame(width: 96, alignment: .leading) + Text("Onyx 0123456789") + .font(font) + .foregroundStyle(Onyx.Color.textPrimary) + } + } + } + } + } + + private var metrics: some View { + let values: [(String, CGFloat)] = [ + ("panelPadding", Onyx.Metric.panelPadding), + ("tileSpacing", Onyx.Metric.tileSpacing), + ("hairlineWidth", Onyx.Metric.hairlineWidth), + ] + + return section("Panelgeometrie") { + VStack(alignment: .leading, spacing: Onyx.Space.md) { + ForEach(values, id: \.0) { name, value in + HStack(spacing: Onyx.Space.lg) { + Text(name) + .font(Onyx.Font.micro) + .foregroundStyle(Onyx.Color.textTertiary) + .frame(width: 96, alignment: .leading) + Rectangle() + .fill(Onyx.Color.accent) + .frame(width: max(value, 1), height: 12) + Text("\(Int(value))") + .font(Onyx.Font.metricMicro) + .foregroundStyle(Onyx.Color.textSecondary) + } + } + } + } + } + + /// Bewegung lässt sich nicht abbilden, nur vorführen — deshalb ein Knopf + /// und ein echter Profilwechsel statt einer Tabelle mit Federkonstanten. + private var motion: some View { + let curves: [(String, Animation)] = [ + ("expand", Onyx.Motion.expand), + ("collapse", Onyx.Motion.collapse), + ("value", Onyx.Motion.value), + ] + + return section("Bewegung") { + VStack(alignment: .leading, spacing: Onyx.Space.lg) { + Picker("Profil", selection: Binding( + get: { OnyxTheme.shared.motion }, + set: { OnyxTheme.shared.motion = $0 })) { + ForEach(MotionProfile.allCases, id: \.self) { profile in + Text(profile.rawValue).tag(profile) + } + } + .pickerStyle(.segmented) + .labelsHidden() + .frame(width: 280) + + ForEach(curves, id: \.0) { name, animation in + HStack(spacing: Onyx.Space.lg) { + Text(name) + .font(Onyx.Font.micro) + .foregroundStyle(Onyx.Color.textTertiary) + .frame(width: 96, alignment: .leading) + ZStack(alignment: .leading) { + Capsule() + .fill(Onyx.Color.hairline) + .frame(height: 4) + Circle() + .fill(Onyx.Color.accent) + .frame(width: 14, height: 14) + .offset(x: moved ? 246 : 0) + .animation(animation, value: moved) + } + .frame(width: 260, height: 14) + } + } + + Button(moved ? "Zurück" : "Los") { moved.toggle() } + } + } + } + + @ViewBuilder + private func section(_ title: String, @ViewBuilder content: () -> some View) -> some View { + VStack(alignment: .leading, spacing: Onyx.Space.lg) { + Text(title) + .font(Onyx.Font.title) + .foregroundStyle(Onyx.Color.textPrimary) + content() + } + } +} diff --git a/Onyx/GalleryWindowController.swift b/Onyx/GalleryWindowController.swift new file mode 100644 index 0000000..7a876e8 --- /dev/null +++ b/Onyx/GalleryWindowController.swift @@ -0,0 +1,100 @@ +import AppKit +import SwiftUI + +/// Führt das Schaufenster. +/// +/// Aufgebaut wie `SettingsWindowController` — aus demselben Grund: SwiftUIs +/// eigene Fensterszenen kommen bei einer App ohne Menüleiste unzuverlässig an. +@MainActor +final class GalleryWindowController: NSObject, NSWindowDelegate { + + private var window: NSWindow? + + /// Ob Onyx dauerhaft im Dock steht. Bestimmt, wohin nach dem Schließen + /// zurückgeschaltet wird. + private var keepsDockIcon = false + + /// Ob das Schaufenster überhaupt erreichbar ist. + /// + /// Im Debug immer, im Release nur auf ausdrücklichen Wunsch. Es ist + /// Werkzeug, kein Programmteil — und ein Menüeintrag, den niemand braucht, + /// ist selbst schon Unordnung. + /// + /// ``` + /// defaults write com.scarriffleservices.onyx onyx.gallery -bool YES + /// ``` + static var isAvailable: Bool { + #if DEBUG + true + #else + UserDefaults.standard.bool(forKey: "onyx.gallery") + #endif + } + + /// Ob es beim Start gleich aufgehen soll. + /// + /// Der Werkstatt-Kreislauf ist bauen, starten, hinsehen. Ohne das ist der + /// dritte Schritt ein Griff zur Maus und zwei Klicks ins Statusmenü — bei + /// jedem Durchlauf. `UserDefaults` liest Startargumente von selbst mit: + /// + /// ``` + /// open Onyx.app --args -onyx.gallery.open YES + /// ``` + static var opensAtLaunch: Bool { + isAvailable && UserDefaults.standard.bool(forKey: "onyx.gallery.open") + } + + func show(model: AppModel) { + keepsDockIcon = model.showsDockIcon + + // Solange ein Fenster offen ist, gehört Onyx in den Dock — sonst ist es + // weder über den Dock noch über ⌘-Tab zurückzuholen. + NSApp.setActivationPolicy(.regular) + + if window == nil { + let hosting = NSHostingController(rootView: GalleryView(model: model)) + + let window = NSWindow(contentViewController: hosting) + window.title = "Onyx — Schaufenster" + window.titlebarAppearsTransparent = true + window.titlebarSeparatorStyle = .none + window.styleMask = [.titled, .closable, .miniaturizable, .resizable] + window.setContentSize(NSSize(width: 1040, height: 720)) + window.contentMinSize = NSSize(width: 780, height: 540) + window.isReleasedWhenClosed = false + window.delegate = self + OnyxWindowPlacement.center(window) + window.setFrameAutosaveName("onyx.gallery") + self.window = window + } + + NSApp.activate(ignoringOtherApps: true) + window?.makeKeyAndOrderFront(nil) + } + + /// Der rote Knopf blendet aus, statt zu schließen — wie bei den + /// Einstellungen, und aus demselben Grund: Onyx hat kein Hauptfenster, und + /// manche Hilfsprogramme beenden eine App, sobald ihr letztes Fenster + /// verschwindet. + func windowShouldClose(_ sender: NSWindow) -> Bool { + hide() + return false + } + + private func hide() { + window?.orderOut(nil) + + // Nur zurück in den Hintergrund, wenn wirklich nichts mehr offen ist. + // Das Einstellungsfenster kann noch stehen — es hier mit auszublenden + // sähe aus wie ein Absturz. + let otherVisible = NSApp.windows.contains { + $0 !== window && $0.isVisible && $0.styleMask.contains(.titled) + } + guard !otherVisible else { return } + + if !keepsDockIcon { + NSApp.setActivationPolicy(.accessory) + } + NSApp.hide(nil) + } +} diff --git a/Onyx/Localizable.xcstrings b/Onyx/Localizable.xcstrings index a63bb7c..5a37403 100644 --- a/Onyx/Localizable.xcstrings +++ b/Onyx/Localizable.xcstrings @@ -73,6 +73,198 @@ } } }, + "activity.power.atLimit": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Ladelimit erreicht" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Charge limit reached" + } + } + } + }, + "activity.power.charging": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Lädt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Charging" + } + } + } + }, + "activity.power.low": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Akku fast leer" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Battery low" + } + } + } + }, + "activity.power.plugged": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Am Netz" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Plugged in" + } + } + } + }, + "activity.power.unplugged": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Netzteil getrennt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Unplugged" + } + } + } + }, + "band.battery": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Akku" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Battery" + } + } + } + }, + "band.clock": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Uhr" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Clock" + } + } + } + }, + "band.cpu": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "CPU" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "CPU" + } + } + } + }, + "band.memory": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Speicher" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Memory" + } + } + } + }, + "band.none": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Nichts" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Nothing" + } + } + } + }, + "band.temperature": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Temperatur" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Temperature" + } + } + } + }, + "band.weather": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wetter" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Weather" + } + } + } + }, "calendar.source.status.denied": { "localizations": { "de": { @@ -541,6 +733,22 @@ } } }, + "color.suggestions": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Vorschläge" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Suggestions" + } + } + } + }, "color.swap": { "localizations": { "de": { @@ -573,6 +781,22 @@ } } }, + "fans.action.recheck": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Erneut prüfen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Check again" + } + } + } + }, "fans.approval.needed": { "extractionState": "stale", "localizations": { @@ -693,6 +917,54 @@ } } }, + "fans.group.control": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Lüfter" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Fans" + } + } + } + }, + "fans.group.danger": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zurücknehmen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Undo" + } + } + } + }, + "fans.group.setup": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Lüftersteuerung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Fan control" + } + } + } + }, "fans.hottest": { "extractionState": "stale", "localizations": { @@ -1572,6 +1844,38 @@ } } }, + "options.calendar.calendars": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Welche Kalender zählen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Calendars shown" + } + } + } + }, + "options.calendar.calendars.hint": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Gespeichert werden die abgewählten. Ein neuer Kalender ist damit automatisch dabei, statt dass du ihn suchen musst, ohne zu wissen, dass es ihn gibt." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The unchecked ones are what gets saved. A new calendar is therefore included automatically, instead of you having to look for something you don’t know exists." + } + } + } + }, "options.calendar.display": { "localizations": { "de": { @@ -1748,6 +2052,22 @@ } } }, + "options.calendar.serverGroup": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Calendarr-Server" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Calendarr server" + } + } + } + }, "options.calendar.source": { "localizations": { "de": { @@ -1764,6 +2084,70 @@ } } }, + "options.calendar.status": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Stand" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Status" + } + } + } + }, + "options.calendar.viaServer": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Direkt vom Server" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Straight from the server" + } + } + } + }, + "options.calendar.viaSnapshot": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Aus Calendarrs Schnappschuss" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "From Calendarr’s snapshot" + } + } + } + }, + "options.calendar.viaSnapshot.hint": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Schnappschuss ist nur so aktuell wie der letzte Start der Calendarr-App. Trag oben die Zugangsdaten ein, dann holt Onyx die Termine selbst." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The snapshot is only as current as the last time Calendarr ran. Enter the credentials above and Onyx fetches the events itself." + } + } + } + }, "options.shelf": { "extractionState": "stale", "localizations": { @@ -1896,6 +2280,278 @@ } } }, + "settings.action.adopt": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Übernehmen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Adopt" + } + } + } + }, + "settings.action.open": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Öffnen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Open" + } + } + } + }, + "settings.action.remove": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Entfernen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove" + } + } + } + }, + "settings.action.reset": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zurücksetzen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + } + } + }, + "settings.action.show": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zeigen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Show" + } + } + } + }, + "settings.action.swap": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Tauschen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Swap" + } + } + } + }, + "settings.appearance.colours": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Farben" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Colours" + } + } + } + }, + "settings.appearance.material": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Werkstoff" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Material" + } + } + } + }, + "settings.appearance.material.hint": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Woraus die Fläche gemacht ist. „Stein“ ist deckend und verschmilzt als Einziges sauber mit der Hardware-Notch — alles andere hat dort eine Kante zum Gehäuse." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "What the surface is made of. “Stone” is opaque and the only one that merges cleanly with the hardware notch — the others leave an edge against the enclosure." + } + } + } + }, + "settings.appearance.motion": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Bewegung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Motion" + } + } + } + }, + "settings.appearance.motion.hint": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wie das Panel aus- und einfährt. „Schnell“ ist die Voreinstellung." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "How the panel expands and collapses. “Snappy” is the default." + } + } + } + }, + "settings.appearance.profile": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Profil" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Profile" + } + } + } + }, + "settings.appearance.surface": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Fläche" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Surface" + } + } + } + }, + "settings.band": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Neben der Notch" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Beside the notch" + } + } + } + }, + "settings.band.hint": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Was auf Menüleistenhöhe links und rechts der Notch steht, wenn das Panel offen ist. Je Seite ein Wert — für mehr ist der Streifen zu schmal." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "What sits at menu bar height to the left and right of the notch while the panel is open. One value per side — the strip is too narrow for more." + } + } + } + }, + "settings.band.leading": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Links" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Left" + } + } + } + }, + "settings.band.trailing": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Rechts" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Right" + } + } + } + }, "settings.display.all": { "localizations": { "de": { @@ -1977,6 +2633,22 @@ } } }, + "settings.display.mainDisplay": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Nur dem Hauptbildschirm" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Main display only" + } + } + } + }, "settings.display.menuBarIcon": { "extractionState": "stale", "localizations": { @@ -2044,6 +2716,70 @@ } } }, + "settings.general.appearanceGroup": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Sichtbarkeit" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Visibility" + } + } + } + }, + "settings.general.behaviour": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verhalten" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Behaviour" + } + } + } + }, + "settings.island.hint": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Die eingeklappte Fläche bleibt stehen, damit das Panel aus ihr herauswächst. Auf Bildschirmen ohne Notch ist sie der sichtbare Ursprung." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Keeps the collapsed surface in place so the panel grows out of it. On displays without a notch it is the visible origin." + } + } + } + }, + "settings.island.show": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Insel immer zeigen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Always show the island" + } + } + } + }, "settings.launchAtLogin": { "localizations": { "de": { @@ -2076,6 +2812,54 @@ } } }, + "settings.material.blur": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Milchglas" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Blur" + } + } + } + }, + "settings.material.glass": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Glas" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Glass" + } + } + } + }, + "settings.material.stone": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Stein" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Stone" + } + } + } + }, "settings.menubar.bars": { "extractionState": "stale", "localizations": { @@ -2244,6 +3028,262 @@ } } }, + "settings.motion.bouncy": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Federnd" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Bouncy" + } + } + } + }, + "settings.motion.calm": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Ruhig" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Calm" + } + } + } + }, + "settings.motion.snappy": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Schnell" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Snappy" + } + } + } + }, + "settings.notch.displays": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Bildschirme" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Displays" + } + } + } + }, + "settings.notch.style": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Form" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Shape" + } + } + } + }, + "settings.notch.style.automatic": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Automatisch" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Automatic" + } + } + } + }, + "settings.notch.style.hint": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Verschmolzen klebt die Fläche an der Bildschirmkante und geht in die Notch über. Schwebend hängt sie als Insel darunter, rundum gerundet." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Merged keeps the surface against the screen edge, continuing the notch. Floating hangs it below as an island, rounded on every side." + } + } + } + }, + "settings.notch.style.island": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Immer schwebend" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Always floating" + } + } + } + }, + "settings.notch.style.notch": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Immer verschmolzen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Always merged" + } + } + } + }, + "settings.notch.triggerArea": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Auslösefläche" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Trigger area" + } + } + } + }, + "settings.notch.width": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Breite je Bildschirm" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Width per display" + } + } + } + }, + "settings.notch.width.fixed": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Von der Hardware" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "From the hardware" + } + } + } + }, + "settings.notch.width.hardware": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Die echte Notch gibt das Maß vor." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The real notch sets the size." + } + } + } + }, + "settings.notch.width.hint": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wie breit die Insel auf dem jeweiligen Bildschirm ist. Die Höhe richtet sich nach der Menüleiste, damit die Insel darin liegt." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "How wide the island is on each display. The height follows the menu bar so the island sits inside it." + } + } + } + }, + "settings.notch.width.reset": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Auf die Standardbreite zurücksetzen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset to the default width" + } + } + } + }, + "settings.onboarding.hint": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Übersicht der Berechtigungen vom ersten Start." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The permission overview from first launch." + } + } + } + }, "settings.onboarding.show": { "extractionState": "stale", "localizations": { @@ -2261,6 +3301,22 @@ } } }, + "settings.onboarding.title": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Einrichtung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Setup" + } + } + } + }, "settings.permissions.calendar": { "extractionState": "stale", "localizations": { @@ -2312,6 +3368,22 @@ } } }, + "settings.permissions.group": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Zugriff" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Access" + } + } + } + }, "settings.permissions.location": { "extractionState": "stale", "localizations": { @@ -2380,6 +3452,38 @@ } } }, + "settings.section.appearance": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Erscheinung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Appearance" + } + } + } + }, + "settings.section.notch": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Notch" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Notch" + } + } + } + }, "settings.shelf.cleanup": { "localizations": { "de": { @@ -2803,6 +3907,22 @@ } } }, + "settings.widgets.hintShort": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Reihenfolge per Ziehen. Sie ist auch die Reihenfolge im Panel." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Drag to reorder. This is also the order in the panel." + } + } + } + }, "settings.widgets.inPanel": { "extractionState": "stale", "localizations": { @@ -2820,6 +3940,54 @@ } } }, + "settings.widgets.preview": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Vorschau" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Preview" + } + } + } + }, + "settings.widgets.preview.hint": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "So sieht die Kachel in der Notch aus." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "This is how the tile looks in the notch." + } + } + } + }, + "settings.widgets.preview.none": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Wähle links ein Widget, um es hier in echter Größe zu sehen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Select a widget on the left to see it here at actual size." + } + } + } + }, "settings.widgets.remove": { "extractionState": "stale", "localizations": { diff --git a/Onyx/MixerMenuBarModule.swift b/Onyx/MixerMenuBarModule.swift index bb206b7..9ac1ad9 100644 --- a/Onyx/MixerMenuBarModule.swift +++ b/Onyx/MixerMenuBarModule.swift @@ -46,7 +46,7 @@ final class MixerMenuBarModule: MenuBarModule { AnyView( ScrollView { MixerView(mixer: mixer, system: system) - .padding(16) + .padding(Onyx.Space.xl) } .scrollIndicators(.automatic) // Deutlich breiter als das Panel: hier ist der Mixer zu Hause. diff --git a/Onyx/OnboardingView.swift b/Onyx/OnboardingView.swift index 37bd6b7..0012edf 100644 --- a/Onyx/OnboardingView.swift +++ b/Onyx/OnboardingView.swift @@ -33,7 +33,7 @@ struct OnboardingView: View { Divider() ScrollView { - VStack(alignment: .leading, spacing: 0) { + OnyxFormCard { OnboardingRow( symbol: "calendar", title: "onboarding.calendar", @@ -74,9 +74,10 @@ struct OnboardingView: View { fanControl.openApprovalSettings() } } - .padding(.horizontal, 22) - .padding(.vertical, 6) + .padding(.horizontal, Onyx.Space.xxl) + .padding(.vertical, Onyx.Space.lg) } + .scrollContentBackground(.hidden) Divider() footer @@ -95,10 +96,10 @@ struct OnboardingView: View { // MARK: - Kopf und Fuß private var header: some View { - HStack(spacing: 14) { + HStack(spacing: Onyx.Space.xl) { Image(nsImage: NSApp.applicationIconImage) .resizable().frame(width: 52, height: 52) - VStack(alignment: .leading, spacing: 3) { + VStack(alignment: .leading, spacing: Onyx.Space.xs) { Text("onboarding.title").font(.title2).fontWeight(.semibold) Text("onboarding.subtitle") .font(.callout).foregroundStyle(.secondary) @@ -106,7 +107,7 @@ struct OnboardingView: View { } Spacer(minLength: 0) } - .padding(22) + .padding(Onyx.Space.xxl) } private var footer: some View { @@ -128,7 +129,7 @@ struct OnboardingView: View { Button("onboarding.done", action: onFinish) .keyboardShortcut(.defaultAction) } - .padding(22) + .padding(Onyx.Space.xxl) } // MARK: - Zustände @@ -169,33 +170,25 @@ private struct OnboardingRow: View { enum State { case granted, denied, undetermined } let symbol: String - let title: LocalizedStringKey - let detail: LocalizedStringKey + let title: String.LocalizationValue + let detail: String.LocalizationValue let state: State let request: () async -> Void let openSettings: () -> Void + /// Dieselbe Zeile wie in den Einstellungen. Vorher war es eine eigene, die + /// zufällig gleich aussah — bis auf die Trennlinie, die sie sich selbst + /// unterlegte, und die deshalb auch unter der letzten stand. var body: some View { - HStack(alignment: .top, spacing: 14) { - Image(systemName: symbol) - .font(.system(size: 15)) - .foregroundStyle(state == .granted ? Onyx.Color.positive : .secondary) - .frame(width: 22, height: 22) - - VStack(alignment: .leading, spacing: 2) { - Text(title).fontWeight(.medium) - Text(detail) - .font(.callout).foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - - Spacer(minLength: 8) - + OnyxFormRow(String(localized: title), + hint: String(localized: detail), + symbol: symbol, + tone: state == .granted ? .normal : .normal) { switch state { case .granted: Image(systemName: "checkmark.circle.fill") .foregroundStyle(Onyx.Color.positive) - .font(.system(size: 16)) + .font(.system(size: Onyx.Symbol.large)) case .undetermined: Button("onboarding.allow") { Task { await request() } } case .denied: @@ -204,7 +197,5 @@ private struct OnboardingRow: View { Button("onboarding.openSettings", action: openSettings) } } - .padding(.vertical, 12) - .overlay(alignment: .bottom) { Divider() } } } diff --git a/Onyx/OnboardingWindowController.swift b/Onyx/OnboardingWindowController.swift index 0a6dd3a..9ec8681 100644 --- a/Onyx/OnboardingWindowController.swift +++ b/Onyx/OnboardingWindowController.swift @@ -46,7 +46,10 @@ final class OnboardingWindowController: NSObject, NSWindowDelegate { mixer: mixer, fanControl: fanControl, launchAtLogin: launchAtLogin, onFinish: { [weak self] in self?.finish() })) - let window = NSWindow(contentViewController: hosting) + // Derselbe Grund wie im Einstellungsfenster — siehe + // `OnyxBackdropController`. + let window = NSWindow(contentViewController: + OnyxBackdropController(content: hosting)) window.title = "Onyx" window.styleMask = [.titled, .closable] window.isReleasedWhenClosed = false diff --git a/Onyx/OnyxApp.swift b/Onyx/OnyxApp.swift index 3d3ad55..01274b9 100644 --- a/Onyx/OnyxApp.swift +++ b/Onyx/OnyxApp.swift @@ -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) } diff --git a/Onyx/SettingsSection.swift b/Onyx/SettingsSection.swift new file mode 100644 index 0000000..b9ecdfb --- /dev/null +++ b/Onyx/SettingsSection.swift @@ -0,0 +1,69 @@ +import SwiftUI + +/// Die Bereiche des Einstellungsfensters. +/// +/// **Seitenleiste statt Reiterband.** Sieben deutsche Reiterbeschriftungen +/// brauchten rund 880 Punkte nebeneinander — das Fenster hatte deshalb eine +/// Mindestbreite, die nichts mit seinem Inhalt zu tun hatte, sondern nur damit, +/// dass sonst der erste und der letzte Reiter über die Kante hinausragten. Eine +/// Seitenleiste wächst nach unten, und dort ist Platz. +/// +/// **Keine leeren Einträge auf Vorrat.** Sperrbildschirm, Zwischenablage und +/// Notizen kommen mit ihren Phasen. Ein Eintrag, der sich öffnen lässt und +/// nichts enthält, ist ein Versprechen, das die App nicht hält. +enum SettingsSection: String, CaseIterable, Identifiable, Hashable { + case general + case widgets + case widgetOptions + case notch + case appearance + case menubar + case fans + case permissions + + var id: Self { self } + + var title: String { + switch self { + case .general: String(localized: "settings.general") + case .widgets: String(localized: "settings.tab.widgets") + case .widgetOptions: String(localized: "settings.tab.options") + case .notch: String(localized: "settings.section.notch") + case .appearance: String(localized: "settings.section.appearance") + case .menubar: String(localized: "settings.tab.menubar") + case .fans: String(localized: "settings.tab.fans") + case .permissions: String(localized: "settings.tab.permissions") + } + } + + var symbol: String { + switch self { + case .general: "gearshape.fill" + case .widgets: "square.grid.2x2.fill" + case .widgetOptions: "wrench.adjustable.fill" + case .notch: "macbook" + case .appearance: "paintpalette.fill" + case .menubar: "menubar.rectangle" + case .fans: "fan.fill" + case .permissions: "lock.fill" + } + } + + /// Die Farbe des Plättchens. + /// + /// Fest und **nicht** die Akzentfarbe: acht Einträge in derselben Farbe + /// wären wieder acht gleich aussehende Zeilen. Die Farbe unterscheidet + /// hier, sie schmückt nicht. + var tint: Color { + switch self { + case .general: Color(red: 0.44, green: 0.47, blue: 0.51) + case .widgets: Color(red: 0.31, green: 0.53, blue: 0.93) + case .widgetOptions: Color(red: 0.29, green: 0.68, blue: 0.66) + case .notch: Color(red: 0.55, green: 0.44, blue: 0.85) + case .appearance: Color(red: 0.90, green: 0.60, blue: 0.28) + case .menubar: Color(red: 0.85, green: 0.45, blue: 0.66) + case .fans: Color(red: 0.33, green: 0.68, blue: 0.85) + case .permissions: Color(red: 0.82, green: 0.37, blue: 0.35) + } + } +} diff --git a/Onyx/SettingsView.swift b/Onyx/SettingsView.swift index 73dde69..4d60a13 100644 --- a/Onyx/SettingsView.swift +++ b/Onyx/SettingsView.swift @@ -9,10 +9,6 @@ import MetricsProvider import OnyxHelperProtocol import WeatherProvider -enum SettingsTab: Hashable { - case widgets, display, colors, sources, menubar, fans, permissions -} - struct SettingsView: View { @Bindable var model: AppModel let calendarModel: CalendarModel @@ -22,87 +18,215 @@ struct SettingsView: View { let fanControl: FanControl let launchAtLogin: LaunchAtLogin let showOnboarding: () -> Void - /// Der gewählte Reiter liegt im Fenster-Controller, damit ein erneutes + /// Der gewählte Bereich liegt im Fenster-Controller, damit ein erneutes /// Öffnen ihn setzen kann — etwa auf „Berechtigungen", wenn eine fehlt. - @Binding var selectedTab: SettingsTab - - /// Die Reiter in der Reihenfolge, in der sie oben stehen. - private static let tabs: [(tab: SettingsTab, label: LocalizedStringKey)] = [ - (.widgets, "settings.tab.widgets"), - (.display, "settings.tab.display"), - (.colors, "settings.tab.colors"), - (.sources, "settings.tab.options"), - (.menubar, "settings.tab.menubar"), - (.fans, "settings.tab.fans"), - (.permissions, "settings.tab.permissions"), - ] + @Binding var section: SettingsSection var body: some View { - // Eigene Reiterleiste statt `TabView`. + // **Zweiteilung von Hand, kein `NavigationSplitView`.** // - // `TabView` zieht unter seiner Leiste einen Trenner quer durchs - // Fenster — nicht abschaltbar, und die Leiste sitzt mittig darauf. - // Abstand nach oben half nicht: der Trenner wandert mit. Also die - // Leiste selbst gebaut, dann gibt es die Linie gar nicht erst. - VStack(spacing: 0) { - Picker("", selection: $selectedTab) { - ForEach(Self.tabs, id: \.tab) { entry in - Text(entry.label).tag(entry.tab) - } - } - .pickerStyle(.segmented) - .labelsHidden() - // Ohne feste Breite: sieben deutsche Beschriftungen brauchen rund - // 880 Punkte, und mit `fixedSize` nahm sich die Leiste die auch - // dann, wenn das Fenster schmaler war — sie ragte dann links und - // rechts über die Kante hinaus. Das Mindestmaß des Fensters sorgt - // dafür, dass hier trotzdem nichts gestaucht wird. - .frame(maxWidth: 1000) - .padding(.horizontal, 20) - .padding(.top, 10) - .padding(.bottom, 14) + // Der malt Seitenleiste und Inhaltsfläche selbst deckend zu. Die + // Vibrancy-Ebene des Fensters kam deshalb nur unter dem Titelbalken + // durch — oben durchsichtig, darunter eine Platte. + // `.scrollContentBackground(.hidden)` erreicht sie nicht: es gilt für + // Listen und Scrollflächen, nicht für den Rahmen darum. + // + // Gebraucht wird hier ohnehin keine Navigation, sondern eine Auswahl + // und zwei Spalten. + HStack(spacing: 0) { + sidebar + .frame(width: 200) + + Divider() content + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } - // Eine Fläche für alles. - // - // Vorher hatte jeder Bereich seinen eigenen Untergrund: die - // Formulare bringen einen mit, der Rest des Fensters hat den des - // Systems, und die beiden liegen in Dunkelgrau ein, zwei Prozent - // auseinander. Sichtbar wurde das als Kante quer durchs Fenster. - .background(Color(nsColor: .windowBackgroundColor)) - // Farbton und Symbolfarbe hier und nicht beim Erzeugen des Fensters. - // - // Beim Erzeugen wird der Wert **einmal** gelesen, außerhalb jeder - // View — SwiftUI erfährt nie, dass er sich geändert hat. Die Reiter - // und Knöpfe blieben deshalb in der Farbe stehen, die beim Öffnen - // galt, während der Farbwähler daneben schon die neue zeigte. .tint(Onyx.Color.accent) .labelStyle(.onyx) + // Außerhalb eines `Form` zeichnet macOS jeden `Toggle` als + // Ankreuzfeld. In einer Karte mit Schaltern sieht das aus wie ein + // Formular von 2009 — und schlimmer: die beiden Formen bedeuten + // dasselbe und sehen verschieden aus. + .toggleStyle(.switch) // Nur ein Mindestmaß, keine feste Größe: sonst kann das Fenster // wachsen, der Inhalt aber nicht mit. - .frame(minWidth: 740, minHeight: 460) + .frame(minWidth: 760, minHeight: 480) + } + + private var sidebar: some View { + ScrollView { + VStack(spacing: Onyx.Space.xxs) { + ForEach(SettingsSection.allCases) { entry in + Button { section = entry } label: { + OnyxSidebarLabel(entry.title, symbol: entry.symbol, tint: entry.tint) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, Onyx.Space.md) + .padding(.vertical, Onyx.Space.sm) + .background { + RoundedRectangle(cornerRadius: Onyx.Radius.thumbnail, style: .continuous) + .fill(section == entry ? AnyShapeStyle(Onyx.Color.accent) + : AnyShapeStyle(.clear)) + } + // Auf der Akzentfarbe steht Weiß, nicht die + // Textfarbe des Systems — die wäre im Hellmodus + // schwarz auf kräftigem Blau. + .foregroundStyle(section == entry ? AnyShapeStyle(.white) + : AnyShapeStyle(.primary)) + .contentShape(.rect) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, Onyx.Space.md) + .padding(.vertical, Onyx.Space.row) + } + .scrollContentBackground(.hidden) } @ViewBuilder private var content: some View { - switch selectedTab { + switch section { + case .general: + GeneralSettings(model: model, launchAtLogin: launchAtLogin, + showOnboarding: showOnboarding) case .widgets: - WidgetSettings(model: model, shelfStore: shelfStore) - case .display: - DisplaySettings(model: model) - case .colors: - ColorSettingsView(theme: OnyxTheme.shared) - case .sources: + WidgetSettings(model: model) + case .widgetOptions: WidgetOptionsView(calendarModel: calendarModel, weatherModel: weatherModel, shelfStore: shelfStore) + case .notch: + NotchSettings(model: model) + case .appearance: + AppearanceSettingsView(theme: OnyxTheme.shared) case .menubar: MenuBarSettings(model: model, metricsModel: metricsModel) case .fans: FanSettingsView(control: fanControl) case .permissions: - PermissionSettings(calendarModel: calendarModel, weatherModel: weatherModel, - launchAtLogin: launchAtLogin, showOnboarding: showOnboarding) + PermissionSettings(calendarModel: calendarModel, weatherModel: weatherModel) + } + } +} + +// MARK: - Allgemein + +private struct GeneralSettings: View { + @Bindable var model: AppModel + let launchAtLogin: LaunchAtLogin + let showOnboarding: () -> Void + + var body: some View { + SettingsPage { + OnyxFormCard(String(localized: "settings.general.behaviour")) { + OnyxFormRow(String(localized: "settings.launchAtLogin")) { + Toggle("", isOn: Binding( + get: { launchAtLogin.isEnabled }, + set: { _ = launchAtLogin.set($0) })) + .labelsHidden() + .disabled(launchAtLogin.needsSystemSettings) + } + + // Der Zustand, der sonst in eine Schleife führt: der Schalter + // steht auf aus, Drücken bewirkt nichts, weil das System den + // Eintrag blockiert. Also steht hier, wo es weitergeht. + if launchAtLogin.needsSystemSettings { + OnyxFormRow(String(localized: "settings.launchAtLogin.blocked")) { + Button("settings.action.open") { + launchAtLogin.openSystemSettings() + } + } + } + + OnyxFormRow(String(localized: "settings.onboarding.title"), + hint: String(localized: "settings.onboarding.hint")) { + Button("settings.action.show", action: showOnboarding) + } + } + + OnyxFormCard(String(localized: "settings.general.appearanceGroup")) { + OnyxFormRow(String(localized: "settings.display.menuBarIcon"), + hint: String(localized: "settings.display.menuBarIcon.hint")) { + Toggle("", isOn: $model.showsMenuBarIcon).labelsHidden() + } + + OnyxFormRow(String(localized: "settings.display.dockIcon"), + hint: String(localized: "settings.display.dockIcon.hint")) { + Toggle("", isOn: $model.showsDockIcon).labelsHidden() + } + } + } + .task { launchAtLogin.refresh() } + } +} + +// MARK: - Notch + +private struct NotchSettings: View { + @Bindable var model: AppModel + + var body: some View { + SettingsPage { + OnyxFormCard(String(localized: "settings.notch.displays")) { + OnyxFormRow(String(localized: "settings.display.policy"), + hint: String(localized: "settings.display.hint")) { + Picker("", selection: $model.displayPolicy) { + Text("settings.display.builtInOnly").tag(DisplayPolicy.builtInOnly) + Text("settings.display.mainDisplay").tag(DisplayPolicy.mainDisplay) + Text("settings.display.all").tag(DisplayPolicy.allDisplays) + } + .labelsHidden() + .frame(width: 200) + } + + OnyxFormRow(String(localized: "settings.notch.style"), + hint: String(localized: "settings.notch.style.hint")) { + Picker("", selection: $model.notchStyle) { + Text("settings.notch.style.automatic").tag(NotchStyle.automatic) + Text("settings.notch.style.notch").tag(NotchStyle.notch) + Text("settings.notch.style.island").tag(NotchStyle.island) + } + .labelsHidden() + .frame(width: 200) + } + + OnyxFormRow(String(localized: "settings.island.show"), + hint: String(localized: "settings.island.hint")) { + Toggle("", isOn: $model.showsIdleIsland).labelsHidden() + } + } + + OnyxFormCard(String(localized: "settings.band"), + hint: String(localized: "settings.band.hint")) { + OnyxFormRow(String(localized: "settings.band.leading")) { + Picker("", selection: $model.bandLeading) { + ForEach(BandAccessory.allCases, id: \.self) { kind in + Text(kind.localizedName).tag(kind) + } + } + .labelsHidden() + .frame(width: 200) + } + OnyxFormRow(String(localized: "settings.band.trailing")) { + Picker("", selection: $model.bandTrailing) { + ForEach(BandAccessory.allCases, id: \.self) { kind in + Text(kind.localizedName).tag(kind) + } + } + .labelsHidden() + .frame(width: 200) + } + } + + NotchWidthSettings(model: model) + + OnyxFormCard(String(localized: "settings.notch.triggerArea")) { + // Die Auslösefläche mit Vorschau: zwei Zahlen in Punkten sagen + // niemandem etwas, ein nachgebildeter Bildschirm schon. + TriggerAreaSettings(trigger: $model.triggerArea) + .padding(.horizontal, OnyxForm.rowPadding) + .padding(.bottom, OnyxForm.rowPadding) + } } } } @@ -118,8 +242,6 @@ struct SettingsView: View { private struct PermissionSettings: View { let calendarModel: CalendarModel let weatherModel: WeatherModel - let launchAtLogin: LaunchAtLogin - let showOnboarding: () -> Void @State private var calendarGranted = false private var locationState: PermissionRow.State { @@ -131,67 +253,29 @@ private struct PermissionSettings: View { } var body: some View { - Form { - Section("settings.general") { - Toggle(isOn: Binding( - get: { launchAtLogin.isEnabled }, - set: { _ = launchAtLogin.set($0) })) { - Text("settings.launchAtLogin") - } - .disabled(launchAtLogin.needsSystemSettings) - - // Der Zustand, der sonst in eine Schleife führt: der Schalter - // steht auf aus, Drücken bewirkt nichts, weil das System den - // Eintrag blockiert. Also steht hier, wo es weitergeht. - if launchAtLogin.needsSystemSettings { - HStack { - Text("settings.launchAtLogin.blocked") - .font(.callout).foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - Spacer() - Button("settings.permissions.openSystem") { - launchAtLogin.openSystemSettings() - } - } - } - - HStack { - Spacer() - Button("settings.onboarding.show", action: showOnboarding) - .buttonStyle(.borderless) - } - } - - Section { + SettingsPage { + OnyxFormCard(String(localized: "settings.permissions.group")) { PermissionRow( - title: "settings.permissions.calendar", + title: String(localized: "settings.permissions.calendar"), symbol: "calendar", state: calendarGranted ? .granted : (calendarModel.authorization == .denied ? .denied : .undetermined), - explanation: "settings.permissions.calendar.why", + explanation: String(localized: "settings.permissions.calendar.why"), pane: .calendar, request: { calendarGranted = await calendarModel.requestAccessNow() }) - } - Section { PermissionRow( - title: "settings.permissions.location", + title: String(localized: "settings.permissions.location"), symbol: "location", // Direkt aus dem beobachtbaren Wert: der Zustand ändert // sich, während dieses Fenster offen steht. state: locationState, - explanation: "settings.permissions.location.why", + explanation: String(localized: "settings.permissions.location.why"), pane: .location, request: { weatherModel.requestLocationAuthorization() }) } } - .formStyle(.grouped) - // Der eigene Untergrund des Formulars weicht ein, zwei Prozent von dem - // des Fensters ab. In Dunkelgrau sieht man genau das als Kante. - .scrollContentBackground(.hidden) - .padding() .task { - launchAtLogin.refresh() calendarGranted = calendarModel.hasAccess guard !calendarGranted, calendarModel.authorization == .undetermined else { return } // Kurz warten, bis das Fenster wirklich vorne steht: TCC zeigt den @@ -226,17 +310,15 @@ private struct PermissionRow: View { } } - let title: LocalizedStringKey + let title: String let symbol: String let state: State - let explanation: LocalizedStringKey + let explanation: String let pane: Pane let request: () async -> Void var body: some View { - HStack { - Label(title, systemImage: symbol) - Spacer() + OnyxFormRow(title, hint: explanation, symbol: symbol) { switch state { case .granted: // Die Farbe sitzt am Symbol, nicht am ganzen Label: der @@ -260,10 +342,6 @@ private struct PermissionRow: View { } } } - Text(explanation) - .font(.callout) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) } } @@ -278,7 +356,7 @@ private struct MenuBarSettings: View { @Bindable var model: AppModel let metricsModel: MetricsModel? - private static let modules: [(id: String, label: LocalizedStringKey, symbol: String)] = [ + private static let modules: [(id: String, label: String.LocalizationValue, symbol: String)] = [ ("metric.cpu", "metric.cpu", "cpu"), ("metric.gpu", "metric.gpu", "cpu.fill"), ("metric.memory", "metric.memory", "memorychip"), @@ -290,22 +368,20 @@ private struct MenuBarSettings: View { ] var body: some View { - VStack(alignment: .leading, spacing: 12) { + SettingsPage { Text("settings.menubar.hint") .font(.callout) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) - List { + OnyxFormCard { ForEach(Self.modules, id: \.id) { module in ModuleRow(model: model, id: module.id, label: module.label, symbol: module.symbol, metricsModel: metricsModel) } } - .listStyle(.inset) } - .padding() // Der Zähler zwingt die Liste zum Neuzeichnen, wenn sich ein Modul // ändert — sonst müsste jede Zeile ihren eigenen Zustand beobachten. .id(model.menuBarRevision) @@ -315,7 +391,7 @@ private struct MenuBarSettings: View { private struct ModuleRow: View { @Bindable var model: AppModel let id: String - let label: LocalizedStringKey + let label: String.LocalizationValue let symbol: String let metricsModel: MetricsModel? @@ -344,69 +420,68 @@ private struct ModuleRow: View { var body: some View { let settings = model.menuBarSettings(for: id) - HStack { - Toggle(isOn: Binding( - get: { settings.isEnabled }, - set: { enabled in - var updated = settings - updated.isEnabled = enabled - model.setMenuBarSettings(updated, for: id) - })) { - Label(label, systemImage: symbol) + OnyxFormRow(String(localized: label), symbol: symbol) { + HStack(spacing: Onyx.Space.md) { + // Bei den Sensoren: welcher Punkt. Ohne diese Wahl stand dort + // das stille Maximum über alle Sensoren — eine Zahl, von der + // niemand weiß, woher sie kommt. + if id == "metric.temperature", let metricsModel { + Picker("", selection: Binding( + get: { metricsModel.selectedSensorKey ?? "" }, + set: { metricsModel.selectedSensorKey = $0.isEmpty ? nil : $0 })) { + Text("settings.menubar.sensor.hottest").tag("") + ForEach(metricsModel.snapshot.sensors) { sensor in + Text(sensor.name).tag(sensor.key) + } + } + .labelsHidden() + .frame(width: 150) + .disabled(!settings.isEnabled) } - Spacer() - - // Bei den Sensoren: welcher Punkt. Ohne diese Wahl stand dort das - // stille Maximum über alle Sensoren — eine Zahl, von der niemand - // weiß, woher sie kommt. - if id == "metric.temperature", let metricsModel { - Picker("", selection: Binding( - get: { metricsModel.selectedSensorKey ?? "" }, - set: { metricsModel.selectedSensorKey = $0.isEmpty ? nil : $0 })) { - Text("settings.menubar.sensor.hottest").tag("") - ForEach(metricsModel.snapshot.sensors) { sensor in - Text(sensor.name).tag(sensor.key) + // Bei CPU und GPU zusätzlich wählbar, was gezeigt wird. Beide + // Werte stammen aus derselben Messung — ein zweites Element in + // der ohnehin knappen Menüleiste wäre Verschwendung. + if let metric = metricKind, metric.supportsDisplayModes, let metricsModel { + Picker("", selection: Binding( + get: { metricsModel.displayMode(for: metric) }, + set: { metricsModel.setDisplayMode($0, for: metric) })) { + Text("metric.mode.usage").tag(MetricDisplayMode.usage) + Text("metric.mode.temperature").tag(MetricDisplayMode.temperature) } - } - .labelsHidden() - .frame(width: 150) - .disabled(!settings.isEnabled) - } + .labelsHidden() + .frame(width: 110) + .disabled(!settings.isEnabled) + } - // Bei CPU und GPU zusätzlich wählbar, was gezeigt wird. Beide Werte - // stammen aus derselben Messung — ein zweites Element in der - // ohnehin knappen Menüleiste wäre Verschwendung. - if let metric = metricKind, metric.supportsDisplayModes, let metricsModel { - Picker("", selection: Binding( - get: { metricsModel.displayMode(for: metric) }, - set: { metricsModel.setDisplayMode($0, for: metric) })) { - Text("metric.mode.usage").tag(MetricDisplayMode.usage) - Text("metric.mode.temperature").tag(MetricDisplayMode.temperature) - } - .labelsHidden() - .frame(width: 110) - .disabled(!settings.isEnabled) - } + // Nur anbieten, was das Modul auch zeichnen kann. Der Mixer hat + // keinen einzelnen Messwert, den man als Zahl oder Verlauf + // zeigen könnte — eine Auswahl ohne Wirkung wäre eine Falle. + if !Self.presentations(for: id).isEmpty { + Picker("", selection: Binding( + get: { settings.presentation }, + set: { presentation in + var updated = settings + updated.presentation = presentation + model.setMenuBarSettings(updated, for: id) + })) { + ForEach(Self.presentations(for: id)) { presentation in + Text(presentation.localizedName).tag(presentation) + } + } + .labelsHidden() + .frame(width: 150) + .disabled(!settings.isEnabled) + } - // Nur anbieten, was das Modul auch zeichnen kann. Der Mixer hat - // keinen einzelnen Messwert, den man als Zahl oder Verlauf zeigen - // könnte — eine Auswahl ohne Wirkung wäre eine Falle. - if !Self.presentations(for: id).isEmpty { - Picker("", selection: Binding( - get: { settings.presentation }, - set: { presentation in + Toggle("", isOn: Binding( + get: { settings.isEnabled }, + set: { enabled in var updated = settings - updated.presentation = presentation + updated.isEnabled = enabled model.setMenuBarSettings(updated, for: id) - })) { - ForEach(Self.presentations(for: id)) { presentation in - Text(presentation.localizedName).tag(presentation) - } - } + })) .labelsHidden() - .frame(width: 150) - .disabled(!settings.isEnabled) } } } @@ -430,22 +505,30 @@ extension MenuBarPresentation { private struct WidgetSettings: View { @Bindable var model: AppModel - let shelfStore: ShelfStore? + + /// Welches Widget rechts gezeigt wird. + /// + /// Der Name allein sagt nicht, was eine Kachel zeigt — „Sensoren" kann + /// vieles heißen. Und ausprobieren hieß bisher: hinzufügen, Notch + /// anfahren, ansehen, wieder entfernen. + @State private var previewID: String? var body: some View { - VStack(alignment: .leading, spacing: 12) { - Text("settings.widgets.hint") - .font(.callout) - .foregroundStyle(.secondary) - - List { - Section("settings.widgets.inPanel") { + HStack(spacing: 0) { + // Eine Liste, keine Karte: das Ziehen zum Umsortieren kommt von + // `List.onMove`. Und **nur** die Liste, ohne Rahmen darum — eine + // Liste in einer Spalte mit eigenem Rand lief unter den + // Fensterknöpfen hindurch, weil sie ihre eigene Scrollfläche + // mitbringt und die dann zwei Ränder gegeneinander schieben. + List(selection: $previewID) { + Section { // Die Reihenfolge hier ist die Reihenfolge im Panel: die // Layout-Engine setzt die Widgets von oben links der Reihe // nach ein. ForEach(model.layout) { placement in if let widget = WidgetRegistry.shared.widget(id: placement.widgetID) { PlacementRow(model: model, placement: placement, widget: widget) + .tag(placement.widgetID) } } .onMove { model.move(fromOffsets: $0, toOffset: $1) } @@ -454,6 +537,13 @@ private struct WidgetSettings: View { Text("settings.widgets.empty") .foregroundStyle(.secondary) } + } header: { + Text("settings.widgets.inPanel") + } footer: { + Text("settings.widgets.hintShort") + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) } let available = WidgetRegistry.shared.all.filter { !model.isInPanel($0.id) } @@ -466,47 +556,69 @@ private struct WidgetSettings: View { Button("settings.widgets.add") { model.add(widget) } .buttonStyle(.borderless) } + .tag(widget.id) } } } - } .listStyle(.inset) + .scrollContentBackground(.hidden) + + Divider() + + WidgetPreview(widgetID: previewID) + .frame(width: 252) + } + .task { + // Beim Öffnen gleich etwas zeigen: eine leere Vorschau neben einer + // vollen Liste sieht aus, als wäre sie kaputt. + guard previewID == nil else { return } + previewID = model.layout.first?.widgetID ?? WidgetRegistry.shared.all.first?.id } - .padding() } } -/// Einstellungen der Ablage. +/// Die gewählte Kachel, so wie sie in der Notch aussieht. /// -/// Automatisches Aufräumen ist ausdrücklich abschaltbar und standardmäßig aus: -/// eine Ablage, die ungefragt löscht, ist kein Zwischenlager, sondern ein -/// Papierkorb mit Zeitschaltuhr. -private struct ShelfSettingsRows: View { - @Bindable var store: ShelfStore - - private static let choices: [Int?] = [nil, 1, 7, 30, 90] +/// Echte Größe (180 × 200) auf echter Paneloberfläche und mit den echten +/// Modellen — eine verkleinerte oder erfundene Vorschau wäre eine Behauptung. +/// Dieselben Schritte wie in `WidgetGrid`: Kachel, Rahmen, beschneiden. +private struct WidgetPreview: View { + let widgetID: String? var body: some View { - Picker(selection: $store.autoCleanupDays) { - ForEach(Self.choices, id: \.self) { days in - if let days { - Text("settings.shelf.days \(days)").tag(Optional(days)) - } else { - Text("settings.shelf.never").tag(Int?.none) - } - } - } label: { - Text("settings.shelf.cleanup") - } + VStack(spacing: Onyx.Space.row) { + if let widgetID, let widget = WidgetRegistry.shared.widget(id: widgetID) { + OnyxTile { widget.makeView() } + .frame(width: LayoutEngine.tileSize.width, + height: LayoutEngine.tileSize.height) + .clipped() + .padding(Onyx.Metric.panelPadding) + // Die Fläche bleibt schwarzer Stein, auch wenn das Fenster + // hell ist — sie zeigt die Notch, nicht das Fenster. + .background(OnyxSurface(cornerRadius: Onyx.Radius.tile)) + .tint(Onyx.Color.accent) - HStack { - Text("settings.shelf.count \(store.items.count)") - .foregroundStyle(.secondary) - Spacer() - Button("settings.shelf.reveal") { store.revealInFinder() } - .buttonStyle(.borderless) + VStack(spacing: Onyx.Space.xxs) { + Text(widget.displayName) + .font(.callout) + Text("settings.widgets.preview.hint") + .font(.caption) + .foregroundStyle(.secondary) + } + .multilineTextAlignment(.center) + } else { + Text("settings.widgets.preview.none") + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + + Spacer(minLength: 0) } + .frame(maxWidth: .infinity, alignment: .top) + .padding(OnyxForm.pagePadding) } } @@ -532,50 +644,90 @@ private struct PlacementRow: View { } } -// MARK: - Anzeige +// MARK: - Seite -private struct DisplaySettings: View { +/// Der Rahmen eines Bereichs, der aus Karten besteht. +/// +/// Die `ScrollView` sitzt hier und nicht im Fenster: Bereiche mit einer `List` +/// oder einem `Form` bringen ihre eigene mit, und zwei ineinander ergeben eine +/// Fläche, die beim Ziehen mal die eine und mal die andere bewegt. +struct SettingsPage: View { + private let content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: OnyxForm.cardSpacing) { + content + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(OnyxForm.pagePadding) + } + .scrollContentBackground(.hidden) + } +} + + +/// Die Inselbreite, je Bildschirm. +/// +/// **Nur Breite, keine Höhe.** Die Höhe ist nicht mehr frei: bei verschmolzener +/// Notch gibt sie die Hardware vor, bei schwebender Insel die Menüleiste, damit +/// die Insel darin liegt statt darunter herauszuhängen. Ein Höhenregler wäre +/// ein Bedienelement, das nichts tut. +/// +/// Bildschirme mit echter Notch bekommen keinen Regler, sondern die Auskunft, +/// warum es keinen gibt — sonst sucht man den Fehler bei sich. +private struct NotchWidthSettings: View { @Bindable var model: AppModel var body: some View { - Form { - Picker("settings.display.policy", selection: $model.displayPolicy) { - Text("settings.display.builtInOnly").tag(DisplayPolicy.builtInOnly) - Text("settings.display.all").tag(DisplayPolicy.allDisplays) + OnyxFormCard(String(localized: "settings.notch.width"), + hint: String(localized: "settings.notch.width.hint")) { + ForEach(NSScreen.screens, id: \.displayID) { screen in + row(for: screen) } - .pickerStyle(.radioGroup) - - Text("settings.display.hint") - .font(.callout) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - - Divider() - - // Die Auslösefläche mit Vorschau: zwei Zahlen in Punkten sagen - // niemandem etwas, ein nachgebildeter Bildschirm schon. - TriggerAreaSettings(trigger: $model.triggerArea) - - Divider() - - Toggle("settings.display.menuBarIcon", isOn: $model.showsMenuBarIcon) - Text("settings.display.menuBarIcon.hint") - .font(.callout) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - - Divider() - - Toggle("settings.display.dockIcon", isOn: $model.showsDockIcon) - Text("settings.display.dockIcon.hint") - .font(.callout) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) } - .formStyle(.grouped) - // Der eigene Untergrund des Formulars weicht ein, zwei Prozent von dem - // des Fensters ab. In Dunkelgrau sieht man genau das als Kante. - .scrollContentBackground(.hidden) - .padding() + } + + @ViewBuilder + private func row(for screen: NSScreen) -> some View { + let id = screen.displayID + // Dieselbe Frage, die auch die Geometrie stellt — und dieselbe Antwort. + let hasHardware = NotchGeometry.hardwareRect(screen.onyxMetrics) != nil + let merged = model.notchStyle.merges(withHardware: hasHardware) + + OnyxFormRow(screen.localizedName, + hint: merged ? String(localized: "settings.notch.width.hardware") : nil) { + if merged { + Text("settings.notch.width.fixed") + .foregroundStyle(.secondary) + } else { + HStack(spacing: Onyx.Space.lg) { + Slider(value: binding(for: id), + in: NotchCoordinator.widthRange, step: 2) + .frame(width: 180) + Text(verbatim: "\(Int(model.notchWidth(for: id) ?? NotchCoordinator.defaultVirtualSize.width))") + .font(.body.monospacedDigit()) + .foregroundStyle(.secondary) + .frame(width: 32, alignment: .trailing) + Button { + model.setNotchWidth(nil, for: id) + } label: { + Image(systemName: "arrow.counterclockwise") + } + .buttonStyle(.borderless) + .disabled(model.notchWidth(for: id) == nil) + .help(Text("settings.notch.width.reset")) + } + } + } + } + + private func binding(for id: CGDirectDisplayID) -> Binding { + Binding(get: { model.notchWidth(for: id) ?? NotchCoordinator.defaultVirtualSize.width }, + set: { model.setNotchWidth($0, for: id) }) } } diff --git a/Onyx/SettingsWindowController.swift b/Onyx/SettingsWindowController.swift index 97ec9e1..c9cad77 100644 --- a/Onyx/SettingsWindowController.swift +++ b/Onyx/SettingsWindowController.swift @@ -27,8 +27,9 @@ import ShelfProvider final class SettingsWindowController: NSObject, NSWindowDelegate { @ObservationIgnored private var window: NSWindow? - /// Muss außerhalb der View liegen, damit ein erneutes Öffnen den Reiter setzen kann. - private var selectedTab: SettingsTab = .widgets + /// Muss außerhalb der View liegen, damit ein erneutes Öffnen den Bereich + /// setzen kann. + private var section: SettingsSection = .general /// Ob Onyx dauerhaft im Dock steht. Bestimmt, wohin nach dem Schließen /// zurückgeschaltet wird. @@ -37,8 +38,8 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { func show(model: AppModel, calendarModel: CalendarModel, weatherModel: WeatherModel, metricsModel: MetricsModel?, shelfStore: ShelfStore?, fanControl: FanControl, launchAtLogin: LaunchAtLogin, showOnboarding: @escaping () -> Void, - tab: SettingsTab = .widgets) { - selectedTab = tab + section: SettingsSection = .general) { + self.section = section keepsDockIcon = model.showsDockIcon // Solange ein Fenster offen ist, gehört Onyx in den Dock. @@ -59,39 +60,48 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { fanControl: fanControl, launchAtLogin: launchAtLogin, showOnboarding: showOnboarding, - selectedTab: Binding( - get: { [weak self] in self?.selectedTab ?? .widgets }, - set: { [weak self] in self?.selectedTab = $0 }))) + section: Binding( + get: { [weak self] in self?.section ?? .general }, + set: { [weak self] in self?.section = $0 }))) - let window = NSWindow(contentViewController: hosting) + let window = NSWindow(contentViewController: OnyxBackdropController(content: hosting)) window.title = "Onyx" - // Der Titelbalken geht in den Inhalt über, **und** seine Trennlinie - // verschwindet. + // **Kein durchsichtiger Titelbalken mehr.** // - // `titlebarAppearsTransparent` allein reichte nicht: macOS zieht - // unter dem Titelbalken zusätzlich einen Trenner quer durchs - // Fenster, und die Reiterleiste saß mittig darauf. Man setzt keinen - // Titel auf eine Trennlinie. + // Er war für die selbstgebaute Reiterleiste da: macOS zieht unter + // dem Titelbalken einen Trenner quer durchs Fenster, und die + // Leiste saß mittig darauf. Die Leiste gibt es nicht mehr — und + // durchsichtig ist er jetzt schädlich: Seitenleiste und Inhalt + // zeichnen darunter weiter, statt darunter zu beginnen. Sichtbar + // wurde das im Widgets-Bereich, wo die Liste unter den + // Fensterknöpfen hindurchlief. + window.titlebarSeparatorStyle = .automatic + // **Kein `isOpaque = false`, kein durchsichtiger Hintergrund.** + // + // Beides klingt nach dem Weg zur Durchlässigkeit und ist ihr + // Gegenteil, sobald die Vibrancy-Ebene selbst der Fensterinhalt + // ist: erklärt sich das Fenster für durchsichtig, stellt der + // Fensterserver ihm keinen Hintergrund mehr zum Abtasten bereit, + // und `behindWindow` hat nichts, woraus es Unschärfe machen könnte. + // Der Titelbalken teilt sich die Fläche mit dem Inhalt. Ohne das + // zeichnet macOS dort seine **eigene**, deutlich durchlässigere + // Vibrancy — sichtbar als heller Streifen quer über den oberen + // Rand, durch den man den Hintergrund lesen kann. window.titlebarAppearsTransparent = true - window.titlebarSeparatorStyle = .none // Veränderbar, und das ist keine Kür: der Bereich „Displays" mit // der Vorschau der Auslösefläche passt in 420 Punkte Höhe nicht // mehr, und ein Fenster, in dem man scrollen muss, obwohl der // Bildschirm frei ist, ist eine Zumutung. window.styleMask = [.titled, .closable, .miniaturizable, .resizable] - window.setContentSize(NSSize(width: 920, height: 680)) - // Breit genug für die Reiterleiste. - // - // Sieben Reiter mit deutschen Beschriftungen brauchen rund 880 - // Punkte — mehr, als man beim Nachrechnen der reinen Textbreiten - // herausbekommt: macOS legt je Reiter Innenabstand und Trenner - // dazu. Ist das Fenster schmaler, ragt die Leiste über beide - // Kanten hinaus, und der erste wie der letzte Reiter sind nicht - // mehr erreichbar. - window.contentMinSize = NSSize(width: 900, height: 460) + window.setContentSize(NSSize(width: 940, height: 700)) + // Seit die Bereiche in einer Seitenleiste stehen, hängt die + // Mindestbreite am Inhalt statt an der Beschriftung: das + // Reiterband brauchte 900 Punkte, nur damit der erste und der + // letzte Reiter nicht über die Kante hinausragten. + window.contentMinSize = NSSize(width: 780, height: 480) window.isReleasedWhenClosed = false window.delegate = self - Self.place(window) + OnyxWindowPlacement.center(window) // Über die Position freuen sich Nutzer mit mehreren Displays: // beim zweiten Öffnen steht es wieder da, wo sie es hingeschoben haben. window.setFrameAutosaveName("onyx.settings") @@ -104,27 +114,6 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { window?.makeKeyAndOrderFront(nil) } - /// Setzt das Fenster so, dass seine Titelleiste **nicht** an der Notch - /// klebt. - /// - /// `center()` setzt etwas oberhalb der Mitte — bei einem 452 Punkt hohen - /// Fenster landet der Schließknopf damit knapp unter der Menüleiste. Wer - /// ihn treffen will, fährt durch die Auslösefläche der Notch und hat - /// plötzlich das Panel vor sich. - private static func place(_ window: NSWindow) { - guard let screen = window.screen ?? NSScreen.main else { window.center(); return } - let visible = screen.visibleFrame - let size = window.frame.size - // Wirklich mittig, nicht „etwas unterhalb der Notch". - // - // `center()` setzt oberhalb der Bildschirmmitte, und mein erster - // Versuch mit 60 Punkten Abstand war zu zaghaft: der Titelbalken lag - // weiter so hoch, dass man auf dem Weg zu seinen Knöpfen durch die - // Auslösefläche der Notch fuhr und aus Versehen das Panel öffnete. - window.setFrameOrigin(NSPoint(x: visible.midX - size.width / 2, - y: visible.midY - size.height / 2)) - } - /// Der rote Knopf blendet aus, statt zu schließen. /// /// Onyx ist eine App ohne Hauptfenster; „Fenster zu" heißt hier nie @@ -147,3 +136,116 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { NSApp.hide(nil) } } + + +/// Legt eine Vibrancy-Ebene **unter das ganze Fenster**, Titelbalken +/// eingeschlossen. +/// +/// In SwiftUI ginge das nicht: ein `.background` dort endet an der sicheren +/// Fläche, und über dem oberen Rand blieb ein Streifen, durch den man den +/// Hintergrund lesen konnte. Hier liegt die Ebene im Fensterinhalt und wächst +/// mit ihm. +/// +/// Zwei Schichten, nicht eine: die Vibrancy allein ist zu durchlässig — man +/// liest den Schreibtisch mit, statt die Einstellung. Der getönte Grund darüber +/// lässt die Unschärfe stehen und nimmt ihr die Lesbarkeit. +@MainActor +final class OnyxBackdropController: NSViewController { + + /// Wie viel vom Fensterhintergrund über der Vibrancy stehen bleibt. + /// + /// 1 wäre undurchlässig, 0 ließe das nackte Material stehen — dann ist der + /// Hintergrund stellenweise mitzulesen. **Das ist die Schraube**, wenn es + /// zu durchlässig oder zu deckend wirkt. + static let tintOpacity: CGFloat = 0 + + private let content: NSViewController + + init(content: NSViewController) { + self.content = content + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { fatalError("init(coder:) wird nicht benutzt") } + + override func loadView() { + // Die Vibrancy-Ebene **ist** der Fensterinhalt, sie liegt nicht darin. + // + // Als Unteransicht in einem gewöhnlichen `NSView` blieb sie wirkungslos + // — sichtbar wurde nur der Titelbalken, den macOS selbst zeichnet. Als + // `contentView` greift `behindWindow` zuverlässig, und zusammen mit + // `.fullSizeContentView` am Fenster reicht sie auch unter den + // Titelbalken. + let container = NSVisualEffectView() + // `.sidebar` und nicht `.underWindowBackground`: letzteres ist so + // deckend, dass man von der Unschärfe nichts sieht, sobald hinter dem + // Fenster etwas Dunkles liegt. `.sidebar` ist das Material, das man von + // Finder und Systemeinstellungen kennt. + container.material = .sidebar + container.blendingMode = .behindWindow + // `.active` statt `.followsWindowActiveState`: sonst fällt die Ebene + // zurück, sobald das Fenster nicht vorn ist — dann sieht dasselbe + // Fenster je nach Aufmerksamkeit verschieden aus, und das liest sich + // als Fehler, nicht als Zustand. + container.state = .active + + let tint = TintView() + tint.autoresizingMask = [.width, .height] + container.addSubview(tint) + + addChild(content) + content.view.autoresizingMask = [.width, .height] + // Ohne das bleibt die Vibrancy unsichtbar: `NSHostingView` legt einen + // deckenden Grund unter den SwiftUI-Inhalt, und der steht genau + // dazwischen. Nur der Titelbalken kam vorbei — er wird von macOS + // gezeichnet, nicht von SwiftUI. + content.view.wantsLayer = true + content.view.layer?.backgroundColor = NSColor.clear.cgColor + container.addSubview(content.view) + + self.view = container + } + + override func viewDidLayout() { + super.viewDidLayout() + for subview in view.subviews { subview.frame = view.bounds } + } + + /// Der Titelbalken bekommt keinen eigenen Grund — er liegt auf derselben + /// Fläche wie alles andere. + override func viewDidAppear() { + super.viewDidAppear() + view.window?.styleMask.insert(.fullSizeContentView) + } +} + + +/// Der getönte Grund über der Vibrancy. +/// +/// Eine eigene Klasse, weil die Farbe erscheinungsabhängig ist: eine einmal +/// gelesene `CGColor` bliebe beim Wechsel auf Hell dunkel stehen. +/// `viewDidChangeEffectiveAppearance` gibt es nur an der View, nicht am +/// Controller — daran ist der erste Versuch gescheitert. +private final class TintView: NSView { + + override var wantsUpdateLayer: Bool { true } + + override init(frame: NSRect) { + super.init(frame: frame) + wantsLayer = true + } + + required init?(coder: NSCoder) { fatalError("init(coder:) wird nicht benutzt") } + + override func updateLayer() { + effectiveAppearance.performAsCurrentDrawingAppearance { + layer?.backgroundColor = NSColor.windowBackgroundColor + .withAlphaComponent(OnyxBackdropController.tintOpacity).cgColor + } + } + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + needsDisplay = true + } +} diff --git a/Onyx/WidgetOptionsView.swift b/Onyx/WidgetOptionsView.swift index 653eb59..da5c8e4 100644 --- a/Onyx/WidgetOptionsView.swift +++ b/Onyx/WidgetOptionsView.swift @@ -17,26 +17,13 @@ struct WidgetOptionsView: View { let shelfStore: ShelfStore? var body: some View { - Form { - Section("options.calendar") { - CalendarOptions(model: calendarModel) - } - - Section("options.weather") { - WeatherOptions(model: weatherModel) - } - + SettingsPage { + CalendarOptions(model: calendarModel) + WeatherOptions(model: weatherModel) if let shelfStore { - Section("options.shelf") { - ShelfOptions(store: shelfStore) - } + ShelfOptions(store: shelfStore) } } - .formStyle(.grouped) - // Der eigene Untergrund des Formulars weicht ein, zwei Prozent von dem - // des Fensters ab. In Dunkelgrau sieht man genau das als Kante. - .scrollContentBackground(.hidden) - .padding() } } @@ -44,82 +31,93 @@ struct WidgetOptionsView: View { private struct CalendarOptions: View { @Bindable var model: CalendarModel - @State private var calendarrState: CalendarSourceState? var body: some View { - Picker(selection: $model.kind) { - ForEach(CalendarSourceKind.allCases, id: \.self) { kind in - Text(kind.localizedName).tag(kind) - } - } label: { - Text("options.calendar.source") - } - .pickerStyle(.menu) - - Picker(selection: $model.showsMonthGrid) { - Text("options.calendar.agenda").tag(false) - Text("options.calendar.month").tag(true) - } label: { - Text("options.calendar.display") - } - .pickerStyle(.menu) - - // Welche Kalender mitzählen. Gespeichert werden die **abgewählten**: - // kommt ein neuer dazu, ist er automatisch dabei, statt dass man ihn - // suchen muss, ohne zu wissen, dass es ihn gibt. - if model.availableCalendars.isEmpty { - Text("options.calendar.noneFound") - .font(.callout).foregroundStyle(.secondary) - } else { - ForEach(model.availableCalendars) { calendar in - Toggle(isOn: Binding( - get: { !model.hiddenCalendarIDs.contains(calendar.id) }, - set: { shown in - if shown { model.hiddenCalendarIDs.remove(calendar.id) } - else { model.hiddenCalendarIDs.insert(calendar.id) } - })) { - Label { - Text(calendar.title) - } icon: { - Circle() - .fill(Color(hex: calendar.colorHex) ?? .secondary) - .frame(width: 9, height: 9) - } + OnyxFormCard(String(localized: "options.calendar")) { + OnyxFormRow(String(localized: "options.calendar.source")) { + Picker("", selection: $model.kind) { + ForEach(CalendarSourceKind.allCases, id: \.self) { kind in + Text(kind.localizedName).tag(kind) } + } + .labelsHidden() + .frame(width: 180) + } + + OnyxFormRow(String(localized: "options.calendar.display")) { + Picker("", selection: $model.showsMonthGrid) { + Text("options.calendar.agenda").tag(false) + Text("options.calendar.month").tag(true) + } + .labelsHidden() + .frame(width: 180) + } + } + + OnyxFormCard(String(localized: "options.calendar.calendars"), + hint: String(localized: "options.calendar.calendars.hint")) { + if model.availableCalendars.isEmpty { + OnyxFormRow(String(localized: "options.calendar.noneFound")) + } else { + ForEach(model.availableCalendars) { calendar in + OnyxFormRow(calendar.title) { + Circle() + .fill(Color(hex: calendar.colorHex) ?? .secondary) + .frame(width: 9, height: 9) + Toggle("", isOn: Binding( + get: { !model.hiddenCalendarIDs.contains(calendar.id) }, + set: { shown in + if shown { model.hiddenCalendarIDs.remove(calendar.id) } + else { model.hiddenCalendarIDs.insert(calendar.id) } + })) + .labelsHidden() + } + } } } if model.kind != .appleCalendar { - CalendarrServerSettings(model: model) - CalendarrStatus(state: calendarrState, - isLoading: model.isLoading, - onRefresh: { model.refresh() }) - .task(id: model.kind) { - guard model.kind != .appleCalendar else { return } - let now = Date() - calendarrState = await CalendarrSource().load( - DateInterval(start: now.addingTimeInterval(-14 * 86_400), - end: now.addingTimeInterval(60 * 86_400))) - } + OnyxFormCard(String(localized: "options.calendar.serverGroup")) { + CalendarrServerSettings(model: model) + + // Der Zustand kommt aus dem Modell, nicht aus einem zweiten + // eigenen Aufruf. Der las immer den Schnappschuss — auch wenn + // längst der Server gefragt wurde — und der Knopf daneben + // aktualisierte ihn nie. + CalendarrStatus(state: model.calendarrState, + usesServer: model.usesCalendarrServer, + isLoading: model.isLoading, + onRefresh: { model.refresh() }) + } } } } /// Was Calendarr gerade liefert. Bleibt das Widget leer, steht hier das Warum. /// -/// Mit einem Knopf zum Nachsehen. Onyx holt sich nichts — es liest den +/// Mit einem Knopf zum Nachsehen. Ohne Zugangsdaten liest Onyx den /// Schnappschuss, den Calendarr schreibt, und erfährt neue über eine /// Darwin-Nachricht. Die ist flüchtig: wer Calendarr gerade erst gestartet hat, -/// will nicht auf den nächsten Blick warten, sondern jetzt nachsehen. +/// will nicht auf den nächsten Blick warten, sondern jetzt nachsehen. Mit +/// Zugangsdaten fragt Onyx den Server selbst — dann sagt diese Zeile, wann das +/// zuletzt gelang. private struct CalendarrStatus: View { let state: CalendarSourceState? + /// Ob gerade direkt vom Server gelesen wird oder aus dem Schnappschuss. + /// + /// Ohne diese Auskunft ist der Zeitstempel daneben nicht zu deuten: ein + /// altes Datum heißt beim Schnappschuss „Calendarr lief lange nicht" und + /// beim Server „etwas stimmt nicht". + let usesServer: Bool let isLoading: Bool let onRefresh: () -> Void var body: some View { - HStack(spacing: 8) { + OnyxFormRow(String(localized: usesServer ? "options.calendar.viaServer" + : "options.calendar.viaSnapshot"), + hint: usesServer ? nil + : String(localized: "options.calendar.viaSnapshot.hint")) { message - Spacer(minLength: 8) if case .ready(let window) = state { Text(window.asOf?.formatted(date: .abbreviated, time: .shortened) ?? "–") .foregroundStyle(.secondary) @@ -180,44 +178,40 @@ private struct WeatherOptions: View { private var usesCurrentLocation: Bool { model.place.isCurrent } var body: some View { - Toggle(isOn: Binding( - get: { usesCurrentLocation }, - set: { model.place = $0 ? .current : model.place })) { - Text("options.weather.automatic") + OnyxFormCard(String(localized: "options.weather")) { + OnyxFormRow(String(localized: "options.weather.automatic"), + // Ohne Ortungsberechtigung nützt die Automatik nichts — + // und das steht hier, statt dass das Widget stumm leer + // bleibt. + hint: usesCurrentLocation && model.locationIsDenied + ? String(localized: "options.weather.needsLocation") : nil) { + Toggle("", isOn: Binding( + get: { usesCurrentLocation }, + set: { model.place = $0 ? .current : model.place })) + .labelsHidden() + .disabled(usesCurrentLocation) } - .disabled(usesCurrentLocation) - if usesCurrentLocation { - // Ohne Ortungsberechtigung nützt die Automatik nichts — und das - // steht hier, statt dass das Widget stumm leer bleibt. - if model.locationIsDenied { - Text("options.weather.needsLocation") - .font(.callout).foregroundStyle(Onyx.Color.warning) - .fixedSize(horizontal: false, vertical: true) + if case .fixed(let name, _, _) = model.place { + OnyxFormRow(String(localized: "options.weather.place")) { + Text(name).foregroundStyle(.secondary) + } } - } else if case .fixed(let name, _, _) = model.place { - LabeledContent { - Text(name).foregroundStyle(.secondary) - } label: { - Text("options.weather.place") - } - } - HStack { - TextField("options.weather.search", text: $query) - .textFieldStyle(.roundedBorder) - .onSubmit { Task { await search() } } - if searching { - ProgressView().controlSize(.small) - } else { - Button("options.weather.set") { Task { await search() } } - .disabled(query.trimmingCharacters(in: .whitespaces).isEmpty) + OnyxFormRow(String(localized: "options.weather.search"), + hint: notFound ? String(localized: "options.weather.notFound") : nil) { + TextField("", text: $query) + .textFieldStyle(.roundedBorder) + .frame(width: 160) + .onSubmit { Task { await search() } } + if searching { + ProgressView().controlSize(.small) + } else { + Button("options.weather.set") { Task { await search() } } + .disabled(query.trimmingCharacters(in: .whitespaces).isEmpty) + } } } - - if notFound { - Text("options.weather.notFound").font(.callout).foregroundStyle(Onyx.Color.warning) - } } private func search() async { @@ -241,23 +235,26 @@ private struct ShelfOptions: View { private static let choices: [Int?] = [nil, 1, 7, 30, 90] var body: some View { - Picker(selection: $store.autoCleanupDays) { - ForEach(Self.choices, id: \.self) { days in - if let days { - Text("settings.shelf.days \(days)").tag(Optional(days)) - } else { - Text("settings.shelf.never").tag(Int?.none) + OnyxFormCard(String(localized: "options.shelf")) { + OnyxFormRow(String(localized: "settings.shelf.cleanup")) { + Picker("", selection: $store.autoCleanupDays) { + ForEach(Self.choices, id: \.self) { days in + if let days { + Text("settings.shelf.days \(days)").tag(Optional(days)) + } else { + Text("settings.shelf.never").tag(Int?.none) + } + } } + .labelsHidden() + .frame(width: 180) } - } label: { - Text("settings.shelf.cleanup") - } - HStack { - Text("settings.shelf.count \(store.items.count)").foregroundStyle(.secondary) - Spacer() - Button("settings.shelf.reveal") { store.revealInFinder() } - .buttonStyle(.borderless) + OnyxFormRow(String(localized: "options.shelf")) { + Text("settings.shelf.count \(store.items.count)") + .foregroundStyle(.secondary) + Button("settings.shelf.reveal") { store.revealInFinder() } + } } } } @@ -298,7 +295,7 @@ private struct CalendarrServerSettings: View { var body: some View { if let account { LabeledContent { - HStack(spacing: 10) { + HStack(spacing: Onyx.Space.row) { Text(account.username).foregroundStyle(.secondary) Button { credentials.clear() diff --git a/Onyx/WindowPlacement.swift b/Onyx/WindowPlacement.swift new file mode 100644 index 0000000..9758867 --- /dev/null +++ b/Onyx/WindowPlacement.swift @@ -0,0 +1,26 @@ +import AppKit + +/// Wohin ein Fenster von Onyx gehört. +/// +/// `NSWindow.center()` setzt etwas **oberhalb** der Bildschirmmitte. Bei einem +/// nicht sehr hohen Fenster landet der Schließknopf damit knapp unter der +/// Menüleiste — wer ihn treffen will, fährt durch die Auslösefläche der Notch +/// und hat plötzlich das Panel vor sich. +/// +/// Das gilt für jedes Fenster, das Onyx aufmacht, nicht nur für die +/// Einstellungen. Deshalb steht es hier und nicht in einem der Controller. +enum OnyxWindowPlacement { + + /// Wirklich mittig, nicht „etwas unterhalb der Notch". + /// + /// Ein erster Versuch mit 60 Punkten Abstand zur Oberkante war zu zaghaft: + /// der Titelbalken lag weiter so hoch, dass man auf dem Weg zu seinen + /// Knöpfen durch die Auslösefläche fuhr. + static func center(_ window: NSWindow) { + guard let screen = window.screen ?? NSScreen.main else { window.center(); return } + let visible = screen.visibleFrame + let size = window.frame.size + window.setFrameOrigin(NSPoint(x: visible.midX - size.width / 2, + y: visible.midY - size.height / 2)) + } +} diff --git a/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift b/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift index f318f8d..df9fc6a 100644 --- a/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift +++ b/Packages/OnyxKit/Sources/AudioProvider/MixerView.swift @@ -31,10 +31,12 @@ public struct MixerView: View { // Mixer-Schalters: sie brauchen keinen Tap, keine Berechtigung und // keinen laufenden Mixer. Wer nur die Lautstärke sucht, soll dafür // nichts einschalten müssen. - SectionTitle("mixer.section.system", compact: compact) + OnyxSectionTitle(String(localized: "mixer.section.system", bundle: .module)) + .padding(.top, compact ? 0 : Onyx.Space.xxs) SystemSection(system: system, compact: compact) - SectionTitle("mixer.section.apps", compact: compact) + OnyxSectionTitle(String(localized: "mixer.section.apps", bundle: .module)) + .padding(.top, compact ? 0 : Onyx.Space.xxs) if mixer.isEnabled { if mixer.groups.isEmpty { @@ -42,24 +44,20 @@ public struct MixerView: View { .font(Onyx.Font.caption) .foregroundStyle(Onyx.Color.textTertiary) .frame(maxWidth: .infinity, alignment: .center) - .padding(.vertical, 8) + .padding(.vertical, Onyx.Space.md) } else { // Eine Karte um die Zeilen: sie fasst zusammen, was // zusammengehört, und trennt es vom Ausschalter darunter. - VStack(spacing: 0) { + OnyxCard { + VStack(spacing: 0) { ForEach(Array(mixer.groups.prefix(compact ? 4 : 12).enumerated()), id: \.element.id) { index, group in - if index > 0 { Divider().overlay(Onyx.Color.hairline) } + if index > 0 { OnyxHairline() } ProcessRow(mixer: mixer, group: group, compact: compact) - .padding(.horizontal, 10) - .padding(.vertical, compact ? 6 : 8) + .padding(.horizontal, Onyx.Space.row) + .padding(.vertical, compact ? Onyx.Space.sm : Onyx.Space.md) } - } - .background(Onyx.Color.elevated.opacity(0.55), - in: .rect(cornerRadius: 10, style: .continuous)) - .overlay { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .strokeBorder(Onyx.Color.hairline, lineWidth: 1) + } } } @@ -78,7 +76,7 @@ public struct MixerView: View { .foregroundStyle(Onyx.Color.textSecondary) } .buttonStyle(.plain) - .padding(.top, 2) + .padding(.top, Onyx.Space.xxs) } else { OffState(mixer: mixer, compact: compact) autostartToggle @@ -128,10 +126,10 @@ private struct OffState: View { let compact: Bool var body: some View { - VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 8) { + VStack(alignment: .leading, spacing: Onyx.Space.row) { + HStack(spacing: Onyx.Space.md) { Image(systemName: "slider.horizontal.3") - .font(.system(size: 15, weight: .light)) + .font(.system(size: Onyx.Symbol.large, weight: .light)) .foregroundStyle(Onyx.Color.textTertiary) Text("mixer.off.explain", bundle: .module) .font(Onyx.Font.caption) @@ -146,15 +144,7 @@ private struct OffState: View { .fixedSize(horizontal: false, vertical: true) } - Button { mixer.enable() } label: { - Text("mixer.on", bundle: .module) - .font(Onyx.Font.caption) - .foregroundStyle(Onyx.Color.surface) - .padding(.horizontal, 10) - .padding(.vertical, 4) - .background(Onyx.Color.accent, in: .capsule) - } - .buttonStyle(.plain) + OnyxPillButton(String(localized: "mixer.on", bundle: .module)) { mixer.enable() } } } } @@ -174,7 +164,7 @@ private struct ProcessRow: View { // ist weg — bei einem erkennbaren Programmsymbol ist er redundant, und // die gewonnene Zeile macht die Liste halb so hoch. Wer ihn braucht, // hält den Zeiger auf das Symbol. - HStack(spacing: 10) { + HStack(spacing: Onyx.Space.row) { icon Button { @@ -182,7 +172,7 @@ private struct ProcessRow: View { mixer.setMuted(!muted, for: group.bundleID) } label: { Image(systemName: muted ? "speaker.slash.fill" : speakerSymbol) - .font(.system(size: 11)) + .font(.system(size: Onyx.Symbol.small)) .frame(width: 15, alignment: .leading) } .buttonStyle(.plain) @@ -190,8 +180,8 @@ private struct ProcessRow: View { : (managed ? Onyx.Color.textSecondary : Onyx.Color.textTertiary)) - VolumeBar( - volume: volume, + OnyxSlider( + value: volume, maximum: Gain.maximum(overdrive: overdriven), level: mixer.level(for: group.bundleID), isActive: managed && !muted, @@ -238,7 +228,7 @@ private struct ProcessRow: View { mixer.setOverdrive(!overdriven, for: group.bundleID) } label: { Image(systemName: "bolt.fill") - .font(.system(size: 9, weight: .medium)) + .font(.system(size: Onyx.Symbol.caption, weight: .medium)) } .buttonStyle(.plain) .foregroundStyle(overdriven ? Onyx.Color.warning : Onyx.Color.textTertiary.opacity(0.55)) @@ -265,7 +255,7 @@ private struct ProcessRow: View { mixer.setVolume(1, for: group.bundleID) } label: { Image(systemName: "arrow.counterclockwise") - .font(.system(size: 9, weight: .medium)) + .font(.system(size: Onyx.Symbol.caption, weight: .medium)) } .buttonStyle(.plain) .foregroundStyle(Onyx.Color.textTertiary) @@ -304,7 +294,7 @@ private struct ProcessRow: View { .opacity(managed ? 1 : 0.65) } else { Image(systemName: "app.dashed") - .font(.system(size: 15)) + .font(.system(size: Onyx.Symbol.large)) .foregroundStyle(Onyx.Color.textTertiary) } } @@ -326,95 +316,6 @@ private struct ProcessRow: View { return "\(group.name) (\(group.processes.count))" } } - -/// Der Lautstärkeregler. -/// -/// Selbst gezeichnet statt `Slider`: der Systemregler bringt sein eigenes -/// Erscheinungsbild mit, das auf der dunklen Fläche wie ein Fremdkörper sitzt. -/// Und er kann nicht zeigen, was hier den Unterschied macht — den Pegel und -/// die Grenze bei 100 %. -private struct VolumeBar: View { - let volume: Double - /// Der rechte Anschlag: 1 im Normalfall, 4 mit Overdrive. - let maximum: Double - let level: Double - let isActive: Bool - let onChange: (Double) -> Void - - @State private var isDragging = false - - private let trackHeight: CGFloat = 6 - - var body: some View { - GeometryReader { geometry in - let width = geometry.size.width - let fraction = min(max(volume / maximum, 0), 1) - let knob: CGFloat = isDragging ? 13 : 11 - // Der Knopf soll an den Enden nicht überstehen. - let travel = max(width - knob, 1) - let fillWidth = travel * fraction + knob / 2 - - ZStack(alignment: .leading) { - Capsule() - .fill(Onyx.Color.surface) - .frame(height: trackHeight) - - // Markierung bei 100 %: darüber wird verstärkt, und man soll - // sehen, wann man diese Grenze überschreitet. Ohne Overdrive - // liegt sie am rechten Anschlag und sagt dort nichts mehr. - if maximum > 1 { - Rectangle() - .fill(Onyx.Color.hairline) - .frame(width: 1, height: trackHeight + 4) - .offset(x: knob / 2 + travel / maximum) - } - - Capsule() - .fill(volume > 1 ? Onyx.Color.warning : Onyx.Color.accent) - .frame(width: fillWidth, height: trackHeight) - .opacity(isActive ? 1 : 0.3) - - // Der Pegel läuft **im** Regler mit, nicht daneben: eine - // zweite Leiste pro Zeile wäre doppelt so viel Grafik für - // dieselbe Auskunft. - if isActive, level > 0.001 { - Capsule() - .fill(.white.opacity(0.4)) - .frame(width: fillWidth * meterFraction, height: trackHeight) - .blendMode(.plusLighter) - .allowsHitTesting(false) - } - - Circle() - .fill(.white) - .frame(width: knob, height: knob) - .shadow(color: .black.opacity(0.45), radius: 1.5, y: 0.5) - .offset(x: travel * fraction) - .opacity(isActive ? 1 : 0.55) - } - .frame(height: 16) - .contentShape(.rect) - .gesture( - DragGesture(minimumDistance: 0) - .onChanged { value in - isDragging = true - let position = min(max((value.location.x - knob / 2) / travel, 0), 1) - onChange(position * maximum) - } - .onEnded { _ in isDragging = false }) - } - .frame(height: 16) - .animation(Onyx.Motion.value, value: isDragging) - } - - /// Ausschläge sind linear in der Amplitude und damit optisch nutzlos: - /// halb so laut sieht aus wie fast nichts. Die Wurzel spreizt den unteren - /// Bereich so weit, dass man überhaupt etwas sieht. - private var meterFraction: Double { - min(1, sqrt(min(max(level, 0), 1))) - } -} - // MARK: - Widget public struct AudioMixerWidget: OnyxWidget { @@ -442,24 +343,6 @@ public struct AudioMixerWidget: OnyxWidget { // MARK: - Systemzeilen -private struct SectionTitle: View { - let key: LocalizedStringKey - let compact: Bool - - init(_ key: LocalizedStringKey, compact: Bool) { - self.key = key - self.compact = compact - } - - var body: some View { - Text(key, bundle: .module) - .font(Onyx.Font.metricSmall) - .foregroundStyle(Onyx.Color.textTertiary) - .textCase(.uppercase) - .padding(.top, compact ? 0 : 2) - } -} - /// Ausgabe, Eingabe und Gerätewahl. /// /// Bewusst dieselbe Zeilenform wie bei den Programmen: Symbol, Stummschalter, @@ -471,7 +354,8 @@ private struct SystemSection: View { let compact: Bool var body: some View { - VStack(spacing: 0) { + OnyxCard { + VStack(spacing: 0) { SystemRow( symbol: system.outputMuted ? "speaker.slash.fill" : "speaker.wave.2.fill", title: outputName, @@ -486,7 +370,7 @@ private struct SystemSection: View { onMute: { system.setOutputMuted(!system.outputMuted) }, onSelect: { system.selectOutput($0) }) - Divider().overlay(Onyx.Color.hairline) + OnyxHairline() SystemRow( symbol: system.inputMuted ? "mic.slash.fill" : "mic.fill", @@ -504,12 +388,7 @@ private struct SystemSection: View { onVolume: { system.setInputVolume($0) }, onMute: { system.setInputMuted(!system.inputMuted) }, onSelect: { system.selectInput($0) }) - } - .background(Onyx.Color.elevated.opacity(0.55), - in: .rect(cornerRadius: 10, style: .continuous)) - .overlay { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .strokeBorder(Onyx.Color.hairline, lineWidth: 1) + } } } @@ -555,14 +434,14 @@ private struct SystemRow: View { } } } label: { - HStack(spacing: 4) { + HStack(spacing: Onyx.Space.xs) { Text(title) .font(Onyx.Font.caption) .foregroundStyle(Onyx.Color.textPrimary) .lineLimit(1) .truncationMode(.middle) Image(systemName: "chevron.up.chevron.down") - .font(.system(size: 7)) + .font(.system(size: Onyx.Symbol.caption)) .foregroundStyle(Onyx.Color.textTertiary) Spacer(minLength: 0) } @@ -571,18 +450,18 @@ private struct SystemRow: View { .menuIndicator(.hidden) .fixedSize() - HStack(spacing: 9) { + HStack(spacing: Onyx.Space.row) { Button(action: onMute) { Image(systemName: symbol) - .font(.system(size: 11)) + .font(.system(size: Onyx.Symbol.small)) .frame(width: 15, alignment: .leading) } .buttonStyle(.plain) .foregroundStyle(muted ? Onyx.Color.critical : Onyx.Color.textSecondary) if adjustable { - SystemVolumeBar(volume: volume, level: level, - isActive: !muted, onChange: onVolume) + OnyxSlider(value: volume, level: level, + isActive: !muted, onChange: onVolume) if let metering { metering } Text("\(Int(volume * 100)) %") .font(Onyx.Font.metricSmall).monospacedDigit() @@ -599,7 +478,7 @@ private struct SystemRow: View { } } } - .padding(.horizontal, 10) + .padding(.horizontal, Onyx.Space.row) .padding(.vertical, compact ? 7 : 9) } } @@ -617,7 +496,7 @@ struct MeteringButton: View { var body: some View { Button(action: toggle) { Image(systemName: isOn ? "waveform.circle.fill" : "waveform.circle") - .font(.system(size: 12)) + .font(.system(size: Onyx.Symbol.medium)) .frame(width: 14, height: 14) } .buttonStyle(.plain) @@ -625,68 +504,3 @@ struct MeteringButton: View { .help(Text(isOn ? "mixer.input.meterOff" : "mixer.input.meterOn", bundle: .module)) } } - -/// Wie der Programmregler, nur ohne Verstärkung und ohne Pegel: die -/// Systemlautstärke geht von null bis hundert, mehr gibt Core Audio nicht her. -private struct SystemVolumeBar: View { - let volume: Double - let level: Double - let isActive: Bool - let onChange: (Double) -> Void - - @State private var isDragging = false - - var body: some View { - GeometryReader { geometry in - let knob: CGFloat = isDragging ? 13 : 11 - let travel = max(geometry.size.width - knob, 1) - let fraction = min(max(volume, 0), 1) - - ZStack(alignment: .leading) { - Capsule().fill(Onyx.Color.surface).frame(height: 6) - let fillWidth = travel * fraction + knob / 2 - - Capsule() - .fill(Onyx.Color.accent) - .frame(width: fillWidth, height: 6) - .opacity(isActive ? 1 : 0.3) - - // Derselbe mitlaufende Pegel wie in den Programmzeilen. Er - // sitzt **im** Regler, weil eine zweite Leiste daneben doppelt - // so viel Grafik für dieselbe Auskunft wäre. - if isActive, level > 0.001 { - Capsule() - .fill(.white.opacity(0.4)) - .frame(width: fillWidth * meterFraction, height: 6) - .blendMode(.plusLighter) - .allowsHitTesting(false) - } - - Circle() - .fill(.white) - .frame(width: knob, height: knob) - .shadow(color: .black.opacity(0.45), radius: 1.5, y: 0.5) - .offset(x: travel * fraction) - .opacity(isActive ? 1 : 0.55) - } - .frame(height: 16) - .contentShape(.rect) - .gesture( - DragGesture(minimumDistance: 0) - .onChanged { value in - isDragging = true - onChange(min(max((value.location.x - knob / 2) / travel, 0), 1)) - } - .onEnded { _ in isDragging = false }) - } - .frame(height: 16) - .animation(Onyx.Motion.value, value: isDragging) - } - - /// Ausschläge sind linear in der Amplitude und damit optisch nutzlos: - /// halb so laut sieht aus wie fast nichts. Die Wurzel spreizt den unteren - /// Bereich so weit, dass man überhaupt etwas sieht. - private var meterFraction: Double { - min(1, sqrt(min(max(level, 0), 1))) - } -} diff --git a/Packages/OnyxKit/Sources/AudioProvider/ProcessTap.swift b/Packages/OnyxKit/Sources/AudioProvider/ProcessTap.swift index 67ec8f4..94b4280 100644 --- a/Packages/OnyxKit/Sources/AudioProvider/ProcessTap.swift +++ b/Packages/OnyxKit/Sources/AudioProvider/ProcessTap.swift @@ -56,6 +56,15 @@ final class ProcessTap { private var procID: AudioDeviceIOProcID? private let tapUUID = UUID() + /// Der erste Eingangspuffer, der dem Tap gehört. + /// + /// **Nicht immer null.** Bringt das Ausgabegerät selbst einen Eingang mit — + /// jedes Headset tut das, jedes USB-Interface auch —, dann reiht das + /// Aggregate-Gerät dessen Puffer vor die des Taps. Wird in `start()` + /// bestimmt und im Echtzeitpfad nur gelesen; geschrieben wird ausschließlich + /// vor `AudioDeviceStart` und nach `AudioDeviceStop`, also nie nebenläufig. + private var tapBufferOffset = 0 + /// Wann zuletzt ein Puffer mit Inhalt kam — für den Wachhund. private(set) var isRunning = false @@ -131,6 +140,24 @@ final class ProcessTap { } aggregateID = device + // Wo der Tap im Eingang liegt, steht erst fest, wenn das Gerät gebaut + // ist. Danach fragen, vorher nichts starten: liegt er nicht dort, wo + // wir ihn erwarten, wäre die Folge Stille bei stummgeschaltetem + // Original — genau der Fehler, der am Headset auftrat. + guard let subDeviceID = Self.device(forUID: outputDeviceUID), + let subDeviceInputs = Self.inputChannelCounts(subDeviceID), + let aggregateInputs = Self.inputChannelCounts(aggregateID), + let tapChannels = Self.tapChannels(tapID), + let offset = TapBufferLayout.offset( + subDeviceInputBuffers: subDeviceInputs.count, + aggregateInputChannels: aggregateInputs, + tapChannels: tapChannels) + else { + cleanUp() + throw AudioMixerError.unexpectedLayout + } + tapBufferOffset = offset + var proc: AudioDeviceIOProcID? let ioStatus = AudioDeviceCreateIOProcIDWithBlock(&proc, aggregateID, nil) { [weak self] _, inputData, _, outputData, _ in @@ -149,7 +176,7 @@ final class ProcessTap { isRunning = true lastAudioUptime.store(UInt64(Self.uptime() * 1000), ordering: .relaxed) - log.notice("Tap aktiv für \(self.process.name, privacy: .public)") + log.notice("Tap aktiv für \(self.process.name, privacy: .public), Eingangspuffer \(self.tapBufferOffset, privacy: .public)") } // MARK: - Echtzeitpfad @@ -166,11 +193,14 @@ final class ProcessTap { var peak: Float = 0 for (index, outputBuffer) in outputList.enumerated() { - guard index < inputList.count, - let source = inputList[index].mData, + // Der Ausgang zählt ab null, der Eingang ab dem Puffer des Taps. + // Ohne den Versatz stünde hier bei einem Headset das Mikrofon. + let sourceIndex = tapBufferOffset + index + guard sourceIndex < inputList.count, + let source = inputList[sourceIndex].mData, let destination = outputBuffer.mData else { continue } - let count = Int(min(inputList[index].mDataByteSize, outputBuffer.mDataByteSize)) + let count = Int(min(inputList[sourceIndex].mDataByteSize, outputBuffer.mDataByteSize)) / MemoryLayout.size let inputSamples = source.assumingMemoryBound(to: Float.self) let outputSamples = destination.assumingMemoryBound(to: Float.self) @@ -224,6 +254,89 @@ final class ProcessTap { AudioHardwareDestroyProcessTap(tapID) tapID = AudioObjectID(kAudioObjectUnknown) } + tapBufferOffset = 0 + } + + // MARK: - Core Audio + + private static func device(forUID uid: String) -> AudioObjectID? { + var address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyTranslateUIDToDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + var qualifier = uid as CFString + var device = AudioObjectID(kAudioObjectUnknown) + var size = UInt32(MemoryLayout.size) + let status = withUnsafeMutablePointer(to: &qualifier) { + AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &address, + UInt32(MemoryLayout.size), $0, &size, &device) + } + guard status == noErr, device != AudioObjectID(kAudioObjectUnknown) else { return nil } + return device + } + + /// Die Kanäle je Eingangspuffer eines Geräts. Leer heißt: kein Eingang — + /// das ist ein gültiges Ergebnis, `nil` heißt, es ließ sich nicht lesen. + private static func inputChannelCounts(_ device: AudioObjectID) -> [UInt32]? { + var address = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyStreamConfiguration, + mScope: kAudioObjectPropertyScopeInput, + mElement: kAudioObjectPropertyElementMain) + var size: UInt32 = 0 + guard AudioObjectGetPropertyDataSize(device, &address, 0, nil, &size) == noErr + else { return nil } + guard size > 0 else { return [] } + + let raw = UnsafeMutableRawPointer.allocate( + byteCount: Int(size), alignment: MemoryLayout.alignment) + defer { raw.deallocate() } + guard AudioObjectGetPropertyData(device, &address, 0, nil, &size, raw) == noErr + else { return nil } + + let list = UnsafeMutableAudioBufferListPointer( + raw.assumingMemoryBound(to: AudioBufferList.self)) + return list.map(\.mNumberChannels) + } + + private static func tapChannels(_ tap: AudioObjectID) -> UInt32? { + var address = AudioObjectPropertyAddress( + mSelector: kAudioTapPropertyFormat, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + var format = AudioStreamBasicDescription() + var size = UInt32(MemoryLayout.size) + guard AudioObjectGetPropertyData(tap, &address, 0, nil, &size, &format) == noErr + else { return nil } + return format.mChannelsPerFrame + } +} + +/// Wo im Eingang eines Aggregate-Geräts die Puffer des Taps liegen. +/// +/// Ein Aggregate-Gerät reiht seine Eingangspuffer in fester Ordnung: erst die +/// des Untergeräts, dann die des Taps. Bei den eingebauten Lautsprechern gibt +/// es keine eigenen — dort liegt der Tap an null, und genau daran hat sich der +/// erste Entwurf gewöhnt. Ein Headset bringt sein Mikrofon mit und schiebt den +/// Tap eine Stelle weiter. +/// +/// Als reine Funktion, weil sie sonst nur mit angestecktem Headset prüfbar wäre. +enum TapBufferLayout { + + /// - Returns: der Index des ersten Tap-Puffers, oder `nil`, wenn die + /// Aufteilung nicht der erwarteten entspricht. Dann wird **nicht geraten**: + /// ein falsch geratener Puffer heißt Stille bei stummgeschaltetem + /// Original, ein abgewiesener Tap heißt nur, dass dieses Programm + /// ungeregelt bleibt — hörbar. + static func offset(subDeviceInputBuffers: Int, + aggregateInputChannels: [UInt32], + tapChannels: UInt32) -> Int? { + guard subDeviceInputBuffers >= 0, tapChannels > 0 else { return nil } + guard subDeviceInputBuffers < aggregateInputChannels.count else { return nil } + // Die Gegenprobe: an der erwarteten Stelle muss das Format des Taps + // stehen. Läge die Ordnung anders, fiele das hier auf, statt still zu + // bleiben. + guard aggregateInputChannels[subDeviceInputBuffers] == tapChannels else { return nil } + return subDeviceInputBuffers } } @@ -233,6 +346,7 @@ public enum AudioMixerError: Error, LocalizedError { case ioProcFailed(OSStatus) case startFailed case noOutputDevice + case unexpectedLayout public var errorDescription: String? { switch self { @@ -246,6 +360,8 @@ public enum AudioMixerError: Error, LocalizedError { "Das Mischgerät startet nicht." case .noOutputDevice: "Kein Ausgabegerät gefunden." + case .unexpectedLayout: + "Das Mischgerät reicht den Ton an unerwarteter Stelle durch." } } } diff --git a/Packages/OnyxKit/Sources/AudioProvider/SystemAudio.swift b/Packages/OnyxKit/Sources/AudioProvider/SystemAudio.swift index 524555c..26e40ed 100644 --- a/Packages/OnyxKit/Sources/AudioProvider/SystemAudio.swift +++ b/Packages/OnyxKit/Sources/AudioProvider/SystemAudio.swift @@ -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 diff --git a/Packages/OnyxKit/Sources/CalendarProvider/CalendarSourceSettings.swift b/Packages/OnyxKit/Sources/CalendarProvider/CalendarSourceSettings.swift index 1203812..9e3d624 100644 --- a/Packages/OnyxKit/Sources/CalendarProvider/CalendarSourceSettings.swift +++ b/Packages/OnyxKit/Sources/CalendarProvider/CalendarSourceSettings.swift @@ -32,7 +32,7 @@ public struct CalendarSourceSettings: View { if model.kind != .appleCalendar { Section { LabeledContent { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: Onyx.Space.xs) { statusText Text("calendar.source.readOnly", bundle: .module) .font(.caption) @@ -53,7 +53,7 @@ public struct CalendarSourceSettings: View { private var statusText: some View { switch calendarrState { case .ready(let window): - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: Onyx.Space.xxs) { Text("calendar.source.status.ready \(window.events.count.formatted()) \(asOfText(window.asOf))", bundle: .module) if let coverage = window.coverage { diff --git a/Packages/OnyxKit/Sources/CalendarProvider/CalendarWidget.swift b/Packages/OnyxKit/Sources/CalendarProvider/CalendarWidget.swift index 2d60363..3745d3e 100644 --- a/Packages/OnyxKit/Sources/CalendarProvider/CalendarWidget.swift +++ b/Packages/OnyxKit/Sources/CalendarProvider/CalendarWidget.swift @@ -45,6 +45,18 @@ public final class CalendarModel { /// Die Kalender, die zur Auswahl stehen. public private(set) var availableCalendars: [OnyxCalendar] = [] + /// Der Zustand der Calendarr-Quelle **allein**, für die Einstellungen. + /// + /// Getrennt von `state`, weil das bei „beide" auch Apple-Termine enthält — + /// eine Zahl daraus sagt über Calendarr nichts. + /// + /// Und getrennt geführt statt in den Einstellungen nachgeladen: dort stand + /// bisher ein zweiter, eigener Aufruf von `CalendarrSource`, also immer der + /// **Schnappschuss** — auch wenn längst der Server gefragt wurde. Der + /// Zeitstempel daneben war deshalb das Alter einer Datei, die niemand mehr + /// liest, und sah aus wie ein Kalender, der seit Wochen stillsteht. + public private(set) var calendarrState: CalendarSourceState? + private static let kindKey = "calendar.source" private static let hiddenKey = "calendar.hiddenCalendars" private static let gridKey = "calendar.showsMonthGrid" @@ -194,6 +206,7 @@ public final class CalendarModel { let interval = DateInterval(start: start, end: end) var states: [CalendarSourceState] = [] + let calendarr = kind == .appleCalendar ? nil : calendarrChoice as AnyObject for source in activeSources { guard !Task.isCancelled else { return } // Hier wird NICHT nachgefragt. Ein Berechtigungsdialog aus einer @@ -201,7 +214,9 @@ public final class CalendarModel { // passiert — die Anfrage muss aus dem Einstellungsfenster kommen. // Ohne Berechtigung ist der ehrliche Zustand `denied` und nicht // eine leere Terminliste, die "nichts mehr heute" behaupten würde. - states.append(source.isAuthorized ? await source.load(interval) : .denied) + let loaded = source.isAuthorized ? await source.load(interval) : .denied + if let calendarr, source as AnyObject === calendarr { calendarrState = loaded } + states.append(loaded) } guard !Task.isCancelled else { return } state = CalendarSourceKind.merge(states).filteringHidden(hiddenCalendarIDs) @@ -266,41 +281,26 @@ private struct CalendarWidgetView: View { AgendaList(events: model.upcoming, coverage: window.coverage) } case .denied: - CalendarNotice(symbol: "lock", text: "widget.calendar.denied") + OnyxNotice(symbol: "lock", + text: String(localized: "widget.calendar.denied", bundle: .module)) case .neverWritten: - CalendarNotice(symbol: "clock.arrow.circlepath", text: "widget.calendar.neverWritten") + OnyxNotice(symbol: "clock.arrow.circlepath", + text: String(localized: "widget.calendar.neverWritten", bundle: .module)) case .loggedOut: - CalendarNotice(symbol: "person.crop.circle.badge.xmark", text: "widget.calendar.loggedOut") + OnyxNotice(symbol: "person.crop.circle.badge.xmark", + text: String(localized: "widget.calendar.loggedOut", bundle: .module)) case .incompatible: - CalendarNotice(symbol: "exclamationmark.triangle", text: "widget.calendar.incompatible") + OnyxNotice(symbol: "exclamationmark.triangle", + text: String(localized: "widget.calendar.incompatible", bundle: .module)) case .unreadable: - CalendarNotice(symbol: "questionmark.folder", text: "widget.calendar.unreadable") + OnyxNotice(symbol: "questionmark.folder", + text: String(localized: "widget.calendar.unreadable", bundle: .module)) } } .task { model.refresh() } .onDisappear { model.stop() } } } - -/// Kein Platzhalter, sondern eine Antwort: jeder Zustand sagt, was zu tun ist. -private struct CalendarNotice: View { - let symbol: String - let text: LocalizedStringKey - - var body: some View { - VStack(spacing: 6) { - Image(systemName: symbol) - .font(.system(size: 16)) - .foregroundStyle(Onyx.Color.textTertiary) - Text(text, bundle: .module) - .font(Onyx.Font.caption) - .foregroundStyle(Onyx.Color.textSecondary) - .multilineTextAlignment(.center) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } -} - // MARK: - Mini-Monat private struct LargeCalendar: View { @@ -310,9 +310,9 @@ private struct LargeCalendar: View { private var calendar: Calendar { .autoupdatingCurrent } var body: some View { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: Onyx.Space.md) { MiniMonth(window: window) - Divider().overlay(Onyx.Color.hairline) + OnyxHairline() AgendaList(events: Array(upcoming.prefix(3)), coverage: window.coverage) } } @@ -328,11 +328,11 @@ private struct MiniMonth: View { let busy = window.events.busyDays(calendar: calendar) let today = calendar.startOfDay(for: Date()) - VStack(spacing: 3) { + VStack(spacing: Onyx.Space.xs) { HStack(spacing: 0) { ForEach(weekdaySymbols, id: \.self) { symbol in Text(symbol) - .font(.system(size: 8, weight: .medium)) + .font(Onyx.Font.micro) .foregroundStyle(Onyx.Color.textTertiary) .frame(maxWidth: .infinity) } @@ -370,7 +370,7 @@ private struct DayCell: View { Circle().fill(Onyx.Color.accent.opacity(0.9)).frame(width: 15, height: 15) } Text("\(Calendar.autoupdatingCurrent.component(.day, from: day.date))") - .font(.system(size: 9, weight: isToday ? .semibold : .regular)) + .font(Onyx.Font.micro.weight(isToday ? .semibold : .regular)) .foregroundStyle(textColor) } .frame(maxWidth: .infinity) @@ -413,10 +413,10 @@ private struct AgendaList: View { } else { // Nach Tagen getrennt: „8:00" allein sagt nicht, an welchem. let days = AgendaGrouping.group(events) - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: Onyx.Space.xs) { ForEach(Array(days.enumerated()), id: \.element.id) { index, day in Text(AgendaGrouping.label(for: day.date)) - .font(.system(size: 9, weight: .semibold)) + .font(Onyx.Font.micro.weight(.semibold)) .foregroundStyle(Onyx.Color.textSecondary) .textCase(.uppercase) // Luft nur zwischen den Tagen, nicht über dem ersten. @@ -435,18 +435,18 @@ private struct EventRow: View { let event: OnyxEvent var body: some View { - HStack(spacing: 6) { + HStack(spacing: Onyx.Space.sm) { RoundedRectangle(cornerRadius: 1.5, style: .continuous) .fill(Color(hex: event.colorHex) ?? Onyx.Color.accent) .frame(width: 3) - VStack(alignment: .leading, spacing: 1) { + VStack(alignment: .leading, spacing: Onyx.Space.xxs) { Text(event.title) .font(Onyx.Font.caption) .foregroundStyle(Onyx.Color.textPrimary) .lineLimit(1) Text(timeText) - .font(.system(size: 9)) + .font(Onyx.Font.micro) .monospacedDigit() .foregroundStyle(Onyx.Color.textTertiary) .lineLimit(1) diff --git a/Packages/OnyxKit/Sources/MediaProvider/MediaWidget.swift b/Packages/OnyxKit/Sources/MediaProvider/MediaWidget.swift index f5ad35b..4a4f5de 100644 --- a/Packages/OnyxKit/Sources/MediaProvider/MediaWidget.swift +++ b/Packages/OnyxKit/Sources/MediaProvider/MediaWidget.swift @@ -41,7 +41,7 @@ private struct Idle: View { let model: MediaModel var body: some View { - VStack(spacing: 8) { + VStack(spacing: Onyx.Space.md) { Text("widget.media.nothing", bundle: .module) .font(Onyx.Font.caption) .foregroundStyle(Onyx.Color.textTertiary) @@ -59,12 +59,12 @@ private struct Playing: View { // Übereinander, nicht nebeneinander. Die Kachel ist hochkant: neben // einem Cover blieben für den Titel noch gut hundert Punkte, und dann // steht dort „Bernhard H…" statt eines Titels. - VStack(alignment: .leading, spacing: 7) { + VStack(alignment: .leading, spacing: Onyx.Space.md) { Artwork(data: state.artwork) .frame(maxWidth: .infinity) .frame(height: 88) - VStack(alignment: .leading, spacing: 1) { + VStack(alignment: .leading, spacing: Onyx.Space.xxs) { Text(state.title) .font(Onyx.Font.caption.weight(.medium)) .foregroundStyle(Onyx.Color.textPrimary) @@ -91,7 +91,7 @@ private struct Playing: View { // greift: dann fehlen Cover und Browser-Wiedergaben, // und das soll erklärbar sein statt rätselhaft. Image(systemName: "applescript") - .font(.system(size: 9)) + .font(.system(size: Onyx.Symbol.caption)) .foregroundStyle(Onyx.Color.textTertiary) .help(Text("widget.media.viaAppleScript", bundle: .module)) } @@ -107,7 +107,7 @@ private struct Artwork: View { let data: Data? var body: some View { - let shape = RoundedRectangle(cornerRadius: 8, style: .continuous) + let shape = RoundedRectangle(cornerRadius: Onyx.Radius.control, style: .continuous) // Reihenfolge zählt: erst der Rahmen, dann das Beschneiden. Andersherum // beschneidet man das Bild auf seine eigene, viel größere Größe — und es @@ -122,7 +122,7 @@ private struct Artwork: View { shape.fill(Onyx.Color.elevated) .overlay { Image(systemName: "music.note") - .font(.system(size: 18)) + .font(.system(size: Onyx.Symbol.xlarge)) .foregroundStyle(Onyx.Color.textTertiary) } } @@ -143,7 +143,7 @@ private struct Scrubber: View { let now = model.tick let progress = state.progress(at: now) - VStack(spacing: 2) { + VStack(spacing: Onyx.Space.xxs) { GeometryReader { geometry in ZStack(alignment: .leading) { Capsule().fill(Onyx.Color.hairline) @@ -166,7 +166,7 @@ private struct Scrubber: View { Spacer(minLength: 0) Text(format(state.duration ?? 0)) } - .font(.system(size: 8)) + .font(Onyx.Font.micro) .monospacedDigit() .foregroundStyle(Onyx.Color.textTertiary) } @@ -185,7 +185,7 @@ private struct TransportControls: View { let size: CGFloat var body: some View { - HStack(spacing: 12) { + HStack(spacing: Onyx.Space.lg) { button("backward.fill") { model.send(.previous) } button(model.nowPlaying?.isPlaying == true ? "pause.fill" : "play.fill") { model.send(.playPause) diff --git a/Packages/OnyxKit/Sources/MetricsProvider/BatteryGlyph.swift b/Packages/OnyxKit/Sources/MetricsProvider/BatteryGlyph.swift index 680d40e..8bca38d 100644 --- a/Packages/OnyxKit/Sources/MetricsProvider/BatteryGlyph.swift +++ b/Packages/OnyxKit/Sources/MetricsProvider/BatteryGlyph.swift @@ -1,4 +1,5 @@ import AppKit +import OnyxDesign import OnyxMenuBar /// Der Akku, gezeichnet wie der von macOS. @@ -259,9 +260,9 @@ public struct BatteryGlyphView: View { private var width: CGFloat { BatteryGlyph.size.width * scale } public var body: some View { - HStack(spacing: 1 * scale) { + HStack(spacing: Onyx.Space.xxs * scale) { ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 3 * scale, style: .continuous) + RoundedRectangle(cornerRadius: Onyx.Radius.bar * scale, style: .continuous) .strokeBorder(.primary.opacity(0.45), lineWidth: 1) GeometryReader { geometry in @@ -296,7 +297,7 @@ public struct BatteryGlyphView: View { } .compositingGroup() - RoundedRectangle(cornerRadius: 1 * scale, style: .continuous) + RoundedRectangle(cornerRadius: Onyx.Radius.bar * scale, style: .continuous) .fill(.primary.opacity(0.45)) .frame(width: 2 * scale, height: 5 * scale) } diff --git a/Packages/OnyxKit/Sources/MetricsProvider/ChargeLimitView.swift b/Packages/OnyxKit/Sources/MetricsProvider/ChargeLimitView.swift index ee9059b..a0df374 100644 --- a/Packages/OnyxKit/Sources/MetricsProvider/ChargeLimitView.swift +++ b/Packages/OnyxKit/Sources/MetricsProvider/ChargeLimitView.swift @@ -34,7 +34,7 @@ public struct ChargeLimitControl: View { } public var body: some View { - HStack(spacing: 6) { + HStack(spacing: Onyx.Space.sm) { Text("charge.limit", bundle: .module) .font(Onyx.Font.caption) .foregroundStyle(Onyx.Color.textSecondary) @@ -62,7 +62,7 @@ public struct ChargeLimitControl: View { URL(string: "x-apple.systempreferences:com.apple.Battery-Settings.extension")!) } label: { Image(systemName: "arrow.up.forward.app") - .font(.system(size: 10)) + .font(.system(size: Onyx.Symbol.small)) .foregroundStyle(Onyx.Color.textTertiary) } .buttonStyle(.plain) diff --git a/Packages/OnyxKit/Sources/MetricsProvider/MetricPopovers.swift b/Packages/OnyxKit/Sources/MetricsProvider/MetricPopovers.swift index 07ccdef..3d08b54 100644 --- a/Packages/OnyxKit/Sources/MetricsProvider/MetricPopovers.swift +++ b/Packages/OnyxKit/Sources/MetricsProvider/MetricPopovers.swift @@ -12,7 +12,7 @@ struct MetricPopoverContent: View { let metric: MetricKind var body: some View { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: Onyx.Space.lg) { // Ein Titel oben: bei drei gleich aussehenden Ringen sieht man // sonst nicht, ob man vor dem CPU- oder dem GPU-Popover steht. Text(String(localized: .init(metric.localizationKey), bundle: .module)) @@ -25,7 +25,7 @@ struct MetricPopoverContent: View { case .temperature: SensorDetail(snapshot: model.snapshot) } } - .padding(14) + .padding(Onyx.Space.xl) .frame(width: 280) } } @@ -34,17 +34,6 @@ struct MetricPopoverContent: View { /// Überschrift eines Abschnitts. Ohne sie ist eine lange Werteliste nur eine /// lange Werteliste. -private struct SectionTitle: View { - let text: LocalizedStringKey - - var body: some View { - Text(text, bundle: .module) - .font(.caption.weight(.semibold)) - .foregroundStyle(Onyx.Color.accent) - .textCase(.uppercase) - } -} - private struct Row: View { let label: String let value: String @@ -88,7 +77,7 @@ private struct Dial: View { var body: some View { // Sechs Punkte Abstand statt zwei: die Beschriftung klebte am Ring und // sah aus, als gehörte sie noch hinein. - VStack(spacing: 6) { + VStack(spacing: Onyx.Space.sm) { ZStack { Circle().stroke(.quaternary, lineWidth: 5) Circle() @@ -96,13 +85,13 @@ private struct Dial: View { .stroke(tint ?? Onyx.Color.accent, style: .init(lineWidth: 5, lineCap: .round)) .rotationEffect(.degrees(-90)) - Text(text).font(.system(size: 15, weight: .medium).monospacedDigit()) + Text(text).font(Onyx.Font.headline.monospacedDigit()) } .frame(width: 62, height: 62) .alignmentGuide(.dialCenter) { $0[VerticalAlignment.center] } Text(caption).font(.caption2).foregroundStyle(.secondary) } - .padding(.bottom, 2) + .padding(.bottom, Onyx.Space.xxs) } } @@ -115,7 +104,7 @@ private struct ProcessorDetail: View { var body: some View { let snapshot = model.snapshot - HStack(spacing: 14) { + HStack(spacing: Onyx.Space.xl) { Dial(value: metric == .cpu ? snapshot.cpu.total : (snapshot.gpu ?? 0), caption: metric == .cpu ? "CPU" : "GPU", text: MetricFormat.percent(metric == .cpu ? snapshot.cpu.total @@ -135,11 +124,11 @@ private struct ProcessorDetail: View { .frame(maxWidth: .infinity) if metric == .cpu, !model.snapshot.cpu.perCore.isEmpty { - SectionTitle(text: "popover.perCore") + OnyxPopoverTitle(String(localized: "popover.perCore", bundle: .module)) CoreGrid(values: model.snapshot.cpu.perCore) } - SectionTitle(text: "popover.power") + OnyxPopoverTitle(String(localized: "popover.power", bundle: .module)) if let cpu = snapshot.power.cpu { Row(label: String(localized: "popover.power.cpu", bundle: .module), value: MetricFormat.watts(cpu)) @@ -154,7 +143,7 @@ private struct ProcessorDetail: View { } if !snapshot.fans.isEmpty { - SectionTitle(text: "popover.fans") + OnyxPopoverTitle(String(localized: "popover.fans", bundle: .module)) ForEach(snapshot.fans) { fan in Row(label: fan.name, value: "\(Int(fan.rpm)) U/min") } @@ -181,13 +170,13 @@ private struct CoreGrid: View { let values: [Double] var body: some View { - LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 3), count: 8), - spacing: 3) { + LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: Onyx.Space.xs), count: 8), + spacing: Onyx.Space.xs) { ForEach(Array(values.enumerated()), id: \.offset) { _, value in - RoundedRectangle(cornerRadius: 2, style: .continuous) + RoundedRectangle(cornerRadius: Onyx.Radius.bar, style: .continuous) .fill(Onyx.Color.accent.opacity(0.18)) .overlay(alignment: .bottom) { - RoundedRectangle(cornerRadius: 2, style: .continuous) + RoundedRectangle(cornerRadius: Onyx.Radius.bar, style: .continuous) .fill(Onyx.Color.accent) .frame(height: max(14 * value, 1.5)) } @@ -205,12 +194,12 @@ private struct MemoryDetail: View { var body: some View { let memory = snapshot.memory - HStack(spacing: 14) { + HStack(spacing: Onyx.Space.xl) { Dial(value: memory.usedFraction, caption: String(localized: "popover.memory.used", bundle: .module), text: MetricFormat.percent(memory.usedFraction), tint: pressureColor) - VStack(alignment: .leading, spacing: 3) { + VStack(alignment: .leading, spacing: Onyx.Space.xs) { legend(Onyx.Color.accent, "popover.memory.app", memory.app) legend(.purple, "popover.memory.wired", memory.wired) legend(.teal, "popover.memory.compressed", memory.compressed) @@ -218,7 +207,7 @@ private struct MemoryDetail: View { } } - SectionTitle(text: "popover.memory.swap") + OnyxPopoverTitle(String(localized: "popover.memory.swap", bundle: .module)) // Auslagerung ist der Wert, der zählt: solange nicht ausgelagert wird, // ist ein voller Speicher kein Problem — macOS füllt ihn absichtlich. Row(label: String(localized: "popover.memory.swapUsed", bundle: .module), @@ -226,7 +215,7 @@ private struct MemoryDetail: View { emphasis: memory.swapUsed > 512 * 1024 * 1024 ? Onyx.Color.warning : nil) if !snapshot.topMemoryProcesses.isEmpty { - SectionTitle(text: "popover.processes") + OnyxPopoverTitle(String(localized: "popover.processes", bundle: .module)) ForEach(snapshot.topMemoryProcesses) { process in Row(label: process.name, value: bytes(process.bytes)) } @@ -242,7 +231,7 @@ private struct MemoryDetail: View { } private func legend(_ color: Color, _ key: LocalizedStringKey, _ value: UInt64) -> some View { - HStack(spacing: 5) { + HStack(spacing: Onyx.Space.sm) { Circle().fill(color).frame(width: 7, height: 7) Text(key, bundle: .module).font(.caption) Spacer(minLength: 8) @@ -263,7 +252,7 @@ private struct BatteryDetail: View { var body: some View { if let battery = snapshot.battery { - HStack(alignment: .dialCenter, spacing: 14) { + HStack(alignment: .dialCenter, spacing: Onyx.Space.xl) { Dial(value: battery.charge, caption: String(localized: "metric.battery", bundle: .module), text: MetricFormat.percent(battery.charge), @@ -275,7 +264,7 @@ private struct BatteryDetail: View { // darunter waren zweimal dieselbe Auskunft an zwei Stellen — // und die zweite stand allein unter dem Diagramm, wo sie // aussah, als gehörte sie zu dessen Beschriftung. - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: Onyx.Space.xs) { if let remaining = battery.timeRemaining { Text(duration(remaining)).font(.title3.monospacedDigit()) Text(battery.isCharging ? "popover.battery.untilFull" @@ -302,11 +291,11 @@ private struct BatteryDetail: View { // eine Akkusache, und man greift danach, während man auf den // Ladestand schaut. if let control { - Divider().overlay(Onyx.Color.hairline) + OnyxHairline() ChargeLimitControl(control: control, roomy: true) } - SectionTitle(text: "popover.battery.condition") + OnyxPopoverTitle(String(localized: "popover.battery.condition", bundle: .module)) if let health = battery.health { Row(label: String(localized: "popover.battery.health", bundle: .module), value: MetricFormat.percent(health)) @@ -320,7 +309,7 @@ private struct BatteryDetail: View { value: MetricFormat.temperature(sensor.celsius)) } - SectionTitle(text: "popover.power") + OnyxPopoverTitle(String(localized: "popover.power", bundle: .module)) if let watts = battery.watts { Row(label: String(localized: battery.isCharging ? "popover.battery.charging" : "popover.battery.draining", @@ -354,7 +343,7 @@ private struct BatteryDetail: View { isPluggedIn: battery.isPluggedIn, charge: battery.charge, limit: control?.chargeLimit) - HStack(spacing: 6) { + HStack(spacing: Onyx.Space.sm) { Image(systemName: symbol(for: activity)) .font(.caption) .foregroundStyle(tint(for: activity)) @@ -401,7 +390,7 @@ private struct SensorDetail: View { let snapshot: MetricsSnapshot var body: some View { - SectionTitle(text: "popover.temperature") + OnyxPopoverTitle(String(localized: "popover.temperature", bundle: .module)) ForEach(snapshot.sensors) { sensor in HStack { Text(sensor.name).font(.callout) @@ -421,9 +410,9 @@ private struct SensorDetail: View { } if !snapshot.fans.isEmpty { - SectionTitle(text: "popover.fans") + OnyxPopoverTitle(String(localized: "popover.fans", bundle: .module)) ForEach(snapshot.fans) { fan in - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: Onyx.Space.xxs) { HStack { Text(fan.name).font(.callout) Spacer(minLength: 12) diff --git a/Packages/OnyxKit/Sources/NetworkProvider/NetworkPopover.swift b/Packages/OnyxKit/Sources/NetworkProvider/NetworkPopover.swift index 5fb0ecb..82f442f 100644 --- a/Packages/OnyxKit/Sources/NetworkProvider/NetworkPopover.swift +++ b/Packages/OnyxKit/Sources/NetworkProvider/NetworkPopover.swift @@ -8,7 +8,7 @@ struct NetworkPopoverContent: View { let model: NetworkModel var body: some View { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: Onyx.Space.lg) { HStack(alignment: .top) { rate(model.snapshot.downloadRate, "network.download", Onyx.Color.download) Spacer() @@ -43,17 +43,17 @@ struct NetworkPopoverContent: View { .foregroundStyle(.secondary) } - section("network.addresses") + OnyxPopoverTitle(String(localized: "network.addresses", bundle: .module)) field("network.field.interface", model.snapshot.interfaceName) field("network.field.ipv4", model.snapshot.ipv4) field("network.field.ipv6", model.snapshot.ipv6) field("network.field.router", model.snapshot.router) - section("network.transferred") + OnyxPopoverTitle(String(localized: "network.transferred", bundle: .module)) field("network.total.received", bytes(model.snapshot.totalReceived)) field("network.total.sent", bytes(model.snapshot.totalSent)) - section("network.public") + OnyxPopoverTitle(String(localized: "network.public", bundle: .module)) if model.showsPublicAddress { HStack { if model.publicAddress.isLoading { @@ -86,7 +86,7 @@ struct NetworkPopoverContent: View { } if !model.processes.isEmpty { - section("network.processes") + OnyxPopoverTitle(String(localized: "network.processes", bundle: .module)) ForEach(model.processes) { process in HStack { Text(process.name).font(.callout).lineLimit(1) @@ -101,7 +101,7 @@ struct NetworkPopoverContent: View { } } } - .padding(14) + .padding(Onyx.Space.xl) .frame(width: 300) .onAppear { model.addDetailConsumer() } .onDisappear { model.removeDetailConsumer() } @@ -117,10 +117,10 @@ struct NetworkPopoverContent: View { } private func rate(_ value: Double, _ key: LocalizedStringKey, _ tint: Color) -> some View { - VStack(alignment: .leading, spacing: 1) { + VStack(alignment: .leading, spacing: Onyx.Space.xxs) { Text(Throughput.formatted(value)) - .font(.system(size: 18, weight: .medium).monospacedDigit()) - HStack(spacing: 4) { + .font(Onyx.Font.display.monospacedDigit()) + HStack(spacing: Onyx.Space.xs) { Circle().fill(tint).frame(width: 6, height: 6) Text(key, bundle: .module).font(.caption).foregroundStyle(.secondary) } @@ -128,19 +128,12 @@ struct NetworkPopoverContent: View { } private func peak(_ value: Double, _ key: LocalizedStringKey) -> some View { - HStack(spacing: 4) { + HStack(spacing: Onyx.Space.xs) { Text(key, bundle: .module).font(.caption2).foregroundStyle(.secondary) Text(Throughput.formatted(value)).font(.caption2.monospacedDigit()) } } - private func section(_ key: LocalizedStringKey) -> some View { - Text(key, bundle: .module) - .font(.caption.weight(.semibold)) - .foregroundStyle(Onyx.Color.accent) - .textCase(.uppercase) - } - @ViewBuilder private func field(_ key: LocalizedStringKey, _ value: String?) -> some View { if let value { @@ -175,16 +168,16 @@ private struct TrafficChart: View { // Fünftel je Balken, und mit jedem weiteren wurden sie schmaler. // Frisch gestartet sah das Diagramm dadurch aus wie ein Ausschlag. let width = GraphLayout.barWidth(capacity: NetworkModel.historyLength, - totalWidth: geometry.size.width, spacing: 1) + totalWidth: geometry.size.width, spacing: Onyx.Space.xxs) let half = geometry.size.height / 2 - HStack(alignment: .center, spacing: 1) { + HStack(alignment: .center, spacing: Onyx.Space.xxs) { // Fehlende Messwerte bleiben als freie Fläche links stehen; der // Verlauf wächst nach links weg, wie bei jedem Zeitdiagramm. Spacer(minLength: 0) ForEach(Array(points.enumerated()), id: \.offset) { _, snapshot in - VStack(spacing: 1) { + VStack(spacing: Onyx.Space.xxs) { Rectangle() .fill(Onyx.Color.upload) .frame(height: max(half * CGFloat(snapshot.uploadRate / peak), 0.5)) diff --git a/Packages/OnyxKit/Sources/NetworkProvider/NetworkWidget.swift b/Packages/OnyxKit/Sources/NetworkProvider/NetworkWidget.swift index 238e95b..54a7dfc 100644 --- a/Packages/OnyxKit/Sources/NetworkProvider/NetworkWidget.swift +++ b/Packages/OnyxKit/Sources/NetworkProvider/NetworkWidget.swift @@ -141,10 +141,10 @@ private struct NetworkWidgetView: View { @State private var token: UUID? var body: some View { - VStack(alignment: .leading, spacing: 5) { - HStack(spacing: 6) { + VStack(alignment: .leading, spacing: Onyx.Space.sm) { + HStack(spacing: Onyx.Space.sm) { Image(systemName: model.snapshot.interfaceKind.symbolName) - .font(.system(size: 11)) + .font(.system(size: Onyx.Symbol.small)) .foregroundStyle(Onyx.Color.download) Text(connectionName) .font(Onyx.Font.caption) @@ -158,7 +158,7 @@ private struct NetworkWidgetView: View { capacity: NetworkModel.historyLength) .frame(height: 18) - Divider().overlay(Onyx.Color.hairline) + OnyxHairline() Details(snapshot: model.snapshot) Spacer(minLength: 0) @@ -189,16 +189,16 @@ private struct Rates: View { let compact: Bool var body: some View { - VStack(alignment: .leading, spacing: 1) { + VStack(alignment: .leading, spacing: Onyx.Space.xxs) { row("arrow.down", Throughput.formatted(snapshot.downloadRate), Onyx.Color.download) row("arrow.up", Throughput.formatted(snapshot.uploadRate), Onyx.Color.upload) } } private func row(_ symbol: String, _ text: String, _ tint: Color) -> some View { - HStack(spacing: 3) { + HStack(spacing: Onyx.Space.xs) { Image(systemName: symbol) - .font(.system(size: 8, weight: .bold)) + .font(.system(size: Onyx.Symbol.caption, weight: .bold)) .foregroundStyle(tint) Text(text) .font(compact ? Onyx.Font.metricSmall : Onyx.Font.metricSmall) @@ -212,7 +212,7 @@ private struct Details: View { let snapshot: NetworkSnapshot var body: some View { - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: Onyx.Space.xxs) { field("network.field.interface", snapshot.interfaceName) field("network.field.ipv4", snapshot.ipv4) field("network.field.router", snapshot.router) @@ -222,12 +222,12 @@ private struct Details: View { @ViewBuilder private func field(_ label: LocalizedStringKey, _ value: String?) -> some View { if let value { - HStack(spacing: 4) { + HStack(spacing: Onyx.Space.xs) { Text(label, bundle: .module) - .font(.system(size: 9)) + .font(Onyx.Font.micro) .foregroundStyle(Onyx.Color.textTertiary) Text(value) - .font(.system(size: 9)) + .font(Onyx.Font.micro) .monospacedDigit() .foregroundStyle(Onyx.Color.textSecondary) .textSelection(.enabled) diff --git a/Packages/OnyxKit/Sources/OnyxWidgetKit/WidgetGrid.swift b/Packages/OnyxKit/Sources/OnyxWidgetKit/WidgetGrid.swift index cd6cb80..b2326b5 100644 --- a/Packages/OnyxKit/Sources/OnyxWidgetKit/WidgetGrid.swift +++ b/Packages/OnyxKit/Sources/OnyxWidgetKit/WidgetGrid.swift @@ -41,7 +41,7 @@ public struct WidgetGrid: View { // Sollte nach dem Filtern in LayoutStore nicht vorkommen. Falls doch, // ist eine sichtbare Lücke mit Namen besser als ein stiller Ausfall. OnyxTile { - VStack(spacing: 4) { + VStack(spacing: Onyx.Space.xs) { Image(systemName: "questionmark.square.dashed") Text(item.placement.widgetID) .font(Onyx.Font.caption) diff --git a/Packages/OnyxKit/Sources/ShelfProvider/AirDropWidget.swift b/Packages/OnyxKit/Sources/ShelfProvider/AirDropWidget.swift index 74b3546..65323e3 100644 --- a/Packages/OnyxKit/Sources/ShelfProvider/AirDropWidget.swift +++ b/Packages/OnyxKit/Sources/ShelfProvider/AirDropWidget.swift @@ -45,9 +45,9 @@ public struct AirDropView: View { if compact { // Nebeneinander statt übereinander: in 52 Punkten Höhe ist für // Symbol über Text kein Platz. - HStack(spacing: 8) { + HStack(spacing: Onyx.Space.md) { Image(systemName: "shareplay") - .font(.system(size: 15, weight: .light)) + .font(.system(size: Onyx.Symbol.large, weight: .light)) .foregroundStyle(isTargeted ? Onyx.Color.accent : Onyx.Color.textTertiary) Text(failed ? "airdrop.unavailable" : "airdrop.hint", bundle: .module) @@ -57,11 +57,11 @@ public struct AirDropView: View { .fixedSize(horizontal: false, vertical: true) Spacer(minLength: 0) } - .padding(.horizontal, 8) + .padding(.horizontal, Onyx.Space.md) } else { - VStack(spacing: 6) { + VStack(spacing: Onyx.Space.sm) { Image(systemName: "shareplay") - .font(.system(size: 20, weight: .light)) + .font(.system(size: Onyx.Symbol.xlarge, weight: .light)) .foregroundStyle(isTargeted ? Onyx.Color.accent : Onyx.Color.textTertiary) Text(failed ? "airdrop.unavailable" : "airdrop.hint", bundle: .module) @@ -74,15 +74,12 @@ public struct AirDropView: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity) - .background { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .strokeBorder(isTargeted ? Onyx.Color.accent : Onyx.Color.hairline, - style: StrokeStyle(lineWidth: 1, dash: [4, 3])) - } + // Diese Fläche ist **nur** zum Ablegen da. Ihre Kante steht deshalb + // immer — ohne sie wüsste niemand, dass sie eine ist. + .onyxDropZone(isTargeted: isTargeted, style: .always) // Derselbe Weg wie in der Ablage: auch hier kommen Anhänge als // Versprechen an, nicht als fertige Datei. .contentShape(.rect) .onFileDrop(isTargeted: $isTargeted) { urls in failed = !AirDrop.send(urls) } - .animation(Onyx.Motion.value, value: isTargeted) } } diff --git a/Packages/OnyxKit/Sources/ShelfProvider/ShelfWidget.swift b/Packages/OnyxKit/Sources/ShelfProvider/ShelfWidget.swift index c9c7e67..aa35a32 100644 --- a/Packages/OnyxKit/Sources/ShelfProvider/ShelfWidget.swift +++ b/Packages/OnyxKit/Sources/ShelfProvider/ShelfWidget.swift @@ -50,12 +50,12 @@ public struct ShelfView: View { } public var body: some View { - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: Onyx.Space.sm) { if store.items.isEmpty { EmptyShelf() } else { ScrollView { - LazyVStack(alignment: .leading, spacing: 4) { + LazyVStack(alignment: .leading, spacing: Onyx.Space.xs) { ForEach(store.items.prefix(compact ? 4 : 20)) { item in ShelfRow(item: item, store: store, thumbnails: thumbnails) } @@ -63,7 +63,7 @@ public struct ShelfView: View { } .scrollIndicators(.never) - HStack(spacing: 10) { + HStack(spacing: Onyx.Space.row) { Button { store.removeAll() } label: { Text("shelf.clear", bundle: .module).font(Onyx.Font.caption) } @@ -82,24 +82,17 @@ public struct ShelfView: View { // Die ganze Kachel nimmt Dateien an, nicht nur ein Streifen darin. .contentShape(.rect) .onFileDrop(isTargeted: $isTargeted) { urls in store.add(urls) } - .overlay { - if isTargeted { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .strokeBorder(Onyx.Color.accent, style: StrokeStyle(lineWidth: 1.5, dash: [4, 3])) - .background(Onyx.Color.accent.opacity(0.08), - in: .rect(cornerRadius: 10, style: .continuous)) - .allowsHitTesting(false) - } - } - .animation(Onyx.Motion.value, value: isTargeted) + // Die Kachel nimmt nebenbei Dateien an — die Kante erscheint deshalb + // erst, wenn wirklich etwas darüber schwebt. + .onyxDropZone(isTargeted: isTargeted, style: .whenTargeted) } } private struct EmptyShelf: View { var body: some View { - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: Onyx.Space.xs) { Image(systemName: "tray") - .font(.system(size: 16)) + .font(.system(size: Onyx.Symbol.large)) .foregroundStyle(Onyx.Color.textTertiary) Text("shelf.empty", bundle: .module) .font(Onyx.Font.caption) @@ -117,9 +110,9 @@ private struct ShelfRow: View { @State private var isHovered = false var body: some View { - HStack(spacing: 8) { + HStack(spacing: Onyx.Space.md) { thumbnail - VStack(alignment: .leading, spacing: 1) { + VStack(alignment: .leading, spacing: Onyx.Space.xxs) { Text(item.name) .font(Onyx.Font.caption) .foregroundStyle(Onyx.Color.textPrimary) @@ -133,19 +126,19 @@ private struct ShelfRow: View { if isHovered { Button { AirDrop.send([item.url]) } label: { - Image(systemName: "shareplay").font(.system(size: 10)) + Image(systemName: "shareplay").font(.system(size: Onyx.Symbol.small)) } .buttonStyle(.plain) .foregroundStyle(Onyx.Color.textTertiary) Button { store.remove(item) } label: { - Image(systemName: "xmark.circle.fill").font(.system(size: 11)) + Image(systemName: "xmark.circle.fill").font(.system(size: Onyx.Symbol.small)) } .buttonStyle(.plain) .foregroundStyle(Onyx.Color.textTertiary) } } - .padding(.vertical, 2) + .padding(.vertical, Onyx.Space.xxs) .contentShape(.rect) .onHover { isHovered = $0 } // `NSItemProvider(contentsOf:)` statt `.draggable(item.url)`: letzteres @@ -176,9 +169,9 @@ private struct ShelfRow: View { Image(nsImage: image) .resizable().aspectRatio(contentMode: .fit) .frame(width: 22, height: 22) - .clipShape(.rect(cornerRadius: 3, style: .continuous)) + .clipShape(.rect(cornerRadius: Onyx.Radius.bar, style: .continuous)) } else { - RoundedRectangle(cornerRadius: 3, style: .continuous) + RoundedRectangle(cornerRadius: Onyx.Radius.bar, style: .continuous) .fill(Onyx.Color.elevated) .frame(width: 22, height: 22) } @@ -204,13 +197,13 @@ public struct ShelfWidget: OnyxWidget { public func makeView() -> AnyView { AnyView( - VStack(spacing: 8) { + VStack(spacing: Onyx.Space.md) { // AirDrop oben und schmal: es ist eine reine Ablegefläche ohne // Zustand, die Ablage darunter braucht den Platz für ihre Liste. AirDropView(compact: true) .frame(height: 52) - Divider().overlay(Onyx.Color.hairline) + OnyxHairline() ShelfView(store: store) } diff --git a/Packages/OnyxKit/Sources/WeatherProvider/WeatherWidget.swift b/Packages/OnyxKit/Sources/WeatherProvider/WeatherWidget.swift index 2efee9d..e14b8c2 100644 --- a/Packages/OnyxKit/Sources/WeatherProvider/WeatherWidget.swift +++ b/Packages/OnyxKit/Sources/WeatherProvider/WeatherWidget.swift @@ -195,12 +195,15 @@ private struct WeatherWidgetView: View { if let lastKnown { content(lastKnown, isStale: true) } else { - Notice(symbol: "wifi.slash", text: "widget.weather.unavailable") + OnyxNotice(symbol: "wifi.slash", + text: String(localized: "widget.weather.unavailable", bundle: .module)) } case .locationDenied: - Notice(symbol: "location.slash", text: "widget.weather.locationDenied") + OnyxNotice(symbol: "location.slash", + text: String(localized: "widget.weather.locationDenied", bundle: .module)) case .locationUndetermined: - Notice(symbol: "location.circle", text: "widget.weather.locationUndetermined") + OnyxNotice(symbol: "location.circle", + text: String(localized: "widget.weather.locationUndetermined", bundle: .module)) case .loading, .idle: ProgressView() .controlSize(.small) @@ -217,25 +220,6 @@ private struct WeatherWidgetView: View { WideWeather(snapshot: snapshot, isStale: isStale) } } - -private struct Notice: View { - let symbol: String - let text: LocalizedStringKey - - var body: some View { - VStack(spacing: 6) { - Image(systemName: symbol) - .font(.system(size: 15)) - .foregroundStyle(Onyx.Color.textTertiary) - Text(text, bundle: .module) - .font(Onyx.Font.caption) - .foregroundStyle(Onyx.Color.textSecondary) - .multilineTextAlignment(.center) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } -} - private struct WideWeather: View { let snapshot: WeatherSnapshot let isStale: Bool @@ -246,15 +230,15 @@ private struct WideWeather: View { // die untere Hälfte war leer — bei Schriftgrößen von acht Punkten, die // man auf Armlänge nicht entziffert. VStack(alignment: .leading, spacing: 0) { - HStack(alignment: .center, spacing: 10) { + HStack(alignment: .center, spacing: Onyx.Space.row) { Image(systemName: snapshot.condition.symbolName(isDaylight: snapshot.isDaylight)) - .font(.system(size: 34)) + .font(.system(size: Onyx.Symbol.hero)) .symbolRenderingMode(.hierarchical) .foregroundStyle(Onyx.Color.textPrimary) VStack(alignment: .leading, spacing: 0) { Text(snapshot.temperature.onyxFormatted) - .font(.system(size: 30, weight: .medium)) + .font(Onyx.Font.displayLarge) .monospacedDigit() .foregroundStyle(Onyx.Color.textPrimary) if let high = snapshot.high, let low = snapshot.low { @@ -271,7 +255,7 @@ private struct WideWeather: View { .font(Onyx.Font.caption) .foregroundStyle(Onyx.Color.textSecondary) .lineLimit(1) - .padding(.top, 2) + .padding(.top, Onyx.Space.xxs) if snapshot.apparentTemperature != snapshot.temperature { Text("weather.feelsLike \(snapshot.apparentTemperature.onyxFormatted)", @@ -283,19 +267,19 @@ private struct WideWeather: View { Spacer(minLength: 6) if !snapshot.hourly.isEmpty { - Divider().overlay(Onyx.Color.hairline) + OnyxHairline() // Vier statt fünf Stunden: bei 180 Punkten Breite bleiben so // gut vierzig je Spalte, und die Zahlen lassen sich in einer // lesbaren Größe setzen. HStack(spacing: 0) { ForEach(snapshot.hourly.prefix(4)) { point in - VStack(spacing: 3) { + VStack(spacing: Onyx.Space.xs) { Text(point.date.formatted(.dateTime.hour())) .font(Onyx.Font.metricSmall) .foregroundStyle(Onyx.Color.textTertiary) Image(systemName: point.condition.symbolName(isDaylight: true)) - .font(.system(size: 13)) + .font(.system(size: Onyx.Symbol.medium)) .symbolRenderingMode(.hierarchical) .foregroundStyle(Onyx.Color.textSecondary) Text(point.temperature.onyxFormatted) @@ -306,15 +290,15 @@ private struct WideWeather: View { .frame(maxWidth: .infinity) } } - .padding(.vertical, 8) + .padding(.vertical, Onyx.Space.md) } Spacer(minLength: 0) - HStack(spacing: 4) { + HStack(spacing: Onyx.Space.xs) { if isStale { Image(systemName: "exclamationmark.triangle.fill") - .font(.system(size: 9)) + .font(.system(size: Onyx.Symbol.caption)) .foregroundStyle(Onyx.Color.warning) } Text(stampText) @@ -338,10 +322,13 @@ private struct WideWeather: View { } } -extension Measurement where UnitType == UnitTemperature { +public extension Measurement where UnitType == UnitTemperature { /// Ganzzahlig und in der Einheit, die zur Region des Nutzers passt. /// Nachkommastellen bei der Außentemperatur täuschen eine Genauigkeit vor, /// die die Vorhersage nicht hat. + /// + /// Öffentlich, seit die Temperatur auch neben der Notch steht: dieselbe + /// Zahl zweimal verschieden zu runden wäre der Anfang zweier Wahrheiten. var onyxFormatted: String { formatted(.measurement(width: .narrow, usage: .weather, diff --git a/Packages/OnyxKit/Tests/AudioProviderTests/TapBufferLayoutTests.swift b/Packages/OnyxKit/Tests/AudioProviderTests/TapBufferLayoutTests.swift new file mode 100644 index 0000000..6e03ecc --- /dev/null +++ b/Packages/OnyxKit/Tests/AudioProviderTests/TapBufferLayoutTests.swift @@ -0,0 +1,71 @@ +import Testing +@testable import AudioProvider + +// Der Mixer war an einem Headset völlig still. Nachgemessen am Aggregate-Gerät +// über der „PRO X Wireless Gaming Headset": +// +// MacBook Pro-Lautsprecher Eingang 1 Puffer: 2 Kan. → nur der Tap +// PRO X Headset Eingang 2 Puffer: 1 Kan. | 2 Kan. → Mikrofon, dann Tap +// +// Ein Ausgabegerät, das selbst einen Eingang mitbringt — jedes Headset, jedes +// USB-Interface — schiebt seine eigenen Eingangspuffer vor die des Taps. Wer +// blind Puffer null nimmt, greift das Mikrofon ab statt des Programms: der +// Programmton wird nie ausgegeben, während der Tap das Original stumm hält. +// Die eingebauten Lautsprecher haben keinen Eingang, deshalb fiel es dort nie auf. + +@Suite("Wo der Tap im Aggregate-Gerät liegt") +struct TapBufferLayoutTests { + + @Test("Reines Ausgabegerät: der Tap liegt vorn") + func outputOnlyDeviceKeepsTapFirst() { + #expect(TapBufferLayout.offset(subDeviceInputBuffers: 0, + aggregateInputChannels: [2], + tapChannels: 2) == 0) + } + + @Test("Headset: der eigene Mikrofonpuffer schiebt den Tap nach hinten") + func headsetMicrophoneShiftsTap() { + // Der gemessene Fall. Vorher wurde hier Puffer null genommen — das + // Mikrofon — und der Programmton blieb liegen. + #expect(TapBufferLayout.offset(subDeviceInputBuffers: 1, + aggregateInputChannels: [1, 2], + tapChannels: 2) == 1) + } + + @Test("Mehrere Eingangspuffer des Geräts zählen alle") + func severalDeviceInputsCount() { + #expect(TapBufferLayout.offset(subDeviceInputBuffers: 2, + aggregateInputChannels: [1, 1, 2], + tapChannels: 2) == 2) + } + + @Test("Passt die Kanalzahl an der erwarteten Stelle nicht, wird nicht geraten") + func mismatchedChannelsAreRefused() { + // Läge der Tap wider Erwarten woanders, wäre die Folge wieder Stille + // bei stummgeschaltetem Original. Dann lieber gar kein Tap: der Aufbau + // fliegt auf, das Programm bleibt hörbar. + #expect(TapBufferLayout.offset(subDeviceInputBuffers: 0, + aggregateInputChannels: [1, 2], + tapChannels: 2) == nil) + } + + @Test("Fehlt der Puffer ganz, wird nicht geraten") + func missingBufferIsRefused() { + #expect(TapBufferLayout.offset(subDeviceInputBuffers: 2, + aggregateInputChannels: [1, 2], + tapChannels: 2) == nil) + #expect(TapBufferLayout.offset(subDeviceInputBuffers: 0, + aggregateInputChannels: [], + tapChannels: 2) == nil) + } + + @Test("Unsinnige Angaben werden abgewiesen, nicht verrechnet") + func nonsenseIsRefused() { + #expect(TapBufferLayout.offset(subDeviceInputBuffers: -1, + aggregateInputChannels: [2], + tapChannels: 2) == nil) + #expect(TapBufferLayout.offset(subDeviceInputBuffers: 0, + aggregateInputChannels: [2], + tapChannels: 0) == nil) + } +}