From a1ea553cd123540821c86099523c714ee7b22ace Mon Sep 17 00:00:00 2001 From: gabornyergesX Date: Sun, 5 Jul 2026 16:10:09 +0200 Subject: [PATCH] feat: add Retype Mode for remote-desktop/VDI dictation targets Types dictated text one character at a time with a warm-up pause and a slow-ramp-then-steady per-character delay, for remote-desktop/VDI targets where fast keystroke/unicode injection gets garbled or dropped because the remote keyboard channel needs a moment to sync session/layout state. Reuses the existing chunked-CGEvent posting infrastructure instead of adding a separate insertion path. --- Fluid.xcodeproj/project.pbxproj | 12 ++ Sources/Fluid/Persistence/SettingsStore.swift | 66 ++++++++++ Sources/Fluid/Services/TypingService.swift | 119 ++++++++++++++++-- Sources/Fluid/UI/SettingsView.swift | 107 ++++++++++++++++ .../RetypeModeChunkingTests.swift | 100 +++++++++++++++ .../RetypeModeFocusSettleTests.swift | 36 ++++++ .../RetypeModeSettingsTests.swift | 87 +++++++++++++ 7 files changed, 516 insertions(+), 11 deletions(-) create mode 100644 Tests/FluidDictationIntegrationTests/RetypeModeChunkingTests.swift create mode 100644 Tests/FluidDictationIntegrationTests/RetypeModeFocusSettleTests.swift create mode 100644 Tests/FluidDictationIntegrationTests/RetypeModeSettingsTests.swift diff --git a/Fluid.xcodeproj/project.pbxproj b/Fluid.xcodeproj/project.pbxproj index 3c98aacd..7355c6e8 100644 --- a/Fluid.xcodeproj/project.pbxproj +++ b/Fluid.xcodeproj/project.pbxproj @@ -16,6 +16,9 @@ 7CDB0A2E2F3C4D5600FB7CAD /* AudioFixtureLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7CDB0A2A2F3C4D5600FB7CAD /* AudioFixtureLoader.swift */; }; 86CAA2D4EF18433096185602 /* LLMClientRequestBodyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */; }; 272BFB5CB271489892CAE50C /* TemperatureSupportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */; }; + A151A2638402FB94A8B623B0 /* RetypeModeChunkingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B006C71F68032201FC764C29 /* RetypeModeChunkingTests.swift */; }; + 4B6FB68A15BCDAE48784EFE4 /* RetypeModeSettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A1D1884585C2592C6BEA589 /* RetypeModeSettingsTests.swift */; }; + 4736A7DABDC6DC91AC3ED756 /* RetypeModeFocusSettleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB05EADEF59C2A8730DBB46D /* RetypeModeFocusSettleTests.swift */; }; 7CDB0A2F2F3C4D5600FB7CAD /* dictation_fixture.wav in Resources */ = {isa = PBXBuildFile; fileRef = 7CDB0A2B2F3C4D5600FB7CAD /* dictation_fixture.wav */; }; 7CDB0A302F3C4D5600FB7CAD /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7CDB0A2C2F3C4D5600FB7CAD /* XCTest.framework */; }; 7CE006BD2E80EBE600DDCCD6 /* AppUpdater in Frameworks */ = {isa = PBXBuildFile; productRef = 7CE006BC2E80EBE600DDCCD6 /* AppUpdater */; }; @@ -38,6 +41,9 @@ 7CDB0A292F3C4D5600FB7CAD /* DictationE2ETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DictationE2ETests.swift; sourceTree = ""; }; 343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LLMClientRequestBodyTests.swift; sourceTree = ""; }; 980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureSupportTests.swift; sourceTree = ""; }; + B006C71F68032201FC764C29 /* RetypeModeChunkingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetypeModeChunkingTests.swift; sourceTree = ""; }; + 9A1D1884585C2592C6BEA589 /* RetypeModeSettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetypeModeSettingsTests.swift; sourceTree = ""; }; + FB05EADEF59C2A8730DBB46D /* RetypeModeFocusSettleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetypeModeFocusSettleTests.swift; sourceTree = ""; }; 7CDB0A2A2F3C4D5600FB7CAD /* AudioFixtureLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioFixtureLoader.swift; sourceTree = ""; }; 7CDB0A2B2F3C4D5600FB7CAD /* dictation_fixture.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = dictation_fixture.wav; sourceTree = ""; }; 7CDB0A2C2F3C4D5600FB7CAD /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/MacOSX.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; }; @@ -110,6 +116,9 @@ 7C91B0022F42AA0100C0DEF0 /* HotkeyShortcutTests.swift */, 343B29013F4441D6A797D12D /* LLMClientRequestBodyTests.swift */, 980330F3CE464336ADCE3E23 /* TemperatureSupportTests.swift */, + B006C71F68032201FC764C29 /* RetypeModeChunkingTests.swift */, + 9A1D1884585C2592C6BEA589 /* RetypeModeSettingsTests.swift */, + FB05EADEF59C2A8730DBB46D /* RetypeModeFocusSettleTests.swift */, ); path = FluidDictationIntegrationTests; sourceTree = ""; @@ -266,6 +275,9 @@ 7C91B0012F42AA0100C0DEF0 /* HotkeyShortcutTests.swift in Sources */, 86CAA2D4EF18433096185602 /* LLMClientRequestBodyTests.swift in Sources */, 272BFB5CB271489892CAE50C /* TemperatureSupportTests.swift in Sources */, + A151A2638402FB94A8B623B0 /* RetypeModeChunkingTests.swift in Sources */, + 4B6FB68A15BCDAE48784EFE4 /* RetypeModeSettingsTests.swift in Sources */, + 4736A7DABDC6DC91AC3ED756 /* RetypeModeFocusSettleTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/Fluid/Persistence/SettingsStore.swift b/Sources/Fluid/Persistence/SettingsStore.swift index 715b2525..274370aa 100644 --- a/Sources/Fluid/Persistence/SettingsStore.swift +++ b/Sources/Fluid/Persistence/SettingsStore.swift @@ -18,6 +18,10 @@ final class SettingsStore: ObservableObject { static let privateAIContextTokenLimitRange: ClosedRange = 2048...8192 static let privateAIContextTokenLimitStep = 512 static let defaultPrivateAIContextTokenLimit = 4096 + static let slowTypeWarmupMsRange: ClosedRange = 0...2000 + static let slowTypeRampCharCountRange: ClosedRange = 0...30 + static let slowTypeRampDelayMsRange: ClosedRange = 0...200 + static let slowTypeSteadyDelayMsRange: ClosedRange = 0...100 static let privateAIDictationSystemOverheadTokens = 1280 static let privateAIDictationMinimumOutputTokens = 256 static let privateAIDictationRoundTripTokenCost = 2.75 @@ -4477,6 +4481,10 @@ private extension SettingsStore { static let experimentalDirectAudioCaptureEnabled = "ExperimentalDirectAudioCaptureEnabled" static let copyTranscriptionToClipboard = "CopyTranscriptionToClipboard" static let textInsertionMode = "TextInsertionMode" + static let slowTypeWarmupMs = "SlowTypeWarmupMs" + static let slowTypeRampCharCount = "SlowTypeRampCharCount" + static let slowTypeRampDelayMs = "SlowTypeRampDelayMs" + static let slowTypeSteadyDelayMs = "SlowTypeSteadyDelayMs" static let autoUpdateCheckEnabled = "AutoUpdateCheckEnabled" static let betaReleasesEnabled = "BetaReleasesEnabled" static let lastUpdateCheckDate = "LastUpdateCheckDate" @@ -4601,6 +4609,7 @@ extension SettingsStore { enum TextInsertionMode: String, CaseIterable, Identifiable, Codable { case standard case reliablePaste + case slowType var id: String { self.rawValue @@ -4612,6 +4621,8 @@ extension SettingsStore { return "Clipboard Free Insert" case .reliablePaste: return "Clipboard Paste" + case .slowType: + return "Retype Mode" } } @@ -4621,6 +4632,8 @@ extension SettingsStore { return "Fastest path. Inserts text without changing the clipboard, with paste fallback if direct insertion is unavailable." case .reliablePaste: return "Compatibility path. Uses a temporary clipboard paste, so clipboard history apps may briefly record dictated text." + case .slowType: + return "Types one character at a time with a short warm-up and per-character delay. Slower, but more reliable with remote-desktop, VDI, or virtualization apps that garble fast text insertion or block the clipboard." } } } @@ -4640,6 +4653,59 @@ extension SettingsStore { } } + /// Warm-up delay (ms) before Retype Mode sends its first keystroke, letting a + /// remote session finish syncing. Only used in `.slowType` mode. + var slowTypeWarmupMs: Double { + get { + let stored = self.defaults.object(forKey: Keys.slowTypeWarmupMs) as? NSNumber + return stored?.doubleValue ?? 350.0 + } + set { + objectWillChange.send() + let clamped = min(max(newValue, Self.slowTypeWarmupMsRange.lowerBound), Self.slowTypeWarmupMsRange.upperBound) + self.defaults.set(clamped, forKey: Keys.slowTypeWarmupMs) + } + } + + /// Number of leading characters typed at the slower ramp pace in Retype Mode. + var slowTypeRampCharCount: Int { + get { + let stored = self.defaults.object(forKey: Keys.slowTypeRampCharCount) as? NSNumber + return stored?.intValue ?? 6 + } + set { + objectWillChange.send() + let clamped = min(max(newValue, Self.slowTypeRampCharCountRange.lowerBound), Self.slowTypeRampCharCountRange.upperBound) + self.defaults.set(clamped, forKey: Keys.slowTypeRampCharCount) + } + } + + /// Per-character delay (ms) during Retype Mode's ramp phase. + var slowTypeRampDelayMs: Double { + get { + let stored = self.defaults.object(forKey: Keys.slowTypeRampDelayMs) as? NSNumber + return stored?.doubleValue ?? 45.0 + } + set { + objectWillChange.send() + let clamped = min(max(newValue, Self.slowTypeRampDelayMsRange.lowerBound), Self.slowTypeRampDelayMsRange.upperBound) + self.defaults.set(clamped, forKey: Keys.slowTypeRampDelayMs) + } + } + + /// Per-character delay (ms) after Retype Mode's ramp phase. + var slowTypeSteadyDelayMs: Double { + get { + let stored = self.defaults.object(forKey: Keys.slowTypeSteadyDelayMs) as? NSNumber + return stored?.doubleValue ?? 18.0 + } + set { + objectWillChange.send() + let clamped = min(max(newValue, Self.slowTypeSteadyDelayMsRange.lowerBound), Self.slowTypeSteadyDelayMsRange.upperBound) + self.defaults.set(clamped, forKey: Keys.slowTypeSteadyDelayMs) + } + } + var betaReleasesEnabled: Bool { get { let value = self.defaults.object(forKey: Keys.betaReleasesEnabled) diff --git a/Sources/Fluid/Services/TypingService.swift b/Sources/Fluid/Services/TypingService.swift index c52c4de6..7a8f8c89 100644 --- a/Sources/Fluid/Services/TypingService.swift +++ b/Sources/Fluid/Services/TypingService.swift @@ -259,6 +259,22 @@ final class TypingService { self.typeOutputPlanInstantly(.plain(text), preferredTargetPID: preferredTargetPID, textReadyAt: textReadyAt) } + /// How long to wait, before attempting insertion, for macOS to finish restoring focus + /// to the real target app. Needed whenever `preferredTargetPID` is nil, since the PID + /// is then resolved from whatever is *currently* focused — if that resolution runs too + /// soon (e.g. right after we hide our own window), it can capture a stale app instead. + /// Retype Mode's own warm-up delay (see `insertTextSlowly`) happens *after* PID + /// resolution, so it does not substitute for this pre-capture settle. + /// Internal (not `private`) so regression tests can exercise it directly — see + /// `RetypeModeChunkingTests`. + static func settleDelayMs(mode: SettingsStore.TextInsertionMode, preferredTargetPID: pid_t?) -> Int { + guard preferredTargetPID == nil else { return 0 } + switch mode { + case .reliablePaste: return 80 + case .slowType, .standard: return 200 + } + } + func typeOutputPlanInstantly( _ plan: DictationLiteralOutputPlan, preferredTargetPID: pid_t?, @@ -267,12 +283,7 @@ final class TypingService { let requestedAt = ProcessInfo.processInfo.systemUptime let text = plan.plainText let mode = self.textInsertionMode - let settleDelayMs: Int = { - if mode == .reliablePaste { - return preferredTargetPID == nil ? 80 : 0 - } - return preferredTargetPID == nil ? 200 : 0 - }() + let settleDelayMs = Self.settleDelayMs(mode: mode, preferredTargetPID: preferredTargetPID) let textReadyAge = textReadyAt.map { Self.elapsedMs(from: $0, to: requestedAt) } self.bench( "request chars=\(text.count) mode=\(mode.rawValue) autocompleteSteps=\(plan.steps.count) preferredPID=\(preferredTargetPID.map { String($0) } ?? "nil") textReadyAgeMs=\(textReadyAge.map { String($0) } ?? "nil")" @@ -350,6 +361,15 @@ final class TypingService { self.log("[TypingService] insertTextInstantly called with \(text.count) characters") self.log("[TypingService] Attempting to type text: \"\(text.prefix(50))\(text.count > 50 ? "..." : "")\"") + if self.textInsertionMode == .slowType { + self.log("[TypingService] Slow/compatibility typing mode enabled") + if self.insertTextSlowly(text, preferredTargetPID: preferredTargetPID) { + self.log("[TypingService] SUCCESS: Slow typing completed") + return + } + self.log("[TypingService] Slow typing fell through to standard pipeline") + } + if self.textInsertionMode == .reliablePaste { self.log("[TypingService] Reliable Paste mode enabled") if self.tryReliablePasteInsertion(text, preferredTargetPID: preferredTargetPID) { @@ -684,9 +704,61 @@ final class TypingService { } } + // MARK: - Retype Mode (remote-desktop compatibility) + + // Remote-desktop keyboard channels need a moment + // to sync session/layout state; sending the whole string instantly garbles the + // leading characters. Warm-up delay and per-character pacing are user-tunable in + // Settings (see `SettingsStore.slowTypeWarmupMs` and friends) since the right values + // depend on the remote session's own latency. + + /// Types text one character at a time with a warm-up delay and a slow-then-normal + /// ramp, for apps that garble fast unicode insertion (remote-desktop/VDI targets). + private func insertTextSlowly(_ text: String, preferredTargetPID: pid_t?) -> Bool { + let targetPID = preferredTargetPID.flatMap { $0 > 0 ? $0 : nil } + ?? self.getSystemFocusedElementAndPID()?.pid + + guard let targetPID, targetPID > 0 else { + self.log("[TypingService] Slow typing: no target PID available, deferring to fallback pipeline") + return false + } + + let settings = SettingsStore.shared + if NSWorkspace.shared.frontmostApplication?.processIdentifier != targetPID { + _ = Self.activateApp(pid: targetPID) + usleep(80_000) + } + usleep(useconds_t(settings.slowTypeWarmupMs * 1000)) + + let utf16Array = Array(text.utf16) + self.log("[TypingService] Starting slow typing (\(utf16Array.count) UTF16 units) to PID \(targetPID)") + + let rampEnd = Self.rampEnd(for: utf16Array, rampCharCount: settings.slowTypeRampCharCount) + let rampSucceeded = self.postUnicodeChunks( + Array(utf16Array[0.. Void ) -> Bool { guard utf16Array.isEmpty == false else { return true } @@ -697,7 +769,7 @@ final class TypingService { var chunkStart = 0 var chunkCount = 0 while chunkStart < buffer.count { - let chunkEnd = Self.unicodeChunkEnd(in: utf16Array, start: chunkStart) + let chunkEnd = Self.unicodeChunkEnd(in: utf16Array, start: chunkStart, chunkSize: chunkSize) let chunkLength = chunkEnd - chunkStart guard let keyDown = CGEvent(keyboardEventSource: nil, virtualKey: 0, keyDown: true), @@ -713,6 +785,9 @@ final class TypingService { post(keyDown) post(keyUp) + if interChunkDelayMicros > 0 { + usleep(interChunkDelayMicros) + } chunkStart = chunkEnd chunkCount += 1 @@ -722,18 +797,40 @@ final class TypingService { guard chunkCount >= 0 else { return false } - self.log("[TypingService] Posted \(chunkCount) unicode CGEvent chunk(s) to \(destinationDescription) with chunkSize=\(Self.cgEventUnicodeChunkSize) interChunkDelayMs=0") + self.log("[TypingService] Posted \(chunkCount) unicode CGEvent chunk(s) to \(destinationDescription) with chunkSize=\(chunkSize) interChunkDelayMs=\(Double(interChunkDelayMicros) / 1000)") return true } - private static func unicodeChunkEnd(in utf16Array: [UInt16], start: Int) -> Int { - var end = min(start + Self.cgEventUnicodeChunkSize, utf16Array.count) + /// Boundary between Retype Mode's ramp and steady phases. `unicodeChunkEnd` always + /// advances by at least one unit (it's built for chunking loops that must make forward + /// progress), so a ramp length of 0 would still floor to a 1-unit ramp slice instead of + /// skipping the ramp phase entirely — and if that lone unit were a high surrogate, it'd + /// split the pair at this boundary before `postUnicodeChunks` ever sees it. A ramp + /// length of 0 means "no ramp phase," so skip straight to 0 rather than chunking. + /// Internal (not `private`) so regression tests can exercise it directly — see + /// `RetypeModeChunkingTests`. + static func rampEnd(for utf16Array: [UInt16], rampCharCount: Int) -> Int { + rampCharCount > 0 ? Self.unicodeChunkEnd(in: utf16Array, start: 0, chunkSize: rampCharCount) : 0 + } + + /// Internal (not `private`) so regression tests can exercise the surrogate-pair + /// chunking logic directly — see `RetypeModeChunkingTests`. + static func unicodeChunkEnd(in utf16Array: [UInt16], start: Int, chunkSize: Int = TypingService.cgEventUnicodeChunkSize) -> Int { + var end = min(start + chunkSize, utf16Array.count) if end < utf16Array.count, end > start, Self.isHighSurrogate(utf16Array[end - 1]), Self.isLowSurrogate(utf16Array[end]) { - end -= 1 + // Backing off keeps the pair intact for the *next* chunk, but only if that + // leaves this chunk non-empty. At chunkSize 1 (Retype Mode) the pair's high + // surrogate IS the whole chunk, so back off would produce an empty chunk; + // widen forward instead to keep both surrogate halves together here. + if end - 1 > start { + end -= 1 + } else { + end += 1 + } } return max(end, start + 1) } diff --git a/Sources/Fluid/UI/SettingsView.swift b/Sources/Fluid/UI/SettingsView.swift index 5b551d40..74a4e804 100644 --- a/Sources/Fluid/UI/SettingsView.swift +++ b/Sources/Fluid/UI/SettingsView.swift @@ -861,6 +861,12 @@ struct SettingsView: View { .pickerStyle(.menu) .frame(width: 170, alignment: .trailing) } + + if SettingsStore.shared.textInsertionMode == .slowType { + Divider().opacity(0.2) + self.slowTypeTuningControls() + .padding(.top, 2) + } Divider().opacity(0.2) self.optionToggleRow( @@ -2520,6 +2526,107 @@ struct FlowLayout: Layout { } private extension SettingsView { + func slowTypeTuningControls() -> some View { + VStack(spacing: 12) { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text("Warm-up Delay") + .font(self.theme.typography.bodyStrong) + .foregroundStyle(self.settingsTitleText) + Text("Pause before the first keystroke, letting the remote session sync.") + .font(self.theme.typography.bodySmall) + .foregroundStyle(self.settingsSecondaryText) + } + Spacer(minLength: 16) + HStack(spacing: 6) { + Slider(value: self.$settings.slowTypeWarmupMs, in: SettingsStore.slowTypeWarmupMsRange, step: 50) + .frame(width: 110) + .controlSize(.small) + Text("\(Int(self.settings.slowTypeWarmupMs)) ms") + .font(.caption.monospaced()) + .foregroundStyle(self.settingsTertiaryText) + .frame(width: 56, alignment: .trailing) + } + } + + Divider().opacity(0.2) + + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text("Ramp Length") + .font(self.theme.typography.bodyStrong) + .foregroundStyle(self.settingsTitleText) + Text("Leading characters typed at the slower ramp pace.") + .font(self.theme.typography.bodySmall) + .foregroundStyle(self.settingsSecondaryText) + } + Spacer(minLength: 16) + HStack(spacing: 6) { + Slider( + value: Binding( + get: { Double(self.settings.slowTypeRampCharCount) }, + set: { self.settings.slowTypeRampCharCount = Int($0.rounded()) } + ), + in: Double(SettingsStore.slowTypeRampCharCountRange.lowerBound)...Double(SettingsStore.slowTypeRampCharCountRange.upperBound), + step: 1 + ) + .frame(width: 110) + .controlSize(.small) + Text("\(self.settings.slowTypeRampCharCount) chars") + .font(.caption.monospaced()) + .foregroundStyle(self.settingsTertiaryText) + .frame(width: 56, alignment: .trailing) + } + } + + Divider().opacity(0.2) + + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text("Ramp Pace") + .font(self.theme.typography.bodyStrong) + .foregroundStyle(self.settingsTitleText) + Text("Per-character delay during the ramp.") + .font(self.theme.typography.bodySmall) + .foregroundStyle(self.settingsSecondaryText) + } + Spacer(minLength: 16) + HStack(spacing: 6) { + Slider(value: self.$settings.slowTypeRampDelayMs, in: SettingsStore.slowTypeRampDelayMsRange, step: 5) + .frame(width: 110) + .controlSize(.small) + Text("\(Int(self.settings.slowTypeRampDelayMs)) ms") + .font(.caption.monospaced()) + .foregroundStyle(self.settingsTertiaryText) + .frame(width: 56, alignment: .trailing) + } + } + + Divider().opacity(0.2) + + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 2) { + Text("Steady Pace") + .font(self.theme.typography.bodyStrong) + .foregroundStyle(self.settingsTitleText) + Text("Per-character delay after the ramp.") + .font(self.theme.typography.bodySmall) + .foregroundStyle(self.settingsSecondaryText) + } + Spacer(minLength: 16) + HStack(spacing: 6) { + Slider(value: self.$settings.slowTypeSteadyDelayMs, in: SettingsStore.slowTypeSteadyDelayMsRange, step: 2) + .frame(width: 110) + .controlSize(.small) + Text("\(Int(self.settings.slowTypeSteadyDelayMs)) ms") + .font(.caption.monospaced()) + .foregroundStyle(self.settingsTertiaryText) + .frame(width: 56, alignment: .trailing) + } + } + } + } + var lowLatencyAudioCaptureToggle: some View { Group { self.settingsToggleRow( diff --git a/Tests/FluidDictationIntegrationTests/RetypeModeChunkingTests.swift b/Tests/FluidDictationIntegrationTests/RetypeModeChunkingTests.swift new file mode 100644 index 00000000..5e90e823 --- /dev/null +++ b/Tests/FluidDictationIntegrationTests/RetypeModeChunkingTests.swift @@ -0,0 +1,100 @@ +@testable import FluidVoice_Debug +import XCTest + +// Regression tests for Retype Mode's character-by-character (chunkSize: 1) unicode +// chunking. `unicodeChunkEnd`'s surrogate-pair guard was written for the bulk path +// (chunkSize 200): on a boundary split it backs off by one unit, deferring the pair to +// the next chunk. At chunkSize 1 that backoff produced an *empty* chunk, which the +// `max(end, start + 1)` safety net then forced back to a 1-unit chunk anyway — silently +// splitting the surrogate pair into two lone-surrogate CGEvents. +// See PR review on https://github.com/altic-dev/FluidVoice/pull/562. + +final class RetypeModeChunkingTests: XCTestCase { + private func chunks(of text: String, chunkSize: Int) -> [[UInt16]] { + let units = Array(text.utf16) + var result: [[UInt16]] = [] + var start = 0 + while start < units.count { + let end = TypingService.unicodeChunkEnd(in: units, start: start, chunkSize: chunkSize) + result.append(Array(units[start.. 0 ? value : default`, which can't distinguish a genuinely +// stored 0 from an unset key (both read back as 0.0 from UserDefaults) — so a user who +// dragged a slider to its minimum (0ms, which all three ranges explicitly allow) silently +// got the fallback default forever. See PR review on +// https://github.com/altic-dev/FluidVoice/pull/562. + +@MainActor +final class RetypeModeSettingsTests: XCTestCase { + private let warmupKey = "SlowTypeWarmupMs" + private let rampDelayKey = "SlowTypeRampDelayMs" + private let steadyDelayKey = "SlowTypeSteadyDelayMs" + + func testZeroWarmupDelayPersists() { + self.withRestoredDefaults(keys: [self.warmupKey]) { + SettingsStore.shared.slowTypeWarmupMs = 0 + XCTAssertEqual(SettingsStore.shared.slowTypeWarmupMs, 0) + } + } + + func testZeroRampDelayPersists() { + self.withRestoredDefaults(keys: [self.rampDelayKey]) { + SettingsStore.shared.slowTypeRampDelayMs = 0 + XCTAssertEqual(SettingsStore.shared.slowTypeRampDelayMs, 0) + } + } + + func testZeroSteadyDelayPersists() { + self.withRestoredDefaults(keys: [self.steadyDelayKey]) { + SettingsStore.shared.slowTypeSteadyDelayMs = 0 + XCTAssertEqual(SettingsStore.shared.slowTypeSteadyDelayMs, 0) + } + } + + func testUnsetWarmupDelayFallsBackToDocumentedDefault() { + self.withRestoredDefaults(keys: [self.warmupKey]) { + UserDefaults.standard.removeObject(forKey: self.warmupKey) + XCTAssertEqual(SettingsStore.shared.slowTypeWarmupMs, 350) + } + } + + func testUnsetRampAndSteadyDelaysFallBackToDocumentedDefaults() { + self.withRestoredDefaults(keys: [self.rampDelayKey, self.steadyDelayKey]) { + UserDefaults.standard.removeObject(forKey: self.rampDelayKey) + UserDefaults.standard.removeObject(forKey: self.steadyDelayKey) + XCTAssertEqual(SettingsStore.shared.slowTypeRampDelayMs, 45) + XCTAssertEqual(SettingsStore.shared.slowTypeSteadyDelayMs, 18) + } + } + + func testNonZeroDelaysRoundTrip() { + self.withRestoredDefaults(keys: [self.warmupKey, self.rampDelayKey, self.steadyDelayKey]) { + SettingsStore.shared.slowTypeWarmupMs = 500 + SettingsStore.shared.slowTypeRampDelayMs = 60 + SettingsStore.shared.slowTypeSteadyDelayMs = 25 + XCTAssertEqual(SettingsStore.shared.slowTypeWarmupMs, 500) + XCTAssertEqual(SettingsStore.shared.slowTypeRampDelayMs, 60) + XCTAssertEqual(SettingsStore.shared.slowTypeSteadyDelayMs, 25) + } + } + + private func withRestoredDefaults(keys: [String], run: () -> Void) { + let defaults = UserDefaults.standard + var snapshot: [String: Any] = [:] + for key in keys { + if let value = defaults.object(forKey: key) { + snapshot[key] = value + } + } + + defer { + for key in keys { + if let previous = snapshot[key] { + defaults.set(previous, forKey: key) + } else { + defaults.removeObject(forKey: key) + } + } + } + + run() + } +}