feat: add component gallery with snapshot mode
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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"]),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user