From 9ac8df7b9d1ed7f0c708bc49ce92ae4969a439fd Mon Sep 17 00:00:00 2001 From: Theo Weijmer Date: Thu, 1 Oct 2026 15:29:33 +0200 Subject: [PATCH] feat: add component gallery with snapshot mode Co-Authored-By: Claude Sonnet 5.5 --- Package.swift | 1 + Sources/SstGallery/GalleryApp.swift | 138 ++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 Sources/SstGallery/GalleryApp.swift diff --git a/Package.swift b/Package.swift index f7272f3..098a8bb 100644 --- a/Package.swift +++ b/Package.swift @@ -20,6 +20,7 @@ let package = Package( ), .executableTarget(name: "Sst", dependencies: ["SstCore", "SstWhisper"]), .executableTarget(name: "SstProbe", dependencies: ["SstCore", "SstWhisper"]), + .executableTarget(name: "SstGallery", dependencies: ["SstUI"]), .testTarget(name: "SstCoreTests", dependencies: ["SstCore"]), .testTarget(name: "SstDataTests", dependencies: ["SstData", "SstCore"]), .testTarget(name: "SstWhisperTests", dependencies: ["SstWhisper"]), diff --git a/Sources/SstGallery/GalleryApp.swift b/Sources/SstGallery/GalleryApp.swift new file mode 100644 index 0000000..cd9f0da --- /dev/null +++ b/Sources/SstGallery/GalleryApp.swift @@ -0,0 +1,138 @@ +import SwiftUI +import SstUI + +@main +struct GalleryApp: App { + init() { + // `SstGallery --snapshot out.png` renders both finishes to a PNG and exits (for checking without a screen grab). + let args = CommandLine.arguments + guard let flag = args.firstIndex(of: "--snapshot"), args.count > flag + 1 else { return } + let path = args[flag + 1] + Task { @MainActor in + let view = HStack(spacing: 0) { + Finish(scheme: .light, title: "Aluminium", frozen: true) + Finish(scheme: .dark, title: "Graphite", frozen: true) + } + .frame(width: Layout.windowDefaultWidth, height: Layout.windowDefaultHeight) + let renderer = ImageRenderer(content: view) + renderer.scale = 2 + if let tiff = renderer.nsImage?.tiffRepresentation, let png = NSBitmapImageRep(data: tiff)?.representation(using: .png, properties: [:]) { + try? png.write(to: URL(fileURLWithPath: path)) + } + exit(0) + } + } + + var body: some Scene { + WindowGroup("Sst componenten") { + HStack(spacing: 0) { + Finish(scheme: .light, title: "Aluminium") + Finish(scheme: .dark, title: "Graphite") + } + .frame(minWidth: Layout.windowMinWidth, minHeight: Layout.windowMinHeight) + } + .defaultSize(width: Layout.windowDefaultWidth, height: Layout.windowDefaultHeight) + } +} + +/// Fake speech level so the meter, LEDs and counter can be judged in motion. +private func simulatedDbfs(at t: Double) -> Double { + if t.truncatingRemainder(dividingBy: 9) > 6.2 { return -90 } + let syllable = floor(t * 4.2) + let r = abs(sin(syllable * 12.9898) * 43758.5453).truncatingRemainder(dividingBy: 1) + return r > 0.12 ? -38 + r * 22 : -90 +} + +struct Finish: View { + let scheme: ColorScheme + let title: String + var frozen = false + @State private var recording = true + @State private var started = Date() + @State private var query = "" + + var body: some View { + Group { + if frozen { + content(elapsed: 12.3, dbfs: -26) + } else { + TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { timeline in + let elapsed = timeline.date.timeIntervalSince(started) + let dbfs = recording ? simulatedDbfs(at: elapsed) : -120 + content(elapsed: recording ? elapsed : 0, dbfs: dbfs) + } + } + } + .environment(\.colorScheme, scheme) + } + + private func content(elapsed: Double, dbfs: Double) -> some View { + ZStack { + BrushedBackground() + VStack(alignment: .leading, spacing: Layout.panelGap) { + SilkLabel("Afwerking ยท \(title)") + VStack(alignment: .leading, spacing: Layout.panelGap) { + HStack(alignment: .top, spacing: Layout.panelGap) { + VUMeterView(levelDbfs: dbfs, isActive: recording, initialFraction: frozen ? VUScale.fraction(forDbfs: dbfs) : 0) + VStack(alignment: .leading, spacing: Layout.panelGap) { + SilkLabel("Tijd", style: Typography.labelSmall) + SegmentCounterView(Self.clock(elapsed)) + SilkLabel("Niveau", style: Typography.labelSmall) + LEDBarView(fraction: VUScale.fraction(forDbfs: dbfs)) + } + } + SeamDivider() + HStack(spacing: Layout.panelGap) { + Button { recording.toggle(); started = Date() } label: { + HStack { LampView(isOn: recording); Text("Rec") } + } + .buttonStyle(KeyButtonStyle(kind: .record, latched: recording)) + Button("Stop") { recording = false }.buttonStyle(KeyButtonStyle()) + Button("Kopieer") {}.buttonStyle(KeyButtonStyle()) + Button("Uit") {}.buttonStyle(KeyButtonStyle()).disabled(true) + Spacer() + LampView(isOn: recording, large: true) + LampView(isOn: true, tint: .amber) + } + } + .padding(Layout.panelPadding) + .background(PanelBackground()) + + VStack(alignment: .leading, spacing: 0) { + row("14:02:11", "Ik wil de meeting verplaatsen naar tomorrow morning.", selected: false) + row("14:03:40", "Hij stuurt het door naar Claude Code vandaag.", selected: true) + } + .background(PaperBackground()) + + HStack { + SilkLabel("Zoek") + Group { + if frozen { + Text("claude").textStyle(Typography.body).frame(maxWidth: .infinity, alignment: .leading) + } else { + TextField("", text: $query).textFieldStyle(.plain).textStyle(Typography.body) + } + } + .padding(Space.s3).background(WellBackground()) + } + } + .padding(Layout.panelPadding) + } + } + + private func row(_ time: String, _ text: String, selected: Bool) -> some View { + HStack(alignment: .firstTextBaseline, spacing: Space.s4) { + Text(time).textStyle(Typography.counterSmall).foregroundStyle(Palette.inkSecondary) + Text(text).textStyle(Typography.reading).foregroundStyle(Palette.inkPrimary) + Spacer() + } + .padding(.horizontal, Space.s4).padding(.vertical, Space.s3) + .frame(minHeight: Size.rowHeight) + .background(selected ? Palette.surfaceSelection : Color.clear) + } + + private static func clock(_ seconds: Double) -> String { + let total = Int(seconds * 10) + return String(format: "%02d:%02d.%d", (total / 600) % 100, (total / 10) % 60, total % 10) + } +}