diff --git a/Sources/Sst/AppCommands.swift b/Sources/Sst/AppCommands.swift index 2b42c4e..e427bad 100644 --- a/Sources/Sst/AppCommands.swift +++ b/Sources/Sst/AppCommands.swift @@ -23,7 +23,7 @@ struct AppCommands: Commands { Button(Strings.dictionary) { show(.dictionary) } .keyboardShortcut("2", modifiers: .command) Button(Strings.search) { - show(.transcripts) + show(model.section) model.focusSearch() } .keyboardShortcut("f", modifiers: .command) diff --git a/Sources/Sst/AppModel.swift b/Sources/Sst/AppModel.swift index d7abf27..fe1be64 100644 --- a/Sources/Sst/AppModel.swift +++ b/Sources/Sst/AppModel.swift @@ -47,6 +47,7 @@ final class AppModel: ObservableObject { @Published private(set) var installedVariants: Set = [] @Published private(set) var recordingStartedAt: Date? @Published private(set) var lastDuration: TimeInterval? + @Published private(set) var loadingVariant: String? /// Bumped whenever the history or dictionary changes, so views that cache derived data can refresh. @Published private(set) var historyRevision = 0 @Published private(set) var dictionaryRevision = 0 @@ -61,7 +62,6 @@ final class AppModel: ObservableObject { private let policy: InsertionPolicy private let controller: DictationController private var modelTask: Task? - private var loadingVariant: String? private var cancellables = Set() convenience init() { @@ -123,6 +123,11 @@ final class AppModel: ObservableObject { } } + /// Like `recordingBlocker`, plus: a window recording is only kept when the history is on. + var windowRecordingBlocker: String? { + recordingBlocker ?? (settings.keepHistory ? nil : Strings.historyNeededForWindow) + } + var statusLine: String { if let blocker = recordingBlocker, state == .idle { return blocker } switch state { @@ -153,14 +158,19 @@ final class AppModel: ObservableObject { controller.onStateChange = { [weak self] state in guard let self else { return } self.state = state + if state != .recording, let started = self.recordingStartedAt { + // The counter shows the recording length, not the time spent transcribing and pasting. + self.lastDuration = Date().timeIntervalSince(started) + self.recordingStartedAt = nil + } switch state { case .recording: self.lastError = nil self.recordingStartedAt = Date() self.levelSource.reset() case .idle: - if let started = self.recordingStartedAt { self.lastDuration = Date().timeIntervalSince(started) } - self.recordingStartedAt = nil + // Also covers a window recording that was ended with the hotkey; the paste decision is already made. + self.policy.windowSession = false self.levelSource.reset() default: break } @@ -192,6 +202,9 @@ final class AppModel: ObservableObject { permissions.objectWillChange .sink { [weak self] _ in self?.objectWillChange.send() } .store(in: &cancellables) + settings.objectWillChange + .sink { [weak self] _ in self?.objectWillChange.send() } + .store(in: &cancellables) dictionary.onChange = { [weak self] in guard let self else { return } @@ -199,10 +212,15 @@ final class AppModel: ObservableObject { self.dictionaryList.reload() self.dictionaryRevision += 1 } + dictionary.onExternalChange = { [weak self] in self?.dictionaryList.externalChangeDetected() } history.onChange = { [weak self] in self?.historyRevision += 1 self?.historyList.refresh() } + // Backstop for editors that write in place, which the folder watcher can miss. + NotificationCenter.default.addObserver(forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main) { [weak self] _ in + MainActor.assumeIsolated { self?.dictionary.reloadIfChanged() } + } NotificationCenter.default.addObserver(forName: NSApplication.willTerminateNotification, object: nil, queue: .main) { [history] _ in MainActor.assumeIsolated { history.flush() } } @@ -211,19 +229,28 @@ final class AppModel: ObservableObject { private func start() async { do { try dictionary.load() } catch { lastError = error.localizedDescription } dictionary.startWatching() - if case .recoveredCorrupt = history.load() { + switch history.load() { + case .recoveredCorrupt: lastError = "Het geschiedenisbestand was beschadigd en is opzij gezet." + case .unreadable: + lastError = Strings.historyUnreadable + case .empty, .loaded: + break } refreshInstalled() + if !ModelCatalog.all.contains(where: { $0.id == settings.modelVariant }) { + settings.modelVariant = ModelCatalog.defaultVariant + } + activateModel(settings.modelVariant) // the download must not wait for the microphone prompt await permissions.requestMissing() retryHotkey() - activateModel(settings.modelVariant) } func retryHotkey() { permissions.refresh() do { try hotkey.start() + if lastError == HotkeyMonitor.HotkeyError.tapUnavailable.errorDescription { lastError = nil } } catch { lastError = error.localizedDescription } @@ -232,7 +259,7 @@ final class AppModel: ObservableObject { // MARK: recording private func handleHotkey(_ event: HotkeyEvent) { - guard modelReady else { return } + guard event == .released || modelReady else { return } if event == .pressed, AVCaptureDevice.authorizationStatus(for: .audio) != .authorized { lastError = Strings.microphoneMissing return @@ -250,7 +277,7 @@ final class AppModel: ObservableObject { } func startWindowRecording() { - guard recordingBlocker == nil, state == .idle else { return } + guard windowRecordingBlocker == nil, state == .idle else { return } policy.windowSession = true Task { await controller.handle(.pressed) @@ -305,7 +332,7 @@ final class AppModel: ObservableObject { } func deleteModel(_ variant: String) { - guard variant != activeVariant else { return } + guard variant != activeVariant, variant != loadingVariant else { return } do { try storage.delete(variant) } catch { diff --git a/Sources/Sst/DeckView.swift b/Sources/Sst/DeckView.swift index b324c17..9983a05 100644 --- a/Sources/Sst/DeckView.swift +++ b/Sources/Sst/DeckView.swift @@ -47,8 +47,8 @@ struct DeckView: View { } } .buttonStyle(KeyButtonStyle(kind: .record, latched: model.isRecording)) - .disabled(!model.isRecording && model.recordingBlocker != nil) - .help(model.recordingBlocker ?? "") + .disabled(!model.isRecording && model.windowRecordingBlocker != nil) + .help(model.windowRecordingBlocker ?? "") Button(Strings.stop) { model.stopWindowRecording() } .buttonStyle(KeyButtonStyle()) diff --git a/Sources/Sst/DictionaryView.swift b/Sources/Sst/DictionaryView.swift index 40a9952..5b95b7d 100644 --- a/Sources/Sst/DictionaryView.swift +++ b/Sources/Sst/DictionaryView.swift @@ -95,6 +95,7 @@ struct DictionaryView: View { selection = nil } } + .onChange(of: model.searchFocusTick) { _, _ in searchFocused = true } .onChange(of: list.rows) { _, rows in if let s = selection, !rows.contains(where: { $0.id == s }) { selection = nil } } @@ -215,7 +216,9 @@ private struct EntryEditor: View { HStack(spacing: Space.s3) { Spacer() Button(Strings.cancel, action: onClose).buttonStyle(KeyButtonStyle()) + .keyboardShortcut(.cancelAction) Button(Strings.save, action: save).buttonStyle(KeyButtonStyle(kind: .record)).disabled(!canSave) + .keyboardShortcut(.defaultAction) } } .padding(Layout.panelPadding) @@ -246,6 +249,11 @@ private struct EntryEditor: View { private func save() { if let id = editingID { + guard list.rows.contains(where: { $0.id == id }) else { + list.errorMessage = Strings.entryGone + onClose() + return + } list.update(id: id, to: kind) } else { list.add(kind) diff --git a/Sources/Sst/SettingsView.swift b/Sources/Sst/SettingsView.swift index b9249dd..deb4c1e 100644 --- a/Sources/Sst/SettingsView.swift +++ b/Sources/Sst/SettingsView.swift @@ -185,9 +185,12 @@ private struct ModelSettingsView: View { Spacer() if installed && !isActive { Button(Strings.useModel) { model.activateModel(option.id) }.buttonStyle(KeyButtonStyle()) + .disabled(model.loadingVariant != nil) Button(Strings.remove) { model.deleteModel(option.id) }.buttonStyle(KeyButtonStyle()) + .disabled(model.loadingVariant != nil) } else if !installed { Button(Strings.download) { model.activateModel(option.id) }.buttonStyle(KeyButtonStyle()) + .disabled(model.loadingVariant != nil) } } } diff --git a/Sources/Sst/Strings.swift b/Sources/Sst/Strings.swift index b20e13b..71593ba 100644 --- a/Sources/Sst/Strings.swift +++ b/Sources/Sst/Strings.swift @@ -30,6 +30,7 @@ enum Strings { static let undo = "Herstel" static let save = "Bewaar" static let editEntry = "Invoer" + static let entryGone = "Deze invoer bestaat niet meer; je wijziging is niet bewaard." static let emptyDictionary = "Nog geen invoeren. Voeg een woord of een correctie toe met +." // deck @@ -66,6 +67,8 @@ enum Strings { static let microphoneMissing = "Microfoon-toegang ontbreekt" static let openSystemSettings = "Open Instellingen" static let open = "Open" + static let historyNeededForWindow = "Zet ‘Transcripties bewaren’ aan om vanuit het venster op te nemen." + static let historyUnreadable = "Het geschiedenisbestand kon niet worden gelezen; nieuwe transcripties worden niet bewaard." static let permissionsMissing = "Geef eerst de rechten onder Controles" // settings