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 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 SwiftUI
|
||||||
import SstUI
|
import SstUI
|
||||||
|
|
||||||
@main
|
|
||||||
struct SstApp: App {
|
struct SstApp: App {
|
||||||
@StateObject private var model = AppModel()
|
@StateObject private var model = AppModel()
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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