Files
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

354 lines
13 KiB
Swift

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