From daacfa20c8405a8f7f780b0cb8e2d116f0c26dd8 Mon Sep 17 00:00:00 2001 From: Theo Weijmer Date: Thu, 1 Oct 2026 15:46:29 +0200 Subject: [PATCH] feat: wire list view models into AppModel, add strings, list helpers and debug snapshot mode Co-Authored-By: Claude Sonnet 5.5 --- Sources/Sst/AppModel.swift | 67 ++++++++++++++++++++++++++++++----- Sources/Sst/ListSupport.swift | 50 ++++++++++++++++++++++++++ Sources/Sst/Snapshot.swift | 31 ++++++++++++++++ Sources/Sst/SstApp.swift | 1 - Sources/Sst/Strings.swift | 27 ++++++++++++++ Sources/Sst/main.swift | 12 +++++++ 6 files changed, 179 insertions(+), 9 deletions(-) create mode 100644 Sources/Sst/ListSupport.swift create mode 100644 Sources/Sst/Snapshot.swift create mode 100644 Sources/Sst/main.swift diff --git a/Sources/Sst/AppModel.swift b/Sources/Sst/AppModel.swift index 9dc054a..c021a33 100644 --- a/Sources/Sst/AppModel.swift +++ b/Sources/Sst/AppModel.swift @@ -37,6 +37,8 @@ final class AppModel: ObservableObject { let history: HistoryStore let dictionary: DictionaryStore let storage = ModelStorage() + let historyList: HistoryViewModel + let dictionaryList: DictionaryViewModel // Observable state @Published private(set) var state: DictationState = .idle @@ -62,13 +64,19 @@ final class AppModel: ObservableObject { private var loadingVariant: String? private var cancellables = Set() - init() { - let settings = AppSettings() + convenience init() { + self.init(preview: false) + } + + /// `preview: true` is for snapshots: sample data in a temporary folder, no permission prompts, no model, no hotkey. + init(preview: Bool) { + let settings = preview ? AppSettings(defaults: UserDefaults(suiteName: "sst-preview")!) : AppSettings() let policy = InsertionPolicy() let knowledge = SystemWordKnowledge() let corrector = DictionaryCorrector(knowledge: knowledge) - let dictionary = DictionaryStore(fileURL: AppPaths.dictionary) - let history = HistoryStore(fileURL: AppPaths.history) + let folder = FileManager.default.temporaryDirectory.appending(path: "sst-preview-\(UUID().uuidString)", directoryHint: .isDirectory) + let dictionary = DictionaryStore(fileURL: preview ? folder.appending(path: "dictionary.txt") : AppPaths.dictionary) + let history = HistoryStore(fileURL: preview ? folder.appending(path: "history.json") : AppPaths.history) let levelSource = self.levelSource let recorder = AudioRecorder() recorder.onLevel = { levelSource.update($0) } @@ -78,6 +86,8 @@ final class AppModel: ObservableObject { self.corrector = corrector self.dictionary = dictionary self.history = history + self.historyList = HistoryViewModel(store: history) + self.dictionaryList = DictionaryViewModel(store: dictionary, knowledge: knowledge) self.controller = DictationController( recorder: recorder, transcriber: transcriber, @@ -89,7 +99,11 @@ final class AppModel: ObservableObject { vocabularyProvider: { settings.usePriming ? VocabularyBuilder.build(from: dictionary.entries) : [] }) wire() - Task { await start() } + if preview { + loadPreviewData() + } else { + Task { await start() } + } } // MARK: derived @@ -100,7 +114,7 @@ final class AppModel: ObservableObject { /// Why recording is not possible right now, or nil when it is. var recordingBlocker: String? { - if !permissions.allGranted { return Strings.openSystemSettings } + if !permissions.allGranted { return Strings.permissionsMissing } switch modelStatus { case .ready: return nil case .loading(let variant): return Strings.modelLoading(title(for: variant)) @@ -181,9 +195,13 @@ final class AppModel: ObservableObject { dictionary.onChange = { [weak self] in guard let self else { return } self.corrector.update(entries: self.dictionary.entries) + self.dictionaryList.reload() self.dictionaryRevision += 1 } - history.onChange = { [weak self] in self?.historyRevision += 1 } + history.onChange = { [weak self] in + self?.historyRevision += 1 + self?.historyList.refresh() + } NotificationCenter.default.addObserver(forName: NSApplication.willTerminateNotification, object: nil, queue: .main) { [history] _ in MainActor.assumeIsolated { history.flush() } } @@ -240,7 +258,7 @@ final class AppModel: ObservableObject { } func stopWindowRecording() { - guard policy.windowSession else { return } + guard isRecording else { return } Task { await controller.handle(.released) policy.windowSession = false @@ -310,4 +328,37 @@ final class AppModel: ObservableObject { history.removeAll() history.flush() } + + func openDictionaryFile() { + NSWorkspace.shared.open(dictionary.fileURL) + } + + // MARK: preview + + private func loadPreviewData() { + try? dictionary.load() + try? dictionary.apply { file in + file.append(.term("Anthropic")) + file.append(.term("Supabase")) + file.append(.correction(heard: "cloud code", wrote: "Claude Code")) + file.append(.correction(heard: "cloud", wrote: "Claude")) + } + let now = Date() + history.add(HistoryEntry(date: now.addingTimeInterval(-86_400 * 2), duration: 5.1, + rawText: "een oudere notitie over de planning", finalText: "Een oudere notitie over de planning.")) + history.add(HistoryEntry(date: now.addingTimeInterval(-3_600), duration: 7.2, + rawText: "ik wil de meeting verplaatsen naar tomorrow morning", + finalText: "Ik wil de meeting verplaatsen naar tomorrow morning.")) + history.add(HistoryEntry( + date: now.addingTimeInterval(-300), duration: 4.4, + rawText: "ik gebruik cloud code met supabase", finalText: "Ik gebruik Claude Code met Supabase.", + corrections: [ + AppliedCorrection(heard: "cloud code", wrote: "Claude Code", rule: "cloud code", start: 11, length: 11), + AppliedCorrection(heard: "supabase", wrote: "Supabase", rule: "Supabase", start: 27, length: 8), + ])) + activeVariant = ModelCatalog.defaultVariant + modelStatus = .ready + lastDuration = 4.4 + historyList.selection = history.entries.first?.id + } } diff --git a/Sources/Sst/ListSupport.swift b/Sources/Sst/ListSupport.swift new file mode 100644 index 0000000..36dd7c3 --- /dev/null +++ b/Sources/Sst/ListSupport.swift @@ -0,0 +1,50 @@ +import SstUI +import SwiftUI + +/// Debug snapshots (`Sst --snapshot`) are drawn by `ImageRenderer`, which cannot draw scroll views or text fields. +/// While rendering one, the views below fall back to plain stacks and text. +@MainActor +enum SnapshotMode { + static var isActive = false +} + +/// A scrolling, lazily built list (a plain stack while a snapshot is rendered). +struct ScrollList: View { + @ViewBuilder var content: Content + + var body: some View { + if SnapshotMode.isActive { + VStack(alignment: .leading, spacing: 0) { content } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } else { + ScrollView { + LazyVStack(alignment: .leading, spacing: 0, pinnedViews: .sectionHeaders) { content } + } + } + } +} + +/// The recessed "ZOEK" field. +struct SearchField: View { + @Binding var text: String + var focused: FocusState.Binding + + var body: some View { + HStack(spacing: Space.s4) { + SilkLabel(Strings.search) + Group { + if SnapshotMode.isActive { + Text(text).frame(maxWidth: .infinity, alignment: .leading) + } else { + TextField("", text: $text) + .textFieldStyle(.plain) + .focused(focused) + } + } + .textStyle(Typography.body) + .foregroundStyle(Palette.inkPrimary) + .padding(Space.s3) + .background(WellBackground()) + } + } +} diff --git a/Sources/Sst/Snapshot.swift b/Sources/Sst/Snapshot.swift new file mode 100644 index 0000000..220bbce --- /dev/null +++ b/Sources/Sst/Snapshot.swift @@ -0,0 +1,31 @@ +#if DEBUG +import AppKit +import SstUI +import SwiftUI + +@MainActor +enum Snapshot { + static func run(prefix: String) { + _ = NSApplication.shared + SnapshotMode.isActive = true + let model = AppModel(preview: true) + for section in AppModel.Section.allCases { + model.section = section + let view = HStack(spacing: 0) { + MainWindow().environment(\.colorScheme, .light) + MainWindow().environment(\.colorScheme, .dark) + } + .environmentObject(model) + .environmentObject(model.settings) + .frame(width: Layout.windowDefaultWidth * 2, height: Layout.windowDefaultHeight) + + let renderer = ImageRenderer(content: view) + renderer.scale = 1 + if let tiff = renderer.nsImage?.tiffRepresentation, + let png = NSBitmapImageRep(data: tiff)?.representation(using: .png, properties: [:]) { + try? png.write(to: URL(fileURLWithPath: "\(prefix)-\(section.rawValue).png")) + } + } + } +} +#endif diff --git a/Sources/Sst/SstApp.swift b/Sources/Sst/SstApp.swift index 6799290..eeedd03 100644 --- a/Sources/Sst/SstApp.swift +++ b/Sources/Sst/SstApp.swift @@ -1,7 +1,6 @@ import SwiftUI import SstUI -@main struct SstApp: App { @StateObject private var model = AppModel() diff --git a/Sources/Sst/Strings.swift b/Sources/Sst/Strings.swift index cac1d72..b20e13b 100644 --- a/Sources/Sst/Strings.swift +++ b/Sources/Sst/Strings.swift @@ -7,6 +7,31 @@ enum Strings { static let dictionary = "Woordenboek" static let controls = "Controles" + // lists + static let copy = "Kopieer" + static let copyOriginal = "Kopieer origineel" + static let delete = "Verwijder" + static let original = "Origineel" + static let noTranscripts = "Nog geen transcripties. Houd de sneltoets ingedrukt en praat." + static let noResults = "Geen resultaten" + static let historyOff = "De geschiedenis staat uit (Instellingen → Privacy)." + static func corrections(_ count: Int) -> String { count == 1 ? "1 correctie" : "\(count) correcties" } + static func entries(_ count: Int) -> String { count == 1 ? "1 invoer" : "\(count) invoeren" } + + // dictionary + static let heard = "Gehoord" + static let wrote = "Schrijf" + static let term = "Term" + static let correction = "Correctie" + static let add = "+" + static let openFile = "Open bestand" + static let priming = "Voorrang" + static let correcting = "Correctie" + static let undo = "Herstel" + static let save = "Bewaar" + static let editEntry = "Invoer" + static let emptyDictionary = "Nog geen invoeren. Voeg een woord of een correctie toe met +." + // deck static let record = "Rec" static let stop = "Stop" @@ -40,6 +65,8 @@ enum Strings { static let modelLamp = "Model" static let microphoneMissing = "Microfoon-toegang ontbreekt" static let openSystemSettings = "Open Instellingen" + static let open = "Open" + static let permissionsMissing = "Geef eerst de rechten onder Controles" // settings static let tabHotkey = "Sneltoets" diff --git a/Sources/Sst/main.swift b/Sources/Sst/main.swift new file mode 100644 index 0000000..c234e37 --- /dev/null +++ b/Sources/Sst/main.swift @@ -0,0 +1,12 @@ +import AppKit + +#if DEBUG +// `Sst --snapshot ` renders the main window with sample data to -transcripts.png and +// -dictionary.png (Aluminium left, Graphite right) and exits. No prompts, no model, no hotkey. +if let flag = CommandLine.arguments.firstIndex(of: "--snapshot"), CommandLine.arguments.count > flag + 1 { + MainActor.assumeIsolated { Snapshot.run(prefix: CommandLine.arguments[flag + 1]) } + exit(0) +} +#endif + +SstApp.main()