fix: window session reset, model-row locking, startup order, hotkey release, counter, history gate, dictionary editor and search polish
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -47,6 +47,7 @@ final class AppModel: ObservableObject {
|
||||
@Published private(set) var installedVariants: Set<String> = []
|
||||
@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<Void, Never>?
|
||||
private var loadingVariant: String?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user