feat: wire list view models into AppModel, add strings, list helpers and debug snapshot mode

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 15:46:29 +02:00
co-authored by Claude Sonnet 5.5
parent fffd9d7022
commit daacfa20c8
6 changed files with 179 additions and 9 deletions
+59 -8
View File
@@ -37,6 +37,8 @@ final class AppModel: ObservableObject {
let history: HistoryStore let history: HistoryStore
let dictionary: DictionaryStore let dictionary: DictionaryStore
let storage = ModelStorage() let storage = ModelStorage()
let historyList: HistoryViewModel
let dictionaryList: DictionaryViewModel
// Observable state // Observable state
@Published private(set) var state: DictationState = .idle @Published private(set) var state: DictationState = .idle
@@ -62,13 +64,19 @@ final class AppModel: ObservableObject {
private var loadingVariant: String? private var loadingVariant: String?
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
init() { convenience init() {
let settings = AppSettings() 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 policy = InsertionPolicy()
let knowledge = SystemWordKnowledge() let knowledge = SystemWordKnowledge()
let corrector = DictionaryCorrector(knowledge: knowledge) let corrector = DictionaryCorrector(knowledge: knowledge)
let dictionary = DictionaryStore(fileURL: AppPaths.dictionary) let folder = FileManager.default.temporaryDirectory.appending(path: "sst-preview-\(UUID().uuidString)", directoryHint: .isDirectory)
let history = HistoryStore(fileURL: AppPaths.history) 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 levelSource = self.levelSource
let recorder = AudioRecorder() let recorder = AudioRecorder()
recorder.onLevel = { levelSource.update($0) } recorder.onLevel = { levelSource.update($0) }
@@ -78,6 +86,8 @@ final class AppModel: ObservableObject {
self.corrector = corrector self.corrector = corrector
self.dictionary = dictionary self.dictionary = dictionary
self.history = history self.history = history
self.historyList = HistoryViewModel(store: history)
self.dictionaryList = DictionaryViewModel(store: dictionary, knowledge: knowledge)
self.controller = DictationController( self.controller = DictationController(
recorder: recorder, recorder: recorder,
transcriber: transcriber, transcriber: transcriber,
@@ -89,7 +99,11 @@ final class AppModel: ObservableObject {
vocabularyProvider: { settings.usePriming ? VocabularyBuilder.build(from: dictionary.entries) : [] }) vocabularyProvider: { settings.usePriming ? VocabularyBuilder.build(from: dictionary.entries) : [] })
wire() wire()
Task { await start() } if preview {
loadPreviewData()
} else {
Task { await start() }
}
} }
// MARK: derived // MARK: derived
@@ -100,7 +114,7 @@ final class AppModel: ObservableObject {
/// Why recording is not possible right now, or nil when it is. /// Why recording is not possible right now, or nil when it is.
var recordingBlocker: String? { var recordingBlocker: String? {
if !permissions.allGranted { return Strings.openSystemSettings } if !permissions.allGranted { return Strings.permissionsMissing }
switch modelStatus { switch modelStatus {
case .ready: return nil case .ready: return nil
case .loading(let variant): return Strings.modelLoading(title(for: variant)) case .loading(let variant): return Strings.modelLoading(title(for: variant))
@@ -181,9 +195,13 @@ final class AppModel: ObservableObject {
dictionary.onChange = { [weak self] in dictionary.onChange = { [weak self] in
guard let self else { return } guard let self else { return }
self.corrector.update(entries: self.dictionary.entries) self.corrector.update(entries: self.dictionary.entries)
self.dictionaryList.reload()
self.dictionaryRevision += 1 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 NotificationCenter.default.addObserver(forName: NSApplication.willTerminateNotification, object: nil, queue: .main) { [history] _ in
MainActor.assumeIsolated { history.flush() } MainActor.assumeIsolated { history.flush() }
} }
@@ -240,7 +258,7 @@ final class AppModel: ObservableObject {
} }
func stopWindowRecording() { func stopWindowRecording() {
guard policy.windowSession else { return } guard isRecording else { return }
Task { Task {
await controller.handle(.released) await controller.handle(.released)
policy.windowSession = false policy.windowSession = false
@@ -310,4 +328,37 @@ final class AppModel: ObservableObject {
history.removeAll() history.removeAll()
history.flush() 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
}
} }
+50
View File
@@ -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<Content: View>: 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<Bool>.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())
}
}
}
+31
View File
@@ -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
-1
View File
@@ -1,7 +1,6 @@
import SwiftUI import SwiftUI
import SstUI import SstUI
@main
struct SstApp: App { struct SstApp: App {
@StateObject private var model = AppModel() @StateObject private var model = AppModel()
+27
View File
@@ -7,6 +7,31 @@ enum Strings {
static let dictionary = "Woordenboek" static let dictionary = "Woordenboek"
static let controls = "Controles" 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 // deck
static let record = "Rec" static let record = "Rec"
static let stop = "Stop" static let stop = "Stop"
@@ -40,6 +65,8 @@ enum Strings {
static let modelLamp = "Model" static let modelLamp = "Model"
static let microphoneMissing = "Microfoon-toegang ontbreekt" static let microphoneMissing = "Microfoon-toegang ontbreekt"
static let openSystemSettings = "Open Instellingen" static let openSystemSettings = "Open Instellingen"
static let open = "Open"
static let permissionsMissing = "Geef eerst de rechten onder Controles"
// settings // settings
static let tabHotkey = "Sneltoets" static let tabHotkey = "Sneltoets"
+12
View File
@@ -0,0 +1,12 @@
import AppKit
#if DEBUG
// `Sst --snapshot <prefix>` renders the main window with sample data to <prefix>-transcripts.png and
// <prefix>-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()