import SwiftUI import OnyxDesign import OnyxWidgetKit /// Das Schaufenster: jede Kachel, das ganze Panel und alle Token — ohne die /// Notch anzufahren. /// /// **Warum ein Fenster und keine `#Preview`.** Der Baum setzt /// `ENABLE_DEBUG_DYLIB = NO`, weil Xcode den Programmcode sonst in eine eigene /// dylib auslagert und TCC die Berechtigungsanfragen dem verbleibenden Rumpf /// zuordnet, der weder Info.plist noch Entitlements trägt. Genau diese /// Auslagerung brauchen SwiftUIs Vorschauen aber. Die Vorschau ist hier also /// nicht vergessen worden, sie ist der Preis einer Entscheidung, die vorher /// fiel. Ein Fenster kostet ihn nicht — und zeigt zudem die echten Modelle /// statt erfundener. /// /// **Nicht lokalisiert, und das mit Absicht.** Das Schaufenster ist Werkzeug, /// keine Programmoberfläche. Seine Beschriftungen im Sprachkatalog hätten /// dieselbe Sichtbarkeit wie die der Einstellungen und wären doch nie zu /// übersetzen. struct GalleryView: View { let model: AppModel /// Bleibt stehen, wo man zuletzt war. /// /// Nicht nur bequem: `UserDefaults` liest Startargumente mit, also führt /// `open Onyx.app --args -onyx.gallery.section tokens` direkt in den /// Bereich, an dem man gerade arbeitet. Bauen, starten, hinsehen — ohne /// Zwischenklick. @AppStorage("onyx.gallery.section") private var section: GallerySection = .tiles var body: some View { VStack(spacing: 0) { Picker("", selection: $section) { ForEach(GallerySection.allCases) { Text($0.title).tag($0) } } .pickerStyle(.segmented) .labelsHidden() .padding(.horizontal, Onyx.Space.window) .padding(.top, Onyx.Space.row) .padding(.bottom, Onyx.Space.xl) Divider() ScrollView { switch section { case .tiles: TileGallery() case .panel: PanelGallery(model: model) case .components: ComponentGallery() case .tokens: TokenGallery() } } .background(Color(nsColor: .windowBackgroundColor)) } .frame(minWidth: 760, minHeight: 520) // Im Rumpf gelesen, damit ein Farbwechsel ankommt — wie im Panel. .tint(Onyx.Color.accent) .labelStyle(.onyx) } } enum GallerySection: String, CaseIterable, Identifiable { case tiles, panel, components, tokens var id: Self { self } var title: String { switch self { case .tiles: "Kacheln" case .panel: "Panel" case .components: "Bauteile" case .tokens: "Token" } } } // MARK: - Kacheln /// Jede angemeldete Kachel in echter Größe auf echter Paneloberfläche. /// /// Echte Größe und echte Fläche sind der ganze Zweck: eine Kachel, die man in /// einem beliebig breiten Fenster beurteilt, sieht dort immer gut aus. 180 × 200 /// auf schwarzem Stein ist der Ort, an dem sie später steht. private struct TileGallery: View { private struct Entry: Identifiable { let id: String let widget: any OnyxWidget } var body: some View { let entries = WidgetRegistry.shared.all.map { Entry(id: $0.id, widget: $0) } VStack(alignment: .leading, spacing: Onyx.Space.lg) { if entries.isEmpty { Text("Keine Kacheln angemeldet.") .font(.callout) .foregroundStyle(.secondary) } else { LazyVGrid( columns: [GridItem(.adaptive(minimum: LayoutEngine.tileSize.width, maximum: LayoutEngine.tileSize.width), spacing: Onyx.Metric.tileSpacing)], alignment: .leading, spacing: Onyx.Space.xxl ) { ForEach(entries) { entry in VStack(spacing: Onyx.Space.sm) { // Dieselbe Reihenfolge wie in `WidgetGrid`: Kachel, // dann Rahmen, dann beschneiden. Wer erst // beschneidet, sieht Überlauf nicht, den das Panel // später zeigt. OnyxTile { entry.widget.makeView() } .frame(width: LayoutEngine.tileSize.width, height: LayoutEngine.tileSize.height) .clipped() Text(entry.id) .font(.caption) .foregroundStyle(.secondary) } } } .padding(Onyx.Metric.panelPadding) .background(OnyxSurface()) } } .frame(maxWidth: .infinity, alignment: .leading) .padding(Onyx.Space.window) } } // MARK: - Panel /// Das ganze Panel, so breit, wie man es einstellt. /// /// Die Breite ist regelbar, weil die Spaltenzahl an ihr hängt: `LayoutEngine` /// rechnet aus der verfügbaren Breite, wie viele Kacheln nebeneinander passen, /// und ab wann eine zweite Reihe entsteht. Genau diese Sprünge will man sehen, /// ohne dafür einen Bildschirm anzustecken. private struct PanelGallery: View { let model: AppModel @State private var availableWidth: Double = 1680 var body: some View { let width = CGFloat(availableWidth) let size = WidgetGrid.panelSize(for: model.layout, availableWidth: width) VStack(alignment: .leading, spacing: Onyx.Space.xl) { HStack(spacing: Onyx.Space.lg) { Text("Verfügbare Breite") .font(.callout) Slider(value: $availableWidth, in: 400...3000) .frame(maxWidth: 320) Text("\(Int(availableWidth)) pt") .font(.callout.monospacedDigit()) .foregroundStyle(.secondary) } Text("Panel \(Int(size.width)) × \(Int(size.height)) pt · " + "\(LayoutEngine.columns(availableWidth: width)) Spalten · " + "\(model.layout.count) Kacheln") .font(.caption.monospacedDigit()) .foregroundStyle(.secondary) WidgetGrid(placements: model.layout, availableWidth: width) .background(OnyxSurface()) } .frame(maxWidth: .infinity, alignment: .leading) .padding(Onyx.Space.window) } } // MARK: - Bauteile /// Jedes Bauteil in jedem Zustand. /// /// Das ist der Teil, den es bei den Kacheln noch nicht gibt: deren Modelle /// führen ihren Zustand als `private(set)`, „leer" oder „Fehler" lässt sich von /// außen nicht setzen. Bei den Bauteilen ist der Zustand ein Argument — hier /// kann man ihn also einfach hinschreiben. private struct ComponentGallery: View { @State private var volume = 0.62 @State private var overdrive = 1.8 @State private var targeted = true var body: some View { VStack(alignment: .leading, spacing: Onyx.Space.xxl) { group("OnyxCard mit OnyxHairline und OnyxSectionTitle") { VStack(alignment: .leading, spacing: Onyx.Space.md) { OnyxSectionTitle("System") OnyxCard { VStack(spacing: 0) { row("MacBook Pro-Lautsprecher", "41 %") OnyxHairline() row("MacBook Pro-Mikrofon", "27 %") } } } .frame(width: 260) } group("OnyxNotice") { HStack(alignment: .top, spacing: Onyx.Space.lg) { tile { OnyxNotice(symbol: "wifi.slash", text: "Kein Netz erreichbar.") } tile { OnyxNotice(symbol: "hand.raised", text: "Der Helfer wartet auf deine Freigabe.") { OnyxPillButton("Einstellungen") {} } } tile { OnyxNotice(symbol: "fan", text: "Noch keine Lüfterdaten.", placement: .leading) } tile { OnyxNotice(symbol: "fan", text: "Der Helfer ist nicht installiert.", placement: .leading) { OnyxPillButton("Installieren") {} } } } } group("OnyxPillButton") { HStack(spacing: Onyx.Space.lg) { OnyxPillButton("Einschalten") {} OnyxPillButton("Automatik", isFilled: false) {} OnyxPillButton("Auto", size: .compact) {} OnyxPillButton("Auto", isFilled: false, size: .compact) {} } } group("OnyxSlider") { VStack(alignment: .leading, spacing: Onyx.Space.lg) { sliderRow("normal") { OnyxSlider(value: volume) { volume = $0 } } sliderRow("mit Pegel") { OnyxSlider(value: volume, level: 0.35) { volume = $0 } } sliderRow("stumm") { OnyxSlider(value: volume, isActive: false) { volume = $0 } } sliderRow("Overdrive") { OnyxSlider(value: overdrive, maximum: 4, level: 0.5) { overdrive = $0 } } } .frame(width: 320) } group("OnyxDropZone") { VStack(alignment: .leading, spacing: Onyx.Space.lg) { Toggle("Zeiger schwebt darüber", isOn: $targeted) .font(Onyx.Font.body) .foregroundStyle(Onyx.Color.textSecondary) HStack(spacing: Onyx.Space.lg) { tile { OnyxNotice(symbol: "paperplane", text: "Zum Senden ablegen") .onyxDropZone(isTargeted: targeted, style: .always) } tile { OnyxNotice(symbol: "tray", text: "Nimmt nebenbei Dateien an") .onyxDropZone(isTargeted: targeted, style: .whenTargeted) } } } } } .frame(maxWidth: .infinity, alignment: .leading) .padding(Onyx.Metric.panelPadding) .background(OnyxSurface()) .padding(Onyx.Space.window) } private func row(_ name: String, _ value: String) -> some View { HStack { Text(name) .font(Onyx.Font.caption) .foregroundStyle(Onyx.Color.textSecondary) Spacer(minLength: Onyx.Space.md) Text(value) .font(Onyx.Font.metricSmall) .foregroundStyle(Onyx.Color.textTertiary) } .padding(.horizontal, Onyx.Space.md) .padding(.vertical, Onyx.Space.md) } /// Bauteile in einer echten Kachel zeigen, nicht frei auf der Fläche: ein /// Leerzustand, der sich mittig ausrichtet, braucht etwas, worin er das tun /// kann. private func tile(@ViewBuilder content: () -> some View) -> some View { OnyxTile { content() } .frame(width: LayoutEngine.tileSize.width, height: 130) } private func sliderRow(_ name: String, @ViewBuilder content: () -> some View) -> some View { HStack(spacing: Onyx.Space.lg) { Text(name) .font(Onyx.Font.micro) .foregroundStyle(Onyx.Color.textTertiary) .frame(width: 72, alignment: .leading) content() } } private func group(_ title: String, @ViewBuilder content: () -> some View) -> some View { VStack(alignment: .leading, spacing: Onyx.Space.lg) { Text(title) .font(Onyx.Font.title) .foregroundStyle(Onyx.Color.textPrimary) content() } } } // MARK: - Token /// Alle Token an einem Ort — auf der Fläche, für die sie gemacht sind. /// /// Farben auf hellem Fensterhintergrund zu zeigen wäre irreführend: `hairline` /// ist Weiß mit 8 % Deckkraft und auf Weiß schlicht nicht da. private struct TokenGallery: View { @State private var moved = false var body: some View { VStack(alignment: .leading, spacing: Onyx.Space.xxl) { colors spaces radii fonts metrics motion } .frame(maxWidth: .infinity, alignment: .leading) .padding(Onyx.Metric.panelPadding) .background(OnyxSurface()) .padding(Onyx.Space.window) } private var colors: some View { let swatches: [(String, Color)] = [ ("surface", Onyx.Color.surface), ("elevated", Onyx.Color.elevated), ("hairline", Onyx.Color.hairline), ("textPrimary", Onyx.Color.textPrimary), ("textSecondary", Onyx.Color.textSecondary), ("textTertiary", Onyx.Color.textTertiary), ("accent", Onyx.Color.accent), ("download", Onyx.Color.download), ("upload", Onyx.Color.upload), ("positive", Onyx.Color.positive), ("warning", Onyx.Color.warning), ("critical", Onyx.Color.critical), ] return section("Farben") { LazyVGrid(columns: [GridItem(.adaptive(minimum: 120, maximum: 160), spacing: Onyx.Space.row)], alignment: .leading, spacing: Onyx.Space.row) { ForEach(swatches, id: \.0) { name, color in HStack(spacing: Onyx.Space.md) { RoundedRectangle(cornerRadius: Onyx.Radius.thumbnail, style: .continuous) .fill(color) .overlay { RoundedRectangle(cornerRadius: Onyx.Radius.thumbnail, style: .continuous) .strokeBorder(Onyx.Color.hairline, lineWidth: Onyx.Metric.hairlineWidth) } .frame(width: 28, height: 28) Text(name) .font(Onyx.Font.caption) .foregroundStyle(Onyx.Color.textSecondary) } } } } } private var spaces: some View { let steps: [(String, CGFloat)] = [ ("xxs", Onyx.Space.xxs), ("xs", Onyx.Space.xs), ("sm", Onyx.Space.sm), ("md", Onyx.Space.md), ("lg", Onyx.Space.lg), ("xl", Onyx.Space.xl), ("xxl", Onyx.Space.xxl), ] return section("Abstände") { VStack(alignment: .leading, spacing: Onyx.Space.md) { ForEach(steps, id: \.0) { name, value in HStack(spacing: Onyx.Space.lg) { Text(name) .font(Onyx.Font.micro) .foregroundStyle(Onyx.Color.textTertiary) .frame(width: 40, alignment: .leading) Rectangle() .fill(Onyx.Color.accent) .frame(width: value, height: 12) Text("\(Int(value))") .font(Onyx.Font.metricMicro) .foregroundStyle(Onyx.Color.textSecondary) } } } } } private var radii: some View { let steps: [(String, CGFloat)] = [ ("chip", Onyx.Radius.chip), ("control", Onyx.Radius.control), ("card", Onyx.Radius.card), ("tile", Onyx.Radius.tile), ("notch", Onyx.Radius.notch), ("panel", Onyx.Radius.panel), ] return section("Rundungen") { HStack(alignment: .top, spacing: Onyx.Space.lg) { ForEach(steps, id: \.0) { name, value in VStack(spacing: Onyx.Space.sm) { RoundedRectangle(cornerRadius: value, style: .continuous) .fill(Onyx.Color.elevated) .overlay { RoundedRectangle(cornerRadius: value, style: .continuous) .strokeBorder(Onyx.Color.hairline, lineWidth: Onyx.Metric.hairlineWidth) } .frame(width: 64, height: 64) Text("\(name) \(Int(value))") .font(Onyx.Font.micro) .foregroundStyle(Onyx.Color.textTertiary) } } } } } private var fonts: some View { let specimens: [(String, Font)] = [ ("micro", Onyx.Font.micro), ("caption", Onyx.Font.caption), ("footnote", Onyx.Font.footnote), ("body", Onyx.Font.body), ("title", Onyx.Font.title), ("headline", Onyx.Font.headline), ("display", Onyx.Font.display), ("displayLarge", Onyx.Font.displayLarge), ("metricMicro", Onyx.Font.metricMicro), ("metricSmall", Onyx.Font.metricSmall), ("metric", Onyx.Font.metric), ("metricLarge", Onyx.Font.metricLarge), ("symbolHero", Onyx.Font.symbolHero), ] return section("Schrift") { VStack(alignment: .leading, spacing: Onyx.Space.md) { ForEach(specimens, id: \.0) { name, font in HStack(alignment: .firstTextBaseline, spacing: Onyx.Space.lg) { Text(name) .font(Onyx.Font.micro) .foregroundStyle(Onyx.Color.textTertiary) .frame(width: 96, alignment: .leading) Text("Onyx 0123456789") .font(font) .foregroundStyle(Onyx.Color.textPrimary) } } } } } private var metrics: some View { let values: [(String, CGFloat)] = [ ("panelPadding", Onyx.Metric.panelPadding), ("tileSpacing", Onyx.Metric.tileSpacing), ("hairlineWidth", Onyx.Metric.hairlineWidth), ] return section("Panelgeometrie") { VStack(alignment: .leading, spacing: Onyx.Space.md) { ForEach(values, id: \.0) { name, value in HStack(spacing: Onyx.Space.lg) { Text(name) .font(Onyx.Font.micro) .foregroundStyle(Onyx.Color.textTertiary) .frame(width: 96, alignment: .leading) Rectangle() .fill(Onyx.Color.accent) .frame(width: max(value, 1), height: 12) Text("\(Int(value))") .font(Onyx.Font.metricMicro) .foregroundStyle(Onyx.Color.textSecondary) } } } } } /// Bewegung lässt sich nicht abbilden, nur vorführen — deshalb ein Knopf /// und ein echter Profilwechsel statt einer Tabelle mit Federkonstanten. private var motion: some View { let curves: [(String, Animation)] = [ ("expand", Onyx.Motion.expand), ("collapse", Onyx.Motion.collapse), ("value", Onyx.Motion.value), ] return section("Bewegung") { VStack(alignment: .leading, spacing: Onyx.Space.lg) { Picker("Profil", selection: Binding( get: { OnyxTheme.shared.motion }, set: { OnyxTheme.shared.motion = $0 })) { ForEach(MotionProfile.allCases, id: \.self) { profile in Text(profile.rawValue).tag(profile) } } .pickerStyle(.segmented) .labelsHidden() .frame(width: 280) ForEach(curves, id: \.0) { name, animation in HStack(spacing: Onyx.Space.lg) { Text(name) .font(Onyx.Font.micro) .foregroundStyle(Onyx.Color.textTertiary) .frame(width: 96, alignment: .leading) ZStack(alignment: .leading) { Capsule() .fill(Onyx.Color.hairline) .frame(height: 4) Circle() .fill(Onyx.Color.accent) .frame(width: 14, height: 14) .offset(x: moved ? 246 : 0) .animation(animation, value: moved) } .frame(width: 260, height: 14) } } Button(moved ? "Zurück" : "Los") { moved.toggle() } } } } @ViewBuilder private func section(_ title: String, @ViewBuilder content: () -> some View) -> some View { VStack(alignment: .leading, spacing: Onyx.Space.lg) { Text(title) .font(Onyx.Font.title) .foregroundStyle(Onyx.Color.textPrimary) content() } } }