Files
onyx/Packages/OnyxKit/Sources/OnyxDesign/OnyxTheme.swift
T
ScarriffleandClaude Opus 5 ecb39fbe5f Eine Designschicht statt hundertfach gesetzter Zahlen
Token für Schrift, Symbolgröße, Abstand, Radius und Bewegung, dazu sieben
Bauteile, die vorher in jeder Kachel neu gebaut wurden — Karte, Abschnittstitel,
Hinweis, Pillenknopf, Regler, Ablegefläche, Haarlinie.

Symbole bekommen eine **eigene** Staffel und nicht die der Schrift: ein
9-Punkt-Symbol neben 9-Punkt-Text wirkt kleiner, nicht gleich groß. Beide in
eine Staffel zu zwingen hieße, an jeder Stelle gegen sie zu arbeiten.

Das Flächenrezept — Tint, Bänderung, Kante — stand zweimal da, einmal für das
Panel und einmal für Fenster. Es ist jetzt `OnyxStone`, generisch über die Form,
damit auch die animierende Notch-Silhouette es benutzen kann.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-05 21:04:51 +02:00

143 lines
6.0 KiB
Swift

import SwiftUI
import AppKit
/// Was am Aussehen von Onyx einstellbar ist: die Farben und das
/// Bewegungsprofil.
///
/// **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.
///
/// **Wärmer als das Signalgrün, und das ist der Punkt.** Vorher war dieser
/// Wert Zeichen für Zeichen `Onyx.Color.positive` — derselbe grüne Balken
/// hieß im Netzwerk „lädt herunter" und in den Messwerten „alles in
/// Ordnung". Zwei Bedeutungen in einer Farbe sind eine zu viel.
///
/// Wärmer und nicht kühler, weil das Gegenstück rosa bis cyan sein darf:
/// ein Grün, das ins Türkise zieht, wäre vom Hochladen kaum noch zu
/// unterscheiden. Nach Gelb hin ist von beidem Abstand.
public static let defaultDownload = Color(red: 0.561, green: 0.835, blue: 0.408)
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) }
}
/// Wie sich das Panel bewegt.
///
/// Getrennt von den Farben gespeichert und **nicht** von `reset()` erfasst:
/// der Knopf dort heißt „Farben zurücksetzen", und ein Knopf, der nebenbei
/// die Animationen umstellt, ist eine Falle.
public var motion: MotionProfile = .snappy {
didSet { defaults.set(motion.rawValue, forKey: Keys.motion) }
}
/// Woraus die Panelfläche gemacht ist.
///
/// Wie `motion` von den Farben getrennt und **nicht** von `reset()`
/// erfasst: „Farben zurücksetzen" soll Farben zurücksetzen.
public var material: NotchMaterial = .stone {
didSet { defaults.set(material.rawValue, forKey: Keys.material) }
}
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"
static let motion = "onyx.theme.motion"
static let material = "onyx.theme.material"
}
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
// Unbekannte Profilnamen fallen auf die Voreinstellung zurück, statt die
// Bewegung ganz ausfallen zu lassen — eine App ohne Animation sieht aus,
// als hinge sie.
motion = MotionProfile(rawValue: defaults.string(forKey: Keys.motion) ?? "") ?? .snappy
material = NotchMaterial(rawValue: defaults.string(forKey: Keys.material) ?? "") ?? .stone
}
/// Ob **die Farben** auf den Werkseinstellungen stehen — für den
/// Zurücksetzen-Knopf, der sonst auch dann anböte, etwas zu tun, wenn es
/// nichts zu tun gibt. Das Bewegungsprofil zählt bewusst nicht mit; siehe
/// `motion`.
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)
}
}