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