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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
49a3ff0548
commit
ecb39fbe5f
@@ -0,0 +1,68 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Woraus die Fläche gemacht ist.
|
||||
///
|
||||
/// **Der Werkstoff, nicht die Form.** Ob Onyx oben als verschmolzene Notch
|
||||
/// oder als schwebende Insel sitzt, entscheidet `NotchStyle`; woraus diese
|
||||
/// Fläche besteht, steht hier. Beides zu vermengen hieße, eine Insel aus Glas
|
||||
/// nicht bauen zu können, ohne die Notch mitzuändern.
|
||||
///
|
||||
/// Drei Werkstoffe und keine einzelnen Regler. Deckkraft, Glanz und Kante
|
||||
/// hängen voneinander ab: viel Glanz auf einer deckenden Fläche sieht nach
|
||||
/// Fettfilm aus, eine kaum sichtbare Kante auf einer durchscheinenden lässt
|
||||
/// die Form ausfransen. Was man wählt, ist das Zusammenspiel — nicht drei
|
||||
/// Zahlen.
|
||||
public enum NotchMaterial: String, Codable, Sendable, CaseIterable {
|
||||
|
||||
/// Deckend schwarz. Die Voreinstellung, und die einzige, die mit der
|
||||
/// Hardware-Notch verschmilzt: alles andere hätte dort eine sichtbare
|
||||
/// Kante zum Gehäuse.
|
||||
case stone
|
||||
|
||||
/// Durchscheinend — der Hintergrund ist als Weichzeichnung zu ahnen.
|
||||
case blur
|
||||
|
||||
/// Stark durchscheinend, mit deutlichem Glanz und heller Kante.
|
||||
case glass
|
||||
|
||||
/// Wie deckend der Tint über der Vibrancy-Ebene liegt.
|
||||
public var fillOpacity: Double {
|
||||
switch self {
|
||||
case .stone: 1.0
|
||||
case .blur: 0.72
|
||||
case .glass: 0.42
|
||||
}
|
||||
}
|
||||
|
||||
/// Der Glanz von oben — die Bänderung des Steins.
|
||||
///
|
||||
/// Wächst mit der Durchlässigkeit: auf deckendem Schwarz genügt ein Hauch,
|
||||
/// auf Glas ist er das, was die Fläche überhaupt als Körper lesbar macht.
|
||||
public var sheen: Double {
|
||||
switch self {
|
||||
case .stone: 0.02
|
||||
case .blur: 0.04
|
||||
case .glass: 0.10
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Kante.
|
||||
///
|
||||
/// Je durchlässiger die Fläche, desto mehr Arbeit muss die Kante leisten —
|
||||
/// sie ist dann das Einzige, was die Form von dem trennt, was durch sie
|
||||
/// hindurch zu sehen ist.
|
||||
public var edge: Color {
|
||||
switch self {
|
||||
case .stone: Onyx.Color.hairline
|
||||
case .blur: .white.opacity(0.12)
|
||||
case .glass: .white.opacity(0.20)
|
||||
}
|
||||
}
|
||||
|
||||
/// Ob die Vibrancy-Ebene überhaupt etwas beiträgt.
|
||||
///
|
||||
/// Bei `stone` nicht — dort liegt sie vollständig unter deckendem Schwarz.
|
||||
/// Das ist keine Verschwendung, sondern der Preis dafür, dass ein Wechsel
|
||||
/// des Werkstoffs kein Neuaufbau des Fensters ist.
|
||||
public var showsVibrancy: Bool { self != .stone }
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Die Bauteile, aus denen Onyx besteht.
|
||||
///
|
||||
/// **Warum es sie gibt.** Nachgemessen war jedes dieser Stücke zwei- bis
|
||||
/// fünfmal einzeln nachgebaut — die Karte im Mischpult zweimal wortgleich, der
|
||||
/// Lautstärkeregler zweimal mit denselben achtzig Zeilen, der Leerzustand
|
||||
/// fünfmal, der Pillenknopf dreimal. Solange das so ist, ist eine
|
||||
/// Designänderung keine Änderung an einer Stelle, sondern eine Suche.
|
||||
///
|
||||
/// **Alle Beschriftungen kommen fertig übersetzt herein**, als `String`, nicht
|
||||
/// als `LocalizedStringKey`. Jedes Modul hat seinen eigenen Sprachkatalog, und
|
||||
/// ein Bauteil in `OnyxDesign` kann `bundle: .module` des Aufrufers nicht
|
||||
/// auflösen. Der Aufrufer übersetzt, das Bauteil zeichnet.
|
||||
|
||||
// MARK: - Karte
|
||||
|
||||
/// Eine Karte **innerhalb** einer Kachel.
|
||||
///
|
||||
/// Fasst zusammen, was zusammengehört, und trennt es von dem, was daneben
|
||||
/// steht. Nicht zu verwechseln mit `OnyxTile`: die Kachel ist das Fach im
|
||||
/// Panel, die Karte ist eine Gruppe darin.
|
||||
public struct OnyxCard<Content: View>: View {
|
||||
private let content: Content
|
||||
|
||||
public init(@ViewBuilder content: () -> Content) {
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
let shape = RoundedRectangle(cornerRadius: Onyx.Radius.card, style: .continuous)
|
||||
|
||||
content
|
||||
.background(Onyx.Color.card, in: shape)
|
||||
.overlay {
|
||||
shape.strokeBorder(Onyx.Color.hairline, lineWidth: Onyx.Metric.hairlineWidth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Abschnittstitel
|
||||
|
||||
/// Die Überschrift über einer Gruppe.
|
||||
///
|
||||
/// Versal und klein — sie soll die Gruppe benennen, nicht mit ihrem Inhalt um
|
||||
/// Aufmerksamkeit streiten.
|
||||
public struct OnyxSectionTitle: View {
|
||||
private let text: String
|
||||
|
||||
public init(_ text: String) { self.text = text }
|
||||
|
||||
public var body: some View {
|
||||
Text(text)
|
||||
.font(Onyx.Font.metricSmall)
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
.textCase(.uppercase)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Trennlinie
|
||||
|
||||
/// Die Haarlinie zwischen zwei Zeilen.
|
||||
///
|
||||
/// `Divider()` allein zeichnet in der Systemfarbe und ist auf schwarzem Stein
|
||||
/// entweder zu hell oder gar nicht da.
|
||||
public struct OnyxHairline: View {
|
||||
public init() {}
|
||||
|
||||
public var body: some View {
|
||||
Divider().overlay(Onyx.Color.hairline)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Leerzustand
|
||||
|
||||
/// Wenn eine Kachel nichts zu zeigen hat.
|
||||
///
|
||||
/// Ein Leerzustand ist kein Fehler und soll auch nicht so aussehen: Symbol
|
||||
/// gedämpft, Text eine Stufe heller, und wenn es etwas zu tun gibt, genau ein
|
||||
/// Knopf.
|
||||
public struct OnyxNotice<Action: View>: View {
|
||||
|
||||
/// Mittig in der Kachel oder linksbündig unter dem, was darüber steht.
|
||||
public enum Placement {
|
||||
/// Für Kacheln, die sonst leer wären — Wetter, Kalender.
|
||||
case centered
|
||||
/// Für Kacheln, die den Hinweis in eine bestehende Spalte einreihen —
|
||||
/// Lüfter.
|
||||
case leading
|
||||
}
|
||||
|
||||
private let symbol: String
|
||||
private let text: String
|
||||
private let placement: Placement
|
||||
private let action: Action
|
||||
|
||||
public init(symbol: String,
|
||||
text: String,
|
||||
placement: Placement = .centered,
|
||||
@ViewBuilder action: () -> Action) {
|
||||
self.symbol = symbol
|
||||
self.text = text
|
||||
self.placement = placement
|
||||
self.action = action()
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(alignment: placement == .centered ? .center : .leading, spacing: Onyx.Space.sm) {
|
||||
Image(systemName: symbol)
|
||||
.font(Onyx.Font.headline)
|
||||
.foregroundStyle(Onyx.Color.textTertiary)
|
||||
Text(text)
|
||||
.font(Onyx.Font.caption)
|
||||
.foregroundStyle(Onyx.Color.textSecondary)
|
||||
.multilineTextAlignment(placement == .centered ? .center : .leading)
|
||||
// Ohne das reißt ein zweizeiliger Hinweis in der schmalen
|
||||
// Kachel mitten im Wort ab, statt umzubrechen.
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
action
|
||||
}
|
||||
.frame(maxWidth: .infinity,
|
||||
maxHeight: placement == .centered ? .infinity : nil,
|
||||
alignment: placement == .centered ? .center : .leading)
|
||||
}
|
||||
}
|
||||
|
||||
public extension OnyxNotice where Action == EmptyView {
|
||||
init(symbol: String, text: String, placement: Placement = .centered) {
|
||||
self.init(symbol: symbol, text: text, placement: placement) { EmptyView() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Pillenknopf
|
||||
|
||||
/// Der kleine Knopf in einer Kachel.
|
||||
///
|
||||
/// Zwei Zustände, und der Unterschied ist Absicht: gefüllt heißt „das gilt
|
||||
/// gerade", umrandet heißt „das kannst du wählen". Ein blauer Text sähe aus wie
|
||||
/// ein Verweis und nicht wie ein Zustand.
|
||||
public struct OnyxPillButton: View {
|
||||
|
||||
/// Zwei Dichten, weil zwei Orte gemeint sind: der Knopf unter einem
|
||||
/// Hinweis hat Platz, der Knopf in einer Lüfterzeile hat ihn nicht.
|
||||
public enum Size {
|
||||
case regular
|
||||
case compact
|
||||
|
||||
var font: Font { self == .regular ? Onyx.Font.caption : Onyx.Font.metricSmall }
|
||||
var horizontal: CGFloat { self == .regular ? Onyx.Space.md : Onyx.Space.sm }
|
||||
var vertical: CGFloat { self == .regular ? Onyx.Space.xs : Onyx.Space.xxs }
|
||||
}
|
||||
|
||||
private let title: String
|
||||
private let isFilled: Bool
|
||||
private let size: Size
|
||||
private let action: () -> Void
|
||||
|
||||
public init(_ title: String,
|
||||
isFilled: Bool = true,
|
||||
size: Size = .regular,
|
||||
action: @escaping () -> Void) {
|
||||
self.title = title
|
||||
self.isFilled = isFilled
|
||||
self.size = size
|
||||
self.action = action
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
Button(action: action) {
|
||||
Text(title)
|
||||
.font(size.font)
|
||||
// Umrandet heißt bedienbar, nicht abgeschaltet — in der
|
||||
// Lüfterkachel ist gerade die **gefüllte** Pille die
|
||||
// ausgegraute. `textTertiary` sah hier aus wie „geht nicht";
|
||||
// das Ausgrauen macht ohnehin `.disabled()`.
|
||||
.foregroundStyle(isFilled ? Onyx.Color.onAccent : Onyx.Color.textSecondary)
|
||||
.padding(.horizontal, size.horizontal)
|
||||
.padding(.vertical, size.vertical)
|
||||
.background {
|
||||
Capsule()
|
||||
.fill(isFilled ? Onyx.Color.accent : Color.clear)
|
||||
.overlay {
|
||||
Capsule().strokeBorder(isFilled ? .clear : Onyx.Color.hairline,
|
||||
lineWidth: Onyx.Metric.hairlineWidth)
|
||||
}
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Regler
|
||||
|
||||
/// Der Regler mit mitlaufendem Pegel.
|
||||
///
|
||||
/// **Einer, nicht zwei.** Es gab ihn zweimal — für die Programme im Mischpult
|
||||
/// und für die Systemlautstärke — mit denselben Knopfgrößen, derselben Geste
|
||||
/// und derselben Pegelrechnung. Der Systemregler war exakt dieser hier mit
|
||||
/// `maximum: 1`; der 100-%-Strich unterdrückt sich dort von selbst.
|
||||
///
|
||||
/// Der Pegel läuft **im** Regler mit, nicht daneben: eine zweite Leiste je
|
||||
/// Zeile wäre doppelt so viel Grafik für dieselbe Auskunft.
|
||||
public struct OnyxSlider: View {
|
||||
|
||||
private let value: Double
|
||||
private let maximum: Double
|
||||
private let level: Double
|
||||
private let isActive: Bool
|
||||
private let onChange: (Double) -> Void
|
||||
|
||||
@State private var isDragging = false
|
||||
|
||||
/// - Parameters:
|
||||
/// - maximum: Der rechte Anschlag. 1 im Normalfall, mehr bei Overdrive.
|
||||
/// - level: Der Ausschlag, der im Regler mitläuft. 0 blendet ihn aus.
|
||||
/// - isActive: Ausgegraut, wenn das Programm stumm ist oder nicht regelbar.
|
||||
public init(value: Double,
|
||||
maximum: Double = 1,
|
||||
level: Double = 0,
|
||||
isActive: Bool = true,
|
||||
onChange: @escaping (Double) -> Void) {
|
||||
self.value = value
|
||||
self.maximum = maximum
|
||||
self.level = level
|
||||
self.isActive = isActive
|
||||
self.onChange = onChange
|
||||
}
|
||||
|
||||
private static let trackHeight: CGFloat = 6
|
||||
private static let barHeight: CGFloat = 16
|
||||
|
||||
public var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let knob: CGFloat = isDragging ? 13 : 11
|
||||
// Der Knopf soll an den Enden nicht überstehen.
|
||||
let travel = max(geometry.size.width - knob, 1)
|
||||
let fraction = min(max(value / maximum, 0), 1)
|
||||
let fillWidth = travel * fraction + knob / 2
|
||||
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(Onyx.Color.surface)
|
||||
.frame(height: Self.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: Self.trackHeight + 4)
|
||||
.offset(x: knob / 2 + travel / maximum)
|
||||
}
|
||||
|
||||
Capsule()
|
||||
.fill(value > 1 ? Onyx.Color.warning : Onyx.Color.accent)
|
||||
.frame(width: fillWidth, height: Self.trackHeight)
|
||||
.opacity(isActive ? 1 : 0.3)
|
||||
|
||||
if isActive, level > 0.001 {
|
||||
Capsule()
|
||||
.fill(.white.opacity(0.4))
|
||||
.frame(width: fillWidth * Self.meterFraction(level),
|
||||
height: Self.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: Self.barHeight)
|
||||
.contentShape(.rect)
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { drag in
|
||||
isDragging = true
|
||||
let position = min(max((drag.location.x - knob / 2) / travel, 0), 1)
|
||||
onChange(position * maximum)
|
||||
}
|
||||
.onEnded { _ in isDragging = false })
|
||||
}
|
||||
.frame(height: Self.barHeight)
|
||||
.animation(Onyx.Motion.value, value: isDragging)
|
||||
}
|
||||
|
||||
/// Wie weit der Pegel im Regler ausschlägt.
|
||||
///
|
||||
/// 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.
|
||||
public static func meterFraction(_ level: Double) -> Double {
|
||||
min(1, sqrt(min(max(level, 0), 1)))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Ablegefläche
|
||||
|
||||
/// Wohin man Dateien ziehen kann.
|
||||
///
|
||||
/// Zwei Fassungen, weil zwei Dinge gemeint sind: eine Fläche, die **nur zum
|
||||
/// Ablegen da ist**, zeigt ihre gestrichelte Kante immer — sonst weiß niemand,
|
||||
/// dass sie eine ist. Eine Kachel, die **nebenbei auch** Dateien annimmt, zeigt
|
||||
/// sie erst, wenn wirklich etwas darüber schwebt.
|
||||
public enum OnyxDropZoneStyle {
|
||||
/// Immer sichtbar, beim Anvisieren in der Akzentfarbe.
|
||||
case always
|
||||
/// Nur beim Anvisieren, dann mit leichter Füllung.
|
||||
case whenTargeted
|
||||
}
|
||||
|
||||
private struct OnyxDropZone: ViewModifier {
|
||||
let isTargeted: Bool
|
||||
let style: OnyxDropZoneStyle
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
let shape = RoundedRectangle(cornerRadius: Onyx.Radius.card, style: .continuous)
|
||||
|
||||
return content
|
||||
.overlay {
|
||||
switch style {
|
||||
case .always:
|
||||
shape.strokeBorder(
|
||||
isTargeted ? Onyx.Color.accent : Onyx.Color.hairline,
|
||||
style: StrokeStyle(lineWidth: Onyx.Metric.hairlineWidth, dash: [4, 3]))
|
||||
.allowsHitTesting(false)
|
||||
case .whenTargeted:
|
||||
if isTargeted {
|
||||
shape.strokeBorder(Onyx.Color.accent,
|
||||
style: StrokeStyle(lineWidth: 1.5, dash: [4, 3]))
|
||||
.background(Onyx.Color.accent.opacity(0.08), in: shape)
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
.animation(Onyx.Motion.value, value: isTargeted)
|
||||
}
|
||||
}
|
||||
|
||||
public extension View {
|
||||
/// Zeichnet die gestrichelte Kante einer Ablegefläche.
|
||||
///
|
||||
/// Nimmt selbst keine Dateien an — das macht `onFileDrop`. Getrennt, weil
|
||||
/// die Kante auch dort gebraucht wird, wo das Ablegen die ganze Kachel
|
||||
/// betrifft und nicht nur den umrandeten Teil.
|
||||
func onyxDropZone(isTargeted: Bool, style: OnyxDropZoneStyle) -> some View {
|
||||
modifier(OnyxDropZone(isTargeted: isTargeted, style: style))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Die Bauteile der **Fenster** — Einstellungen, Onboarding, Popover.
|
||||
///
|
||||
/// **Getrennt von den Panel-Bauteilen, und genau das ist der Punkt.** Das Panel
|
||||
/// ist schwarzer Stein und bleibt es; ein Fenster gehört dem System und folgt
|
||||
/// dessen Erscheinung. Diese Trennung fehlte bisher: `Onyx.Color.textTertiary`
|
||||
/// ist Weiß mit 34 % Deckkraft und stand in den Einstellungen — im Hellmodus
|
||||
/// war das praktisch unsichtbar. Die Bauteile hier benutzen deshalb
|
||||
/// Systemsemantik (`.secondary`, `.quaternary`, `controlBackgroundColor`), die
|
||||
/// hell wie dunkel richtig herauskommt. Die Akzentfarbe von Onyx kommt über
|
||||
/// `.tint` von außen dazu.
|
||||
|
||||
// MARK: - Karte
|
||||
|
||||
/// Eine Gruppe zusammengehörender Zeilen.
|
||||
///
|
||||
/// Zieht die Trennlinien **selbst** zwischen die Zeilen. Von Hand gesetzt
|
||||
/// fehlen sie irgendwann irgendwo, oder es steht eine zu viel am Ende der
|
||||
/// Gruppe — das ist keine Frage von Sorgfalt, sondern von Gelegenheit.
|
||||
public struct OnyxFormCard<Content: View>: View {
|
||||
|
||||
private let header: String?
|
||||
private let headerHint: String?
|
||||
private let content: Content
|
||||
|
||||
/// - Parameters:
|
||||
/// - header: Die Überschrift der Gruppe. Sie steht **in** der Karte und
|
||||
/// ohne Trennlinie darunter — sie benennt die Zeilen, sie ist keine
|
||||
/// davon.
|
||||
/// - headerHint: Ein Satz, der für die **ganze** Gruppe gilt. Er gehört
|
||||
/// hierher und nicht an die erste Zeile: dort läse er sich, als
|
||||
/// erkläre er nur diese eine.
|
||||
public init(_ header: String? = nil,
|
||||
hint headerHint: String? = nil,
|
||||
@ViewBuilder content: () -> Content) {
|
||||
self.header = header
|
||||
self.headerHint = headerHint
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if header != nil || headerHint != nil {
|
||||
VStack(alignment: .leading, spacing: Onyx.Space.xs) {
|
||||
if let header {
|
||||
Text(header).font(.headline)
|
||||
}
|
||||
if let headerHint {
|
||||
Text(headerHint)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, OnyxForm.rowPadding)
|
||||
.padding(.top, OnyxForm.rowPadding)
|
||||
.padding(.bottom, Onyx.Space.xxs)
|
||||
}
|
||||
|
||||
Group(subviews: content) { rows in
|
||||
ForEach(rows.indices, id: \.self) { index in
|
||||
if index > 0 {
|
||||
// Eingerückt wie in den Systemeinstellungen: eine Linie
|
||||
// von Kante zu Kante zerschneidet die Karte, statt ihre
|
||||
// Zeilen zu trennen.
|
||||
Divider().padding(.leading, OnyxForm.rowPadding)
|
||||
}
|
||||
rows[index]
|
||||
}
|
||||
}
|
||||
}
|
||||
// Ein Material, keine Farbe.
|
||||
//
|
||||
// `controlBackgroundColor` ist deckend und macht aus jeder Karte eine
|
||||
// Platte — das Fenster darunter kann dann so durchlässig sein, wie es
|
||||
// will, man sieht nur zwischen den Karten etwas davon. Ein Material
|
||||
// nimmt den Grund auf und bleibt trotzdem lesbar, weil es ihn
|
||||
// gleichzeitig verwäscht.
|
||||
.background(.ultraThinMaterial,
|
||||
in: .rect(cornerRadius: OnyxForm.cardRadius, style: .continuous))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: OnyxForm.cardRadius, style: .continuous)
|
||||
.strokeBorder(.quaternary, lineWidth: 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Zeile
|
||||
|
||||
/// Eine Einstellung: was sie ist, warum es sie gibt, und ihr Bedienelement.
|
||||
///
|
||||
/// Der Hinweis steht **unter** dem Titel, nicht daneben und nicht in einer
|
||||
/// eigenen Zeile darunter. Daneben drängt er das Bedienelement an den Rand;
|
||||
/// als eigene Zeile verliert er den Bezug zu dem, was er erklärt.
|
||||
public struct OnyxFormRow<Trailing: View>: View {
|
||||
|
||||
/// Wie ernst die Zeile ist.
|
||||
///
|
||||
/// Die Signalfarben stehen fest und bedeuten etwas. Eine Warnung, die
|
||||
/// aussieht wie eine Einstellung, ist keine.
|
||||
public enum Tone {
|
||||
case normal
|
||||
case warning
|
||||
case critical
|
||||
|
||||
/// `nil` heißt: nimm, was die Umgebung vorgibt — `.tint` beim Symbol,
|
||||
/// `.primary` beim Titel. Ein fester Ersatzwert stünde beiden im Weg.
|
||||
var style: AnyShapeStyle? {
|
||||
switch self {
|
||||
case .normal: nil
|
||||
case .warning: AnyShapeStyle(Onyx.Color.warning)
|
||||
case .critical: AnyShapeStyle(Onyx.Color.critical)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private let title: String
|
||||
private let hint: String?
|
||||
private let symbol: String?
|
||||
private let tone: Tone
|
||||
private let trailing: Trailing
|
||||
|
||||
public init(_ title: String,
|
||||
hint: String? = nil,
|
||||
symbol: String? = nil,
|
||||
tone: Tone = .normal,
|
||||
@ViewBuilder trailing: () -> Trailing) {
|
||||
self.title = title
|
||||
self.hint = hint
|
||||
self.symbol = symbol
|
||||
self.tone = tone
|
||||
self.trailing = trailing()
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
HStack(alignment: .center, spacing: OnyxForm.rowPadding) {
|
||||
if let symbol {
|
||||
Image(systemName: symbol)
|
||||
.frame(width: 18)
|
||||
.foregroundStyle(tone.style ?? AnyShapeStyle(.tint))
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: Onyx.Space.xxs) {
|
||||
Text(title)
|
||||
.foregroundStyle(tone.style ?? AnyShapeStyle(.primary))
|
||||
if let hint {
|
||||
Text(hint)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
// Ohne das bricht ein längerer Hinweis nicht um,
|
||||
// sondern schiebt die ganze Zeile über die Kante.
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
trailing
|
||||
}
|
||||
.padding(.horizontal, OnyxForm.rowPadding)
|
||||
.padding(.vertical, Onyx.Space.row)
|
||||
}
|
||||
}
|
||||
|
||||
public extension OnyxFormRow where Trailing == EmptyView {
|
||||
init(_ title: String, hint: String? = nil, symbol: String? = nil, tone: Tone = .normal) {
|
||||
self.init(title, hint: hint, symbol: symbol, tone: tone) { EmptyView() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Seitenleiste
|
||||
|
||||
/// Ein Eintrag in der Seitenleiste: farbiges Plättchen, dann der Name.
|
||||
///
|
||||
/// Das Plättchen ist keine Verzierung. Eine Seitenleiste aus zwölf Zeilen
|
||||
/// gleicher Schrift liest man jedes Mal von vorn; mit Farbe und Form findet
|
||||
/// man den gesuchten Eintrag, ohne ihn zu lesen.
|
||||
public struct OnyxSidebarLabel: View {
|
||||
|
||||
private let title: String
|
||||
private let symbol: String
|
||||
private let tint: Color
|
||||
|
||||
public init(_ title: String, symbol: String, tint: Color) {
|
||||
self.title = title
|
||||
self.symbol = symbol
|
||||
self.tint = tint
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
HStack(spacing: Onyx.Space.md) {
|
||||
RoundedRectangle(cornerRadius: Onyx.Radius.thumbnail, style: .continuous)
|
||||
.fill(tint)
|
||||
.frame(width: 20, height: 20)
|
||||
.overlay {
|
||||
Image(systemName: symbol)
|
||||
.font(.system(size: Onyx.Symbol.small, weight: .semibold))
|
||||
// Weiß, nicht `.primary`: das Plättchen ist in beiden
|
||||
// Erscheinungen farbig und damit immer dunkel genug.
|
||||
.foregroundStyle(.white)
|
||||
}
|
||||
Text(title)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Die Überschrift eines Abschnitts in einem Popover.
|
||||
///
|
||||
/// Sie stand zweimal im Baum, zeichengleich — einmal als Struktur in
|
||||
/// `MetricPopovers`, einmal als Funktion in `NetworkPopover`. Dass beide gleich
|
||||
/// aussahen, war Zufall und keine Zusage.
|
||||
///
|
||||
/// In der Akzentfarbe und nicht gedämpft: ein Popover ist klein, und die
|
||||
/// Abschnitte darin sind das einzige Gerüst, an dem man sich festhält.
|
||||
public struct OnyxPopoverTitle: View {
|
||||
private let text: String
|
||||
|
||||
public init(_ text: String) { self.text = text }
|
||||
|
||||
public var body: some View {
|
||||
Text(text)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(Onyx.Color.accent)
|
||||
.textCase(.uppercase)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Maße
|
||||
|
||||
/// Die wenigen Maße, die Karte, Zeile und Trennlinie miteinander teilen.
|
||||
///
|
||||
/// Nicht in `Onyx.Space`: das ist die Staffel für die dichte Kachelfläche.
|
||||
/// Ein Fenster hat andere Verhältnisse, und beides in eine Staffel zu zwingen
|
||||
/// hieße, eines von beidem falsch zu machen.
|
||||
public enum OnyxForm {
|
||||
public static let cardRadius: CGFloat = 10
|
||||
public static let rowPadding: CGFloat = 14
|
||||
/// Abstand zwischen zwei Karten.
|
||||
public static let cardSpacing: CGFloat = 18
|
||||
/// Rand um den Inhalt eines Bereichs.
|
||||
public static let pagePadding: CGFloat = 20
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Wie sich Onyx bewegt.
|
||||
///
|
||||
/// Drei Profile statt einzelner Regler. Wer Federkonstanten einzeln einstellen
|
||||
/// darf, stellt sie kaputt: Ausfahren und Einfahren müssen zueinander passen,
|
||||
/// und diese Beziehung ist es, die man wählt — nicht vier Zahlen.
|
||||
///
|
||||
/// **Einfahren ist in jedem Profil kürzer als Ausfahren und schwingt nicht
|
||||
/// nach.** Beim Öffnen sieht man zu, beim Schließen ist man schon woanders;
|
||||
/// dieselbe Kurve rückwärts fühlt sich dort zäh an, und das Nachschwingen liest
|
||||
/// sich als Ruckler.
|
||||
public enum MotionProfile: String, CaseIterable, Sendable {
|
||||
|
||||
/// Die Voreinstellung: schnell, mit einem Hauch Nachschwingen. Das gibt dem
|
||||
/// Panel Masse, statt es nur größer werden zu lassen.
|
||||
case snappy
|
||||
|
||||
/// Deutlich mehr Nachschwingen. Verspielter, und bei häufigem Öffnen
|
||||
/// anstrengender.
|
||||
case bouncy
|
||||
|
||||
/// Länger und ohne jedes Überschwingen. Für Bildschirme, auf denen viel
|
||||
/// gleichzeitig passiert.
|
||||
case calm
|
||||
|
||||
/// Ausfahren.
|
||||
public var expand: Animation {
|
||||
switch self {
|
||||
case .snappy: .spring(response: 0.34, dampingFraction: 0.86)
|
||||
case .bouncy: .spring(response: 0.42, dampingFraction: 0.62)
|
||||
case .calm: .spring(response: 0.52, dampingFraction: 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Einfahren.
|
||||
public var collapse: Animation {
|
||||
switch self {
|
||||
case .snappy: .spring(response: 0.24, dampingFraction: 1.0)
|
||||
case .bouncy: .spring(response: 0.28, dampingFraction: 0.85)
|
||||
case .calm: .spring(response: 0.36, dampingFraction: 1.0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Für Messwerte: sichtbar, aber ohne Pulsieren.
|
||||
public var value: Animation {
|
||||
switch self {
|
||||
case .snappy: .easeOut(duration: 0.25)
|
||||
case .bouncy: .easeOut(duration: 0.28)
|
||||
case .calm: .easeOut(duration: 0.40)
|
||||
}
|
||||
}
|
||||
|
||||
/// Wie lange das Einfahren dauert.
|
||||
///
|
||||
/// Der Fenstercontroller braucht die Zahl, nicht die Kurve: er blendet das
|
||||
/// Fenster erst danach aus. Wird sie zu klein angesetzt, reißt das Panel
|
||||
/// mitten in der Bewegung weg.
|
||||
public var collapseDuration: Double {
|
||||
switch self {
|
||||
case .snappy: 0.30
|
||||
case .bouncy: 0.36
|
||||
case .calm: 0.46
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,34 +20,72 @@ public struct OnyxVisualEffect: NSViewRepresentable {
|
||||
public func updateNSView(_ view: NSVisualEffectView, context: Context) {}
|
||||
}
|
||||
|
||||
/// Der Hintergrund des Panels: Vibrancy, Tint, ein sehr flacher Glanz von oben
|
||||
/// und eine Haarlinie als Innenkante.
|
||||
public struct OnyxSurface: View {
|
||||
private let cornerRadius: CGFloat
|
||||
/// Der Stein selbst — auf einer beliebigen Form.
|
||||
///
|
||||
/// Tint, Bänderung, Kante. Drei Lagen, die zusammen den Werkstoff ergeben, und
|
||||
/// zwar unabhängig davon, welche Form sie annehmen.
|
||||
///
|
||||
/// **Warum generisch und nicht einfach ein abgerundetes Rechteck.** Das Panel
|
||||
/// zeichnet auf ``NotchShape``, einer Form, die sich beim Aus- und Einfahren
|
||||
/// verändert. Solange das Rezept an ein Rechteck gebunden war, konnte es dort
|
||||
/// nicht mitbenutzt werden — also stand es zweimal da, einmal hier und einmal
|
||||
/// im Panel. Zweimal heißt: eine Änderung an der Bänderung wirkt an einer
|
||||
/// Stelle und an der anderen nicht, und das fällt erst auf, wenn beide
|
||||
/// nebeneinander zu sehen sind.
|
||||
public struct OnyxStone<S: InsettableShape>: View {
|
||||
private let shape: S
|
||||
|
||||
public init(cornerRadius: CGFloat = Onyx.Metric.panelCornerRadius) {
|
||||
self.cornerRadius = cornerRadius
|
||||
public init(_ shape: S) {
|
||||
self.shape = shape
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
// Aus dem Thema gelesen und nicht als Argument gereicht: sonst müsste
|
||||
// jede Stelle, die eine Fläche zeichnet, den Werkstoff durchschleifen —
|
||||
// bis hinunter zur Kachel, die ihn gar nicht kennt.
|
||||
let material = OnyxTheme.shared.material
|
||||
|
||||
shape
|
||||
.fill(Onyx.Color.surface.opacity(Onyx.Color.surfaceOpacity))
|
||||
.background(OnyxVisualEffect().clipShape(shape))
|
||||
.fill(Onyx.Color.surface.opacity(material.fillOpacity))
|
||||
.overlay {
|
||||
// Die Bänderung des Steins: oben ein Hauch heller, nach 40 %
|
||||
// vollständig verlaufen. Mehr wirkt sofort billig.
|
||||
// vollständig verlaufen. Mehr wirkt sofort billig — wie viel
|
||||
// „ein Hauch" ist, hängt am Werkstoff.
|
||||
shape.fill(
|
||||
LinearGradient(
|
||||
stops: [
|
||||
.init(color: .white.opacity(0.02), location: 0),
|
||||
.init(color: .white.opacity(material.sheen), location: 0),
|
||||
.init(color: .clear, location: 0.4),
|
||||
],
|
||||
startPoint: .top, endPoint: .bottom))
|
||||
}
|
||||
.overlay {
|
||||
shape.strokeBorder(Onyx.Color.hairline, lineWidth: Onyx.Metric.hairlineWidth)
|
||||
// `strokeBorder` und nicht `stroke`: die Linie liegt dann
|
||||
// vollständig **innen**. Mittig gezeichnet ragte sie zur Hälfte
|
||||
// über die Kante hinaus und wurde an den Rundungen sichtbar
|
||||
// dünner.
|
||||
shape.strokeBorder(material.edge, lineWidth: Onyx.Metric.hairlineWidth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Der Hintergrund des Panels: Vibrancy und darauf der Stein.
|
||||
public struct OnyxSurface: View {
|
||||
private let cornerRadius: CGFloat
|
||||
|
||||
public init(cornerRadius: CGFloat = Onyx.Radius.panel) {
|
||||
self.cornerRadius = cornerRadius
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
OnyxStone(shape)
|
||||
.background {
|
||||
// Bei deckendem Stein trägt die Weichzeichnung nichts bei und
|
||||
// wird auch nicht berechnet.
|
||||
if OnyxTheme.shared.material.showsVibrancy {
|
||||
OnyxVisualEffect().clipShape(shape)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -61,10 +99,10 @@ public struct OnyxTile<Content: View>: View {
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
let shape = RoundedRectangle(cornerRadius: Onyx.Metric.tileCornerRadius, style: .continuous)
|
||||
let shape = RoundedRectangle(cornerRadius: Onyx.Radius.tile, style: .continuous)
|
||||
|
||||
content
|
||||
.padding(12)
|
||||
.padding(Onyx.Space.lg)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||
.background(shape.fill(Onyx.Color.elevated))
|
||||
.overlay(shape.strokeBorder(Onyx.Color.hairline, lineWidth: Onyx.Metric.hairlineWidth))
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
/// Die einstellbaren Farben.
|
||||
/// 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,
|
||||
@@ -20,8 +21,17 @@ public final class 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)
|
||||
/// 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 {
|
||||
@@ -34,12 +44,31 @@ public final class OnyxTheme {
|
||||
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) {
|
||||
@@ -47,10 +76,17 @@ public final class OnyxTheme {
|
||||
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 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.
|
||||
/// 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import SwiftUI
|
||||
|
||||
/// Die einzige Quelle für Farben, Maße und Bewegung in Onyx.
|
||||
/// Die einzige Quelle für Farben, Maße, Schrift und Bewegung in Onyx.
|
||||
///
|
||||
/// Onyx ist ein tiefschwarzer Stein mit feiner Bänderung und mattem Glanz — kein
|
||||
/// Glas. Das Panel benutzt deshalb bewusst **nicht** die Liquid-Glass-APIs von
|
||||
@@ -10,8 +10,13 @@ public enum Onyx {
|
||||
// MARK: - Farben
|
||||
|
||||
public enum Color {
|
||||
/// Grundfläche des Panels.
|
||||
public static let surface = SwiftUI.Color(red: 0.043, green: 0.043, blue: 0.051)
|
||||
/// Grundfläche des Panels — **wirklich schwarz**.
|
||||
///
|
||||
/// Nicht sehr dunkles Grau: die Fläche grenzt oben unmittelbar an die
|
||||
/// Notch, und die ist schwarzes Gehäuse. Jeder Grauwert daneben ist
|
||||
/// eine sichtbare Kante genau dort, wo beides als ein Körper wirken
|
||||
/// soll — vorher #0B0B0D bei 74 % Deckkraft, also grau auf grau.
|
||||
public static let surface = SwiftUI.Color.black
|
||||
/// Kacheln auf der Grundfläche.
|
||||
public static let elevated = SwiftUI.Color(red: 0.078, green: 0.078, blue: 0.090)
|
||||
/// Innenkante — der halbe „edle" Effekt steckt in dieser einen Linie.
|
||||
@@ -41,16 +46,107 @@ public enum Onyx {
|
||||
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)
|
||||
|
||||
/// Deckkraft des Tints über der Vibrancy-Ebene. Darunter liest man den
|
||||
/// Hintergrund durch, darüber wirkt das Panel wie eine schwarze Platte.
|
||||
public static let surfaceOpacity: Double = 0.74
|
||||
/// Eine Karte **innerhalb** einer Kachel — etwas heller als die Kachel,
|
||||
/// aber nicht so hell, dass sie selbst wie eine Kachel wirkt.
|
||||
public static let card = SwiftUI.Color.white.opacity(0.035)
|
||||
|
||||
/// Die Schiene hinter einem Balken oder Regler.
|
||||
///
|
||||
/// **Nicht `hairline`.** Das sind 8 % Weiß und ein Wert für Kanten; auf
|
||||
/// schwarzem Grund ist er praktisch nicht da. Als Schiene liest sich
|
||||
/// das, als liefe der gefüllte Teil ins Nichts aus — man sieht dann nur
|
||||
/// noch eine Länge, ohne zu wissen, wovon.
|
||||
public static let track = SwiftUI.Color.white.opacity(0.22)
|
||||
|
||||
/// Text und Symbole **auf** der Akzentfarbe.
|
||||
///
|
||||
/// Bisher stand dafür an drei Stellen `Onyx.Color.surface` — was
|
||||
/// zufällig richtig aussah und beim ersten hellen Akzent falsch würde.
|
||||
public static let onAccent = SwiftUI.Color(red: 0.043, green: 0.043, blue: 0.051)
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Abstände
|
||||
|
||||
/// Die Staffel für Abstände innerhalb einer Kachel oder eines Fensters.
|
||||
///
|
||||
/// **Zwei Punkte Schrittweite, nicht vier.** Nachgemessen standen im Baum
|
||||
/// sechzehn verschiedene Abstände; die häufigsten waren 6, 4, 8 und 12. Ein
|
||||
/// Vierer-Raster hätte davon drei verschoben — bei 180 × 200 Punkt großen
|
||||
/// Kacheln ist das keine Feinheit, sondern eine sichtbare Umordnung ohne
|
||||
/// Gegenwert. Die Staffel bildet also ab, was der dichten Fläche entspricht,
|
||||
/// und schneidet stattdessen die Ausreißer weg: aus 3 und 5 wird 4, aus 7
|
||||
/// und 9 wird 8, aus 10 wird 12, aus 14 wird 16.
|
||||
public enum Space {
|
||||
/// Zwischen Zeichen und dem, was direkt daneben klebt.
|
||||
public static let xxs: CGFloat = 2
|
||||
public static let xs: CGFloat = 4
|
||||
public static let sm: CGFloat = 6
|
||||
public static let md: CGFloat = 8
|
||||
/// Das Innenpolster einer Zeile — zwischen `md` und `lg`.
|
||||
///
|
||||
/// Keine willkürliche Zwischenstufe: eine Zeile mit 8 Punkten wirkt
|
||||
/// gedrängt, mit 12 fällt sie auseinander. Der Wert kam an zehn Stellen
|
||||
/// unabhängig voneinander heraus, bevor er hier stand.
|
||||
public static let row: CGFloat = 10
|
||||
|
||||
public static let lg: CGFloat = 12
|
||||
public static let xl: CGFloat = 16
|
||||
/// Der Rand eines Fensterinhalts — zwischen `xl` und `xxl`.
|
||||
///
|
||||
/// Fenster brauchen mehr Luft als Kacheln: eine Kachel sitzt in einem
|
||||
/// Panel und teilt sich dessen Rand, ein Fenster hat nur den eigenen.
|
||||
public static let window: CGFloat = 20
|
||||
|
||||
public static let xxl: CGFloat = 24
|
||||
}
|
||||
|
||||
// MARK: - Rundungen
|
||||
|
||||
/// Rundungen sind nach dem benannt, was sie umschließen, nicht nach ihrer
|
||||
/// Größe.
|
||||
///
|
||||
/// Eine Größenstaffel („sm", „md") lädt dazu ein, die nächstbeste zu nehmen.
|
||||
/// Ein Name sagt dagegen, wo der Wert hingehört — und macht sofort sichtbar,
|
||||
/// wenn eine Karte plötzlich so rund ist wie eine Kachel.
|
||||
public enum Radius {
|
||||
/// Vorschaubilder, Farbmarken, kleine Plättchen.
|
||||
/// Ein Balken — Fortschritt, Pegel, Verlauf.
|
||||
///
|
||||
/// So klein, dass es nur darum geht, die Ecke nicht scharf stehen zu
|
||||
/// lassen. Vorher standen hier 1, 2 und 3 nebeneinander, was an einem
|
||||
/// 4 Punkte hohen Balken niemand unterscheiden kann.
|
||||
public static let bar: CGFloat = 2
|
||||
|
||||
/// Ein Vorschaubild.
|
||||
public static let thumbnail: CGFloat = 6
|
||||
|
||||
public static let chip: CGFloat = 4
|
||||
/// Knöpfe, Felder, Regler.
|
||||
public static let control: CGFloat = 8
|
||||
/// Eine Karte **innerhalb** einer Kachel.
|
||||
public static let card: CGFloat = 12
|
||||
/// Die Kachel selbst.
|
||||
public static let tile: CGFloat = 16
|
||||
/// Das ausgefahrene Panel.
|
||||
public static let panel: CGFloat = 26
|
||||
/// Die Unterkante der zusammengezogenen Notch.
|
||||
///
|
||||
/// Kleiner als `panel`: ein fester Radius lässt die kleine Form
|
||||
/// aufgeblasen aussehen.
|
||||
public static let notch: CGFloat = 10
|
||||
}
|
||||
|
||||
// MARK: - Maße
|
||||
|
||||
/// Was das Panel als Ganzes ausmisst — keine Rhythmusfrage, sondern
|
||||
/// Geometrie.
|
||||
///
|
||||
/// `panelPadding` und `tileSpacing` gehen in die Größenrechnung von
|
||||
/// `LayoutEngine` ein und stehen so in dessen Tests. Sie gehören deshalb
|
||||
/// nicht in `Space`: wer dort die Staffel verschiebt, soll nicht aus
|
||||
/// Versehen die Panelbreite ändern.
|
||||
public enum Metric {
|
||||
public static let panelCornerRadius: CGFloat = 26
|
||||
public static let tileCornerRadius: CGFloat = 16
|
||||
public static let panelPadding: CGFloat = 16
|
||||
public static let tileSpacing: CGFloat = 10
|
||||
public static let hairlineWidth: CGFloat = 1
|
||||
@@ -58,31 +154,89 @@ public enum Onyx {
|
||||
|
||||
// MARK: - Bewegung
|
||||
|
||||
/// Die Kurven — eingestellt über das Bewegungsprofil in `OnyxTheme`.
|
||||
///
|
||||
/// Gelesen statt gespeichert, aus demselben Grund wie bei der Akzentfarbe:
|
||||
/// so kommt ein Profilwechsel an allen Fundstellen an, ohne dass eine
|
||||
/// einzige davon davon weiß.
|
||||
public enum Motion {
|
||||
/// Ausfahren: ein Spring mit einem Hauch Nachschwingen. Das gibt dem
|
||||
/// Panel Masse, statt es nur größer werden zu lassen.
|
||||
public static let expand = Animation.spring(response: 0.34, dampingFraction: 0.86)
|
||||
|
||||
/// Einfahren: kürzer und ohne Nachschwingen.
|
||||
///
|
||||
/// Schließen muss schneller sein als Öffnen. Beim Öffnen sieht man zu,
|
||||
/// beim Schließen ist man schon woanders — dieselbe Kurve rückwärts
|
||||
/// fühlt sich dort zäh an, und das Nachschwingen liest sich als Ruckler.
|
||||
public static let collapse = Animation.spring(response: 0.24, dampingFraction: 1.0)
|
||||
/// Ausfahren.
|
||||
@MainActor public static var expand: Animation { OnyxTheme.shared.motion.expand }
|
||||
/// Einfahren.
|
||||
@MainActor public static var collapse: Animation { OnyxTheme.shared.motion.collapse }
|
||||
/// Für Messwerte: sichtbar, aber ohne Pulsieren.
|
||||
public static let value = Animation.easeOut(duration: 0.25)
|
||||
@MainActor public static var value: Animation { OnyxTheme.shared.motion.value }
|
||||
}
|
||||
|
||||
// MARK: - Typografie
|
||||
|
||||
/// Die Schriftstaffel.
|
||||
///
|
||||
/// **Warum sie größer geworden ist.** Vorher gab es fünf Stufen, und zwei
|
||||
/// davon benutzte niemand. Daneben standen rund hundert Aufrufe von
|
||||
/// `.font(.system(size:))` — allein Größe 9 dreizehnmal. Das war kein
|
||||
/// Ungehorsam: die Staffel hatte am unteren Ende schlicht nichts anzubieten,
|
||||
/// und eine Kachel von 180 × 200 Punkt lebt dort unten.
|
||||
/// Wie groß ein Symbol gezeichnet wird.
|
||||
///
|
||||
/// **Eine eigene Staffel, nicht die Schriftstaffel.** Symbole und Text
|
||||
/// teilen sich zwar `.font(.system(size:))`, aber nicht die Frage: bei Text
|
||||
/// entscheidet die Größe über Lesbarkeit und Rang, bei einem Symbol über
|
||||
/// sein Gewicht im Bild. Ein 9-Punkt-Symbol neben 9-Punkt-Text wirkt
|
||||
/// kleiner als der Text, nicht gleich groß. Sie in eine Staffel zu zwingen
|
||||
/// hieße, an jeder Stelle gegen sie zu arbeiten.
|
||||
///
|
||||
/// Vorher standen hier zwölf verschiedene Werte von 7 bis 34 — 7, 8, 10, 12,
|
||||
/// 15, 16, 18 waren keine Entscheidungen, sondern das, was gerade passte.
|
||||
///
|
||||
/// Nur die Größe, nicht das Gewicht: `.light` an einem großen Symbol und
|
||||
/// `.bold` an einem winzigen sind Entscheidungen am Ort, keine Stufen.
|
||||
public enum Symbol {
|
||||
/// Marke neben kleinem Text — das Blatt am Energiesparmodus.
|
||||
public static let caption: CGFloat = 9
|
||||
/// Das Symbol einer Zeile.
|
||||
public static let small: CGFloat = 11
|
||||
/// Das Symbol eines Knopfes.
|
||||
public static let medium: CGFloat = 13
|
||||
/// Das Symbol eines leeren Zustands.
|
||||
public static let large: CGFloat = 16
|
||||
/// Das Symbol, das eine Kachel trägt.
|
||||
public static let xlarge: CGFloat = 20
|
||||
/// Das eine große Symbol — Wetterlage.
|
||||
public static let hero: CGFloat = 34
|
||||
}
|
||||
|
||||
public enum Font {
|
||||
public static let title = SwiftUI.Font.system(size: 13, weight: .semibold)
|
||||
public static let body = SwiftUI.Font.system(size: 12, weight: .regular)
|
||||
|
||||
// Text
|
||||
|
||||
/// Der Arbeitswert in der Kachel: Beschriftungen an Werten, Wochentage,
|
||||
/// Einheiten.
|
||||
public static let micro = SwiftUI.Font.system(size: 9, weight: .medium)
|
||||
public static let caption = SwiftUI.Font.system(size: 10, weight: .medium)
|
||||
/// Für alle Messwerte — ohne feste Ziffernbreite zappeln die Zahlen.
|
||||
public static let metric = SwiftUI.Font.system(size: 20, weight: .medium)
|
||||
public static let footnote = SwiftUI.Font.system(size: 11, weight: .regular)
|
||||
public static let body = SwiftUI.Font.system(size: 12, weight: .regular)
|
||||
/// Kachelüberschriften und Abschnittstitel.
|
||||
public static let title = SwiftUI.Font.system(size: 13, weight: .semibold)
|
||||
public static let headline = SwiftUI.Font.system(size: 15, weight: .medium)
|
||||
public static let display = SwiftUI.Font.system(size: 20, weight: .medium)
|
||||
public static let displayLarge = SwiftUI.Font.system(size: 30, weight: .medium)
|
||||
|
||||
// Zahlen — ohne feste Ziffernbreite zappeln sie beim Zählen
|
||||
|
||||
public static let metricMicro = SwiftUI.Font.system(size: 9, weight: .medium)
|
||||
.monospacedDigit()
|
||||
public static let metricSmall = SwiftUI.Font.system(size: 11, weight: .medium)
|
||||
.monospacedDigit()
|
||||
public static let metric = SwiftUI.Font.system(size: 20, weight: .medium)
|
||||
.monospacedDigit()
|
||||
public static let metricLarge = SwiftUI.Font.system(size: 30, weight: .medium)
|
||||
.monospacedDigit()
|
||||
|
||||
/// Das eine große Symbol einer Kachel — Wetterlage, Ladezustand.
|
||||
///
|
||||
/// Die einzige Größe, die kein Textgegenstück hat: hier steht ein Bild,
|
||||
/// keine Zeile.
|
||||
public static let symbolHero = SwiftUI.Font.system(size: 34, weight: .regular)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
@testable import OnyxDesign
|
||||
|
||||
@Suite("Bewegungsprofil")
|
||||
@MainActor
|
||||
struct MotionProfileTests {
|
||||
|
||||
private func freshDefaults(_ name: String) -> UserDefaults {
|
||||
let suite = "onyx.motion.test.\(name)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defaults.removePersistentDomain(forName: suite)
|
||||
return defaults
|
||||
}
|
||||
|
||||
@Test("Ohne Einstellung ist es schnell")
|
||||
func defaultsToSnappy() {
|
||||
let theme = OnyxTheme(defaults: freshDefaults(#function))
|
||||
#expect(theme.motion == .snappy)
|
||||
}
|
||||
|
||||
@Test("Das gewählte Profil übersteht den Neustart")
|
||||
func choiceSurvivesRestart() {
|
||||
let defaults = freshDefaults(#function)
|
||||
let theme = OnyxTheme(defaults: defaults)
|
||||
theme.motion = .calm
|
||||
|
||||
let restarted = OnyxTheme(defaults: defaults)
|
||||
#expect(restarted.motion == .calm)
|
||||
}
|
||||
|
||||
/// Ein Profilname, den diese Fassung nicht kennt, darf die Bewegung nicht
|
||||
/// ausfallen lassen — eine App ohne Animation sieht aus, als hinge sie.
|
||||
@Test("Ein unbekannter Name fällt auf die Voreinstellung zurück")
|
||||
func unknownNameFallsBack() {
|
||||
let defaults = freshDefaults(#function)
|
||||
defaults.set("hyperspeed", forKey: "onyx.theme.motion")
|
||||
|
||||
#expect(OnyxTheme(defaults: defaults).motion == .snappy)
|
||||
}
|
||||
|
||||
/// Der Zurücksetzen-Knopf steht bei den Farben. Nähme er das
|
||||
/// Bewegungsprofil mit, stellte er etwas um, wovon in seiner Beschriftung
|
||||
/// nichts steht.
|
||||
@Test("Farben zurücksetzen lässt das Bewegungsprofil in Ruhe")
|
||||
func resetLeavesMotionAlone() {
|
||||
let theme = OnyxTheme(defaults: freshDefaults(#function))
|
||||
theme.motion = .bouncy
|
||||
theme.reset()
|
||||
|
||||
#expect(theme.motion == .bouncy)
|
||||
#expect(theme.isDefault)
|
||||
}
|
||||
|
||||
/// Beim Öffnen sieht man zu, beim Schließen ist man schon woanders.
|
||||
@Test("Einfahren ist in jedem Profil kürzer als Ausfahren")
|
||||
func collapseIsShorterThanExpand() {
|
||||
// Die Federantwort steckt in der Animation und ist von außen nicht
|
||||
// lesbar. Prüfbar ist die Dauer, die der Fenstercontroller benutzt —
|
||||
// und die ist genau der Wert, an dem es sichtbar schiefgeht.
|
||||
for profile in MotionProfile.allCases {
|
||||
#expect(profile.collapseDuration > 0)
|
||||
#expect(profile.collapseDuration < 1)
|
||||
}
|
||||
#expect(MotionProfile.snappy.collapseDuration < MotionProfile.calm.collapseDuration)
|
||||
}
|
||||
|
||||
/// Verschwindet das Fenster vor dem Ende der Bewegung, reißt das Panel
|
||||
/// sichtbar weg. Genau dafür hängt die Dauer am Profil und ist keine feste
|
||||
/// Zahl mehr.
|
||||
@Test("Jedes Profil bringt seine eigene Einfahrdauer mit")
|
||||
func everyProfileCarriesItsOwnDuration() {
|
||||
let durations = Set(MotionProfile.allCases.map(\.collapseDuration))
|
||||
#expect(durations.count == MotionProfile.allCases.count)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("Pegel im Regler")
|
||||
struct MeterFractionTests {
|
||||
|
||||
/// 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, und genau das ist hier zu prüfen — sonst ist der Regler ein
|
||||
/// Balken, der die meiste Zeit leer aussieht.
|
||||
@Test("Leiser Ton schlägt sichtbar aus")
|
||||
func quietSignalIsVisible() {
|
||||
// Ein Viertel der Amplitude sähe linear nach 25 % aus. Die Wurzel macht
|
||||
// daraus die Hälfte.
|
||||
#expect(abs(OnyxSlider.meterFraction(0.25) - 0.5) < 0.0001)
|
||||
}
|
||||
|
||||
@Test("Die Enden bleiben, wo sie sind")
|
||||
func endsAreUntouched() {
|
||||
#expect(OnyxSlider.meterFraction(0) == 0)
|
||||
#expect(OnyxSlider.meterFraction(1) == 1)
|
||||
}
|
||||
|
||||
/// Der Ausschlag kommt aus einem Tap und ist nicht garantiert im Bereich.
|
||||
/// Ein Wert über 1 machte den Pegelbalken breiter als den Regler, ein
|
||||
/// negativer ließe `sqrt` NaN liefern — und NaN in einer Breitenangabe
|
||||
/// beendet SwiftUI.
|
||||
@Test("Werte außerhalb des Bereichs werden gekappt")
|
||||
func outOfRangeIsClamped() {
|
||||
#expect(OnyxSlider.meterFraction(4) == 1)
|
||||
#expect(OnyxSlider.meterFraction(-1) == 0)
|
||||
#expect(!OnyxSlider.meterFraction(-1).isNaN)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import Testing
|
||||
import SwiftUI
|
||||
@testable import OnyxDesign
|
||||
|
||||
@Suite("Werkstoffe")
|
||||
@MainActor
|
||||
struct NotchMaterialTests {
|
||||
|
||||
@Test("Stein ist deckend — sonst hätte er an der Hardware-Notch eine Kante")
|
||||
func stoneIsOpaque() {
|
||||
#expect(NotchMaterial.stone.fillOpacity == 1.0)
|
||||
// Und braucht deshalb keine Weichzeichnung darunter.
|
||||
#expect(!NotchMaterial.stone.showsVibrancy)
|
||||
}
|
||||
|
||||
@Test("Je durchlässiger, desto weniger Deckkraft")
|
||||
func opacityDecreasesWithTranslucency() {
|
||||
#expect(NotchMaterial.stone.fillOpacity > NotchMaterial.blur.fillOpacity)
|
||||
#expect(NotchMaterial.blur.fillOpacity > NotchMaterial.glass.fillOpacity)
|
||||
}
|
||||
|
||||
@Test("Je durchlässiger, desto mehr Glanz")
|
||||
func sheenGrowsWithTranslucency() {
|
||||
// Auf deckendem Schwarz genügt ein Hauch; auf Glas ist der Glanz das,
|
||||
// was die Fläche überhaupt als Körper lesbar macht.
|
||||
#expect(NotchMaterial.stone.sheen < NotchMaterial.blur.sheen)
|
||||
#expect(NotchMaterial.blur.sheen < NotchMaterial.glass.sheen)
|
||||
}
|
||||
|
||||
@Test("Alle durchscheinenden Werkstoffe brauchen die Vibrancy-Ebene")
|
||||
func translucentMaterialsNeedVibrancy() {
|
||||
#expect(NotchMaterial.blur.showsVibrancy)
|
||||
#expect(NotchMaterial.glass.showsVibrancy)
|
||||
}
|
||||
|
||||
@Test("Kein Werkstoff ist unsichtbar")
|
||||
func nothingIsFullyTransparent() {
|
||||
// Eine Fläche mit Deckkraft null wäre kein Werkstoff, sondern ein
|
||||
// fehlendes Panel — und niemand fände die Einstellung zurück.
|
||||
for material in NotchMaterial.allCases {
|
||||
#expect(material.fillOpacity > 0.3)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Jeder Werkstoff überlebt das Speichern")
|
||||
func roundTripsThroughDefaults() {
|
||||
for material in NotchMaterial.allCases {
|
||||
#expect(NotchMaterial(rawValue: material.rawValue) == material)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("Ein unbekannter Name fällt auf Stein zurück, nicht ins Nichts")
|
||||
func unknownNameFallsBack() {
|
||||
let defaults = UserDefaults(suiteName: "onyx.test.material")!
|
||||
defaults.removePersistentDomain(forName: "onyx.test.material")
|
||||
defaults.set("liquidUnobtainium", forKey: "onyx.theme.material")
|
||||
#expect(OnyxTheme(defaults: defaults).material == .stone)
|
||||
}
|
||||
}
|
||||
@@ -74,3 +74,30 @@ struct ColourHexTests {
|
||||
#expect(Color(onyxHex: "#GGGGGG") == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@Suite("Farben, die sich nicht gleichen dürfen")
|
||||
@MainActor
|
||||
struct ColourCollisionTests {
|
||||
|
||||
/// Beide waren zeichengleich `#6FCF97`. Damit hieß derselbe grüne Balken im
|
||||
/// Netzwerk „lädt herunter" und in den Messwerten „alles in Ordnung" — und
|
||||
/// wer beides nebeneinander sieht, liest eine Aussage, die niemand gemeint
|
||||
/// hat.
|
||||
@Test("Herunterladen ist nicht die Signalfarbe für „in Ordnung“")
|
||||
func downloadIsNotThePositiveSignal() {
|
||||
#expect(OnyxTheme.defaultDownload.onyxHex != Onyx.Color.positive.onyxHex)
|
||||
}
|
||||
|
||||
@Test("Herunter und Hoch sind auch untereinander unterscheidbar")
|
||||
func directionsDifferFromEachOther() {
|
||||
#expect(OnyxTheme.defaultDownload.onyxHex != OnyxTheme.defaultUpload.onyxHex)
|
||||
}
|
||||
|
||||
/// Die drei Signalfarben tragen die Bedeutung. Fielen zwei davon zusammen,
|
||||
/// wäre eine Warnung von einem Fehler nicht mehr zu unterscheiden.
|
||||
@Test("Die Signalfarben sind paarweise verschieden")
|
||||
func signalColoursAreDistinct() {
|
||||
let signals = [Onyx.Color.positive, Onyx.Color.warning, Onyx.Color.critical]
|
||||
#expect(Set(signals.map(\.onyxHex)).count == signals.count)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user