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