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:
@@ -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<AnyCancellable>()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,7 +1,6 @@
|
||||
import SwiftUI
|
||||
import SstUI
|
||||
|
||||
@main
|
||||
struct SstApp: App {
|
||||
@StateObject private var model = AppModel()
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user