From e678b41c2a21a7ee3a03d852bb0d6d96ecff9dfe Mon Sep 17 00:00:00 2001 From: Julian Pscheid Date: Sun, 6 Sep 2026 22:09:36 -0700 Subject: [PATCH] Keep the Direct Dictation preview current --- .../Dictation/DirectDictationController.swift | 14 +++----- .../HUD/DictationHUDController.swift | 2 +- .../Dictation/HUD/DictationHUDShellView.swift | 1 + .../Dictation/HUD/HUDRecentDraftText.swift | 9 ++++-- Talkify/Dictation/SpellingReplacement.swift | 32 +++++++++++++++---- TalkifyTests/HUDPlaceholderTests.swift | 12 +++++++ TalkifyTests/SpellingReplacementTests.swift | 27 ++++++++++++++++ 7 files changed, 79 insertions(+), 18 deletions(-) diff --git a/Talkify/Dictation/DirectDictationController.swift b/Talkify/Dictation/DirectDictationController.swift index 65f8e6f..be8c2ec 100644 --- a/Talkify/Dictation/DirectDictationController.swift +++ b/Talkify/Dictation/DirectDictationController.swift @@ -676,18 +676,14 @@ final class DirectDictationController { let hasVisibleText = !displayText .trimmingCharacters(in: .whitespacesAndNewlines) .isEmpty - // Keep the committed/volatile split the HUD uses, and rewrite each - // half so a pair like Hetty→Hedy shows in the live draft, not only - // in the paste. Insertion still applies to the full spoken string. let replacements = currentSessionSettings?.spellingReplacements ?? [] - pendingLiveText = SpellingReplacements.apply( - update.finalizedText, - using: replacements - ) - pendingVolatileText = SpellingReplacements.apply( - update.volatileText, + let draft = SpellingReplacements.apply( + committed: update.finalizedText, + volatile: update.volatileText, using: replacements ) + pendingLiveText = draft.committed + pendingVolatileText = draft.volatile send(.updateReceived(hasVisibleText: hasVisibleText)) pendingLiveText = nil pendingVolatileText = "" diff --git a/Talkify/Dictation/HUD/DictationHUDController.swift b/Talkify/Dictation/HUD/DictationHUDController.swift index 8f990df..f6fbe98 100644 --- a/Talkify/Dictation/HUD/DictationHUDController.swift +++ b/Talkify/Dictation/HUD/DictationHUDController.swift @@ -125,7 +125,7 @@ final class DictationHUDController { } func showLiveText(_ committed: String, volatile: String = "") { - guard isListening, !(committed + volatile).isEmpty else { return } + guard isListening else { return } setDraft(committed, volatile: volatile) } diff --git a/Talkify/Dictation/HUD/DictationHUDShellView.swift b/Talkify/Dictation/HUD/DictationHUDShellView.swift index f396706..ef20034 100644 --- a/Talkify/Dictation/HUD/DictationHUDShellView.swift +++ b/Talkify/Dictation/HUD/DictationHUDShellView.swift @@ -195,6 +195,7 @@ struct DictationHUDShellView: View { volatile: content.volatileText, scale: metrics.scale ) + .equatable() .padding(.horizontal, tagInset * metrics.scale) } } diff --git a/Talkify/Dictation/HUD/HUDRecentDraftText.swift b/Talkify/Dictation/HUD/HUDRecentDraftText.swift index 7fce105..efc7ab5 100644 --- a/Talkify/Dictation/HUD/HUDRecentDraftText.swift +++ b/Talkify/Dictation/HUD/HUDRecentDraftText.swift @@ -156,13 +156,18 @@ private struct HUDRecentDraftLineLayout: Layout { /// Edge Glow + Draft's live draft: one 24-point line of recent words, /// centered in the island when it fits. Each word keeps a stable id, so /// only a new or departing token slides; a finalization rewrites in place. -struct HUDRecentDraftText: View { +struct HUDRecentDraftText: View, Equatable { static let pointSize: CGFloat = 24 let committed: String let volatile: String var scale: CGFloat = 1 + // Audio-level redraws must not tokenize an unchanged transcript again. + nonisolated static func == (lhs: Self, rhs: Self) -> Bool { + lhs.committed == rhs.committed && lhs.volatile == rhs.volatile && lhs.scale == rhs.scale + } + @State private var lastWindow: HUDRecentDraftWindow? private var window: HUDRecentDraftWindow { @@ -187,7 +192,7 @@ struct HUDRecentDraftText: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) .clipped() - .animation(animate ? .easeOut(duration: 0.14) : nil, value: window.tokenIDs) + .animation(animate ? .easeOut(duration: 0.10) : nil, value: window.tokenIDs) .onChange(of: window) { _, new in lastWindow = new } diff --git a/Talkify/Dictation/SpellingReplacement.swift b/Talkify/Dictation/SpellingReplacement.swift index 9ea29c5..01ef711 100644 --- a/Talkify/Dictation/SpellingReplacement.swift +++ b/Talkify/Dictation/SpellingReplacement.swift @@ -29,35 +29,55 @@ enum SpellingReplacements { /// "e-mail" are what the recognizer produces as often as a single token /// is. static func apply(_ text: String, using pairs: [SpellingReplacement]) -> String { + apply(committed: text, volatile: "", using: pairs).committed + } + + /// Match across the recognition boundary too. A replacement touching a + /// provisional word stays provisional until the whole match commits. + static func apply( + committed: String, + volatile: String, + using pairs: [SpellingReplacement] + ) -> (committed: String, volatile: String) { + let text = committed + volatile let active = pairs.compactMap { pair -> Pair? in let from = pair.from.trimmingCharacters(in: .whitespacesAndNewlines) let to = pair.to.trimmingCharacters(in: .whitespacesAndNewlines) guard !from.isEmpty, !to.isEmpty else { return nil } return Pair(from: from, to: to, length: from.count) } - guard !active.isEmpty, !text.isEmpty else { return text } + guard !active.isEmpty, !text.isEmpty else { return (committed, volatile) } - var result = "" - result.reserveCapacity(text.count) + var finalized = "" + var provisional = "" + var committedBytesRemaining = committed.utf8.count var index = text.startIndex var previous: Character? while index < text.endIndex { + let start = index + let replacement: String // A match can only open where a word does, which is also what keeps // "mm" out of the middle of "comment". if !isWordCharacter(previous), let match = longestMatch(in: text, at: index, using: active) { - result += match.to + replacement = match.to // The boundary the next position sees is the text's, not the // replacement's: what was matched stays matched. previous = text[text.index(before: match.end)] index = match.end } else { previous = text[index] - result.append(text[index]) + replacement = String(text[index]) index = text.index(after: index) } + committedBytesRemaining -= text[start..= 0 { + finalized += replacement + } else { + provisional += replacement + } } - return result + return (finalized, provisional) } private struct Pair { diff --git a/TalkifyTests/HUDPlaceholderTests.swift b/TalkifyTests/HUDPlaceholderTests.swift index 65ba822..46424f0 100644 --- a/TalkifyTests/HUDPlaceholderTests.swift +++ b/TalkifyTests/HUDPlaceholderTests.swift @@ -152,6 +152,18 @@ struct HUDPlaceholderTests { #expect(hud.textForTesting == "hello there") } + @Test func withdrawingTheLastGuessClearsTheDraft() { + let store = AppSettings.previewStore() + store.voiceVisual = .glowDraft + let hud = DictationHUDController(stage: HUDStage(settings: store), settings: store) + hud.showListening(on: nil, isLatched: false, settings: session(store)) + hud.showLiveText("", volatile: "withdrawn guess") + #expect(hud.textForTesting == "withdrawn guess") + hud.showLiveText("", volatile: "") + #expect(hud.textForTesting.isEmpty) + hud.hide() + } + @Test func aVolatileGuessShowsBeforeItCommits() { let store = AppSettings.previewStore() store.voiceVisual = .glowDraft diff --git a/TalkifyTests/SpellingReplacementTests.swift b/TalkifyTests/SpellingReplacementTests.swift index 19a40da..19f96fa 100644 --- a/TalkifyTests/SpellingReplacementTests.swift +++ b/TalkifyTests/SpellingReplacementTests.swift @@ -2,6 +2,33 @@ import Testing @testable import Talkify struct SpellingReplacementTests { + @Test func liveReplacementMatchesTheWholeDraftAtEveryBoundary() { + let text = "open ex code today" + let pairs = [SpellingReplacement(id: "1", from: "ex code", to: "Xcode")] + for boundary in Array(text.indices) + [text.endIndex] { + let result = SpellingReplacements.apply( + committed: String(text[..