Farben einstellbar — und erst mal überhaupt eine Farbe

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 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-08-13 23:24:40 +02:00
parent 7a3d2c1c39
commit 2aa60eedf9
20 changed files with 757 additions and 45 deletions

View File

@@ -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)

View File

@@ -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 {

View File

@@ -199,7 +199,7 @@
"de": {
"stringUnit": {
"state": "translated",
"value": "Je Programm"
"value": "Programme"
}
},
"en": {

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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()