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) }
|
Button(Strings.dictionary) { show(.dictionary) }
|
||||||
.keyboardShortcut("2", modifiers: .command)
|
.keyboardShortcut("2", modifiers: .command)
|
||||||
Button(Strings.search) {
|
Button(Strings.search) {
|
||||||
show(.transcripts)
|
show(model.section)
|
||||||
model.focusSearch()
|
model.focusSearch()
|
||||||
}
|
}
|
||||||
.keyboardShortcut("f", modifiers: .command)
|
.keyboardShortcut("f", modifiers: .command)
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ final class AppModel: ObservableObject {
|
|||||||
@Published private(set) var installedVariants: Set<String> = []
|
@Published private(set) var installedVariants: Set<String> = []
|
||||||
@Published private(set) var recordingStartedAt: Date?
|
@Published private(set) var recordingStartedAt: Date?
|
||||||
@Published private(set) var lastDuration: TimeInterval?
|
@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.
|
/// 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 historyRevision = 0
|
||||||
@Published private(set) var dictionaryRevision = 0
|
@Published private(set) var dictionaryRevision = 0
|
||||||
@@ -61,7 +62,6 @@ final class AppModel: ObservableObject {
|
|||||||
private let policy: InsertionPolicy
|
private let policy: InsertionPolicy
|
||||||
private let controller: DictationController
|
private let controller: DictationController
|
||||||
private var modelTask: Task<Void, Never>?
|
private var modelTask: Task<Void, Never>?
|
||||||
private var loadingVariant: String?
|
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
convenience init() {
|
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 {
|
var statusLine: String {
|
||||||
if let blocker = recordingBlocker, state == .idle { return blocker }
|
if let blocker = recordingBlocker, state == .idle { return blocker }
|
||||||
switch state {
|
switch state {
|
||||||
@@ -153,14 +158,19 @@ final class AppModel: ObservableObject {
|
|||||||
controller.onStateChange = { [weak self] state in
|
controller.onStateChange = { [weak self] state in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.state = state
|
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 {
|
switch state {
|
||||||
case .recording:
|
case .recording:
|
||||||
self.lastError = nil
|
self.lastError = nil
|
||||||
self.recordingStartedAt = Date()
|
self.recordingStartedAt = Date()
|
||||||
self.levelSource.reset()
|
self.levelSource.reset()
|
||||||
case .idle:
|
case .idle:
|
||||||
if let started = self.recordingStartedAt { self.lastDuration = Date().timeIntervalSince(started) }
|
// Also covers a window recording that was ended with the hotkey; the paste decision is already made.
|
||||||
self.recordingStartedAt = nil
|
self.policy.windowSession = false
|
||||||
self.levelSource.reset()
|
self.levelSource.reset()
|
||||||
default: break
|
default: break
|
||||||
}
|
}
|
||||||
@@ -192,6 +202,9 @@ final class AppModel: ObservableObject {
|
|||||||
permissions.objectWillChange
|
permissions.objectWillChange
|
||||||
.sink { [weak self] _ in self?.objectWillChange.send() }
|
.sink { [weak self] _ in self?.objectWillChange.send() }
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
settings.objectWillChange
|
||||||
|
.sink { [weak self] _ in self?.objectWillChange.send() }
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
dictionary.onChange = { [weak self] in
|
dictionary.onChange = { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
@@ -199,10 +212,15 @@ final class AppModel: ObservableObject {
|
|||||||
self.dictionaryList.reload()
|
self.dictionaryList.reload()
|
||||||
self.dictionaryRevision += 1
|
self.dictionaryRevision += 1
|
||||||
}
|
}
|
||||||
|
dictionary.onExternalChange = { [weak self] in self?.dictionaryList.externalChangeDetected() }
|
||||||
history.onChange = { [weak self] in
|
history.onChange = { [weak self] in
|
||||||
self?.historyRevision += 1
|
self?.historyRevision += 1
|
||||||
self?.historyList.refresh()
|
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
|
NotificationCenter.default.addObserver(forName: NSApplication.willTerminateNotification, object: nil, queue: .main) { [history] _ in
|
||||||
MainActor.assumeIsolated { history.flush() }
|
MainActor.assumeIsolated { history.flush() }
|
||||||
}
|
}
|
||||||
@@ -211,19 +229,28 @@ final class AppModel: ObservableObject {
|
|||||||
private func start() async {
|
private func start() async {
|
||||||
do { try dictionary.load() } catch { lastError = error.localizedDescription }
|
do { try dictionary.load() } catch { lastError = error.localizedDescription }
|
||||||
dictionary.startWatching()
|
dictionary.startWatching()
|
||||||
if case .recoveredCorrupt = history.load() {
|
switch history.load() {
|
||||||
|
case .recoveredCorrupt:
|
||||||
lastError = "Het geschiedenisbestand was beschadigd en is opzij gezet."
|
lastError = "Het geschiedenisbestand was beschadigd en is opzij gezet."
|
||||||
|
case .unreadable:
|
||||||
|
lastError = Strings.historyUnreadable
|
||||||
|
case .empty, .loaded:
|
||||||
|
break
|
||||||
}
|
}
|
||||||
refreshInstalled()
|
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()
|
await permissions.requestMissing()
|
||||||
retryHotkey()
|
retryHotkey()
|
||||||
activateModel(settings.modelVariant)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func retryHotkey() {
|
func retryHotkey() {
|
||||||
permissions.refresh()
|
permissions.refresh()
|
||||||
do {
|
do {
|
||||||
try hotkey.start()
|
try hotkey.start()
|
||||||
|
if lastError == HotkeyMonitor.HotkeyError.tapUnavailable.errorDescription { lastError = nil }
|
||||||
} catch {
|
} catch {
|
||||||
lastError = error.localizedDescription
|
lastError = error.localizedDescription
|
||||||
}
|
}
|
||||||
@@ -232,7 +259,7 @@ final class AppModel: ObservableObject {
|
|||||||
// MARK: recording
|
// MARK: recording
|
||||||
|
|
||||||
private func handleHotkey(_ event: HotkeyEvent) {
|
private func handleHotkey(_ event: HotkeyEvent) {
|
||||||
guard modelReady else { return }
|
guard event == .released || modelReady else { return }
|
||||||
if event == .pressed, AVCaptureDevice.authorizationStatus(for: .audio) != .authorized {
|
if event == .pressed, AVCaptureDevice.authorizationStatus(for: .audio) != .authorized {
|
||||||
lastError = Strings.microphoneMissing
|
lastError = Strings.microphoneMissing
|
||||||
return
|
return
|
||||||
@@ -250,7 +277,7 @@ final class AppModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func startWindowRecording() {
|
func startWindowRecording() {
|
||||||
guard recordingBlocker == nil, state == .idle else { return }
|
guard windowRecordingBlocker == nil, state == .idle else { return }
|
||||||
policy.windowSession = true
|
policy.windowSession = true
|
||||||
Task {
|
Task {
|
||||||
await controller.handle(.pressed)
|
await controller.handle(.pressed)
|
||||||
@@ -305,7 +332,7 @@ final class AppModel: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func deleteModel(_ variant: String) {
|
func deleteModel(_ variant: String) {
|
||||||
guard variant != activeVariant else { return }
|
guard variant != activeVariant, variant != loadingVariant else { return }
|
||||||
do {
|
do {
|
||||||
try storage.delete(variant)
|
try storage.delete(variant)
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -47,8 +47,8 @@ struct DeckView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.buttonStyle(KeyButtonStyle(kind: .record, latched: model.isRecording))
|
.buttonStyle(KeyButtonStyle(kind: .record, latched: model.isRecording))
|
||||||
.disabled(!model.isRecording && model.recordingBlocker != nil)
|
.disabled(!model.isRecording && model.windowRecordingBlocker != nil)
|
||||||
.help(model.recordingBlocker ?? "")
|
.help(model.windowRecordingBlocker ?? "")
|
||||||
|
|
||||||
Button(Strings.stop) { model.stopWindowRecording() }
|
Button(Strings.stop) { model.stopWindowRecording() }
|
||||||
.buttonStyle(KeyButtonStyle())
|
.buttonStyle(KeyButtonStyle())
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ struct DictionaryView: View {
|
|||||||
selection = nil
|
selection = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onChange(of: model.searchFocusTick) { _, _ in searchFocused = true }
|
||||||
.onChange(of: list.rows) { _, rows in
|
.onChange(of: list.rows) { _, rows in
|
||||||
if let s = selection, !rows.contains(where: { $0.id == s }) { selection = nil }
|
if let s = selection, !rows.contains(where: { $0.id == s }) { selection = nil }
|
||||||
}
|
}
|
||||||
@@ -215,7 +216,9 @@ private struct EntryEditor: View {
|
|||||||
HStack(spacing: Space.s3) {
|
HStack(spacing: Space.s3) {
|
||||||
Spacer()
|
Spacer()
|
||||||
Button(Strings.cancel, action: onClose).buttonStyle(KeyButtonStyle())
|
Button(Strings.cancel, action: onClose).buttonStyle(KeyButtonStyle())
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
Button(Strings.save, action: save).buttonStyle(KeyButtonStyle(kind: .record)).disabled(!canSave)
|
Button(Strings.save, action: save).buttonStyle(KeyButtonStyle(kind: .record)).disabled(!canSave)
|
||||||
|
.keyboardShortcut(.defaultAction)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(Layout.panelPadding)
|
.padding(Layout.panelPadding)
|
||||||
@@ -246,6 +249,11 @@ private struct EntryEditor: View {
|
|||||||
|
|
||||||
private func save() {
|
private func save() {
|
||||||
if let id = editingID {
|
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)
|
list.update(id: id, to: kind)
|
||||||
} else {
|
} else {
|
||||||
list.add(kind)
|
list.add(kind)
|
||||||
|
|||||||
@@ -185,9 +185,12 @@ private struct ModelSettingsView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
if installed && !isActive {
|
if installed && !isActive {
|
||||||
Button(Strings.useModel) { model.activateModel(option.id) }.buttonStyle(KeyButtonStyle())
|
Button(Strings.useModel) { model.activateModel(option.id) }.buttonStyle(KeyButtonStyle())
|
||||||
|
.disabled(model.loadingVariant != nil)
|
||||||
Button(Strings.remove) { model.deleteModel(option.id) }.buttonStyle(KeyButtonStyle())
|
Button(Strings.remove) { model.deleteModel(option.id) }.buttonStyle(KeyButtonStyle())
|
||||||
|
.disabled(model.loadingVariant != nil)
|
||||||
} else if !installed {
|
} else if !installed {
|
||||||
Button(Strings.download) { model.activateModel(option.id) }.buttonStyle(KeyButtonStyle())
|
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 undo = "Herstel"
|
||||||
static let save = "Bewaar"
|
static let save = "Bewaar"
|
||||||
static let editEntry = "Invoer"
|
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 +."
|
static let emptyDictionary = "Nog geen invoeren. Voeg een woord of een correctie toe met +."
|
||||||
|
|
||||||
// deck
|
// deck
|
||||||
@@ -66,6 +67,8 @@ enum Strings {
|
|||||||
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 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"
|
static let permissionsMissing = "Geef eerst de rechten onder Controles"
|
||||||
|
|
||||||
// settings
|
// settings
|
||||||
|
|||||||
Reference in New Issue
Block a user