macOS: fix trigger word input disappearing when typing and on add (#1506)

Fixed issue where trigger words would disappear when typing or when adding new trigger words. The problem was that `swabbleTriggerWords` changes were triggering `VoiceWakeRuntime.refresh()` which sanitized the array by removing empty strings in real-time.

Solution: Introduced local `@State` buffer `triggerEntries` with stable UUID identifiers for each trigger word entry. User edits now only affect the local state buffer and are synced back to `AppState` on explicit actions (submit, remove, disappear). This prevents premature sanitization during editing.

The local state is loaded on view appear and when the view becomes active, ensuring it stays in sync with `AppState`.
This commit is contained in:
Shiva Prasad
2026-01-24 09:08:12 +13:00
committed by GitHub
parent 7d0a0ae3ba
commit fdbaae6a33

View File

@@ -21,6 +21,7 @@ struct VoiceWakeSettings: View {
@State private var micObserver = AudioInputDeviceObserver() @State private var micObserver = AudioInputDeviceObserver()
@State private var micRefreshTask: Task<Void, Never>? @State private var micRefreshTask: Task<Void, Never>?
@State private var availableLocales: [Locale] = [] @State private var availableLocales: [Locale] = []
@State private var triggerEntries: [TriggerEntry] = []
private let fieldLabelWidth: CGFloat = 140 private let fieldLabelWidth: CGFloat = 140
private let controlWidth: CGFloat = 240 private let controlWidth: CGFloat = 240
private let isPreview = ProcessInfo.processInfo.isPreview private let isPreview = ProcessInfo.processInfo.isPreview
@@ -31,9 +32,9 @@ struct VoiceWakeSettings: View {
var id: String { self.uid } var id: String { self.uid }
} }
private struct IndexedWord: Identifiable { private struct TriggerEntry: Identifiable {
let id: Int let id: UUID
let value: String var value: String
} }
private var voiceWakeBinding: Binding<Bool> { private var voiceWakeBinding: Binding<Bool> {
@@ -105,6 +106,7 @@ struct VoiceWakeSettings: View {
.onAppear { .onAppear {
guard !self.isPreview else { return } guard !self.isPreview else { return }
self.startMicObserver() self.startMicObserver()
self.loadTriggerEntries()
} }
.onChange(of: self.state.voiceWakeMicID) { _, _ in .onChange(of: self.state.voiceWakeMicID) { _, _ in
guard !self.isPreview else { return } guard !self.isPreview else { return }
@@ -122,8 +124,10 @@ struct VoiceWakeSettings: View {
self.micRefreshTask = nil self.micRefreshTask = nil
Task { await self.meter.stop() } Task { await self.meter.stop() }
self.micObserver.stop() self.micObserver.stop()
self.syncTriggerEntriesToState()
} else { } else {
self.startMicObserver() self.startMicObserver()
self.loadTriggerEntries()
} }
} }
.onDisappear { .onDisappear {
@@ -136,11 +140,16 @@ struct VoiceWakeSettings: View {
self.micRefreshTask = nil self.micRefreshTask = nil
self.micObserver.stop() self.micObserver.stop()
Task { await self.meter.stop() } Task { await self.meter.stop() }
self.syncTriggerEntriesToState()
} }
} }
private var indexedWords: [IndexedWord] { private func loadTriggerEntries() {
self.state.swabbleTriggerWords.enumerated().map { IndexedWord(id: $0.offset, value: $0.element) } self.triggerEntries = self.state.swabbleTriggerWords.map { TriggerEntry(id: UUID(), value: $0) }
}
private func syncTriggerEntriesToState() {
self.state.swabbleTriggerWords = self.triggerEntries.map(\.value)
} }
private var triggerTable: some View { private var triggerTable: some View {
@@ -154,29 +163,42 @@ struct VoiceWakeSettings: View {
} label: { } label: {
Label("Add word", systemImage: "plus") Label("Add word", systemImage: "plus")
} }
.disabled(self.state.swabbleTriggerWords .disabled(self.triggerEntries
.contains(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })) .contains(where: { $0.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
Button("Reset defaults") { self.state.swabbleTriggerWords = defaultVoiceWakeTriggers } Button("Reset defaults") {
self.triggerEntries = defaultVoiceWakeTriggers.map { TriggerEntry(id: UUID(), value: $0) }
self.syncTriggerEntriesToState()
}
} }
Table(self.indexedWords) { VStack(spacing: 0) {
TableColumn("Word") { row in ForEach(self.$triggerEntries) { $entry in
TextField("Wake word", text: self.binding(for: row.id)) HStack(spacing: 8) {
.textFieldStyle(.roundedBorder) TextField("Wake word", text: $entry.value)
} .textFieldStyle(.roundedBorder)
TableColumn("") { row in .onSubmit {
Button { self.syncTriggerEntriesToState()
self.removeWord(at: row.id) }
} label: {
Image(systemName: "trash") Button {
self.removeWord(id: entry.id)
} label: {
Image(systemName: "trash")
}
.buttonStyle(.borderless)
.help("Remove trigger word")
.frame(width: 24)
}
.padding(8)
if entry.id != self.triggerEntries.last?.id {
Divider()
} }
.buttonStyle(.borderless)
.help("Remove trigger word")
} }
.width(36)
} }
.frame(minHeight: 180) .frame(maxWidth: .infinity, minHeight: 180, alignment: .topLeading)
.background(Color(nsColor: .textBackgroundColor))
.clipShape(RoundedRectangle(cornerRadius: 6)) .clipShape(RoundedRectangle(cornerRadius: 6))
.overlay( .overlay(
RoundedRectangle(cornerRadius: 6) RoundedRectangle(cornerRadius: 6)
@@ -211,24 +233,12 @@ struct VoiceWakeSettings: View {
} }
private func addWord() { private func addWord() {
self.state.swabbleTriggerWords.append("") self.triggerEntries.append(TriggerEntry(id: UUID(), value: ""))
} }
private func removeWord(at index: Int) { private func removeWord(id: UUID) {
guard self.state.swabbleTriggerWords.indices.contains(index) else { return } self.triggerEntries.removeAll { $0.id == id }
self.state.swabbleTriggerWords.remove(at: index) self.syncTriggerEntriesToState()
}
private func binding(for index: Int) -> Binding<String> {
Binding(
get: {
guard self.state.swabbleTriggerWords.indices.contains(index) else { return "" }
return self.state.swabbleTriggerWords[index]
},
set: { newValue in
guard self.state.swabbleTriggerWords.indices.contains(index) else { return }
self.state.swabbleTriggerWords[index] = newValue
})
} }
private func toggleTest() { private func toggleTest() {
@@ -638,13 +648,14 @@ extension VoiceWakeSettings {
state.voicePushToTalkEnabled = true state.voicePushToTalkEnabled = true
state.swabbleTriggerWords = ["Claude", "Hey"] state.swabbleTriggerWords = ["Claude", "Hey"]
let view = VoiceWakeSettings(state: state, isActive: true) var view = VoiceWakeSettings(state: state, isActive: true)
view.availableMics = [AudioInputDevice(uid: "mic-1", name: "Built-in")] view.availableMics = [AudioInputDevice(uid: "mic-1", name: "Built-in")]
view.availableLocales = [Locale(identifier: "en_US")] view.availableLocales = [Locale(identifier: "en_US")]
view.meterLevel = 0.42 view.meterLevel = 0.42
view.meterError = "No input" view.meterError = "No input"
view.testState = .detected("ok") view.testState = .detected("ok")
view.isTesting = true view.isTesting = true
view.triggerEntries = [TriggerEntry(id: UUID(), value: "Claude")]
_ = view.body _ = view.body
_ = view.localePicker _ = view.localePicker
@@ -654,8 +665,9 @@ extension VoiceWakeSettings {
_ = view.chimeSection _ = view.chimeSection
view.addWord() view.addWord()
_ = view.binding(for: 0).wrappedValue if let entryId = view.triggerEntries.first?.id {
view.removeWord(at: 0) view.removeWord(id: entryId)
}
} }
} }
#endif #endif