From 2aa60eedf931d14a3105c1131b335501f662c983 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 13 Aug 2026 23:24:40 +0200 Subject: [PATCH] =?UTF-8?q?Farben=20einstellbar=20=E2=80=94=20und=20erst?= =?UTF-8?q?=20mal=20=C3=BCberhaupt=20eine=20Farbe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Das Grün, das überall auftauchte, war die System-Akzentfarbe: `.tint` und `.accentColor` werden ohne eigene Angabe dorthin durchgereicht, und das betraf alle Popovers und das ganze Einstellungsfenster. Die Notch-Widgets dagegen benutzten Onyx' eigenes Blau. Zwei Farben, ungewollt. Jetzt eine, und die ist einstellbar. Ein neuer Reiter „Farben": Akzentfarbe mit sechs Vorschlägen und einem Knopf, der die Systemfarbe übernimmt, dazu das Paar für Herunter- und Hochladen samt Tauschknopf. Die Signalfarben bleiben fest und stehen nur zur Ansicht dort. Gelb heißt Warnung, Rot heißt kritisch, Grün heißt in Ordnung — bei Temperatur, Akku, Lüfter und Fehlern. Wer sie umstellen kann, kann ihre Bedeutung zerstören; ein rotes „alles gut" liest niemand richtig. Sichtbar sind sie trotzdem, sonst sucht man die Warnfarbe im Reiter und findet sie nirgends. Umgesetzt über die Tokens statt über vierzig Fundstellen: `Onyx.Color.accent` ist keine Konstante mehr, sondern liest bei jedem Zugriff aus `OnyxTheme`. Damit merkt SwiftUI die Abhängigkeit und zeichnet neu, sobald die Farbe sich ändert. Dazu ein `.tint` an den vier Wurzeln — Panel, Popovers, Einstellungen, Einrichtung —, damit auch Haken, Regler und Auswahlfelder mitziehen. Nebenbei die Beschriftung: „Je Programm" heißt jetzt „Programme". Großgesetzt las sich das als englisches „J-E". Co-Authored-By: Claude Opus 5 --- Onyx.xcodeproj/project.pbxproj | 4 + Onyx/ColorSettingsView.swift | 131 +++++++ Onyx/FanSettingsView.swift | 11 +- Onyx/Localizable.xcstrings | 352 ++++++++++++++++++ Onyx/OnboardingView.swift | 5 +- Onyx/OnboardingWindowController.swift | 4 +- Onyx/OnyxApp.swift | 3 +- Onyx/SettingsView.swift | 7 +- Onyx/SettingsWindowController.swift | 7 +- Onyx/WidgetOptionsView.swift | 9 +- Packages/OnyxKit/Package.swift | 2 + .../CalendarSourceSettings.swift | 3 +- .../MetricsProvider/MetricPopovers.swift | 30 +- .../NetworkProvider/Localizable.xcstrings | 2 +- .../NetworkProvider/NetworkPopover.swift | 17 +- .../NetworkProvider/NetworkWidget.swift | 8 +- .../Sources/OnyxDesign/OnyxTheme.swift | 106 ++++++ .../Sources/OnyxDesign/OnyxTokens.swift | 18 +- .../OnyxMenuBar/MenuBarController.swift | 7 +- .../OnyxDesignTests/OnyxThemeTests.swift | 76 ++++ 20 files changed, 757 insertions(+), 45 deletions(-) create mode 100644 Onyx/ColorSettingsView.swift create mode 100644 Packages/OnyxKit/Sources/OnyxDesign/OnyxTheme.swift create mode 100644 Packages/OnyxKit/Tests/OnyxDesignTests/OnyxThemeTests.swift diff --git a/Onyx.xcodeproj/project.pbxproj b/Onyx.xcodeproj/project.pbxproj index 14c43ab..05d241f 100644 --- a/Onyx.xcodeproj/project.pbxproj +++ b/Onyx.xcodeproj/project.pbxproj @@ -40,6 +40,7 @@ C5A4C9B4D21A951159C7D325 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63A5510538846C8427AEB831 /* OnboardingView.swift */; }; CB75EE7C8826AC1CCAD35686 /* FanControl.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC3FDFD387188D36AF8287DE /* FanControl.swift */; }; CD501EBAE39807F5D354C5B2 /* OnyxMenuBar in Frameworks */ = {isa = PBXBuildFile; productRef = A7FD7A4864E5ED4B95C211AF /* OnyxMenuBar */; }; + E8B839A850BE54F32410C2C8 /* ColorSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB76B112C2D5753B2600D42D /* ColorSettingsView.swift */; }; EA4D6312E8A96E177F10797F /* OnyxDesign in Frameworks */ = {isa = PBXBuildFile; productRef = 3D680DD645941493A7D3559E /* OnyxDesign */; }; F2A3AD184864422EA14A8C64 /* graphic-card.svg in Resources */ = {isa = PBXBuildFile; fileRef = 1E6A1C208C7EA56971A055E1 /* graphic-card.svg */; }; FF97F56CE1C734E59F1B40F4 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 055263008CB3306A8D09E6D4 /* AppIcon.icon */; }; @@ -80,6 +81,7 @@ B442E8A39C37C8FD92B4CA83 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; B8214C5599D1857E832447EC /* bolt-light.svg */ = {isa = PBXFileReference; path = "bolt-light.svg"; sourceTree = ""; }; B83C4E3CB821F903F3977E88 /* AppModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppModel.swift; sourceTree = ""; }; + DB76B112C2D5753B2600D42D /* ColorSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorSettingsView.swift; sourceTree = ""; }; F12041E330231599C6540C21 /* FanSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanSettingsView.swift; sourceTree = ""; }; F89CAC2A7DA89B8707D45D65 /* OnyxApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnyxApp.swift; sourceTree = ""; }; FC3FDFD387188D36AF8287DE /* FanControl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanControl.swift; sourceTree = ""; }; @@ -169,6 +171,7 @@ 7A866E72010907DF4BEF025C /* bolt-dark.svg */, B8214C5599D1857E832447EC /* bolt-light.svg */, 085D860E654E09E4CD6E535A /* chip.svg */, + DB76B112C2D5753B2600D42D /* ColorSettingsView.swift */, FC3FDFD387188D36AF8287DE /* FanControl.swift */, F12041E330231599C6540C21 /* FanSettingsView.swift */, 89309D9F5722FA1B91CF2905 /* FanWidget.swift */, @@ -355,6 +358,7 @@ buildActionMask = 2147483647; files = ( C041F171FB69F4381309D978 /* AppModel.swift in Sources */, + E8B839A850BE54F32410C2C8 /* ColorSettingsView.swift in Sources */, CB75EE7C8826AC1CCAD35686 /* FanControl.swift in Sources */, 0CEBFA01137EEED0774A089F /* FanSettingsView.swift in Sources */, 6CD3F5666C39F54506B8430A /* FanWidget.swift in Sources */, diff --git a/Onyx/ColorSettingsView.swift b/Onyx/ColorSettingsView.swift new file mode 100644 index 0000000..4e49c01 --- /dev/null +++ b/Onyx/ColorSettingsView.swift @@ -0,0 +1,131 @@ +import SwiftUI +import OnyxDesign + +/// Die Farbwahl. +/// +/// 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 hier — Gelb heißt Warnung, Rot heißt kritisch, und wer das umstellen +/// kann, kann die Bedeutung zerstören. +struct ColorSettingsView: 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 { + Form { + Section("color.section.accent") { + ColorPicker(selection: $theme.accent, supportsOpacity: false) { + Text("color.accent") + } + + HStack(spacing: 8) { + 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 + } + } + Spacer(minLength: 0) + } + + Button { theme.adoptSystemAccent() } label: { + Text("color.adoptSystem") + } + Text("color.accent.hint") + .font(.caption).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Section("color.section.network") { + ColorPicker(selection: $theme.download, supportsOpacity: false) { + Text("color.download") + } + ColorPicker(selection: $theme.upload, supportsOpacity: false) { + Text("color.upload") + } + Button { + let previous = theme.download + theme.download = theme.upload + theme.upload = previous + } label: { + Text("color.swap") + } + Text("color.network.hint") + .font(.caption).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Section("color.section.fixed") { + // Sichtbar, aber nicht bedienbar: sonst sucht man die + // Warnfarbe hier und findet sie nirgends. + FixedRow(color: Onyx.Color.positive, label: "color.fixed.positive") + FixedRow(color: Onyx.Color.warning, label: "color.fixed.warning") + FixedRow(color: Onyx.Color.critical, label: "color.fixed.critical") + Text("color.fixed.hint") + .font(.caption).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + Section { + Button { theme.reset() } label: { + Text("color.reset") + } + .disabled(theme.isDefault) + } + } + .formStyle(.grouped) + } +} + +/// 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)) + } +} + +private struct FixedRow: View { + let color: Color + let label: LocalizedStringKey + + var body: some View { + HStack { + Text(label) + Spacer(minLength: 12) + Circle().fill(color).frame(width: 14, height: 14) + } + } +} diff --git a/Onyx/FanSettingsView.swift b/Onyx/FanSettingsView.swift index 04d1302..49e1f32 100644 --- a/Onyx/FanSettingsView.swift +++ b/Onyx/FanSettingsView.swift @@ -1,4 +1,5 @@ import SwiftUI +import OnyxDesign import MetricsProvider import OnyxHelperProtocol @@ -47,7 +48,7 @@ struct FanSettingsView: View { // Das Risiko benennen, bevor jemand zustimmt — nicht danach. Label("fans.warning", systemImage: "exclamationmark.triangle.fill") .font(.callout) - .foregroundStyle(.orange) + .foregroundStyle(Onyx.Color.warning) .fixedSize(horizontal: false, vertical: true) Text("fans.safety") @@ -56,7 +57,7 @@ struct FanSettingsView: View { .fixedSize(horizontal: false, vertical: true) if case .failed(let message) = control.installState { - Text(message).font(.callout).foregroundStyle(.red) + Text(message).font(.callout).foregroundStyle(Onyx.Color.critical) } Button("fans.install") { control.install() } @@ -85,7 +86,7 @@ struct FanSettingsView: View { Spacer() Text("\(Int(hottest))°").monospacedDigit() .foregroundStyle(hottest >= FanSafety.criticalCelsius - ? .red : .secondary) + ? Onyx.Color.critical : .secondary) } } ForEach(report.fans, id: \.index) { fan in @@ -108,7 +109,7 @@ struct FanSettingsView: View { Text("fans.connecting").foregroundStyle(.secondary) } if let error = control.lastError { - Text(error).font(.callout).foregroundStyle(.red) + Text(error).font(.callout).foregroundStyle(Onyx.Color.critical) } } } @@ -165,7 +166,7 @@ private struct FanRow: View { if let reason = fan.automaticReason, reason != "noRequest" { Label(explanation(for: reason), systemImage: "shield.lefthalf.filled") .font(.caption) - .foregroundStyle(.orange) + .foregroundStyle(Onyx.Color.warning) .fixedSize(horizontal: false, vertical: true) } } diff --git a/Onyx/Localizable.xcstrings b/Onyx/Localizable.xcstrings index 2156b9f..8264f86 100644 --- a/Onyx/Localizable.xcstrings +++ b/Onyx/Localizable.xcstrings @@ -141,6 +141,342 @@ } } }, + "color.accent": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Farbe" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Colour" + } + } + } + }, + "color.accent.hint": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Gilt überall, wo Onyx sich selbst zeichnet: Regler, Ringe, Abschnittstitel, Haken und Auswahl." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Applies everywhere Onyx draws itself: sliders, dials, section headings, check marks and selection." + } + } + } + }, + "color.adoptSystem": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Systemfarbe übernehmen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Adopt system colour" + } + } + } + }, + "color.download": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Herunterladen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Download" + } + } + } + }, + "color.fixed.critical": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Kritisch" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Critical" + } + } + } + }, + "color.fixed.hint": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Diese drei bleiben, wie sie sind. Sie bedeuten etwas — bei Temperatur, Akku, Lüfter und Fehlern." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "These three stay as they are. They carry meaning — for temperature, battery, fans and errors." + } + } + } + }, + "color.fixed.positive": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "In Ordnung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Normal" + } + } + } + }, + "color.fixed.warning": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Warnung" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Warning" + } + } + } + }, + "color.network.hint": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Welche Richtung grün ist und welche rosa, ist Gewohnheit — nicht Bedeutung." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Which direction is green and which is pink is habit, not meaning." + } + } + } + }, + "color.reset": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Auf Standard zurücksetzen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset to defaults" + } + } + } + }, + "color.section.accent": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Akzentfarbe" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Accent colour" + } + } + } + }, + "color.section.fixed": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Feste Farben" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Fixed colours" + } + } + } + }, + "color.section.network": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Netzwerk" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Network" + } + } + } + }, + "color.suggestion.amber": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Bernstein" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Amber" + } + } + } + }, + "color.suggestion.coral": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Koralle" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Coral" + } + } + } + }, + "color.suggestion.lilac": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Flieder" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Lilac" + } + } + } + }, + "color.suggestion.mint": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Minze" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Mint" + } + } + } + }, + "color.suggestion.onyx": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Onyx-Blau" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Onyx blue" + } + } + } + }, + "color.suggestion.slate": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Schiefer" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Slate" + } + } + } + }, + "color.swap": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Richtungen tauschen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Swap directions" + } + } + } + }, + "color.upload": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Hochladen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Upload" + } + } + } + }, "fans.approval.needed": { "extractionState": "stale", "localizations": { @@ -2014,6 +2350,22 @@ } } }, + "settings.tab.colors": { + "localizations": { + "de": { + "stringUnit": { + "state": "translated", + "value": "Farben" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Colours" + } + } + } + }, "settings.tab.display": { "extractionState": "stale", "localizations": { diff --git a/Onyx/OnboardingView.swift b/Onyx/OnboardingView.swift index c721ba5..1e40ddb 100644 --- a/Onyx/OnboardingView.swift +++ b/Onyx/OnboardingView.swift @@ -1,4 +1,5 @@ import SwiftUI +import OnyxDesign import AppKit import CalendarProvider import WeatherProvider @@ -175,7 +176,7 @@ private struct OnboardingRow: View { HStack(alignment: .top, spacing: 14) { Image(systemName: symbol) .font(.system(size: 15)) - .foregroundStyle(state == .granted ? .green : .secondary) + .foregroundStyle(state == .granted ? Onyx.Color.positive : .secondary) .frame(width: 22, height: 22) VStack(alignment: .leading, spacing: 2) { @@ -190,7 +191,7 @@ private struct OnboardingRow: View { switch state { case .granted: Image(systemName: "checkmark.circle.fill") - .foregroundStyle(.green) + .foregroundStyle(Onyx.Color.positive) .font(.system(size: 16)) case .undetermined: Button("onboarding.allow") { Task { await request() } } diff --git a/Onyx/OnboardingWindowController.swift b/Onyx/OnboardingWindowController.swift index fa49eff..eee4075 100644 --- a/Onyx/OnboardingWindowController.swift +++ b/Onyx/OnboardingWindowController.swift @@ -1,5 +1,6 @@ import AppKit import SwiftUI +import OnyxDesign import CalendarProvider import WeatherProvider import AudioProvider @@ -43,7 +44,8 @@ final class OnboardingWindowController: NSObject, NSWindowDelegate { rootView: OnboardingView( calendarModel: calendarModel, weatherModel: weatherModel, mixer: mixer, fanControl: fanControl, launchAtLogin: launchAtLogin, - onFinish: { [weak self] in self?.finish() })) + onFinish: { [weak self] in self?.finish() }) + .tint(Onyx.Color.accent)) let window = NSWindow(contentViewController: hosting) window.title = "Onyx" diff --git a/Onyx/OnyxApp.swift b/Onyx/OnyxApp.swift index 52d3a22..1d3e8a8 100644 --- a/Onyx/OnyxApp.swift +++ b/Onyx/OnyxApp.swift @@ -76,7 +76,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { NotchHostingView( rootView: NotchContainer(presentation: presentation) { NotchPanelContent(model: model) - }, + } + .tint(Onyx.Color.accent), presentation: presentation) } coordinator.start() diff --git a/Onyx/SettingsView.swift b/Onyx/SettingsView.swift index cd9cbbb..d73230f 100644 --- a/Onyx/SettingsView.swift +++ b/Onyx/SettingsView.swift @@ -10,7 +10,7 @@ import OnyxHelperProtocol import WeatherProvider enum SettingsTab: Hashable { - case widgets, display, sources, menubar, fans, permissions + case widgets, display, colors, sources, menubar, fans, permissions } struct SettingsView: View { @@ -32,6 +32,9 @@ struct SettingsView: View { DisplaySettings(model: model) .tabItem { Label("settings.tab.display", systemImage: "macbook") } .tag(SettingsTab.display) + ColorSettingsView(theme: OnyxTheme.shared) + .tabItem { Label("settings.tab.colors", systemImage: "paintpalette") } + .tag(SettingsTab.colors) WidgetOptionsView(calendarModel: calendarModel, weatherModel: weatherModel, shelfStore: shelfStore) .tabItem { Label("settings.tab.options", systemImage: "slider.horizontal.3") } @@ -189,7 +192,7 @@ private struct PermissionRow: View { switch state { case .granted: Label("settings.permissions.granted", systemImage: "checkmark.circle.fill") - .foregroundStyle(.green) + .foregroundStyle(Onyx.Color.positive) .labelStyle(.titleAndIcon) case .undetermined: Button("settings.permissions.request") { Task { await request() } } diff --git a/Onyx/SettingsWindowController.swift b/Onyx/SettingsWindowController.swift index 03c2fb6..77c73b3 100644 --- a/Onyx/SettingsWindowController.swift +++ b/Onyx/SettingsWindowController.swift @@ -1,5 +1,6 @@ import AppKit import SwiftUI +import OnyxDesign import CalendarProvider import WeatherProvider import MetricsProvider @@ -53,7 +54,11 @@ final class SettingsWindowController: NSObject, NSWindowDelegate { showOnboarding: showOnboarding, selectedTab: Binding( get: { [weak self] in self?.selectedTab ?? .widgets }, - set: { [weak self] in self?.selectedTab = $0 }))) + set: { [weak self] in self?.selectedTab = $0 })) + // Ohne das folgen Haken, Regler und Auswahlfelder der + // Systemfarbe statt der eingestellten. Genau daher kam das + // Grün, das in Onyx sonst nirgends vorkommt. + .tint(Onyx.Color.accent)) let window = NSWindow(contentViewController: hosting) window.title = "Onyx" diff --git a/Onyx/WidgetOptionsView.swift b/Onyx/WidgetOptionsView.swift index 1fb213f..2ced4dc 100644 --- a/Onyx/WidgetOptionsView.swift +++ b/Onyx/WidgetOptionsView.swift @@ -1,4 +1,5 @@ import SwiftUI +import OnyxDesign import CalendarProvider import WeatherProvider import ShelfProvider @@ -116,9 +117,9 @@ private struct CalendarrStatus: View { case .loggedOut: Text("calendar.source.status.loggedOut").font(.callout).foregroundStyle(.secondary) case .incompatible: - Text("calendar.source.status.incompatible").font(.callout).foregroundStyle(.orange) + Text("calendar.source.status.incompatible").font(.callout).foregroundStyle(Onyx.Color.warning) case .unreadable(let reason): - Text(reason).font(.callout).foregroundStyle(.orange) + Text(reason).font(.callout).foregroundStyle(Onyx.Color.warning) case .denied, .none: ProgressView().controlSize(.small) } @@ -148,7 +149,7 @@ private struct WeatherOptions: View { // steht hier, statt dass das Widget stumm leer bleibt. if model.locationIsDenied { Text("options.weather.needsLocation") - .font(.callout).foregroundStyle(.orange) + .font(.callout).foregroundStyle(Onyx.Color.warning) .fixedSize(horizontal: false, vertical: true) } } else if case .fixed(let name, _, _) = model.place { @@ -172,7 +173,7 @@ private struct WeatherOptions: View { } if notFound { - Text("options.weather.notFound").font(.callout).foregroundStyle(.orange) + Text("options.weather.notFound").font(.callout).foregroundStyle(Onyx.Color.warning) } } diff --git a/Packages/OnyxKit/Package.swift b/Packages/OnyxKit/Package.swift index 4ac8ba9..874057b 100644 --- a/Packages/OnyxKit/Package.swift +++ b/Packages/OnyxKit/Package.swift @@ -41,6 +41,8 @@ let package = Package( resources: [.process("Localizable.xcstrings")]), .testTarget(name: "OnyxNotchTests", dependencies: ["OnyxNotch"]), + .testTarget(name: "OnyxDesignTests", dependencies: ["OnyxDesign"]), + .target(name: "OnyxWidgetKit", dependencies: ["OnyxDesign"]), .testTarget(name: "OnyxWidgetKitTests", dependencies: ["OnyxWidgetKit"]), diff --git a/Packages/OnyxKit/Sources/CalendarProvider/CalendarSourceSettings.swift b/Packages/OnyxKit/Sources/CalendarProvider/CalendarSourceSettings.swift index 4c105c1..1203812 100644 --- a/Packages/OnyxKit/Sources/CalendarProvider/CalendarSourceSettings.swift +++ b/Packages/OnyxKit/Sources/CalendarProvider/CalendarSourceSettings.swift @@ -1,4 +1,5 @@ import SwiftUI +import OnyxDesign /// Die Wahl der Terminquelle — samt ehrlicher Auskunft darüber, was Calendarr /// gerade liefert. @@ -73,7 +74,7 @@ public struct CalendarSourceSettings: View { notice("calendar.source.status.incompatible") case .unreadable(let reason): Text("calendar.source.status.unreadable \(reason)", bundle: .module) - .font(.callout).foregroundStyle(.orange) + .font(.callout).foregroundStyle(Onyx.Color.warning) .fixedSize(horizontal: false, vertical: true) case .denied, .none: ProgressView().controlSize(.small) diff --git a/Packages/OnyxKit/Sources/MetricsProvider/MetricPopovers.swift b/Packages/OnyxKit/Sources/MetricsProvider/MetricPopovers.swift index 07d3c34..07ccdef 100644 --- a/Packages/OnyxKit/Sources/MetricsProvider/MetricPopovers.swift +++ b/Packages/OnyxKit/Sources/MetricsProvider/MetricPopovers.swift @@ -40,7 +40,7 @@ private struct SectionTitle: View { var body: some View { Text(text, bundle: .module) .font(.caption.weight(.semibold)) - .foregroundStyle(.tint) + .foregroundStyle(Onyx.Color.accent) .textCase(.uppercase) } } @@ -81,7 +81,9 @@ private struct Dial: View { let value: Double let caption: String let text: String - var tint: Color = .accentColor + /// Ohne Angabe die Akzentfarbe. Als Standardwert ginge sie nicht: der + /// wird außerhalb des Hauptthreads erzeugt, die Farbe liegt aber dort. + var tint: Color? var body: some View { // Sechs Punkte Abstand statt zwei: die Beschriftung klebte am Ring und @@ -91,7 +93,8 @@ private struct Dial: View { Circle().stroke(.quaternary, lineWidth: 5) Circle() .trim(from: 0, to: min(max(value, 0), 1)) - .stroke(tint, style: .init(lineWidth: 5, lineCap: .round)) + .stroke(tint ?? Onyx.Color.accent, + style: .init(lineWidth: 5, lineCap: .round)) .rotationEffect(.degrees(-90)) Text(text).font(.system(size: 15, weight: .medium).monospacedDigit()) } @@ -182,10 +185,10 @@ private struct CoreGrid: View { spacing: 3) { ForEach(Array(values.enumerated()), id: \.offset) { _, value in RoundedRectangle(cornerRadius: 2, style: .continuous) - .fill(Color.accentColor.opacity(0.18)) + .fill(Onyx.Color.accent.opacity(0.18)) .overlay(alignment: .bottom) { RoundedRectangle(cornerRadius: 2, style: .continuous) - .fill(Color.accentColor) + .fill(Onyx.Color.accent) .frame(height: max(14 * value, 1.5)) } .frame(height: 14) @@ -208,7 +211,7 @@ private struct MemoryDetail: View { text: MetricFormat.percent(memory.usedFraction), tint: pressureColor) VStack(alignment: .leading, spacing: 3) { - legend(.accentColor, "popover.memory.app", memory.app) + legend(Onyx.Color.accent, "popover.memory.app", memory.app) legend(.purple, "popover.memory.wired", memory.wired) legend(.teal, "popover.memory.compressed", memory.compressed) legend(.gray, "popover.memory.free", memory.free) @@ -220,7 +223,7 @@ private struct MemoryDetail: View { // ist ein voller Speicher kein Problem — macOS füllt ihn absichtlich. Row(label: String(localized: "popover.memory.swapUsed", bundle: .module), value: "\(bytes(memory.swapUsed)) / \(bytes(memory.swapTotal))", - emphasis: memory.swapUsed > 512 * 1024 * 1024 ? .orange : nil) + emphasis: memory.swapUsed > 512 * 1024 * 1024 ? Onyx.Color.warning : nil) if !snapshot.topMemoryProcesses.isEmpty { SectionTitle(text: "popover.processes") @@ -232,9 +235,9 @@ private struct MemoryDetail: View { private var pressureColor: Color { switch snapshot.memory.pressure { - case .normal: .green - case .warning: .orange - case .critical: .red + case .normal: Onyx.Color.positive + case .warning: Onyx.Color.warning + case .critical: Onyx.Color.critical } } @@ -264,8 +267,9 @@ private struct BatteryDetail: View { Dial(value: battery.charge, caption: String(localized: "metric.battery", bundle: .module), text: MetricFormat.percent(battery.charge), - tint: battery.isCharging ? .green - : (battery.charge < 0.2 ? .red : .accentColor)) + tint: battery.isCharging ? Onyx.Color.positive + : (battery.charge < 0.2 ? Onyx.Color.critical + : Onyx.Color.accent)) // Beides gehört neben den Ring: die Schätzung und der Grund // dafür. „Am Netzteil" über dem Ring und „Am Netz, lädt nicht" // darunter waren zweimal dieselbe Auskunft an zwei Stellen — @@ -382,7 +386,7 @@ private struct BatteryDetail: View { private func tint(for activity: ChargeActivity) -> Color { // Farbe nur, wo sie etwas meldet: Laden ist ein Zustand, den man // sucht. Alles andere bleibt zurückhaltend. - activity == .charging ? .green : .secondary + activity == .charging ? Onyx.Color.positive : .secondary } private func duration(_ seconds: TimeInterval) -> String { diff --git a/Packages/OnyxKit/Sources/NetworkProvider/Localizable.xcstrings b/Packages/OnyxKit/Sources/NetworkProvider/Localizable.xcstrings index d5a2c2d..31d53cf 100644 --- a/Packages/OnyxKit/Sources/NetworkProvider/Localizable.xcstrings +++ b/Packages/OnyxKit/Sources/NetworkProvider/Localizable.xcstrings @@ -199,7 +199,7 @@ "de": { "stringUnit": { "state": "translated", - "value": "Je Programm" + "value": "Programme" } }, "en": { diff --git a/Packages/OnyxKit/Sources/NetworkProvider/NetworkPopover.swift b/Packages/OnyxKit/Sources/NetworkProvider/NetworkPopover.swift index da0ec73..5fb0ecb 100644 --- a/Packages/OnyxKit/Sources/NetworkProvider/NetworkPopover.swift +++ b/Packages/OnyxKit/Sources/NetworkProvider/NetworkPopover.swift @@ -1,5 +1,6 @@ import SwiftUI import MetricsProvider +import OnyxDesign import OnyxMenuBar @MainActor @@ -9,9 +10,9 @@ struct NetworkPopoverContent: View { var body: some View { VStack(alignment: .leading, spacing: 12) { HStack(alignment: .top) { - rate(model.snapshot.downloadRate, "network.download", .green) + rate(model.snapshot.downloadRate, "network.download", Onyx.Color.download) Spacer() - rate(model.snapshot.uploadRate, "network.upload", .pink) + rate(model.snapshot.uploadRate, "network.upload", Onyx.Color.upload) } TrafficChart(history: model.history) @@ -91,9 +92,11 @@ struct NetworkPopoverContent: View { Text(process.name).font(.callout).lineLimit(1) Spacer(minLength: 8) Text(Throughput.formatted(process.downloadRate)) - .font(.caption.monospacedDigit()).foregroundStyle(.green) + .font(.caption.monospacedDigit()) + .foregroundStyle(Onyx.Color.download) Text(Throughput.formatted(process.uploadRate)) - .font(.caption.monospacedDigit()).foregroundStyle(.pink) + .font(.caption.monospacedDigit()) + .foregroundStyle(Onyx.Color.upload) } } } @@ -134,7 +137,7 @@ struct NetworkPopoverContent: View { private func section(_ key: LocalizedStringKey) -> some View { Text(key, bundle: .module) .font(.caption.weight(.semibold)) - .foregroundStyle(.tint) + .foregroundStyle(Onyx.Color.accent) .textCase(.uppercase) } @@ -183,11 +186,11 @@ private struct TrafficChart: View { ForEach(Array(points.enumerated()), id: \.offset) { _, snapshot in VStack(spacing: 1) { Rectangle() - .fill(.pink) + .fill(Onyx.Color.upload) .frame(height: max(half * CGFloat(snapshot.uploadRate / peak), 0.5)) .frame(maxHeight: .infinity, alignment: .bottom) Rectangle() - .fill(.green) + .fill(Onyx.Color.download) .frame(height: max(half * CGFloat(snapshot.downloadRate / peak), 0.5)) .frame(maxHeight: .infinity, alignment: .top) } diff --git a/Packages/OnyxKit/Sources/NetworkProvider/NetworkWidget.swift b/Packages/OnyxKit/Sources/NetworkProvider/NetworkWidget.swift index ceec354..238e95b 100644 --- a/Packages/OnyxKit/Sources/NetworkProvider/NetworkWidget.swift +++ b/Packages/OnyxKit/Sources/NetworkProvider/NetworkWidget.swift @@ -145,7 +145,7 @@ private struct NetworkWidgetView: View { HStack(spacing: 6) { Image(systemName: model.snapshot.interfaceKind.symbolName) .font(.system(size: 11)) - .foregroundStyle(Onyx.Color.accent) + .foregroundStyle(Onyx.Color.download) Text(connectionName) .font(Onyx.Font.caption) .foregroundStyle(Onyx.Color.textSecondary) @@ -154,7 +154,7 @@ private struct NetworkWidgetView: View { Rates(snapshot: model.snapshot, compact: false) - Sparkline(values: model.series(download: true), tint: Onyx.Color.accent, + Sparkline(values: model.series(download: true), tint: Onyx.Color.download, capacity: NetworkModel.historyLength) .frame(height: 18) @@ -190,8 +190,8 @@ private struct Rates: View { var body: some View { VStack(alignment: .leading, spacing: 1) { - row("arrow.down", Throughput.formatted(snapshot.downloadRate), Onyx.Color.accent) - row("arrow.up", Throughput.formatted(snapshot.uploadRate), Onyx.Color.positive) + row("arrow.down", Throughput.formatted(snapshot.downloadRate), Onyx.Color.download) + row("arrow.up", Throughput.formatted(snapshot.uploadRate), Onyx.Color.upload) } } diff --git a/Packages/OnyxKit/Sources/OnyxDesign/OnyxTheme.swift b/Packages/OnyxKit/Sources/OnyxDesign/OnyxTheme.swift new file mode 100644 index 0000000..acb2333 --- /dev/null +++ b/Packages/OnyxKit/Sources/OnyxDesign/OnyxTheme.swift @@ -0,0 +1,106 @@ +import SwiftUI +import AppKit + +/// Die einstellbaren Farben. +/// +/// **Nicht alle Farben sind einstellbar, und das ist Absicht.** Gelb heißt +/// Warnung, Rot heißt kritisch, Grün heißt in Ordnung — bei Temperatur, Akku, +/// Lüfter und Fehlermeldungen. Wer sie umstellen kann, kann ihre Bedeutung +/// zerstören; ein rotes „alles gut" liest niemand richtig. Einstellbar ist +/// deshalb die Akzentfarbe, mit der Onyx sich selbst zeichnet, und das Paar für +/// Herunter- und Hochladen — dort ist die Zuordnung willkürlich und nur eine +/// Gewohnheit. +@MainActor +@Observable +public final class OnyxTheme { + + /// Die Instanz, aus der `Onyx.Color` liest. + public static let shared = OnyxTheme() + + /// Kühl und entsättigt — Farbe ist in Onyx ein Signal, keine Dekoration. + public static let defaultAccent = Color(red: 0.498, green: 0.659, blue: 1.0) + /// Herunterladen grün, Hochladen rosa: die Zuordnung ist reine Gewohnheit, + /// aber eine verbreitete. Wer sie anders kennt, dreht sie um. + public static let defaultDownload = Color(red: 0.435, green: 0.812, blue: 0.592) + public static let defaultUpload = Color(red: 0.937, green: 0.427, blue: 0.612) + + public var accent: Color { + didSet { store(accent, as: Keys.accent) } + } + public var download: Color { + didSet { store(download, as: Keys.download) } + } + public var upload: Color { + didSet { store(upload, as: Keys.upload) } + } + + private let defaults: UserDefaults + + private enum Keys { + static let accent = "onyx.theme.accent" + static let download = "onyx.theme.download" + static let upload = "onyx.theme.upload" + } + + public init(defaults: UserDefaults = .standard) { + self.defaults = defaults + accent = Color(onyxHex: defaults.string(forKey: Keys.accent)) ?? Self.defaultAccent + download = Color(onyxHex: defaults.string(forKey: Keys.download)) ?? Self.defaultDownload + upload = Color(onyxHex: defaults.string(forKey: Keys.upload)) ?? Self.defaultUpload + } + + /// Ob alles auf den Werkseinstellungen steht — für den Zurücksetzen-Knopf, + /// der sonst auch dann anböte, etwas zu tun, wenn es nichts zu tun gibt. + public var isDefault: Bool { + accent.onyxHex == Self.defaultAccent.onyxHex + && download.onyxHex == Self.defaultDownload.onyxHex + && upload.onyxHex == Self.defaultUpload.onyxHex + } + + public func reset() { + accent = Self.defaultAccent + download = Self.defaultDownload + upload = Self.defaultUpload + } + + /// Übernimmt die Akzentfarbe des Systems. + /// + /// Genau die stand hier vorher an vielen Stellen — unfreiwillig, weil + /// `.tint` und `.accentColor` ohne eigene Angabe dorthin durchgereicht + /// werden. Wem das gefiel, soll es behalten können. + public func adoptSystemAccent() { + accent = Color(nsColor: .controlAccentColor) + } + + private func store(_ color: Color, as key: String) { + defaults.set(color.onyxHex, forKey: key) + } +} + +public extension Color { + + /// `#RRGGBB`, immer in sRGB. + /// + /// Der Umweg über `NSColor` ist nötig, weil eine `Color` aus dem Farbwähler + /// in einem beliebigen Farbraum liegen kann — ohne Umrechnung ergäbe die + /// Zerlegung in Komponenten je nach Bildschirm etwas anderes. + var onyxHex: String { + guard let srgb = NSColor(self).usingColorSpace(.sRGB) else { return "#7FA8FF" } + let red = Int((srgb.redComponent * 255).rounded()) + let green = Int((srgb.greenComponent * 255).rounded()) + let blue = Int((srgb.blueComponent * 255).rounded()) + return String(format: "#%02X%02X%02X", red, green, blue) + } + + /// Liest `#RRGGBB` und `RRGGBB`. + init?(onyxHex: String?) { + guard let onyxHex else { return nil } + var text = onyxHex.trimmingCharacters(in: .whitespaces) + if text.hasPrefix("#") { text.removeFirst() } + guard text.count == 6, let value = UInt32(text, radix: 16) else { return nil } + self.init(.sRGB, + red: Double((value >> 16) & 0xFF) / 255, + green: Double((value >> 8) & 0xFF) / 255, + blue: Double(value & 0xFF) / 255) + } +} diff --git a/Packages/OnyxKit/Sources/OnyxDesign/OnyxTokens.swift b/Packages/OnyxKit/Sources/OnyxDesign/OnyxTokens.swift index 9625a6f..202ded8 100644 --- a/Packages/OnyxKit/Sources/OnyxDesign/OnyxTokens.swift +++ b/Packages/OnyxKit/Sources/OnyxDesign/OnyxTokens.swift @@ -21,8 +21,22 @@ public enum Onyx { public static let textSecondary = SwiftUI.Color.white.opacity(0.58) public static let textTertiary = SwiftUI.Color.white.opacity(0.34) - /// Kühl und entsättigt. Farbe ist in Onyx ein Signal, keine Dekoration. - public static let accent = SwiftUI.Color(red: 0.498, green: 0.659, blue: 1.0) + /// Die Farbe, mit der Onyx sich selbst zeichnet — einstellbar. + /// + /// Gelesen statt gespeichert: die Tokens holen den Wert bei jedem + /// Zugriff aus `OnyxTheme`. Damit merkt SwiftUI die Abhängigkeit und + /// zeichnet neu, sobald sich die Farbe ändert — bei einer Konstanten + /// wären alle vierzig Fundstellen einzeln umzustellen gewesen. + @MainActor public static var accent: SwiftUI.Color { OnyxTheme.shared.accent } + + /// Herunter- und Hochladen. Auch einstellbar: welche Richtung grün ist + /// und welche rosa, ist Gewohnheit, keine Bedeutung. + @MainActor public static var download: SwiftUI.Color { OnyxTheme.shared.download } + @MainActor public static var upload: SwiftUI.Color { OnyxTheme.shared.upload } + + /// Die Signalfarben bleiben fest. Gelb heißt Warnung, Rot heißt + /// kritisch, Grün heißt in Ordnung — wer das umstellen kann, kann die + /// Bedeutung zerstören. public static let positive = SwiftUI.Color(red: 0.435, green: 0.812, blue: 0.592) public static let warning = SwiftUI.Color(red: 0.910, green: 0.706, blue: 0.290) public static let critical = SwiftUI.Color(red: 0.898, green: 0.404, blue: 0.361) diff --git a/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarController.swift b/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarController.swift index cd6ecfc..69c52bd 100644 --- a/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarController.swift +++ b/Packages/OnyxKit/Sources/OnyxMenuBar/MenuBarController.swift @@ -1,4 +1,5 @@ import AppKit +import OnyxDesign import SwiftUI /// Verwaltet beliebig viele Menüleisten-Elemente, je eines pro aktiviertem Modul. @@ -139,8 +140,12 @@ public final class MenuBarController { } closeOpenPopover() + // Die Akzentfarbe hier und nicht in jedem Popover einzeln: `.tint` + // vererbt sich nach unten, und ohne sie fielen Haken, Regler und + // Auswahlfelder auf die Systemfarbe zurück — das war der Grund, warum + // die Popovers grün waren und die Widgets blau. entry.popover.contentViewController = NSHostingController( - rootView: entry.module.makePopoverView()) + rootView: entry.module.makePopoverView().tint(Onyx.Color.accent)) entry.popover.show(relativeTo: sender.bounds, of: sender, preferredEdge: .minY) // Ohne das bleibt das Popover hinter dem aktiven Fenster. entry.popover.contentViewController?.view.window?.makeKey() diff --git a/Packages/OnyxKit/Tests/OnyxDesignTests/OnyxThemeTests.swift b/Packages/OnyxKit/Tests/OnyxDesignTests/OnyxThemeTests.swift new file mode 100644 index 0000000..a2780b3 --- /dev/null +++ b/Packages/OnyxKit/Tests/OnyxDesignTests/OnyxThemeTests.swift @@ -0,0 +1,76 @@ +import Testing +import SwiftUI +@testable import OnyxDesign + +@Suite("Farbwahl") +@MainActor +struct OnyxThemeTests { + + private func freshDefaults(_ name: String) -> UserDefaults { + let suite = "onyx.theme.test.\(name)" + let defaults = UserDefaults(suiteName: suite)! + defaults.removePersistentDomain(forName: suite) + return defaults + } + + @Test("Ohne Einstellung stehen die Werkswerte") + func defaultsAreTheFactoryColours() { + let theme = OnyxTheme(defaults: freshDefaults(#function)) + #expect(theme.accent.onyxHex == OnyxTheme.defaultAccent.onyxHex) + #expect(theme.isDefault) + } + + @Test("Eine gewählte Farbe übersteht den Neustart") + func choiceSurvivesRestart() { + let defaults = freshDefaults(#function) + let theme = OnyxTheme(defaults: defaults) + theme.accent = Color(onyxHex: "#FF8800")! + + let restarted = OnyxTheme(defaults: defaults) + #expect(restarted.accent.onyxHex == "#FF8800") + #expect(!restarted.isDefault) + } + + @Test("Herunter und Hoch sind getrennt einstellbar") + func directionsAreIndependent() { + let theme = OnyxTheme(defaults: freshDefaults(#function)) + theme.download = Color(onyxHex: "#112233")! + #expect(theme.download.onyxHex == "#112233") + #expect(theme.upload.onyxHex == OnyxTheme.defaultUpload.onyxHex) + } + + @Test("Zurücksetzen stellt alles wieder her") + func resetRestoresEverything() { + let theme = OnyxTheme(defaults: freshDefaults(#function)) + theme.accent = Color(onyxHex: "#000000")! + theme.download = Color(onyxHex: "#000000")! + theme.upload = Color(onyxHex: "#000000")! + theme.reset() + #expect(theme.isDefault) + } +} + +@Suite("Farben als Text") +struct ColourHexTests { + + @Test("Hin und zurück ergibt denselben Wert") + func roundTrip() { + for hex in ["#000000", "#FFFFFF", "#7FA8FF", "#6FCF97", "#EF6D9C"] { + #expect(Color(onyxHex: hex)?.onyxHex == hex) + } + } + + @Test("Ohne Doppelkreuz geht es auch") + func hashIsOptional() { + #expect(Color(onyxHex: "7FA8FF")?.onyxHex == "#7FA8FF") + } + + @Test("Unsinn ergibt keine Farbe") + func garbageIsRejected() { + // Sonst stünde nach einer verkorksten Einstellung Schwarz auf Schwarz. + #expect(Color(onyxHex: nil) == nil) + #expect(Color(onyxHex: "") == nil) + #expect(Color(onyxHex: "#12345") == nil) + #expect(Color(onyxHex: "#GGGGGG") == nil) + } +}