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>
354 lines
13 KiB
Swift
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))
|
|
}
|
|
}
|