From 584301d83d7ca60ab1ea3f20c5222bdcfce5ac0a Mon Sep 17 00:00:00 2001 From: Will Date: Sun, 6 Sep 2026 16:53:20 +0800 Subject: [PATCH 01/14] Shared // PrefMgr: Add kAutoSwitchToAlphanumericalOnConsecutiveErrors preference. --- Packages/vChewing_Shared/Sources/Shared/PrefMgr_Core.swift | 3 +++ .../Sources/Shared/Protocols/PrefMgrProtocol.swift | 1 + .../vChewing_Shared/Sources/Shared/UserDef/UserDef.swift | 7 +++++++ 3 files changed, 11 insertions(+) diff --git a/Packages/vChewing_Shared/Sources/Shared/PrefMgr_Core.swift b/Packages/vChewing_Shared/Sources/Shared/PrefMgr_Core.swift index cad3b3f0f..35d0926e9 100644 --- a/Packages/vChewing_Shared/Sources/Shared/PrefMgr_Core.swift +++ b/Packages/vChewing_Shared/Sources/Shared/PrefMgr_Core.swift @@ -168,6 +168,9 @@ public final class PrefMgr: PrefMgrProtocol, Sendable { @AppProperty(userDef: .kAutoCorrectReadingCombination) public var autoCorrectReadingCombination: Bool + @AppProperty(userDef: .kAutoSwitchToAlphanumericalOnConsecutiveErrors) + public var autoSwitchToAlphanumericalOnConsecutiveErrors: Bool + @AppProperty(userDef: .kAlsoConfirmAssociatedCandidatesByEnter) public var alsoConfirmAssociatedCandidatesByEnter: Bool diff --git a/Packages/vChewing_Shared/Sources/Shared/Protocols/PrefMgrProtocol.swift b/Packages/vChewing_Shared/Sources/Shared/Protocols/PrefMgrProtocol.swift index e95c92204..f31f200e8 100644 --- a/Packages/vChewing_Shared/Sources/Shared/Protocols/PrefMgrProtocol.swift +++ b/Packages/vChewing_Shared/Sources/Shared/Protocols/PrefMgrProtocol.swift @@ -54,6 +54,7 @@ public protocol PrefMgrProtocol { var reducePOMLifetimeToNoMoreThan12Hours: Bool { get set } var useFixedCandidateOrderOnSelection: Bool { get set } var autoCorrectReadingCombination: Bool { get set } + var autoSwitchToAlphanumericalOnConsecutiveErrors: Bool { get set } var readingNarrationCoverage: Int { get set } var alsoConfirmAssociatedCandidatesByEnter: Bool { get set } var keepReadingUponCompositionError: Bool { get set } diff --git a/Packages/vChewing_Shared/Sources/Shared/UserDef/UserDef.swift b/Packages/vChewing_Shared/Sources/Shared/UserDef/UserDef.swift index c89988b98..21fd49905 100644 --- a/Packages/vChewing_Shared/Sources/Shared/UserDef/UserDef.swift +++ b/Packages/vChewing_Shared/Sources/Shared/UserDef/UserDef.swift @@ -80,6 +80,7 @@ nonisolated public enum UserDef: String, CaseIterable, Identifiable, Sendable { case kFetchSuggestionsFromPerceptionOverrideModel = "FetchSuggestionsFromPerceptionOverrideModel" case kUseFixedCandidateOrderOnSelection = "UseFixedCandidateOrderOnSelection" case kAutoCorrectReadingCombination = "AutoCorrectReadingCombination" + case kAutoSwitchToAlphanumericalOnConsecutiveErrors = "AutoSwitchToAlphanumericalOnConsecutiveErrors" case kReadingNarrationCoverage = "ReadingNarrationCoverage" case kAlsoConfirmAssociatedCandidatesByEnter = "AlsoConfirmAssociatedCandidatesByEnter" case kKeepReadingUponCompositionError = "KeepReadingUponCompositionError" @@ -497,6 +498,7 @@ nonisolated extension UserDef { case .kFetchSuggestionsFromPerceptionOverrideModel: return .bool(true) case .kUseFixedCandidateOrderOnSelection: return .bool(false) case .kAutoCorrectReadingCombination: return .bool(true) + case .kAutoSwitchToAlphanumericalOnConsecutiveErrors: return .bool(true) case .kReadingNarrationCoverage: return .integer(0) case .kAlsoConfirmAssociatedCandidatesByEnter: return .bool(false) case .kKeepReadingUponCompositionError: return .bool(false) @@ -898,6 +900,11 @@ nonisolated extension UserDef { shortTitle: "i18n:UserDef.kAutoCorrectReadingCombination.shortTitle", description: "i18n:UserDef.kAutoCorrectReadingCombination.description" ) + case .kAutoSwitchToAlphanumericalOnConsecutiveErrors: return .init( + userDef: self, + shortTitle: "i18n:UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.shortTitle", + description: "i18n:UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.description" + ) case .kReadingNarrationCoverage: return .init( userDef: self, shortTitle: "i18n:UserDef.kReadingNarrationCoverage.shortTitle", description: "i18n:UserDef.kReadingNarrationCoverage.description", From 914b5a53bf39935e96c902c99526a18623112654 Mon Sep 17 00:00:00 2001 From: Will Date: Sun, 6 Sep 2026 16:53:20 +0800 Subject: [PATCH 02/14] MainAssembly4Darwin // Localization: Add strings for auto-switch on typing errors. --- .../vChewingIME_macOS/Resources/en.lproj/Localizable.strings | 2 ++ .../vChewingIME_macOS/Resources/ja.lproj/Localizable.strings | 2 ++ .../Resources/zh-Hans.lproj/Localizable.strings | 2 ++ .../Resources/zh-Hant.lproj/Localizable.strings | 2 ++ 4 files changed, 8 insertions(+) diff --git a/Sources/vChewingIME_macOS/Resources/en.lproj/Localizable.strings b/Sources/vChewingIME_macOS/Resources/en.lproj/Localizable.strings index ea3c526e0..a9a726091 100644 --- a/Sources/vChewingIME_macOS/Resources/en.lproj/Localizable.strings +++ b/Sources/vChewingIME_macOS/Resources/en.lproj/Localizable.strings @@ -345,6 +345,8 @@ "i18n:UserDef.kAutoCompositeWithLongestPossibleCassetteKey.shortTitle" = "Auto-composite when the longest possible key is formed"; "i18n:UserDef.kAutoCorrectReadingCombination.description" = "This feature is disabled with mixed Chinese-English typing fallback due to compatibility concerns."; "i18n:UserDef.kAutoCorrectReadingCombination.shortTitle" = "Automatically correct reading combinations when typing"; +"i18n:UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.description" = "Under Traditional Chinese typing mode, clear the composition buffer, commit corresponding alphanumeric text, and switch to alphanumeric mode if 5 consecutive invalid phonetic keys are pressed."; +"i18n:UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.shortTitle" = "Auto-switch to alphanumeric mode upon 5 consecutive typing errors"; "i18n:UserDef.kBasicKeyboardLayout.description" = "Choose the macOS-level basic keyboard layout. Zhuyin typing and Pinyin typing are now mutually switchable modes: while typing Pinyin, this layout determines the actual characters produced by your keys, so non-QWERTY alphanumerical keyboard layouts apply as well; while typing Zhuyin, keystrokes are translated as if on a standard layout, and the on-screen keyboard is displayed accordingly."; "i18n:UserDef.kBasicKeyboardLayout.shortTitle" = "Basic Keyboard Layout:"; "i18n:UserDef.kBeepSoundPreference.option.0" = "Muted"; diff --git a/Sources/vChewingIME_macOS/Resources/ja.lproj/Localizable.strings b/Sources/vChewingIME_macOS/Resources/ja.lproj/Localizable.strings index 6599d7c5e..ddb45e0f7 100644 --- a/Sources/vChewingIME_macOS/Resources/ja.lproj/Localizable.strings +++ b/Sources/vChewingIME_macOS/Resources/ja.lproj/Localizable.strings @@ -345,6 +345,8 @@ "i18n:UserDef.kAutoCompositeWithLongestPossibleCassetteKey.shortTitle" = "最長可能キーができた場合、文字を組み立つ"; "i18n:UserDef.kAutoCorrectReadingCombination.description" = "互換性のため、中英混在入力のフォールバックがONの場合、この機能は不作動。"; "i18n:UserDef.kAutoCorrectReadingCombination.shortTitle" = "入力中で打ち間違った発音組み合わせを自動的に訂正する"; +"i18n:UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.description" = "繁体字中国語入力モード時、注音構造に合致しない誤入力が5回連続した場合、未確定文字列を破棄して英数字を出力し、英数モードへ自動切替します。"; +"i18n:UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.shortTitle" = "タイピング誤入力が5回連続した際に英数モードへ自動切替"; "i18n:UserDef.kBasicKeyboardLayout.description" = "macOS 基礎キーボード配置をご指定ください。注音モードと弁音モードは、現在はいつでも相互に切り替えられます。弁音モードでは、この配置が実際のキー出力を決定します(QWERTY 以外の英数キーボードも適用可能)。注音モードでは、キーは常に標準配列として扱われ、キーボードビューアの表示もそれに応じて変わります。"; "i18n:UserDef.kBasicKeyboardLayout.shortTitle" = "基礎キーボード:"; "i18n:UserDef.kBeepSoundPreference.option.0" = "鳴らず"; diff --git a/Sources/vChewingIME_macOS/Resources/zh-Hans.lproj/Localizable.strings b/Sources/vChewingIME_macOS/Resources/zh-Hans.lproj/Localizable.strings index 7ef81633b..4f2245f3d 100644 --- a/Sources/vChewingIME_macOS/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/vChewingIME_macOS/Resources/zh-Hans.lproj/Localizable.strings @@ -345,6 +345,8 @@ "i18n:UserDef.kAutoCompositeWithLongestPossibleCassetteKey.shortTitle" = "在已经敲出最长可能码的时候自动组字"; "i18n:UserDef.kAutoCorrectReadingCombination.description" = "该功能在中英混合输入回退时不起作用,以确保相容性。"; "i18n:UserDef.kAutoCorrectReadingCombination.shortTitle" = "敲字时自动纠正读音组合"; +"i18n:UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.description" = "在「唯音-繁」中文模式下,若连续输入破坏注音结构的错误键达 5 次,将清空组字区并输出对应英数文字,随后自动切换至英数模式。"; +"i18n:UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.shortTitle" = "连续键入错误达 5 键时自动切换至英数模式"; "i18n:UserDef.kBasicKeyboardLayout.description" = "请选择 macOS 基础键盘布局。注音打字与拼音打字现为可随时互相切换的打字模式:拼音打字时,此布局决定实际按键输出(非 QWERTY 英数布局亦适用);注音打字时,按键一律依标准键位翻译,荧幕键盘亦随之显示。"; "i18n:UserDef.kBasicKeyboardLayout.shortTitle" = "基础键盘布局:"; "i18n:UserDef.kBeepSoundPreference.option.0" = "静音"; diff --git a/Sources/vChewingIME_macOS/Resources/zh-Hant.lproj/Localizable.strings b/Sources/vChewingIME_macOS/Resources/zh-Hant.lproj/Localizable.strings index 5a159fdd8..d662833cf 100644 --- a/Sources/vChewingIME_macOS/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/vChewingIME_macOS/Resources/zh-Hant.lproj/Localizable.strings @@ -345,6 +345,8 @@ "i18n:UserDef.kAutoCompositeWithLongestPossibleCassetteKey.shortTitle" = "在已經敲出最長可能碼的時候自動組字"; "i18n:UserDef.kAutoCorrectReadingCombination.description" = "該功能在中英混合輸入回退時不起作用,以確保相容性。"; "i18n:UserDef.kAutoCorrectReadingCombination.shortTitle" = "敲字時自動糾正讀音組合"; +"i18n:UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.description" = "在「唯音-繁」中文模式下,若連續輸入破壞注音結構的錯誤鍵達 5 次,將清空組字區並輸出對應英數文字,隨後自動切換至英數模式。"; +"i18n:UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.shortTitle" = "連續鍵入錯誤達 5 鍵時自動切換至英數模式"; "i18n:UserDef.kBasicKeyboardLayout.description" = "請選擇 macOS 基礎鍵盤佈局。注音打字與拼音打字現為可隨時互相切換的打字模式:拼音打字時,此佈局決定實際按鍵輸出(非 QWERTY 英數佈局亦適用);注音打字時,按鍵一律依標準鍵位翻譯,螢幕鍵盤亦隨之顯示。"; "i18n:UserDef.kBasicKeyboardLayout.shortTitle" = "基礎鍵盤佈局:"; "i18n:UserDef.kBeepSoundPreference.option.0" = "靜音"; From f9027363263e4282136da8f8bbe160aa21aa11a4 Mon Sep 17 00:00:00 2001 From: Will Date: Sun, 6 Sep 2026 16:53:20 +0800 Subject: [PATCH 03/14] SettingsUI // General: Add toggle for auto-switching to alphanumerical on consecutive errors. --- .../SettingsCocoa/VwrSettingsPaneCocoaGeneral.swift | 4 ++++ .../SettingsUI/SettingsUI/VwrSettingsPaneGeneral.swift | 1 + 2 files changed, 5 insertions(+) diff --git a/Packages/vChewing_SettingsUI/Sources/SettingsUI/SettingsCocoa/VwrSettingsPaneCocoaGeneral.swift b/Packages/vChewing_SettingsUI/Sources/SettingsUI/SettingsCocoa/VwrSettingsPaneCocoaGeneral.swift index 4e5de54f0..ae8dae4bf 100644 --- a/Packages/vChewing_SettingsUI/Sources/SettingsUI/SettingsCocoa/VwrSettingsPaneCocoaGeneral.swift +++ b/Packages/vChewing_SettingsUI/Sources/SettingsUI/SettingsCocoa/VwrSettingsPaneCocoaGeneral.swift @@ -64,6 +64,10 @@ extension SettingsPanesCocoa { fixWidth: contentWidth, prefUITab: .tabGeneral ) + UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.renderCocoa( + fixWidth: contentWidth, + prefUITab: .tabGeneral + ) UserDef.kShowHanyuPinyinInCompositionBuffer.renderCocoa( fixWidth: contentWidth, prefUITab: .tabGeneral diff --git a/Packages/vChewing_SettingsUI/Sources/SettingsUI/SettingsUI/VwrSettingsPaneGeneral.swift b/Packages/vChewing_SettingsUI/Sources/SettingsUI/SettingsUI/VwrSettingsPaneGeneral.swift index e4ac44ef9..a15e66439 100644 --- a/Packages/vChewing_SettingsUI/Sources/SettingsUI/SettingsUI/VwrSettingsPaneGeneral.swift +++ b/Packages/vChewing_SettingsUI/Sources/SettingsUI/SettingsUI/VwrSettingsPaneGeneral.swift @@ -64,6 +64,7 @@ public struct VwrSettingsPaneGeneral: View { SpeechSputnik.shared.refreshStatus() } UserDef.kAutoCorrectReadingCombination.renderUI() + UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.renderUI() UserDef.kShowHanyuPinyinInCompositionBuffer.renderUI() UserDef.kKeepReadingUponCompositionError.renderUI() UserDef.kClassicHaninKeyboardSymbolModeShortcutEnabled.renderUI() From 4d251a29924db00238bdc44382dd0076d52742b6 Mon Sep 17 00:00:00 2001 From: Will Date: Sun, 6 Sep 2026 16:53:20 +0800 Subject: [PATCH 04/14] Typewriter // FSM: Auto-switch to alphanumerical on 5 consecutive typing errors. --- .../InputHandler/InputHandler.swift | 1 + .../InputHandler_CoreProtocol.swift | 141 ++++++++++++ .../InputHandler_HandleComposition.swift | 3 + .../InputHandler_TriageInput.swift | 7 + .../Session/SessionCoreProtocol.swift | 7 +- .../Typewriter/Typewriter_BPMFFullMatch.swift | 2 + ...InputHandlerTests_AutoSwitchOnErrors.swift | 210 ++++++++++++++++++ .../MockedInputHandlerAndStates.swift | 2 + 8 files changed, 372 insertions(+), 1 deletion(-) create mode 100644 Packages/vChewing_Typewriter/Tests/TypewriterTests/InputHandlerTests_AutoSwitchOnErrors.swift diff --git a/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler.swift b/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler.swift index 26c447478..ac1aa5442 100644 --- a/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler.swift +++ b/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler.swift @@ -76,6 +76,7 @@ public final class InputHandler: @MainActor InputHandlerProtocol { public var strCodePointBuffer = "" // 內碼輸入專用組碼區 public var calligrapher = "" // 磁帶專用組筆區 public var mixedAlphanumericalBuffer = "" // 混輸暫存 ASCII 緩衝區 + public var consecutiveTypingErrors = [String]() // 連續輸入錯誤鍵暫存區 public var furiousTrail = [String]() // 狂拼模式:自動 chop 提交鍵對應的拼音字母 blob trail public var furiousHighlightOverride: CandidateInState? // 狂拼 copilot 窗高亮候選(當拍消費) public var furiousCoSegmentedOffers = [FuriousCoSegmentedOffer]() // 狂拼 copilot 窗聯合重切(P164)的替代切分 offers diff --git a/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_CoreProtocol.swift b/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_CoreProtocol.swift index f9f4b856b..08972d8a2 100644 --- a/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_CoreProtocol.swift +++ b/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_CoreProtocol.swift @@ -7,6 +7,7 @@ // requirements defined in MIT License. import Foundation +import Shared // MARK: - InputHandlerProtocol @@ -52,6 +53,7 @@ public protocol InputHandlerProtocol: AnyObject { var strCodePointBuffer: String { get set } // 內碼輸入專用組碼區 var calligrapher: String { get set } // 磁帶專用組筆區 var mixedAlphanumericalBuffer: String { get set } // 混輸暫存 ASCII 緩衝區 + var consecutiveTypingErrors: [String] { get set } // 連續輸入錯誤鍵暫存區 var furiousTrail: [String] { get set } // 狂拼模式:自動 chop/空格固化提交鍵對應的拼音字母 blob trail var furiousHighlightOverride: CandidateInState? { get set } // 狂拼 copilot 窗高亮候選(當拍消費) var furiousCoSegmentedOffers: [FuriousCoSegmentedOffer] { get set @@ -431,6 +433,7 @@ extension InputHandlerProtocol { public func clearComposerAndCalligrapher() { calligrapher.removeAll() composer.clear() + consecutiveTypingErrors.removeAll() mixedAlphanumericalBuffer.removeAll() strCodePointBuffer.removeAll() } @@ -1057,3 +1060,141 @@ extension InputHandlerProtocol { return textToCommit } } + +// MARK: - 連續錯誤鍵自動切換英數模式(Auto-switch to Alphanumerical on Consecutive Errors) + +extension InputHandlerProtocol { + /// 檢查給定按鍵是否屬於注音結構破壞鍵/無效鍵。 + public func isConsideredPhoneticErrorKey( + input: some InputSignalProtocol, + inputText: String + ) -> Bool { + guard !composer.isPinyinMode else { return false } + if input.isCommandHeld || input.isControlHeld || input.isOptionHeld { + return false + } + + // 1. 若該按鍵甚至不在當前鍵盤排列當中,直接判定為錯誤鍵。 + if !composer.inputValidityCheck(charStr: inputText) { + return true + } + + // 2. 以副本試驗該按鍵的吸收狀況。 + var trialComposer = composer + let consumed = trialComposer.receiveKey(fromString: inputText) + if !consumed || trialComposer.value == composer.value { + return true + } + + // 2a. 首音調判定:若當前注拼槽為空,且按鍵為聲調,但未開啟前置聲調設定,判定為錯誤鍵。 + if composer.isEmpty { + if !trialComposer.intonation.isEmpty && !prefs.acceptLeadingIntonations { + return true + } + return false + } + + // 2b. 覆寫既有位置判定: + // 聲母後重複輸入聲母 + if !composer.consonant.isEmpty, !trialComposer.consonant.isEmpty, + composer.consonant != trialComposer.consonant { + return true + } + // 介母後重複輸入介母 + if !composer.semivowel.isEmpty, !trialComposer.semivowel.isEmpty, + composer.semivowel != trialComposer.semivowel { + return true + } + // 韻母後重複輸入韻母 + if !composer.vowel.isEmpty, !trialComposer.vowel.isEmpty, + composer.vowel != trialComposer.vowel { + return true + } + // 聲調後重複輸入聲調 + if !composer.intonation.isEmpty, !trialComposer.intonation.isEmpty, + composer.intonation != trialComposer.intonation { + return true + } + + // 2c. 聲/介/韻/調 槽位順序違反判定: + // 韻母後輸入聲母 + if !composer.vowel.isEmpty, trialComposer.consonant != composer.consonant { + return true + } + // 韻母後輸入介母 + if !composer.vowel.isEmpty, trialComposer.semivowel != composer.semivowel { + return true + } + // 介母後輸入聲母 + if !composer.semivowel.isEmpty, trialComposer.consonant != composer.consonant { + return true + } + // 聲調後輸入聲/介/韻 + if !composer.intonation.isEmpty, + trialComposer.consonant != composer.consonant + || trialComposer.semivowel != composer.semivowel + || trialComposer.vowel != composer.vowel { + return true + } + + // 2d. 破壞既有已鍵入音位判定(例如自動糾錯移除掉已有的介母、韻母或聲母): + if !composer.semivowel.isEmpty, trialComposer.semivowel.isEmpty { + return true + } + if !composer.vowel.isEmpty, trialComposer.vowel.isEmpty { + return true + } + if !composer.consonant.isEmpty, trialComposer.consonant.isEmpty { + return true + } + + return false + } + + /// 檢查是否需要因「連續鍵入 5 個錯誤鍵」而自動切換至英數模式。 + public func handleConsecutiveTypingErrorsSwitchIfNeeded( + input: some InputSignalProtocol + ) -> Bool? { + guard prefs.autoSwitchToAlphanumericalOnConsecutiveErrors, + let session = session, + session.inputMode == .imeModeCHT, + !session.isASCIIMode, + !composer.isPinyinMode, + !prefs.mixedAlphanumericalEnabled, + currentTypingMethod == .vChewingFactory, + !input.isCommandHeld, !input.isControlHeld, !input.isOptionHeld, + !input.isEnter, !input.isSpace, !input.isTab, !input.isEsc + else { + if input.isEnter || input.isSpace || input.isTab || input.isEsc || input.isBackSpace || input.isDelete { + consecutiveTypingErrors.removeAll() + } + return nil + } + + var inputText = (input.inputTextIgnoringModifiers ?? input.text) + inputText = inputText.lowercased().applyingTransformFW2HW(reverse: false) + + let isError = isConsideredPhoneticErrorKey(input: input, inputText: inputText) + let charToRecord = input.text.isEmpty ? inputText : input.text + + if isError { + consecutiveTypingErrors.append(charToRecord) + if consecutiveTypingErrors.count >= 5 { + let textToCommit = consecutiveTypingErrors.joined() + consecutiveTypingErrors.removeAll() + clearComposerAndCalligrapher() + assembler.clear() + session.switchState(State.ofCommitting(textToCommit: textToCommit)) + session.isASCIIMode = true + return true + } + } else { + if composer.isEmpty { + consecutiveTypingErrors = [charToRecord] + } else { + consecutiveTypingErrors.removeAll() + } + } + return nil + } +} diff --git a/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_HandleComposition.swift b/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_HandleComposition.swift index 918ac9a0a..0c944ecb5 100644 --- a/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_HandleComposition.swift +++ b/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_HandleComposition.swift @@ -32,6 +32,9 @@ extension InputHandlerProtocol { case .cassette: return CassetteTypewriter(self).handle(input) case .bopomofoKeyblock: + if let handled = handleConsecutiveTypingErrorsSwitchIfNeeded(input: input) { + return handled + } if prefs.mixedAlphanumericalEnabled { return MixedAlphanumericalTypewriter(self).handle(input) } diff --git a/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_TriageInput.swift b/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_TriageInput.swift index 9cc49f232..27a7b07b4 100644 --- a/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_TriageInput.swift +++ b/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_TriageInput.swift @@ -48,6 +48,13 @@ extension InputHandlerProtocol { func triageByKeyCode() -> Bool? { guard let keyCodeType = KeyCode(rawValue: input.keyCode) else { return nil } switch keyCodeType { + case .kEscape, .kContextMenu, .kTab, .kDownArrow, .kLeftArrow, .kRightArrow, .kUpArrow, + .kHome, .kEnd, .kBackSpace, .kWindowsDelete, .kCarriageReturn, .kLineFeed, + .kSymbolMenuPhysicalKeyIntl, .kSymbolMenuPhysicalKeyJIS, .kSpace: + consecutiveTypingErrors.removeAll() + default: break + } + switch keyCodeType { case .kEscape: return handleEsc() case .kContextMenu, .kTab: if input.isTab, hasFuriousFrontPending { diff --git a/Packages/vChewing_Typewriter/Sources/Typewriter/Session/SessionCoreProtocol.swift b/Packages/vChewing_Typewriter/Sources/Typewriter/Session/SessionCoreProtocol.swift index 511f6a424..7dcc7f97b 100644 --- a/Packages/vChewing_Typewriter/Sources/Typewriter/Session/SessionCoreProtocol.swift +++ b/Packages/vChewing_Typewriter/Sources/Typewriter/Session/SessionCoreProtocol.swift @@ -31,7 +31,8 @@ public protocol SessionCoreProtocol: AnyObject & CtlCandidateDelegate { var id: UUID { get } /// 用以記錄當前輸入法狀態的變數。(有 DidSet) var state: IMEState { get set } - var isASCIIMode: Bool { get } + var isASCIIMode: Bool { get set } + var inputMode: Shared.InputMode { get } var clientMitigationLevel: Int { get } var ui: SessionUIProtocol? { get } @@ -156,6 +157,10 @@ extension SessionCoreProtocol { // MARK: - Convenience Overloads extension SessionCoreProtocol { + public var inputMode: Shared.InputMode { + IMEApp.currentInputMode + } + public var isCurrentSession: Bool { id == ui?.currentSessionID } diff --git a/Packages/vChewing_Typewriter/Sources/Typewriter/Typewriter/Typewriter_BPMFFullMatch.swift b/Packages/vChewing_Typewriter/Sources/Typewriter/Typewriter/Typewriter_BPMFFullMatch.swift index dbfeb5256..00a31bba2 100644 --- a/Packages/vChewing_Typewriter/Sources/Typewriter/Typewriter/Typewriter_BPMFFullMatch.swift +++ b/Packages/vChewing_Typewriter/Sources/Typewriter/Typewriter/Typewriter_BPMFFullMatch.swift @@ -324,6 +324,7 @@ public struct BPMFFullMatchTypewriter: Typewriter } handler.composer.clear() + handler.consecutiveTypingErrors.removeAll() switch handler.assembler.isEmpty { case false: session.switchState(handler.generateStateOfInputting()) case true: session.switchState(State.ofAbortion()) @@ -354,6 +355,7 @@ public struct BPMFFullMatchTypewriter: Typewriter let textToCommit = handler.commitOverflownComposition handler.retrievePOMSuggestions(apply: true) handler.composer.clear() + handler.consecutiveTypingErrors.removeAll() var inputting = handler.generateStateOfInputting() inputting.textToCommit = textToCommit diff --git a/Packages/vChewing_Typewriter/Tests/TypewriterTests/InputHandlerTests_AutoSwitchOnErrors.swift b/Packages/vChewing_Typewriter/Tests/TypewriterTests/InputHandlerTests_AutoSwitchOnErrors.swift new file mode 100644 index 000000000..7d8ce71f1 --- /dev/null +++ b/Packages/vChewing_Typewriter/Tests/TypewriterTests/InputHandlerTests_AutoSwitchOnErrors.swift @@ -0,0 +1,210 @@ +// (c) 2021 and onwards The vChewing Project (MIT-NTL License). +// ==================== +// This code is released under the MIT license (SPDX-License-Identifier: MIT) +// ... with NTL restriction stating that: +// No trademark license is granted to use the trade names, trademarks, service +// marks, or product names of Contributor, except as required to fulfill notice +// requirements defined in MIT License. + +import Foundation +import Homa +import LangModelAssembly +import Shared +import Tekkon +import Testing +@testable import Typewriter + +// MARK: - AutoSwitchOnConsecutiveErrors Tests + +extension InputHandlerTests { + @Test + func test_AutoSwitchOnConsecutiveErrors_BasicSwitch() throws { + guard let testHandler, let testSession else { + Issue.record("Test handler or session is nil.") + return + } + testHandler.prefs.autoSwitchToAlphanumericalOnConsecutiveErrors = true + testSession.inputMode = .imeModeCHT + testSession.isASCIIMode = false + testSession.recentCommissions.removeAll() + testSession.resetInputHandler(forceComposerCleanup: true) + + // Dachen: g=ㄕ, r=ㄐ, e=ㄍ, a=ㄇ, t=ㄔ (5 consonants -> 5 consecutive errors) + typeSentence("great") + + #expect(testSession.recentCommissions == ["great"]) + #expect(testSession.isASCIIMode == true) + #expect(testHandler.composer.isEmpty) + #expect(testHandler.assembler.isEmpty) + + // Subsequent input in ASCII mode should return false (pass-through to OS) + let nextEvent = KBEvent.KeyEventData(chars: "s").asEvent + let handled = testHandler.triageInput(event: nextEvent) + #expect(!handled) + } + + @Test + func test_AutoSwitchOnConsecutiveErrors_PriorAssemblerContentDiscarded() throws { + guard let testHandler, let testSession else { + Issue.record("Test handler or session is nil.") + return + } + testHandler.prefs.autoSwitchToAlphanumericalOnConsecutiveErrors = true + testSession.inputMode = .imeModeCHT + testSession.isASCIIMode = false + testSession.recentCommissions.removeAll() + testSession.resetInputHandler(forceComposerCleanup: true) + + // Type a valid Chinese syllable first: ㄧㄡ ("u. ") -> forms a node in assembler + typeSentence("u. ") + #expect(!testHandler.assembler.isEmpty) + + // Now type 5 consecutive error keys: "great" + typeSentence("great") + + // The prior assembled sentence must be discarded, only "great" committed + #expect(testSession.recentCommissions.last == "great") + #expect(testSession.isASCIIMode == true) + #expect(testHandler.assembler.isEmpty) + #expect(testHandler.composer.isEmpty) + } + + @Test + func test_AutoSwitchOnConsecutiveErrors_NormalChineseTypingDoesNotTrigger() throws { + guard let testHandler, let testSession else { + Issue.record("Test handler or session is nil.") + return + } + testHandler.prefs.autoSwitchToAlphanumericalOnConsecutiveErrors = true + testSession.inputMode = .imeModeCHT + testSession.isASCIIMode = false + testSession.recentCommissions.removeAll() + testSession.resetInputHandler(forceComposerCleanup: true) + + // Valid Chinese typing: ㄋㄧˇ ("su3") -> valid syllable + typeSentence("su3") + #expect(testSession.isASCIIMode == false) + #expect(testHandler.consecutiveTypingErrors.isEmpty) + + // Followed by ㄏㄠˇ ("cl3") -> valid syllable + typeSentence("cl3") + #expect(testSession.isASCIIMode == false) + #expect(testHandler.consecutiveTypingErrors.isEmpty) + } + + @Test + func test_AutoSwitchOnConsecutiveErrors_BackSpaceClearsErrorBuffer() throws { + guard let testHandler, let testSession else { + Issue.record("Test handler or session is nil.") + return + } + testHandler.prefs.autoSwitchToAlphanumericalOnConsecutiveErrors = true + testSession.inputMode = .imeModeCHT + testSession.isASCIIMode = false + testSession.recentCommissions.removeAll() + testSession.resetInputHandler(forceComposerCleanup: true) + + // Type 4 consecutive errors: "grea" + typeSentence("grea") + #expect(testHandler.consecutiveTypingErrors.count == 4) + #expect(testSession.isASCIIMode == false) + + // Press BackSpace + _ = testHandler.triageInput(event: KBEvent.KeyEventData.backspace.asEvent) + #expect(testHandler.consecutiveTypingErrors.isEmpty) + + // Type 2 more errors: "ts" + typeSentence("ts") + #expect(testHandler.consecutiveTypingErrors.count == 2) + #expect(testSession.isASCIIMode == false) + } + + @Test + func test_AutoSwitchOnConsecutiveErrors_DisabledPreference() throws { + guard let testHandler, let testSession else { + Issue.record("Test handler or session is nil.") + return + } + testHandler.prefs.autoSwitchToAlphanumericalOnConsecutiveErrors = false + testSession.inputMode = .imeModeCHT + testSession.isASCIIMode = false + testSession.recentCommissions.removeAll() + testSession.resetInputHandler(forceComposerCleanup: true) + + typeSentence("great") + #expect(testSession.isASCIIMode == false) + #expect(testSession.recentCommissions.isEmpty) + } + + @Test + func test_AutoSwitchOnConsecutiveErrors_SimplifiedChineseModeIgnored() throws { + guard let testHandler, let testSession else { + Issue.record("Test handler or session is nil.") + return + } + testHandler.prefs.autoSwitchToAlphanumericalOnConsecutiveErrors = true + testSession.inputMode = .imeModeCHS + testSession.isASCIIMode = false + testSession.recentCommissions.removeAll() + testSession.resetInputHandler(forceComposerCleanup: true) + + typeSentence("great") + #expect(testSession.isASCIIMode == false) + #expect(testSession.recentCommissions.isEmpty) + } + + @Test + func test_AutoSwitchOnConsecutiveErrors_PinyinModeIgnored() throws { + guard let testHandler, let testSession else { + Issue.record("Test handler or session is nil.") + return + } + testHandler.prefs.autoSwitchToAlphanumericalOnConsecutiveErrors = true + testSession.inputMode = .imeModeCHT + testSession.isASCIIMode = false + testSession.recentCommissions.removeAll() + testSession.resetInputHandler(forceComposerCleanup: true) + testHandler.composer.ensureParser(arrange: .ofHanyuPinyin) + + typeSentence("great") + #expect(testSession.isASCIIMode == false) + } + + @Test + func test_AutoSwitchOnConsecutiveErrors_MixedAlphanumericalIgnored() throws { + guard let testHandler, let testSession else { + Issue.record("Test handler or session is nil.") + return + } + testHandler.prefs.autoSwitchToAlphanumericalOnConsecutiveErrors = true + testHandler.prefs.mixedAlphanumericalEnabled = true + testSession.inputMode = .imeModeCHT + testSession.isASCIIMode = false + testSession.recentCommissions.removeAll() + testSession.resetInputHandler(forceComposerCleanup: true) + + typeSentence("great") + #expect(testSession.isASCIIMode == false) + } + + @Test + func test_AutoSwitchOnConsecutiveErrors_UnmappedKeys() throws { + guard let testHandler, let testSession else { + Issue.record("Test handler or session is nil.") + return + } + testHandler.prefs.autoSwitchToAlphanumericalOnConsecutiveErrors = true + testSession.inputMode = .imeModeCHT + testSession.isASCIIMode = false + testSession.recentCommissions.removeAll() + testSession.resetInputHandler(forceComposerCleanup: true) + + // On Dachen, '[' and ']' are not assigned to any Bopomofo phonabet + typeSentence("[][][") + + #expect(testSession.recentCommissions == ["[][]["]) + #expect(testSession.isASCIIMode == true) + #expect(testHandler.composer.isEmpty) + #expect(testHandler.assembler.isEmpty) + } +} diff --git a/Packages/vChewing_Typewriter/Tests/TypewriterTests/TestComponents/MockedInputHandlerAndStates.swift b/Packages/vChewing_Typewriter/Tests/TypewriterTests/TestComponents/MockedInputHandlerAndStates.swift index 03753f0c7..bbb4a3f3f 100644 --- a/Packages/vChewing_Typewriter/Tests/TypewriterTests/TestComponents/MockedInputHandlerAndStates.swift +++ b/Packages/vChewing_Typewriter/Tests/TypewriterTests/TestComponents/MockedInputHandlerAndStates.swift @@ -67,6 +67,7 @@ public final class MockInputHandler: @MainActor InputHandlerProtocol { public var strCodePointBuffer = "" public var calligrapher = "" public var mixedAlphanumericalBuffer = "" + public var consecutiveTypingErrors = [String]() public var furiousTrail = [String]() // 狂拼模式:自動 chop 提交鍵對應的拼音字母 blob trail public var furiousHighlightOverride: CandidateInState? // 狂拼 copilot 窗高亮候選(當拍消費) public var furiousCoSegmentedOffers = [FuriousCoSegmentedOffer]() // 狂拼 copilot 窗聯合重切(P164)的替代切分 offers @@ -101,6 +102,7 @@ public final class MockSession: @MainActor SessionCoreProtocol { public var state: IMEState = .init() public var inputHandler: MockInputHandler? public var isASCIIMode: Bool = false + public var inputMode: Shared.InputMode = .imeModeCHT public var clientMitigationLevel: Int = 0 /// 預設為 nil(候選窗不存在);測試需要模擬候選窗已顯示時才指派。 public var mockCandidateController: MockCandidateController? From 80a067990e9662bab01e7fd8ab453d31d679eab9 Mon Sep 17 00:00:00 2001 From: Will Date: Sun, 6 Sep 2026 17:18:02 +0800 Subject: [PATCH 05/14] IMKUtils // TIS: Add selectSystemABCInputSource. --- .../IMKUtils/TISInputSourceExtension.swift | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/Packages/vChewing_IMKUtils/Sources/IMKUtils/TISInputSourceExtension.swift b/Packages/vChewing_IMKUtils/Sources/IMKUtils/TISInputSourceExtension.swift index 13ac234ed..a78d7345c 100644 --- a/Packages/vChewing_IMKUtils/Sources/IMKUtils/TISInputSourceExtension.swift +++ b/Packages/vChewing_IMKUtils/Sources/IMKUtils/TISInputSourceExtension.swift @@ -97,6 +97,34 @@ return true } + @discardableResult + public static func selectSystemABCInputSource() -> Bool { + // 1. Check if current ASCII capable keyboard input source is selectable, activated, and not vChewing. + if let currentASCII = TISCopyCurrentASCIICapableKeyboardInputSource()?.takeRetainedValue() { + if !currentASCII.identifier.lowercased().contains("vchewing"), currentASCII.isSelectable, currentASCII.isActivated { + if currentASCII.select() { + return true + } + } + } + // 2. Search for com.apple.keylayout.ABC among activated and selectable sources. + let allSources = rawTISInputSources(onlyASCII: true) + if let abcSource = allSources.first(where: { $0.identifier == "com.apple.keylayout.ABC" && $0.isSelectable && $0.isActivated }) { + if abcSource.select() { + return true + } + } + // 3. Fallback: Any selectable and activated ASCII layout that is not vChewing. + if let anyASCII = allSources.first(where: { !$0.identifier.lowercased().contains("vchewing") && $0.isSelectable && $0.isActivated }) { + return anyASCII.select() + } + // 4. Last resort: Any selectable ABC layout even if not marked activated yet. + if let abcSource = allSources.first(where: { $0.identifier == "com.apple.keylayout.ABC" && $0.isSelectable }) { + return abcSource.select() + } + return false + } + @discardableResult public func deactivate() -> Bool { TISDisableInputSource(self) == noErr From 704a7f104b936c1c0f240088029ddb60128ed68d Mon Sep 17 00:00:00 2001 From: Will Date: Sun, 6 Sep 2026 17:18:06 +0800 Subject: [PATCH 06/14] Typewriter // FSM: Switch to system ABC input source on 5 consecutive typing errors. --- .../SessionHostWiring.swift | 1 + .../InputHandler_CoreProtocol.swift | 12 +++++-- .../Typewriter/Session/SessionHost.swift | 2 ++ ...InputHandlerTests_AutoSwitchOnErrors.swift | 35 ++++++++++++++++++- .../Resources/en.lproj/Localizable.strings | 4 +-- .../Resources/ja.lproj/Localizable.strings | 4 +-- .../zh-Hans.lproj/Localizable.strings | 4 +-- .../zh-Hant.lproj/Localizable.strings | 4 +-- 8 files changed, 54 insertions(+), 12 deletions(-) diff --git a/Packages/vChewing_MainAssembly4Darwin/Sources/MainAssembly4Darwin/SessionHostWiring.swift b/Packages/vChewing_MainAssembly4Darwin/Sources/MainAssembly4Darwin/SessionHostWiring.swift index a9064f0c7..fd433c19a 100644 --- a/Packages/vChewing_MainAssembly4Darwin/Sources/MainAssembly4Darwin/SessionHostWiring.swift +++ b/Packages/vChewing_MainAssembly4Darwin/Sources/MainAssembly4Darwin/SessionHostWiring.swift @@ -21,6 +21,7 @@ extension SessionHost { // IMEApp 動作依賴。 host.isKeyboardJIS = { IMEApp.isKeyboardJIS } host.buzz = { IMEApp.buzz() } + host.switchToSystemABCInputSource = { TISInputSource.selectSystemABCInputSource() } // LMMgr 動作依賴。 host.isCoreDBConnected = { LMMgr.isCoreDBConnected } host.syncLMPrefs = { LMMgr.syncLMPrefs() } diff --git a/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_CoreProtocol.swift b/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_CoreProtocol.swift index 08972d8a2..482899350 100644 --- a/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_CoreProtocol.swift +++ b/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_CoreProtocol.swift @@ -1163,9 +1163,13 @@ extension InputHandlerProtocol { !prefs.mixedAlphanumericalEnabled, currentTypingMethod == .vChewingFactory, !input.isCommandHeld, !input.isControlHeld, !input.isOptionHeld, - !input.isEnter, !input.isSpace, !input.isTab, !input.isEsc + !input.isEnter, !input.isSpace, !input.isTab, !input.isEsc, + !input.isBackSpace, !input.isDelete else { - if input.isEnter || input.isSpace || input.isTab || input.isEsc || input.isBackSpace || input.isDelete { + if input.isEnter || input.isSpace || input.isTab || input.isEsc || input.isBackSpace || input.isDelete + || input.isCursorBackward || input.isCursorForward || input.isUp || input.isDown + || input.isLeft || input.isRight || input.isPageUp || input.isPageDown || input.isHome || input.isEnd + { consecutiveTypingErrors.removeAll() } return nil @@ -1185,7 +1189,9 @@ extension InputHandlerProtocol { clearComposerAndCalligrapher() assembler.clear() session.switchState(State.ofCommitting(textToCommit: textToCommit)) - session.isASCIIMode = true + if !SessionHost.shared.switchToSystemABCInputSource() { + session.isASCIIMode = true + } return true } } else { diff --git a/Packages/vChewing_Typewriter/Sources/Typewriter/Session/SessionHost.swift b/Packages/vChewing_Typewriter/Sources/Typewriter/Session/SessionHost.swift index 6d93377a5..c03a19eac 100644 --- a/Packages/vChewing_Typewriter/Sources/Typewriter/Session/SessionHost.swift +++ b/Packages/vChewing_Typewriter/Sources/Typewriter/Session/SessionHost.swift @@ -32,6 +32,8 @@ public final class SessionHost { public var isKeyboardJIS: () -> Bool = { false } /// 蜂鳴或放屁聲。 public var buzz: () -> () = {} + /// 切換至系統 ABC 輸入法(外部鍵盤輸入來源)。 + public var switchToSystemABCInputSource: () -> Bool = { false } // MARK: - LMMgr 動作依賴 diff --git a/Packages/vChewing_Typewriter/Tests/TypewriterTests/InputHandlerTests_AutoSwitchOnErrors.swift b/Packages/vChewing_Typewriter/Tests/TypewriterTests/InputHandlerTests_AutoSwitchOnErrors.swift index 7d8ce71f1..5492d537f 100644 --- a/Packages/vChewing_Typewriter/Tests/TypewriterTests/InputHandlerTests_AutoSwitchOnErrors.swift +++ b/Packages/vChewing_Typewriter/Tests/TypewriterTests/InputHandlerTests_AutoSwitchOnErrors.swift @@ -18,7 +18,7 @@ import Testing extension InputHandlerTests { @Test - func test_AutoSwitchOnConsecutiveErrors_BasicSwitch() throws { + func test_AutoSwitchOnConsecutiveErrors_BasicSwitchToABC() throws { guard let testHandler, let testSession else { Issue.record("Test handler or session is nil.") return @@ -29,6 +29,39 @@ extension InputHandlerTests { testSession.recentCommissions.removeAll() testSession.resetInputHandler(forceComposerCleanup: true) + var switchedToABC = false + SessionHost.shared.switchToSystemABCInputSource = { + switchedToABC = true + return true + } + defer { + SessionHost.shared.switchToSystemABCInputSource = { false } + } + + // Dachen: g=ㄕ, r=ㄐ, e=ㄍ, a=ㄇ, t=ㄔ (5 consonants -> 5 consecutive errors) + typeSentence("great") + + #expect(testSession.recentCommissions == ["great"]) + #expect(switchedToABC == true) + #expect(testSession.isASCIIMode == false) + #expect(testHandler.composer.isEmpty) + #expect(testHandler.assembler.isEmpty) + } + + @Test + func test_AutoSwitchOnConsecutiveErrors_FallbackToASCIIModeWhenABCUnavailable() throws { + guard let testHandler, let testSession else { + Issue.record("Test handler or session is nil.") + return + } + testHandler.prefs.autoSwitchToAlphanumericalOnConsecutiveErrors = true + testSession.inputMode = .imeModeCHT + testSession.isASCIIMode = false + testSession.recentCommissions.removeAll() + testSession.resetInputHandler(forceComposerCleanup: true) + + SessionHost.shared.switchToSystemABCInputSource = { false } + // Dachen: g=ㄕ, r=ㄐ, e=ㄍ, a=ㄇ, t=ㄔ (5 consonants -> 5 consecutive errors) typeSentence("great") diff --git a/Sources/vChewingIME_macOS/Resources/en.lproj/Localizable.strings b/Sources/vChewingIME_macOS/Resources/en.lproj/Localizable.strings index a9a726091..73450f086 100644 --- a/Sources/vChewingIME_macOS/Resources/en.lproj/Localizable.strings +++ b/Sources/vChewingIME_macOS/Resources/en.lproj/Localizable.strings @@ -345,8 +345,8 @@ "i18n:UserDef.kAutoCompositeWithLongestPossibleCassetteKey.shortTitle" = "Auto-composite when the longest possible key is formed"; "i18n:UserDef.kAutoCorrectReadingCombination.description" = "This feature is disabled with mixed Chinese-English typing fallback due to compatibility concerns."; "i18n:UserDef.kAutoCorrectReadingCombination.shortTitle" = "Automatically correct reading combinations when typing"; -"i18n:UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.description" = "Under Traditional Chinese typing mode, clear the composition buffer, commit corresponding alphanumeric text, and switch to alphanumeric mode if 5 consecutive invalid phonetic keys are pressed."; -"i18n:UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.shortTitle" = "Auto-switch to alphanumeric mode upon 5 consecutive typing errors"; +"i18n:UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.description" = "Under Traditional Chinese typing mode, clear the composition buffer, commit corresponding alphanumeric text, and switch to the ABC input method if 5 consecutive invalid phonetic keys are pressed."; +"i18n:UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.shortTitle" = "Auto-switch to ABC input method upon 5 consecutive typing errors"; "i18n:UserDef.kBasicKeyboardLayout.description" = "Choose the macOS-level basic keyboard layout. Zhuyin typing and Pinyin typing are now mutually switchable modes: while typing Pinyin, this layout determines the actual characters produced by your keys, so non-QWERTY alphanumerical keyboard layouts apply as well; while typing Zhuyin, keystrokes are translated as if on a standard layout, and the on-screen keyboard is displayed accordingly."; "i18n:UserDef.kBasicKeyboardLayout.shortTitle" = "Basic Keyboard Layout:"; "i18n:UserDef.kBeepSoundPreference.option.0" = "Muted"; diff --git a/Sources/vChewingIME_macOS/Resources/ja.lproj/Localizable.strings b/Sources/vChewingIME_macOS/Resources/ja.lproj/Localizable.strings index ddb45e0f7..f8c258253 100644 --- a/Sources/vChewingIME_macOS/Resources/ja.lproj/Localizable.strings +++ b/Sources/vChewingIME_macOS/Resources/ja.lproj/Localizable.strings @@ -345,8 +345,8 @@ "i18n:UserDef.kAutoCompositeWithLongestPossibleCassetteKey.shortTitle" = "最長可能キーができた場合、文字を組み立つ"; "i18n:UserDef.kAutoCorrectReadingCombination.description" = "互換性のため、中英混在入力のフォールバックがONの場合、この機能は不作動。"; "i18n:UserDef.kAutoCorrectReadingCombination.shortTitle" = "入力中で打ち間違った発音組み合わせを自動的に訂正する"; -"i18n:UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.description" = "繁体字中国語入力モード時、注音構造に合致しない誤入力が5回連続した場合、未確定文字列を破棄して英数字を出力し、英数モードへ自動切替します。"; -"i18n:UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.shortTitle" = "タイピング誤入力が5回連続した際に英数モードへ自動切替"; +"i18n:UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.description" = "繁体字中国語入力モード時、注音構造に合致しない誤入力が5回連続した場合、未確定文字列を破棄して英数字を出力し、ABC入力ソースへ自動切替します。"; +"i18n:UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.shortTitle" = "タイピング誤入力が5回連続した際にABC入力ソースへ自動切替"; "i18n:UserDef.kBasicKeyboardLayout.description" = "macOS 基礎キーボード配置をご指定ください。注音モードと弁音モードは、現在はいつでも相互に切り替えられます。弁音モードでは、この配置が実際のキー出力を決定します(QWERTY 以外の英数キーボードも適用可能)。注音モードでは、キーは常に標準配列として扱われ、キーボードビューアの表示もそれに応じて変わります。"; "i18n:UserDef.kBasicKeyboardLayout.shortTitle" = "基礎キーボード:"; "i18n:UserDef.kBeepSoundPreference.option.0" = "鳴らず"; diff --git a/Sources/vChewingIME_macOS/Resources/zh-Hans.lproj/Localizable.strings b/Sources/vChewingIME_macOS/Resources/zh-Hans.lproj/Localizable.strings index 4f2245f3d..4cebb3e2a 100644 --- a/Sources/vChewingIME_macOS/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/vChewingIME_macOS/Resources/zh-Hans.lproj/Localizable.strings @@ -345,8 +345,8 @@ "i18n:UserDef.kAutoCompositeWithLongestPossibleCassetteKey.shortTitle" = "在已经敲出最长可能码的时候自动组字"; "i18n:UserDef.kAutoCorrectReadingCombination.description" = "该功能在中英混合输入回退时不起作用,以确保相容性。"; "i18n:UserDef.kAutoCorrectReadingCombination.shortTitle" = "敲字时自动纠正读音组合"; -"i18n:UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.description" = "在「唯音-繁」中文模式下,若连续输入破坏注音结构的错误键达 5 次,将清空组字区并输出对应英数文字,随后自动切换至英数模式。"; -"i18n:UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.shortTitle" = "连续键入错误达 5 键时自动切换至英数模式"; +"i18n:UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.description" = "在「唯音-繁」中文模式下,若连续输入破坏注音结构的错误键达 5 次,将清空组字区并输出对应英数文字,随后自动切换至 ABC 输入法。"; +"i18n:UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.shortTitle" = "连续键入错误达 5 键时自动切换至 ABC 输入法"; "i18n:UserDef.kBasicKeyboardLayout.description" = "请选择 macOS 基础键盘布局。注音打字与拼音打字现为可随时互相切换的打字模式:拼音打字时,此布局决定实际按键输出(非 QWERTY 英数布局亦适用);注音打字时,按键一律依标准键位翻译,荧幕键盘亦随之显示。"; "i18n:UserDef.kBasicKeyboardLayout.shortTitle" = "基础键盘布局:"; "i18n:UserDef.kBeepSoundPreference.option.0" = "静音"; diff --git a/Sources/vChewingIME_macOS/Resources/zh-Hant.lproj/Localizable.strings b/Sources/vChewingIME_macOS/Resources/zh-Hant.lproj/Localizable.strings index d662833cf..ced50fc21 100644 --- a/Sources/vChewingIME_macOS/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/vChewingIME_macOS/Resources/zh-Hant.lproj/Localizable.strings @@ -345,8 +345,8 @@ "i18n:UserDef.kAutoCompositeWithLongestPossibleCassetteKey.shortTitle" = "在已經敲出最長可能碼的時候自動組字"; "i18n:UserDef.kAutoCorrectReadingCombination.description" = "該功能在中英混合輸入回退時不起作用,以確保相容性。"; "i18n:UserDef.kAutoCorrectReadingCombination.shortTitle" = "敲字時自動糾正讀音組合"; -"i18n:UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.description" = "在「唯音-繁」中文模式下,若連續輸入破壞注音結構的錯誤鍵達 5 次,將清空組字區並輸出對應英數文字,隨後自動切換至英數模式。"; -"i18n:UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.shortTitle" = "連續鍵入錯誤達 5 鍵時自動切換至英數模式"; +"i18n:UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.description" = "在「唯音-繁」中文模式下,若連續輸入破壞注音結構的錯誤鍵達 5 次,將清空組字區並輸出對應英數文字,隨後自動切換至 ABC 輸入法。"; +"i18n:UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.shortTitle" = "連續鍵入錯誤達 5 鍵時自動切換至 ABC 輸入法"; "i18n:UserDef.kBasicKeyboardLayout.description" = "請選擇 macOS 基礎鍵盤佈局。注音打字與拼音打字現為可隨時互相切換的打字模式:拼音打字時,此佈局決定實際按鍵輸出(非 QWERTY 英數佈局亦適用);注音打字時,按鍵一律依標準鍵位翻譯,螢幕鍵盤亦隨之顯示。"; "i18n:UserDef.kBasicKeyboardLayout.shortTitle" = "基礎鍵盤佈局:"; "i18n:UserDef.kBeepSoundPreference.option.0" = "靜音"; From 7c52ed76790f9f151a521c1641f07888b393d17b Mon Sep 17 00:00:00 2001 From: Will Date: Sun, 6 Sep 2026 17:43:14 +0800 Subject: [PATCH 07/14] Typewriter // FSM: Fix spaces, symbols, and navigation handling in consecutive typing errors auto-switch. --- .../InputHandler_CoreProtocol.swift | 73 +++++++++++++++++-- .../InputHandler_TriageInput.swift | 2 +- ...InputHandlerTests_AutoSwitchOnErrors.swift | 59 +++++++++++++++ 3 files changed, 128 insertions(+), 6 deletions(-) diff --git a/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_CoreProtocol.swift b/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_CoreProtocol.swift index 482899350..a84ddbddc 100644 --- a/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_CoreProtocol.swift +++ b/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_CoreProtocol.swift @@ -1163,10 +1163,13 @@ extension InputHandlerProtocol { !prefs.mixedAlphanumericalEnabled, currentTypingMethod == .vChewingFactory, !input.isCommandHeld, !input.isControlHeld, !input.isOptionHeld, - !input.isEnter, !input.isSpace, !input.isTab, !input.isEsc, - !input.isBackSpace, !input.isDelete + !input.isEnter, !input.isTab, !input.isEsc, + !input.isBackSpace, !input.isDelete, + !input.isCursorBackward, !input.isCursorForward, + !input.isUp, !input.isDown, !input.isLeft, !input.isRight, + !input.isPageUp, !input.isPageDown, !input.isHome, !input.isEnd else { - if input.isEnter || input.isSpace || input.isTab || input.isEsc || input.isBackSpace || input.isDelete + if input.isEnter || input.isTab || input.isEsc || input.isBackSpace || input.isDelete || input.isCursorBackward || input.isCursorForward || input.isUp || input.isDown || input.isLeft || input.isRight || input.isPageUp || input.isPageDown || input.isHome || input.isEnd { @@ -1175,18 +1178,77 @@ extension InputHandlerProtocol { return nil } + if let keyCodeType = KeyCode(rawValue: input.keyCode) { + switch keyCodeType { + case .kSymbolMenuPhysicalKeyIntl, .kSymbolMenuPhysicalKeyJIS, .kContextMenu: + consecutiveTypingErrors.removeAll() + return nil + default: break + } + } + + // 當已處於連續錯誤狀態(已累積 >= 2 個錯誤鍵)時,Space 被視為英數輸入的一環(如 "cd .."、"ls -la"、"git status"), + // 應計入連續鍵擊並阻斷注音一聲/送字;若未處於錯誤狀態,Space 仍為正常的注音一聲或送字。 + if input.isSpace { + if consecutiveTypingErrors.count >= 2 { + consecutiveTypingErrors.append(" ") + composer.clear() + calligrapher.removeAll() + if consecutiveTypingErrors.count >= 5 { + let textToCommit = consecutiveTypingErrors.joined() + consecutiveTypingErrors.removeAll() + composer.clear() + calligrapher.removeAll() + assembler.clear() + session.switchState(State.ofCommitting(textToCommit: textToCommit)) + if !SessionHost.shared.switchToSystemABCInputSource() { + session.isASCIIMode = true + } + return true + } + return true + } else { + consecutiveTypingErrors.removeAll() + return nil + } + } + var inputText = (input.inputTextIgnoringModifiers ?? input.text) inputText = inputText.lowercased().applyingTransformFW2HW(reverse: false) + let charToRecord = input.text.isEmpty ? inputText : input.text + + // 當已處於連續錯誤狀態(已累積 >= 2 個鍵)時,後續無論是字母、標點符號或數字, + // 皆視為正在輸入英數字串(如 "cd .." 中的 "." 與 "/"),持續累積直到滿 5 鍵自動切換。 + if consecutiveTypingErrors.count >= 2 { + consecutiveTypingErrors.append(charToRecord) + composer.clear() + calligrapher.removeAll() + if consecutiveTypingErrors.count >= 5 { + let textToCommit = consecutiveTypingErrors.joined() + consecutiveTypingErrors.removeAll() + composer.clear() + calligrapher.removeAll() + assembler.clear() + session.switchState(State.ofCommitting(textToCommit: textToCommit)) + if !SessionHost.shared.switchToSystemABCInputSource() { + session.isASCIIMode = true + } + return true + } + return true + } let isError = isConsideredPhoneticErrorKey(input: input, inputText: inputText) - let charToRecord = input.text.isEmpty ? inputText : input.text if isError { consecutiveTypingErrors.append(charToRecord) + composer.clear() + calligrapher.removeAll() if consecutiveTypingErrors.count >= 5 { let textToCommit = consecutiveTypingErrors.joined() consecutiveTypingErrors.removeAll() - clearComposerAndCalligrapher() + composer.clear() + calligrapher.removeAll() assembler.clear() session.switchState(State.ofCommitting(textToCommit: textToCommit)) if !SessionHost.shared.switchToSystemABCInputSource() { @@ -1194,6 +1256,7 @@ extension InputHandlerProtocol { } return true } + return true } else { if composer.isEmpty { consecutiveTypingErrors = [charToRecord] diff --git a/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_TriageInput.swift b/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_TriageInput.swift index 27a7b07b4..e9cd85f0c 100644 --- a/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_TriageInput.swift +++ b/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_TriageInput.swift @@ -50,7 +50,7 @@ extension InputHandlerProtocol { switch keyCodeType { case .kEscape, .kContextMenu, .kTab, .kDownArrow, .kLeftArrow, .kRightArrow, .kUpArrow, .kHome, .kEnd, .kBackSpace, .kWindowsDelete, .kCarriageReturn, .kLineFeed, - .kSymbolMenuPhysicalKeyIntl, .kSymbolMenuPhysicalKeyJIS, .kSpace: + .kSymbolMenuPhysicalKeyIntl, .kSymbolMenuPhysicalKeyJIS: consecutiveTypingErrors.removeAll() default: break } diff --git a/Packages/vChewing_Typewriter/Tests/TypewriterTests/InputHandlerTests_AutoSwitchOnErrors.swift b/Packages/vChewing_Typewriter/Tests/TypewriterTests/InputHandlerTests_AutoSwitchOnErrors.swift index 5492d537f..04fff7c37 100644 --- a/Packages/vChewing_Typewriter/Tests/TypewriterTests/InputHandlerTests_AutoSwitchOnErrors.swift +++ b/Packages/vChewing_Typewriter/Tests/TypewriterTests/InputHandlerTests_AutoSwitchOnErrors.swift @@ -48,6 +48,65 @@ extension InputHandlerTests { #expect(testHandler.assembler.isEmpty) } + @Test + func test_AutoSwitchOnConsecutiveErrors_CdDotDotSlash() throws { + guard let testHandler, let testSession else { + Issue.record("Test handler or session is nil.") + return + } + testHandler.prefs.autoSwitchToAlphanumericalOnConsecutiveErrors = true + testSession.inputMode = .imeModeCHT + testSession.isASCIIMode = false + testSession.recentCommissions.removeAll() + testSession.resetInputHandler(forceComposerCleanup: true) + + var switchedToABC = false + SessionHost.shared.switchToSystemABCInputSource = { + switchedToABC = true + return true + } + defer { + SessionHost.shared.switchToSystemABCInputSource = { false } + } + + // 當輸入到第 5 鍵(即 "cd .." 中的第二個 ".")時,累計達到 5 個錯誤鍵, + // 即刻觸發自動切換至系統 ABC 輸入法,並將已鍵入的 5 個英數字元 "cd .." 遞交。 + // 在真實 macOS 環境中,此時系統已切換為 ABC 輸入法,第 6 鍵 "/" 隨即由 ABC 輸入法直接接收並送出。 + typeSentence("cd ..") + #expect(switchedToABC == true) + #expect(testSession.recentCommissions == ["cd .."]) + #expect(testSession.isASCIIMode == false) + #expect(testHandler.composer.isEmpty) + #expect(testHandler.assembler.isEmpty) + } + + @Test + func test_AutoSwitchOnConsecutiveErrors_CdDotDotSlashFallbackToASCII() throws { + guard let testHandler, let testSession else { + Issue.record("Test handler or session is nil.") + return + } + testHandler.prefs.autoSwitchToAlphanumericalOnConsecutiveErrors = true + testSession.inputMode = .imeModeCHT + testSession.isASCIIMode = false + testSession.recentCommissions.removeAll() + testSession.resetInputHandler(forceComposerCleanup: true) + + SessionHost.shared.switchToSystemABCInputSource = { false } + + // 當切換系統 ABC 輸入法不可用時,降級啟用 session.isASCIIMode = true。 + // 第 5 鍵 "cd .." 觸發遞交並轉為英數模式,後續的第 6 鍵 "/" 即在英數模式下 pass-through 直接交由 OS 送出。 + typeSentence("cd ..") + #expect(testSession.isASCIIMode == true) + #expect(testSession.recentCommissions == ["cd .."]) + #expect(testHandler.composer.isEmpty) + #expect(testHandler.assembler.isEmpty) + + // 第 6 鍵 "/" 在英數模式下 pass-through 直接由系統處理 + let slashHandled = testHandler.triageInput(event: KBEvent.KeyEventData(chars: "/").asEvent) + #expect(!slashHandled) + } + @Test func test_AutoSwitchOnConsecutiveErrors_FallbackToASCIIModeWhenABCUnavailable() throws { guard let testHandler, let testSession else { From b29f57c74bcbdb003185d2ae8ea1e83dd7b6a763 Mon Sep 17 00:00:00 2001 From: Will Date: Sun, 6 Sep 2026 17:56:32 +0800 Subject: [PATCH 08/14] Typewriter // FSM: Set ASCII mode unconditionally when auto-switching to ABC on consecutive errors. --- .../IMKUtils/TISInputSourceExtension.swift | 18 ++++++- .../InputHandler_CoreProtocol.swift | 47 +++++++------------ ...InputHandlerTests_AutoSwitchOnErrors.swift | 11 +++-- 3 files changed, 41 insertions(+), 35 deletions(-) diff --git a/Packages/vChewing_IMKUtils/Sources/IMKUtils/TISInputSourceExtension.swift b/Packages/vChewing_IMKUtils/Sources/IMKUtils/TISInputSourceExtension.swift index a78d7345c..09497112e 100644 --- a/Packages/vChewing_IMKUtils/Sources/IMKUtils/TISInputSourceExtension.swift +++ b/Packages/vChewing_IMKUtils/Sources/IMKUtils/TISInputSourceExtension.swift @@ -103,6 +103,9 @@ if let currentASCII = TISCopyCurrentASCIICapableKeyboardInputSource()?.takeRetainedValue() { if !currentASCII.identifier.lowercased().contains("vchewing"), currentASCII.isSelectable, currentASCII.isActivated { if currentASCII.select() { + DispatchQueue.main.async { + _ = currentASCII.select() + } return true } } @@ -111,16 +114,27 @@ let allSources = rawTISInputSources(onlyASCII: true) if let abcSource = allSources.first(where: { $0.identifier == "com.apple.keylayout.ABC" && $0.isSelectable && $0.isActivated }) { if abcSource.select() { + DispatchQueue.main.async { + _ = abcSource.select() + } return true } } // 3. Fallback: Any selectable and activated ASCII layout that is not vChewing. if let anyASCII = allSources.first(where: { !$0.identifier.lowercased().contains("vchewing") && $0.isSelectable && $0.isActivated }) { - return anyASCII.select() + let result = anyASCII.select() + DispatchQueue.main.async { + _ = anyASCII.select() + } + return result } // 4. Last resort: Any selectable ABC layout even if not marked activated yet. if let abcSource = allSources.first(where: { $0.identifier == "com.apple.keylayout.ABC" && $0.isSelectable }) { - return abcSource.select() + let result = abcSource.select() + DispatchQueue.main.async { + _ = abcSource.select() + } + return result } return false } diff --git a/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_CoreProtocol.swift b/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_CoreProtocol.swift index a84ddbddc..010dae1f2 100644 --- a/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_CoreProtocol.swift +++ b/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_CoreProtocol.swift @@ -1195,16 +1195,7 @@ extension InputHandlerProtocol { composer.clear() calligrapher.removeAll() if consecutiveTypingErrors.count >= 5 { - let textToCommit = consecutiveTypingErrors.joined() - consecutiveTypingErrors.removeAll() - composer.clear() - calligrapher.removeAll() - assembler.clear() - session.switchState(State.ofCommitting(textToCommit: textToCommit)) - if !SessionHost.shared.switchToSystemABCInputSource() { - session.isASCIIMode = true - } - return true + return commitConsecutiveErrorsAndSwitchToABC(session: session) } return true } else { @@ -1224,16 +1215,7 @@ extension InputHandlerProtocol { composer.clear() calligrapher.removeAll() if consecutiveTypingErrors.count >= 5 { - let textToCommit = consecutiveTypingErrors.joined() - consecutiveTypingErrors.removeAll() - composer.clear() - calligrapher.removeAll() - assembler.clear() - session.switchState(State.ofCommitting(textToCommit: textToCommit)) - if !SessionHost.shared.switchToSystemABCInputSource() { - session.isASCIIMode = true - } - return true + return commitConsecutiveErrorsAndSwitchToABC(session: session) } return true } @@ -1245,16 +1227,7 @@ extension InputHandlerProtocol { composer.clear() calligrapher.removeAll() if consecutiveTypingErrors.count >= 5 { - let textToCommit = consecutiveTypingErrors.joined() - consecutiveTypingErrors.removeAll() - composer.clear() - calligrapher.removeAll() - assembler.clear() - session.switchState(State.ofCommitting(textToCommit: textToCommit)) - if !SessionHost.shared.switchToSystemABCInputSource() { - session.isASCIIMode = true - } - return true + return commitConsecutiveErrorsAndSwitchToABC(session: session) } return true } else { @@ -1266,4 +1239,18 @@ extension InputHandlerProtocol { } return nil } + + @discardableResult + private func commitConsecutiveErrorsAndSwitchToABC(session: Session) -> Bool { + let textToCommit = consecutiveTypingErrors.joined() + consecutiveTypingErrors.removeAll() + composer.clear() + calligrapher.removeAll() + assembler.clear() + session.switchState(State.ofCommitting(textToCommit: textToCommit)) + session.isASCIIMode = true + InputSession.isASCIIModeForAllClients = true + _ = SessionHost.shared.switchToSystemABCInputSource() + return true + } } diff --git a/Packages/vChewing_Typewriter/Tests/TypewriterTests/InputHandlerTests_AutoSwitchOnErrors.swift b/Packages/vChewing_Typewriter/Tests/TypewriterTests/InputHandlerTests_AutoSwitchOnErrors.swift index 04fff7c37..e784f7cdd 100644 --- a/Packages/vChewing_Typewriter/Tests/TypewriterTests/InputHandlerTests_AutoSwitchOnErrors.swift +++ b/Packages/vChewing_Typewriter/Tests/TypewriterTests/InputHandlerTests_AutoSwitchOnErrors.swift @@ -43,7 +43,7 @@ extension InputHandlerTests { #expect(testSession.recentCommissions == ["great"]) #expect(switchedToABC == true) - #expect(testSession.isASCIIMode == false) + #expect(testSession.isASCIIMode == true) #expect(testHandler.composer.isEmpty) #expect(testHandler.assembler.isEmpty) } @@ -71,13 +71,18 @@ extension InputHandlerTests { // 當輸入到第 5 鍵(即 "cd .." 中的第二個 ".")時,累計達到 5 個錯誤鍵, // 即刻觸發自動切換至系統 ABC 輸入法,並將已鍵入的 5 個英數字元 "cd .." 遞交。 - // 在真實 macOS 環境中,此時系統已切換為 ABC 輸入法,第 6 鍵 "/" 隨即由 ABC 輸入法直接接收並送出。 + // 同時唯音內部狀態亦切換為英數模式(isASCIIMode == true),確保尚未切離此 session 的後續按鍵(如第 6 鍵 "/") + // 不會被唯音當成注音(大千鍵盤的 "ㄥ")攔截,而是直接 pass-through 由 OS 送出。 typeSentence("cd ..") #expect(switchedToABC == true) #expect(testSession.recentCommissions == ["cd .."]) - #expect(testSession.isASCIIMode == false) + #expect(testSession.isASCIIMode == true) #expect(testHandler.composer.isEmpty) #expect(testHandler.assembler.isEmpty) + + // 第 6 鍵 "/" 在英數模式下 pass-through 直接由系統處理,不被唯音攔截為注音 "ㄥ" + let slashHandled = testHandler.triageInput(event: KBEvent.KeyEventData(chars: "/").asEvent) + #expect(!slashHandled) } @Test From 8699dd93d333da36f0038035baaae88b3ecbdbb8 Mon Sep 17 00:00:00 2001 From: Will Date: Sun, 6 Sep 2026 18:21:14 +0800 Subject: [PATCH 09/14] Typewriter // FSM: Validate Mandarin syllables and retain in-flight keys for consecutive error auto-switch. --- .../Sources/Tekkon/Tekkon_Constants.swift | 10 ++ .../InputHandler/InputHandler.swift | 1 + .../InputHandler_CoreProtocol.swift | 35 +++++-- .../InputHandler_TriageInput.swift | 1 + .../Typewriter/Typewriter_BPMFFullMatch.swift | 2 + ...InputHandlerTests_AutoSwitchOnErrors.swift | 99 +++++++++++++++++++ .../MockedInputHandlerAndStates.swift | 1 + 7 files changed, 143 insertions(+), 6 deletions(-) diff --git a/Packages/vChewing_Tekkon/Sources/Tekkon/Tekkon_Constants.swift b/Packages/vChewing_Tekkon/Sources/Tekkon/Tekkon_Constants.swift index 55bd899ad..9b7dd6ab1 100644 --- a/Packages/vChewing_Tekkon/Sources/Tekkon/Tekkon_Constants.swift +++ b/Packages/vChewing_Tekkon/Sources/Tekkon/Tekkon_Constants.swift @@ -32,6 +32,16 @@ public enum Tekkon { allowedConsonants + allowedSemivowels + allowedVowels + allowedIntonations } + /// 所有合法的國語注音音節與合法前綴(不含聲調)集合。 + public static let allValidMandarinSyllables: Set = { + var set = Set() + for (phona, _) in arrPhonaToHanyuPinyin { + if [" ", "ˊ", "ˇ", "ˋ", "˙"].contains(phona) { continue } + set.insert(phona) + } + return set + }() + // MARK: Internal /// 原始轉換對照表資料貯存專用佇列(數字標調格式)。 diff --git a/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler.swift b/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler.swift index ac1aa5442..1a47a29ca 100644 --- a/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler.swift +++ b/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler.swift @@ -77,6 +77,7 @@ public final class InputHandler: @MainActor InputHandlerProtocol { public var calligrapher = "" // 磁帶專用組筆區 public var mixedAlphanumericalBuffer = "" // 混輸暫存 ASCII 緩衝區 public var consecutiveTypingErrors = [String]() // 連續輸入錯誤鍵暫存區 + public var inFlightComposerKeys = [String]() // 當前注拼槽對應的鍵入字元暫存區 public var furiousTrail = [String]() // 狂拼模式:自動 chop 提交鍵對應的拼音字母 blob trail public var furiousHighlightOverride: CandidateInState? // 狂拼 copilot 窗高亮候選(當拍消費) public var furiousCoSegmentedOffers = [FuriousCoSegmentedOffer]() // 狂拼 copilot 窗聯合重切(P164)的替代切分 offers diff --git a/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_CoreProtocol.swift b/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_CoreProtocol.swift index 010dae1f2..a9dd1b371 100644 --- a/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_CoreProtocol.swift +++ b/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_CoreProtocol.swift @@ -54,6 +54,7 @@ public protocol InputHandlerProtocol: AnyObject { var calligrapher: String { get set } // 磁帶專用組筆區 var mixedAlphanumericalBuffer: String { get set } // 混輸暫存 ASCII 緩衝區 var consecutiveTypingErrors: [String] { get set } // 連續輸入錯誤鍵暫存區 + var inFlightComposerKeys: [String] { get set } // 當前注拼槽對應的鍵入字元暫存區 var furiousTrail: [String] { get set } // 狂拼模式:自動 chop/空格固化提交鍵對應的拼音字母 blob trail var furiousHighlightOverride: CandidateInState? { get set } // 狂拼 copilot 窗高亮候選(當拍消費) var furiousCoSegmentedOffers: [FuriousCoSegmentedOffer] { get set @@ -434,6 +435,7 @@ extension InputHandlerProtocol { calligrapher.removeAll() composer.clear() consecutiveTypingErrors.removeAll() + inFlightComposerKeys.removeAll() mixedAlphanumericalBuffer.removeAll() strCodePointBuffer.removeAll() } @@ -1094,7 +1096,14 @@ extension InputHandlerProtocol { return false } - // 2b. 覆寫既有位置判定: + // 2b. 試驗吸收後的聲介韻組合是否在國語注音合法音節集合內。 + // 若注拼槽已有音位,且追加此鍵後的聲介韻組合不屬於任何合法音節或合法音節前綴,直接判定為錯誤鍵。 + let trialZhuyin = trialComposer.consonant.value + trialComposer.semivowel.value + trialComposer.vowel.value + if !trialZhuyin.isEmpty && !Tekkon.allValidMandarinSyllables.contains(trialZhuyin) { + return true + } + + // 2c. 覆寫既有位置判定: // 聲母後重複輸入聲母 if !composer.consonant.isEmpty, !trialComposer.consonant.isEmpty, composer.consonant != trialComposer.consonant { @@ -1116,7 +1125,7 @@ extension InputHandlerProtocol { return true } - // 2c. 聲/介/韻/調 槽位順序違反判定: + // 2d. 聲/介/韻/調 槽位順序違反判定: // 韻母後輸入聲母 if !composer.vowel.isEmpty, trialComposer.consonant != composer.consonant { return true @@ -1137,7 +1146,7 @@ extension InputHandlerProtocol { return true } - // 2d. 破壞既有已鍵入音位判定(例如自動糾錯移除掉已有的介母、韻母或聲母): + // 2e. 破壞既有已鍵入音位判定(例如自動糾錯移除掉已有的介母、韻母或聲母): if !composer.semivowel.isEmpty, trialComposer.semivowel.isEmpty { return true } @@ -1174,6 +1183,7 @@ extension InputHandlerProtocol { || input.isLeft || input.isRight || input.isPageUp || input.isPageDown || input.isHome || input.isEnd { consecutiveTypingErrors.removeAll() + inFlightComposerKeys.removeAll() } return nil } @@ -1182,6 +1192,7 @@ extension InputHandlerProtocol { switch keyCodeType { case .kSymbolMenuPhysicalKeyIntl, .kSymbolMenuPhysicalKeyJIS, .kContextMenu: consecutiveTypingErrors.removeAll() + inFlightComposerKeys.removeAll() return nil default: break } @@ -1194,12 +1205,14 @@ extension InputHandlerProtocol { consecutiveTypingErrors.append(" ") composer.clear() calligrapher.removeAll() + inFlightComposerKeys.removeAll() if consecutiveTypingErrors.count >= 5 { return commitConsecutiveErrorsAndSwitchToABC(session: session) } return true } else { consecutiveTypingErrors.removeAll() + inFlightComposerKeys.removeAll() return nil } } @@ -1209,11 +1222,12 @@ extension InputHandlerProtocol { let charToRecord = input.text.isEmpty ? inputText : input.text // 當已處於連續錯誤狀態(已累積 >= 2 個鍵)時,後續無論是字母、標點符號或數字, - // 皆視為正在輸入英數字串(如 "cd .." 中的 "." 與 "/"),持續累積直到滿 5 鍵自動切換。 + // 皆視為正在輸入英數字串(如 "cd .." 中的 "." 與 "/"、"git log" 中的字母),持續累積直到滿 5 鍵自動切換。 if consecutiveTypingErrors.count >= 2 { consecutiveTypingErrors.append(charToRecord) composer.clear() calligrapher.removeAll() + inFlightComposerKeys.removeAll() if consecutiveTypingErrors.count >= 5 { return commitConsecutiveErrorsAndSwitchToABC(session: session) } @@ -1223,7 +1237,14 @@ extension InputHandlerProtocol { let isError = isConsideredPhoneticErrorKey(input: input, inputText: inputText) if isError { - consecutiveTypingErrors.append(charToRecord) + // 若在累積錯誤鍵之前注拼槽內已有尚未固化/提交的鍵入字元(如合法的聲母或介母,但因後續鍵入破壞注音結構而判定為英文打字), + // 將這些尚未成為完整漢字的按鍵一併作為錯誤鍵序列納入 + if !inFlightComposerKeys.isEmpty { + consecutiveTypingErrors = inFlightComposerKeys + [charToRecord] + inFlightComposerKeys.removeAll() + } else { + consecutiveTypingErrors.append(charToRecord) + } composer.clear() calligrapher.removeAll() if consecutiveTypingErrors.count >= 5 { @@ -1232,9 +1253,10 @@ extension InputHandlerProtocol { return true } else { if composer.isEmpty { + inFlightComposerKeys = [charToRecord] consecutiveTypingErrors = [charToRecord] } else { - consecutiveTypingErrors.removeAll() + inFlightComposerKeys.append(charToRecord) } } return nil @@ -1244,6 +1266,7 @@ extension InputHandlerProtocol { private func commitConsecutiveErrorsAndSwitchToABC(session: Session) -> Bool { let textToCommit = consecutiveTypingErrors.joined() consecutiveTypingErrors.removeAll() + inFlightComposerKeys.removeAll() composer.clear() calligrapher.removeAll() assembler.clear() diff --git a/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_TriageInput.swift b/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_TriageInput.swift index e9cd85f0c..a5a5610be 100644 --- a/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_TriageInput.swift +++ b/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_TriageInput.swift @@ -52,6 +52,7 @@ extension InputHandlerProtocol { .kHome, .kEnd, .kBackSpace, .kWindowsDelete, .kCarriageReturn, .kLineFeed, .kSymbolMenuPhysicalKeyIntl, .kSymbolMenuPhysicalKeyJIS: consecutiveTypingErrors.removeAll() + inFlightComposerKeys.removeAll() default: break } switch keyCodeType { diff --git a/Packages/vChewing_Typewriter/Sources/Typewriter/Typewriter/Typewriter_BPMFFullMatch.swift b/Packages/vChewing_Typewriter/Sources/Typewriter/Typewriter/Typewriter_BPMFFullMatch.swift index 00a31bba2..48456fd77 100644 --- a/Packages/vChewing_Typewriter/Sources/Typewriter/Typewriter/Typewriter_BPMFFullMatch.swift +++ b/Packages/vChewing_Typewriter/Sources/Typewriter/Typewriter/Typewriter_BPMFFullMatch.swift @@ -325,6 +325,7 @@ public struct BPMFFullMatchTypewriter: Typewriter handler.composer.clear() handler.consecutiveTypingErrors.removeAll() + handler.inFlightComposerKeys.removeAll() switch handler.assembler.isEmpty { case false: session.switchState(handler.generateStateOfInputting()) case true: session.switchState(State.ofAbortion()) @@ -356,6 +357,7 @@ public struct BPMFFullMatchTypewriter: Typewriter handler.retrievePOMSuggestions(apply: true) handler.composer.clear() handler.consecutiveTypingErrors.removeAll() + handler.inFlightComposerKeys.removeAll() var inputting = handler.generateStateOfInputting() inputting.textToCommit = textToCommit diff --git a/Packages/vChewing_Typewriter/Tests/TypewriterTests/InputHandlerTests_AutoSwitchOnErrors.swift b/Packages/vChewing_Typewriter/Tests/TypewriterTests/InputHandlerTests_AutoSwitchOnErrors.swift index e784f7cdd..7adee4be1 100644 --- a/Packages/vChewing_Typewriter/Tests/TypewriterTests/InputHandlerTests_AutoSwitchOnErrors.swift +++ b/Packages/vChewing_Typewriter/Tests/TypewriterTests/InputHandlerTests_AutoSwitchOnErrors.swift @@ -85,6 +85,105 @@ extension InputHandlerTests { #expect(!slashHandled) } + @Test + func test_AutoSwitchOnConsecutiveErrors_GitLog() throws { + guard let testHandler, let testSession else { + Issue.record("Test handler or session is nil.") + return + } + testHandler.prefs.autoSwitchToAlphanumericalOnConsecutiveErrors = true + testSession.inputMode = .imeModeCHT + testSession.isASCIIMode = false + testSession.recentCommissions.removeAll() + testSession.resetInputHandler(forceComposerCleanup: true) + + var switchedToABC = false + SessionHost.shared.switchToSystemABCInputSource = { + switchedToABC = true + return true + } + defer { + SessionHost.shared.switchToSystemABCInputSource = { false } + } + + for ch in "git log" { + _ = testHandler.triageInput(event: KBEvent.KeyEventData(chars: String(ch)).asEvent) + } + #expect(switchedToABC == true) + #expect(testSession.isASCIIMode == true) + #expect(testSession.recentCommissions == ["git l"]) + #expect(testHandler.composer.isEmpty) + #expect(testHandler.assembler.isEmpty) + + // 後續按鍵在英數模式下 pass-through 直接交由 OS 送出 + let oHandled = testHandler.triageInput(event: KBEvent.KeyEventData(chars: "o").asEvent) + #expect(!oHandled) + let gHandled = testHandler.triageInput(event: KBEvent.KeyEventData(chars: "g").asEvent) + #expect(!gHandled) + } + + @Test + func test_AutoSwitchOnConsecutiveErrors_SudoApt() throws { + guard let testHandler, let testSession else { + Issue.record("Test handler or session is nil.") + return + } + testHandler.prefs.autoSwitchToAlphanumericalOnConsecutiveErrors = true + testSession.inputMode = .imeModeCHT + testSession.isASCIIMode = false + testSession.recentCommissions.removeAll() + testSession.resetInputHandler(forceComposerCleanup: true) + + var switchedToABC = false + SessionHost.shared.switchToSystemABCInputSource = { + switchedToABC = true + return true + } + defer { + SessionHost.shared.switchToSystemABCInputSource = { false } + } + + // "sudo " 剛好滿 5 個鍵,即使 "su" 在大千上是合法的 "ㄋㄧ", + // 當鍵入第三鍵 "d"(ㄎ)時因破壞注音結構而判定為英文打字,前序字元 "su" 一併納入錯誤序列; + // 鍵入到第 5 鍵空格時即觸發切換至 ABC 並遞交 "sudo "。 + typeSentence("sudo ") + #expect(switchedToABC == true) + #expect(testSession.isASCIIMode == true) + #expect(testSession.recentCommissions == ["sudo "]) + #expect(testHandler.composer.isEmpty) + #expect(testHandler.assembler.isEmpty) + } + + @Test + func test_AutoSwitchOnConsecutiveErrors_NormalChineseTypingUntouched() throws { + guard let testHandler, let testSession else { + Issue.record("Test handler or session is nil.") + return + } + testHandler.prefs.autoSwitchToAlphanumericalOnConsecutiveErrors = true + testSession.inputMode = .imeModeCHT + testSession.isASCIIMode = false + testSession.recentCommissions.removeAll() + testSession.resetInputHandler(forceComposerCleanup: true) + + var switchedToABC = false + SessionHost.shared.switchToSystemABCInputSource = { + switchedToABC = true + return true + } + defer { + SessionHost.shared.switchToSystemABCInputSource = { false } + } + + // 正常輸入注音:"su3" -> "你" (3聲) + typeSentence("su3") + #expect(!switchedToABC) + #expect(!testSession.isASCIIMode) + #expect(testHandler.consecutiveTypingErrors.isEmpty) + #expect(testHandler.inFlightComposerKeys.isEmpty) + #expect(testHandler.assembler.assembledSentence.values.joined() == "你") + } + @Test func test_AutoSwitchOnConsecutiveErrors_CdDotDotSlashFallbackToASCII() throws { guard let testHandler, let testSession else { diff --git a/Packages/vChewing_Typewriter/Tests/TypewriterTests/TestComponents/MockedInputHandlerAndStates.swift b/Packages/vChewing_Typewriter/Tests/TypewriterTests/TestComponents/MockedInputHandlerAndStates.swift index bbb4a3f3f..f4e5c5bc5 100644 --- a/Packages/vChewing_Typewriter/Tests/TypewriterTests/TestComponents/MockedInputHandlerAndStates.swift +++ b/Packages/vChewing_Typewriter/Tests/TypewriterTests/TestComponents/MockedInputHandlerAndStates.swift @@ -68,6 +68,7 @@ public final class MockInputHandler: @MainActor InputHandlerProtocol { public var calligrapher = "" public var mixedAlphanumericalBuffer = "" public var consecutiveTypingErrors = [String]() + public var inFlightComposerKeys = [String]() public var furiousTrail = [String]() // 狂拼模式:自動 chop 提交鍵對應的拼音字母 blob trail public var furiousHighlightOverride: CandidateInState? // 狂拼 copilot 窗高亮候選(當拍消費) public var furiousCoSegmentedOffers = [FuriousCoSegmentedOffer]() // 狂拼 copilot 窗聯合重切(P164)的替代切分 offers From 7881389ff55d83e79a08abd9accb48acf224763c Mon Sep 17 00:00:00 2001 From: Will Date: Sun, 6 Sep 2026 18:39:13 +0800 Subject: [PATCH 10/14] Typewriter // FSM: Preserve punctuation handling for brackets and symbols in Chinese mode. --- .../InputHandler_CoreProtocol.swift | 10 +++++ ...InputHandlerTests_AutoSwitchOnErrors.swift | 40 +++++++++++++++++-- 2 files changed, 46 insertions(+), 4 deletions(-) diff --git a/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_CoreProtocol.swift b/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_CoreProtocol.swift index a9dd1b371..292768a1b 100644 --- a/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_CoreProtocol.swift +++ b/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_CoreProtocol.swift @@ -1234,6 +1234,16 @@ extension InputHandlerProtocol { return true } + // 若尚未處於錯誤狀態(累積小於 2 個鍵),且該鍵是合法標點符號按鍵(詞庫中有定義標點符號), + // 則視為正常的中文標點輸入,不判定為錯誤。 + if let puncKeys = punctuationQueryStrings(input: input), + puncKeys.contains(where: { currentLM.hasUnigramsFor(keyArray: [$0]) }) + { + consecutiveTypingErrors.removeAll() + inFlightComposerKeys.removeAll() + return nil + } + let isError = isConsideredPhoneticErrorKey(input: input, inputText: inputText) if isError { diff --git a/Packages/vChewing_Typewriter/Tests/TypewriterTests/InputHandlerTests_AutoSwitchOnErrors.swift b/Packages/vChewing_Typewriter/Tests/TypewriterTests/InputHandlerTests_AutoSwitchOnErrors.swift index 7adee4be1..afbafe194 100644 --- a/Packages/vChewing_Typewriter/Tests/TypewriterTests/InputHandlerTests_AutoSwitchOnErrors.swift +++ b/Packages/vChewing_Typewriter/Tests/TypewriterTests/InputHandlerTests_AutoSwitchOnErrors.swift @@ -122,6 +122,29 @@ extension InputHandlerTests { #expect(!gHandled) } + @Test + func test_AutoSwitchOnConsecutiveErrors_LeftBracketInputsFullWidthBracket() throws { + guard let testHandler, let testSession else { + Issue.record("Test handler or session is nil.") + return + } + testHandler.prefs.autoSwitchToAlphanumericalOnConsecutiveErrors = true + testSession.inputMode = .imeModeCHT + testSession.isASCIIMode = false + testSession.recentCommissions.removeAll() + testSession.resetInputHandler(forceComposerCleanup: true) + + let leftBracketEvent = KBEvent.KeyEventData(chars: "[", keyCode: 33).asEvent + let leftHandled = testHandler.triageInput(event: leftBracketEvent) + #expect(leftHandled == true) + #expect(testHandler.assembler.assembledSentence.values.joined() == "「") + + let rightBracketEvent = KBEvent.KeyEventData(chars: "]", keyCode: 30).asEvent + let rightHandled = testHandler.triageInput(event: rightBracketEvent) + #expect(rightHandled == true) + #expect(testHandler.assembler.assembledSentence.values.joined() == "「」") + } + @Test func test_AutoSwitchOnConsecutiveErrors_SudoApt() throws { guard let testHandler, let testSession else { @@ -384,7 +407,7 @@ extension InputHandlerTests { } @Test - func test_AutoSwitchOnConsecutiveErrors_UnmappedKeys() throws { + func test_AutoSwitchOnConsecutiveErrors_MkdirCommand() throws { guard let testHandler, let testSession else { Issue.record("Test handler or session is nil.") return @@ -395,11 +418,20 @@ extension InputHandlerTests { testSession.recentCommissions.removeAll() testSession.resetInputHandler(forceComposerCleanup: true) - // On Dachen, '[' and ']' are not assigned to any Bopomofo phonabet - typeSentence("[][][") + var switchedToABC = false + SessionHost.shared.switchToSystemABCInputSource = { + switchedToABC = true + return true + } + defer { + SessionHost.shared.switchToSystemABCInputSource = { false } + } + + typeSentence("mkdir") - #expect(testSession.recentCommissions == ["[][]["]) + #expect(switchedToABC == true) #expect(testSession.isASCIIMode == true) + #expect(testSession.recentCommissions == ["mkdir"]) #expect(testHandler.composer.isEmpty) #expect(testHandler.assembler.isEmpty) } From 6e7423d0285c18d7cd707bfbe13482a24c00dc1f Mon Sep 17 00:00:00 2001 From: Will Date: Sun, 6 Sep 2026 19:12:24 +0800 Subject: [PATCH 11/14] Preferences // UI: Add configurable threshold for consecutive typing errors auto-switch. --- .../VwrSettingsPaneCocoaGeneral.swift | 4 +++ .../SettingsUI/VwrSettingsPaneGeneral.swift | 1 + .../Sources/Shared/PrefMgr_Core.swift | 3 ++ .../Shared/Protocols/PrefMgrProtocol.swift | 1 + .../Sources/Shared/UserDef/UserDef.swift | 16 ++++++++- .../InputHandler_CoreProtocol.swift | 10 +++--- ...InputHandlerTests_AutoSwitchOnErrors.swift | 33 +++++++++++++++++++ .../Resources/en.lproj/Localizable.strings | 6 ++-- .../Resources/ja.lproj/Localizable.strings | 6 ++-- .../zh-Hans.lproj/Localizable.strings | 6 ++-- .../zh-Hant.lproj/Localizable.strings | 6 ++-- 11 files changed, 79 insertions(+), 13 deletions(-) diff --git a/Packages/vChewing_SettingsUI/Sources/SettingsUI/SettingsCocoa/VwrSettingsPaneCocoaGeneral.swift b/Packages/vChewing_SettingsUI/Sources/SettingsUI/SettingsCocoa/VwrSettingsPaneCocoaGeneral.swift index ae8dae4bf..2f1944155 100644 --- a/Packages/vChewing_SettingsUI/Sources/SettingsUI/SettingsCocoa/VwrSettingsPaneCocoaGeneral.swift +++ b/Packages/vChewing_SettingsUI/Sources/SettingsUI/SettingsCocoa/VwrSettingsPaneCocoaGeneral.swift @@ -68,6 +68,10 @@ extension SettingsPanesCocoa { fixWidth: contentWidth, prefUITab: .tabGeneral ) + UserDef.kConsecutiveTypingErrorsThreshold.renderCocoa( + fixWidth: contentWidth, + prefUITab: .tabGeneral + ) UserDef.kShowHanyuPinyinInCompositionBuffer.renderCocoa( fixWidth: contentWidth, prefUITab: .tabGeneral diff --git a/Packages/vChewing_SettingsUI/Sources/SettingsUI/SettingsUI/VwrSettingsPaneGeneral.swift b/Packages/vChewing_SettingsUI/Sources/SettingsUI/SettingsUI/VwrSettingsPaneGeneral.swift index a15e66439..1d69261dd 100644 --- a/Packages/vChewing_SettingsUI/Sources/SettingsUI/SettingsUI/VwrSettingsPaneGeneral.swift +++ b/Packages/vChewing_SettingsUI/Sources/SettingsUI/SettingsUI/VwrSettingsPaneGeneral.swift @@ -65,6 +65,7 @@ public struct VwrSettingsPaneGeneral: View { } UserDef.kAutoCorrectReadingCombination.renderUI() UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.renderUI() + UserDef.kConsecutiveTypingErrorsThreshold.renderUI() UserDef.kShowHanyuPinyinInCompositionBuffer.renderUI() UserDef.kKeepReadingUponCompositionError.renderUI() UserDef.kClassicHaninKeyboardSymbolModeShortcutEnabled.renderUI() diff --git a/Packages/vChewing_Shared/Sources/Shared/PrefMgr_Core.swift b/Packages/vChewing_Shared/Sources/Shared/PrefMgr_Core.swift index 35d0926e9..b2c6ddff7 100644 --- a/Packages/vChewing_Shared/Sources/Shared/PrefMgr_Core.swift +++ b/Packages/vChewing_Shared/Sources/Shared/PrefMgr_Core.swift @@ -171,6 +171,9 @@ public final class PrefMgr: PrefMgrProtocol, Sendable { @AppProperty(userDef: .kAutoSwitchToAlphanumericalOnConsecutiveErrors) public var autoSwitchToAlphanumericalOnConsecutiveErrors: Bool + @AppProperty(userDef: .kConsecutiveTypingErrorsThreshold) + public var consecutiveTypingErrorsThreshold: Int + @AppProperty(userDef: .kAlsoConfirmAssociatedCandidatesByEnter) public var alsoConfirmAssociatedCandidatesByEnter: Bool diff --git a/Packages/vChewing_Shared/Sources/Shared/Protocols/PrefMgrProtocol.swift b/Packages/vChewing_Shared/Sources/Shared/Protocols/PrefMgrProtocol.swift index f31f200e8..711b11c4b 100644 --- a/Packages/vChewing_Shared/Sources/Shared/Protocols/PrefMgrProtocol.swift +++ b/Packages/vChewing_Shared/Sources/Shared/Protocols/PrefMgrProtocol.swift @@ -55,6 +55,7 @@ public protocol PrefMgrProtocol { var useFixedCandidateOrderOnSelection: Bool { get set } var autoCorrectReadingCombination: Bool { get set } var autoSwitchToAlphanumericalOnConsecutiveErrors: Bool { get set } + var consecutiveTypingErrorsThreshold: Int { get set } var readingNarrationCoverage: Int { get set } var alsoConfirmAssociatedCandidatesByEnter: Bool { get set } var keepReadingUponCompositionError: Bool { get set } diff --git a/Packages/vChewing_Shared/Sources/Shared/UserDef/UserDef.swift b/Packages/vChewing_Shared/Sources/Shared/UserDef/UserDef.swift index 21fd49905..838d338cd 100644 --- a/Packages/vChewing_Shared/Sources/Shared/UserDef/UserDef.swift +++ b/Packages/vChewing_Shared/Sources/Shared/UserDef/UserDef.swift @@ -81,6 +81,7 @@ nonisolated public enum UserDef: String, CaseIterable, Identifiable, Sendable { case kUseFixedCandidateOrderOnSelection = "UseFixedCandidateOrderOnSelection" case kAutoCorrectReadingCombination = "AutoCorrectReadingCombination" case kAutoSwitchToAlphanumericalOnConsecutiveErrors = "AutoSwitchToAlphanumericalOnConsecutiveErrors" + case kConsecutiveTypingErrorsThreshold = "ConsecutiveTypingErrorsThreshold" case kReadingNarrationCoverage = "ReadingNarrationCoverage" case kAlsoConfirmAssociatedCandidatesByEnter = "AlsoConfirmAssociatedCandidatesByEnter" case kKeepReadingUponCompositionError = "KeepReadingUponCompositionError" @@ -375,6 +376,7 @@ nonisolated extension UserDef { case .kSpecifyIntonationKeyBehavior: 0 ... 2 case .kSpecifyShiftBackSpaceKeyBehavior: 0 ... 2 case .kUpperCaseLetterKeyBehavior: 0 ... 4 + case .kConsecutiveTypingErrorsThreshold: 3 ... 8 case .kReadingNarrationCoverage: 0 ... 2 case .kRomanNumeralOutputFormat: 0 ... 3 case .kSpecifyCmdOptCtrlEnterBehavior: 0 ... 4 @@ -498,7 +500,8 @@ nonisolated extension UserDef { case .kFetchSuggestionsFromPerceptionOverrideModel: return .bool(true) case .kUseFixedCandidateOrderOnSelection: return .bool(false) case .kAutoCorrectReadingCombination: return .bool(true) - case .kAutoSwitchToAlphanumericalOnConsecutiveErrors: return .bool(true) + case .kAutoSwitchToAlphanumericalOnConsecutiveErrors: return .bool(false) + case .kConsecutiveTypingErrorsThreshold: return .integer(5) case .kReadingNarrationCoverage: return .integer(0) case .kAlsoConfirmAssociatedCandidatesByEnter: return .bool(false) case .kKeepReadingUponCompositionError: return .bool(false) @@ -905,6 +908,17 @@ nonisolated extension UserDef { shortTitle: "i18n:UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.shortTitle", description: "i18n:UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.description" ) + case .kConsecutiveTypingErrorsThreshold: return .init( + userDef: self, + shortTitle: "i18n:UserDef.kConsecutiveTypingErrorsThreshold.shortTitle", + description: "i18n:UserDef.kConsecutiveTypingErrorsThreshold.description", + options: { + var result = [Int: String]() + guard let validNumeralValueRange else { return nil } + validNumeralValueRange.forEach { result[$0] = $0.description } + return result.isEmpty ? nil : result + }() + ) case .kReadingNarrationCoverage: return .init( userDef: self, shortTitle: "i18n:UserDef.kReadingNarrationCoverage.shortTitle", description: "i18n:UserDef.kReadingNarrationCoverage.description", diff --git a/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_CoreProtocol.swift b/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_CoreProtocol.swift index 292768a1b..eeb1c1120 100644 --- a/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_CoreProtocol.swift +++ b/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_CoreProtocol.swift @@ -1198,6 +1198,8 @@ extension InputHandlerProtocol { } } + let threshold = max(3, prefs.consecutiveTypingErrorsThreshold) + // 當已處於連續錯誤狀態(已累積 >= 2 個錯誤鍵)時,Space 被視為英數輸入的一環(如 "cd .."、"ls -la"、"git status"), // 應計入連續鍵擊並阻斷注音一聲/送字;若未處於錯誤狀態,Space 仍為正常的注音一聲或送字。 if input.isSpace { @@ -1206,7 +1208,7 @@ extension InputHandlerProtocol { composer.clear() calligrapher.removeAll() inFlightComposerKeys.removeAll() - if consecutiveTypingErrors.count >= 5 { + if consecutiveTypingErrors.count >= threshold { return commitConsecutiveErrorsAndSwitchToABC(session: session) } return true @@ -1222,13 +1224,13 @@ extension InputHandlerProtocol { let charToRecord = input.text.isEmpty ? inputText : input.text // 當已處於連續錯誤狀態(已累積 >= 2 個鍵)時,後續無論是字母、標點符號或數字, - // 皆視為正在輸入英數字串(如 "cd .." 中的 "." 與 "/"、"git log" 中的字母),持續累積直到滿 5 鍵自動切換。 + // 皆視為正在輸入英數字串(如 "cd .." 中的 "." 與 "/"、"git log" 中的字母),持續累積直到滿門檻自動切換。 if consecutiveTypingErrors.count >= 2 { consecutiveTypingErrors.append(charToRecord) composer.clear() calligrapher.removeAll() inFlightComposerKeys.removeAll() - if consecutiveTypingErrors.count >= 5 { + if consecutiveTypingErrors.count >= threshold { return commitConsecutiveErrorsAndSwitchToABC(session: session) } return true @@ -1257,7 +1259,7 @@ extension InputHandlerProtocol { } composer.clear() calligrapher.removeAll() - if consecutiveTypingErrors.count >= 5 { + if consecutiveTypingErrors.count >= threshold { return commitConsecutiveErrorsAndSwitchToABC(session: session) } return true diff --git a/Packages/vChewing_Typewriter/Tests/TypewriterTests/InputHandlerTests_AutoSwitchOnErrors.swift b/Packages/vChewing_Typewriter/Tests/TypewriterTests/InputHandlerTests_AutoSwitchOnErrors.swift index afbafe194..be19ffe16 100644 --- a/Packages/vChewing_Typewriter/Tests/TypewriterTests/InputHandlerTests_AutoSwitchOnErrors.swift +++ b/Packages/vChewing_Typewriter/Tests/TypewriterTests/InputHandlerTests_AutoSwitchOnErrors.swift @@ -435,4 +435,37 @@ extension InputHandlerTests { #expect(testHandler.composer.isEmpty) #expect(testHandler.assembler.isEmpty) } + + @Test + func test_AutoSwitchOnConsecutiveErrors_CustomThreshold() throws { + guard let testHandler, let testSession else { + Issue.record("Test handler or session is nil.") + return + } + testHandler.prefs.autoSwitchToAlphanumericalOnConsecutiveErrors = true + testHandler.prefs.consecutiveTypingErrorsThreshold = 3 + testSession.inputMode = .imeModeCHT + testSession.isASCIIMode = false + testSession.recentCommissions.removeAll() + testSession.resetInputHandler(forceComposerCleanup: true) + + var switchedToABC = false + SessionHost.shared.switchToSystemABCInputSource = { + switchedToABC = true + return true + } + defer { + SessionHost.shared.switchToSystemABCInputSource = { false } + testHandler.prefs.consecutiveTypingErrorsThreshold = 5 + } + + // "git" with threshold = 3 should trigger switch on 3 keys + typeSentence("git") + + #expect(switchedToABC == true) + #expect(testSession.isASCIIMode == true) + #expect(testSession.recentCommissions == ["git"]) + #expect(testHandler.composer.isEmpty) + #expect(testHandler.assembler.isEmpty) + } } diff --git a/Sources/vChewingIME_macOS/Resources/en.lproj/Localizable.strings b/Sources/vChewingIME_macOS/Resources/en.lproj/Localizable.strings index 73450f086..c5aff164f 100644 --- a/Sources/vChewingIME_macOS/Resources/en.lproj/Localizable.strings +++ b/Sources/vChewingIME_macOS/Resources/en.lproj/Localizable.strings @@ -345,8 +345,10 @@ "i18n:UserDef.kAutoCompositeWithLongestPossibleCassetteKey.shortTitle" = "Auto-composite when the longest possible key is formed"; "i18n:UserDef.kAutoCorrectReadingCombination.description" = "This feature is disabled with mixed Chinese-English typing fallback due to compatibility concerns."; "i18n:UserDef.kAutoCorrectReadingCombination.shortTitle" = "Automatically correct reading combinations when typing"; -"i18n:UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.description" = "Under Traditional Chinese typing mode, clear the composition buffer, commit corresponding alphanumeric text, and switch to the ABC input method if 5 consecutive invalid phonetic keys are pressed."; -"i18n:UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.shortTitle" = "Auto-switch to ABC input method upon 5 consecutive typing errors"; +"i18n:UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.description" = "Under Traditional Chinese typing mode, clear the composition buffer, commit corresponding alphanumeric text, and switch to the ABC input method if consecutive invalid phonetic keys reach the threshold."; +"i18n:UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.shortTitle" = "Auto-switch to ABC input method upon consecutive typing errors"; +"i18n:UserDef.kConsecutiveTypingErrorsThreshold.description" = "Number of consecutive phonetic error keystrokes before triggering auto-switch to ABC input method (default: 5, range: 3-8)."; +"i18n:UserDef.kConsecutiveTypingErrorsThreshold.shortTitle" = "Consecutive typing errors threshold:"; "i18n:UserDef.kBasicKeyboardLayout.description" = "Choose the macOS-level basic keyboard layout. Zhuyin typing and Pinyin typing are now mutually switchable modes: while typing Pinyin, this layout determines the actual characters produced by your keys, so non-QWERTY alphanumerical keyboard layouts apply as well; while typing Zhuyin, keystrokes are translated as if on a standard layout, and the on-screen keyboard is displayed accordingly."; "i18n:UserDef.kBasicKeyboardLayout.shortTitle" = "Basic Keyboard Layout:"; "i18n:UserDef.kBeepSoundPreference.option.0" = "Muted"; diff --git a/Sources/vChewingIME_macOS/Resources/ja.lproj/Localizable.strings b/Sources/vChewingIME_macOS/Resources/ja.lproj/Localizable.strings index f8c258253..a349e780a 100644 --- a/Sources/vChewingIME_macOS/Resources/ja.lproj/Localizable.strings +++ b/Sources/vChewingIME_macOS/Resources/ja.lproj/Localizable.strings @@ -345,8 +345,10 @@ "i18n:UserDef.kAutoCompositeWithLongestPossibleCassetteKey.shortTitle" = "最長可能キーができた場合、文字を組み立つ"; "i18n:UserDef.kAutoCorrectReadingCombination.description" = "互換性のため、中英混在入力のフォールバックがONの場合、この機能は不作動。"; "i18n:UserDef.kAutoCorrectReadingCombination.shortTitle" = "入力中で打ち間違った発音組み合わせを自動的に訂正する"; -"i18n:UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.description" = "繁体字中国語入力モード時、注音構造に合致しない誤入力が5回連続した場合、未確定文字列を破棄して英数字を出力し、ABC入力ソースへ自動切替します。"; -"i18n:UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.shortTitle" = "タイピング誤入力が5回連続した際にABC入力ソースへ自動切替"; +"i18n:UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.description" = "繁体字中国語入力モード時、注音構造に合致しない誤入力が指定回数連続した場合、未確定文字列を破棄して英数字を出力し、ABC入力ソースへ自動切替します。"; +"i18n:UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.shortTitle" = "タイピング誤入力が閾値連続した際にABC入力ソースへ自動切替"; +"i18n:UserDef.kConsecutiveTypingErrorsThreshold.description" = "英数・ABC入力ソースへの自動切替を発動する連続誤入力の回数を設定します(初期値: 5、範囲: 3〜8)。"; +"i18n:UserDef.kConsecutiveTypingErrorsThreshold.shortTitle" = "連続誤入力切替の閾値:"; "i18n:UserDef.kBasicKeyboardLayout.description" = "macOS 基礎キーボード配置をご指定ください。注音モードと弁音モードは、現在はいつでも相互に切り替えられます。弁音モードでは、この配置が実際のキー出力を決定します(QWERTY 以外の英数キーボードも適用可能)。注音モードでは、キーは常に標準配列として扱われ、キーボードビューアの表示もそれに応じて変わります。"; "i18n:UserDef.kBasicKeyboardLayout.shortTitle" = "基礎キーボード:"; "i18n:UserDef.kBeepSoundPreference.option.0" = "鳴らず"; diff --git a/Sources/vChewingIME_macOS/Resources/zh-Hans.lproj/Localizable.strings b/Sources/vChewingIME_macOS/Resources/zh-Hans.lproj/Localizable.strings index 4cebb3e2a..1f16b9b49 100644 --- a/Sources/vChewingIME_macOS/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/vChewingIME_macOS/Resources/zh-Hans.lproj/Localizable.strings @@ -345,8 +345,10 @@ "i18n:UserDef.kAutoCompositeWithLongestPossibleCassetteKey.shortTitle" = "在已经敲出最长可能码的时候自动组字"; "i18n:UserDef.kAutoCorrectReadingCombination.description" = "该功能在中英混合输入回退时不起作用,以确保相容性。"; "i18n:UserDef.kAutoCorrectReadingCombination.shortTitle" = "敲字时自动纠正读音组合"; -"i18n:UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.description" = "在「唯音-繁」中文模式下,若连续输入破坏注音结构的错误键达 5 次,将清空组字区并输出对应英数文字,随后自动切换至 ABC 输入法。"; -"i18n:UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.shortTitle" = "连续键入错误达 5 键时自动切换至 ABC 输入法"; +"i18n:UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.description" = "在「唯音-繁」中文模式下,若连续输入破坏注音结构的错误键达设定门槛,将清空组字区并输出对应英数文字,随后自动切换至 ABC 输入法。"; +"i18n:UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.shortTitle" = "连续键入错误达门槛时自动切换至 ABC 输入法"; +"i18n:UserDef.kConsecutiveTypingErrorsThreshold.description" = "设定连续键入多少个错误键时触发自动切换至 ABC 输入法(预设 5 键,可选 3~8 键)。"; +"i18n:UserDef.kConsecutiveTypingErrorsThreshold.shortTitle" = "连续误键触发切换门槛:"; "i18n:UserDef.kBasicKeyboardLayout.description" = "请选择 macOS 基础键盘布局。注音打字与拼音打字现为可随时互相切换的打字模式:拼音打字时,此布局决定实际按键输出(非 QWERTY 英数布局亦适用);注音打字时,按键一律依标准键位翻译,荧幕键盘亦随之显示。"; "i18n:UserDef.kBasicKeyboardLayout.shortTitle" = "基础键盘布局:"; "i18n:UserDef.kBeepSoundPreference.option.0" = "静音"; diff --git a/Sources/vChewingIME_macOS/Resources/zh-Hant.lproj/Localizable.strings b/Sources/vChewingIME_macOS/Resources/zh-Hant.lproj/Localizable.strings index ced50fc21..3bb1d2f6f 100644 --- a/Sources/vChewingIME_macOS/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/vChewingIME_macOS/Resources/zh-Hant.lproj/Localizable.strings @@ -345,8 +345,10 @@ "i18n:UserDef.kAutoCompositeWithLongestPossibleCassetteKey.shortTitle" = "在已經敲出最長可能碼的時候自動組字"; "i18n:UserDef.kAutoCorrectReadingCombination.description" = "該功能在中英混合輸入回退時不起作用,以確保相容性。"; "i18n:UserDef.kAutoCorrectReadingCombination.shortTitle" = "敲字時自動糾正讀音組合"; -"i18n:UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.description" = "在「唯音-繁」中文模式下,若連續輸入破壞注音結構的錯誤鍵達 5 次,將清空組字區並輸出對應英數文字,隨後自動切換至 ABC 輸入法。"; -"i18n:UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.shortTitle" = "連續鍵入錯誤達 5 鍵時自動切換至 ABC 輸入法"; +"i18n:UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.description" = "在「唯音-繁」中文模式下,若連續輸入破壞注音結構的錯誤鍵達設定門檻,將清空組字區並輸出對應英數文字,隨後自動切換至 ABC 輸入法。"; +"i18n:UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.shortTitle" = "連續鍵入錯誤達門檻時自動切換至 ABC 輸入法"; +"i18n:UserDef.kConsecutiveTypingErrorsThreshold.description" = "設定連續鍵入多少個錯誤鍵時觸發自動切換至 ABC 輸入法(預設 5 鍵,可選 3~8 鍵)。"; +"i18n:UserDef.kConsecutiveTypingErrorsThreshold.shortTitle" = "連續誤鍵觸發切換門檻:"; "i18n:UserDef.kBasicKeyboardLayout.description" = "請選擇 macOS 基礎鍵盤佈局。注音打字與拼音打字現為可隨時互相切換的打字模式:拼音打字時,此佈局決定實際按鍵輸出(非 QWERTY 英數佈局亦適用);注音打字時,按鍵一律依標準鍵位翻譯,螢幕鍵盤亦隨之顯示。"; "i18n:UserDef.kBasicKeyboardLayout.shortTitle" = "基礎鍵盤佈局:"; "i18n:UserDef.kBeepSoundPreference.option.0" = "靜音"; From 37d2aee4c8b6c0ce149a2ba397ddc65000367d03 Mon Sep 17 00:00:00 2001 From: Will Date: Sun, 6 Sep 2026 19:36:49 +0800 Subject: [PATCH 12/14] Typewriter // Session: Reset ASCII mode back to Chinese on server reactivation after ABC auto-switch. --- .../MainAssemblyTests_Test5.swift | 28 +++++++++++++++++++ .../InputHandler_CoreProtocol.swift | 6 ++-- .../Typewriter/Session/InputSession.swift | 4 +++ .../Typewriter/Session/SessionProtocol.swift | 11 ++++++++ ...InputHandlerTests_AutoSwitchOnErrors.swift | 4 +++ 5 files changed, 51 insertions(+), 2 deletions(-) diff --git a/Packages/vChewing_MainAssembly4Darwin/Tests/MainAssembly4DarwinTests/MainAssemblyTests_Test5.swift b/Packages/vChewing_MainAssembly4Darwin/Tests/MainAssembly4DarwinTests/MainAssemblyTests_Test5.swift index 218e7940a..02d5b73d8 100644 --- a/Packages/vChewing_MainAssembly4Darwin/Tests/MainAssembly4DarwinTests/MainAssemblyTests_Test5.swift +++ b/Packages/vChewing_MainAssembly4Darwin/Tests/MainAssembly4DarwinTests/MainAssemblyTests_Test5.swift @@ -237,4 +237,32 @@ extension MainAssemblyTests { #expect(testSession.isFuriousCopilotCandidateWindowVisible) } } + + /// 測試連續錯誤自動切換至 ABC 後,重新切回唯音時自動恢復為中文模式。 + @Test + func test508_ActivationAfterAutoSwitchToABC_ResetsASCIIModeToChinese() throws { + #expect(testSession.isActivated) + InputSession.current = testSession + defer { + InputSession.isAutoSwitchedToABC = false + testSession.isASCIIMode = false + InputSession.isASCIIModeForAllClients = false + } + + // 模擬自動切換至 ABC 後的狀態:isASCIIMode == true, isAutoSwitchedToABC == true + testSession.isASCIIMode = true + InputSession.isAutoSwitchedToABC = true + + #expect(testSession.isASCIIMode == true) + #expect(InputSession.isAutoSwitchedToABC == true) + + // 模擬使用者切回唯音:呼叫 performServerActivation + testSession.performServerActivation() + + // 驗證 isAutoSwitchedToABC 已被重置為 false,且 isASCIIMode 已自動恢復為 false(中文模式) + #expect(InputSession.isAutoSwitchedToABC == false) + #expect(testSession.isASCIIMode == false) + #expect(InputSession.isASCIIModeForAllClients == false) + } } + diff --git a/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_CoreProtocol.swift b/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_CoreProtocol.swift index eeb1c1120..d57ab1970 100644 --- a/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_CoreProtocol.swift +++ b/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_CoreProtocol.swift @@ -1284,8 +1284,10 @@ extension InputHandlerProtocol { assembler.clear() session.switchState(State.ofCommitting(textToCommit: textToCommit)) session.isASCIIMode = true - InputSession.isASCIIModeForAllClients = true - _ = SessionHost.shared.switchToSystemABCInputSource() + let switched = SessionHost.shared.switchToSystemABCInputSource() + if switched { + InputSession.isAutoSwitchedToABC = true + } return true } } diff --git a/Packages/vChewing_Typewriter/Sources/Typewriter/Session/InputSession.swift b/Packages/vChewing_Typewriter/Sources/Typewriter/Session/InputSession.swift index 2abe7a414..a372a90df 100644 --- a/Packages/vChewing_Typewriter/Sources/Typewriter/Session/InputSession.swift +++ b/Packages/vChewing_Typewriter/Sources/Typewriter/Session/InputSession.swift @@ -53,6 +53,10 @@ public final class InputSession: @MainActor SessionProtocol, Sendable { public static var isASCIIModeForAllClients = false /// 一個共用辭典,專門用來給每個副本用的 isASCIIMode 追蹤用餐數。 public static var isASCIIModeForEachClient: [String: Bool] = [:] + /// 記錄是否剛因連續鍵入錯誤而自動切換至 ABC 輸入法。 + /// 若為 true,則當下次本輸入法被重新啟用(performServerActivation)時, + /// 應自動將英數模式解除,回到中文輸入模式。 + public static var isAutoSwitchedToABC = false // MARK: - 極性雙緩衝 Session 池 diff --git a/Packages/vChewing_Typewriter/Sources/Typewriter/Session/SessionProtocol.swift b/Packages/vChewing_Typewriter/Sources/Typewriter/Session/SessionProtocol.swift index ade70a378..d362160fb 100644 --- a/Packages/vChewing_Typewriter/Sources/Typewriter/Session/SessionProtocol.swift +++ b/Packages/vChewing_Typewriter/Sources/Typewriter/Session/SessionProtocol.swift @@ -38,6 +38,8 @@ public protocol SessionProtocol: AnyObject, CtlCandidateDelegate, static var isASCIIModeForAllClients: Bool { get set } /// 一個共用辭典,專門用來給每個副本用的 isASCIIMode 追蹤用餐數。 static var isASCIIModeForEachClient: [String: Bool] { get set } + /// 記錄是否剛因連續鍵入錯誤而自動切換至 ABC 輸入法。 + static var isAutoSwitchedToABC: Bool { get set } /// 偏好設定。 var prefs: PrefMgrProtocol { get set } /// 上一個被處理過的鍵盤事件。 @@ -210,6 +212,15 @@ extension SessionProtocol { } public func performServerActivation() { + // 檢查若前次是因連續鍵入錯誤而自動切換至 ABC,在使用者重新切回唯音時應自動恢復為中文模式。 + if Self.isAutoSwitchedToABC { + Self.isAutoSwitchedToABC = false + isASCIIMode = false + Self.isASCIIModeForAllClients = false + isASCIIModeForThisClient = false + Self.isASCIIModeForEachClient.removeAll() + } + // MARK: 快速路徑 — 最佳化 CapsLock 中英頻繁切換的場景。 /// 每次 activateServer 都是一次全新的啟用事件, diff --git a/Packages/vChewing_Typewriter/Tests/TypewriterTests/InputHandlerTests_AutoSwitchOnErrors.swift b/Packages/vChewing_Typewriter/Tests/TypewriterTests/InputHandlerTests_AutoSwitchOnErrors.swift index be19ffe16..aa2cfa996 100644 --- a/Packages/vChewing_Typewriter/Tests/TypewriterTests/InputHandlerTests_AutoSwitchOnErrors.swift +++ b/Packages/vChewing_Typewriter/Tests/TypewriterTests/InputHandlerTests_AutoSwitchOnErrors.swift @@ -36,6 +36,7 @@ extension InputHandlerTests { } defer { SessionHost.shared.switchToSystemABCInputSource = { false } + InputSession.isAutoSwitchedToABC = false } // Dachen: g=ㄕ, r=ㄐ, e=ㄍ, a=ㄇ, t=ㄔ (5 consonants -> 5 consecutive errors) @@ -43,6 +44,7 @@ extension InputHandlerTests { #expect(testSession.recentCommissions == ["great"]) #expect(switchedToABC == true) + #expect(InputSession.isAutoSwitchedToABC == true) #expect(testSession.isASCIIMode == true) #expect(testHandler.composer.isEmpty) #expect(testHandler.assembler.isEmpty) @@ -67,6 +69,7 @@ extension InputHandlerTests { } defer { SessionHost.shared.switchToSystemABCInputSource = { false } + InputSession.isAutoSwitchedToABC = false } // 當輸入到第 5 鍵(即 "cd .." 中的第二個 ".")時,累計達到 5 個錯誤鍵, @@ -75,6 +78,7 @@ extension InputHandlerTests { // 不會被唯音當成注音(大千鍵盤的 "ㄥ")攔截,而是直接 pass-through 由 OS 送出。 typeSentence("cd ..") #expect(switchedToABC == true) + #expect(InputSession.isAutoSwitchedToABC == true) #expect(testSession.recentCommissions == ["cd .."]) #expect(testSession.isASCIIMode == true) #expect(testHandler.composer.isEmpty) From 84813e0c3a35c0c37ab484ff2be1927aad4610b3 Mon Sep 17 00:00:00 2001 From: Will Date: Sun, 6 Sep 2026 20:08:04 +0800 Subject: [PATCH 13/14] Typewriter // Session: Use transient pass-through on ABC auto-switch. --- .../IMKUtils/TISInputSourceExtension.swift | 26 +----- .../InputHandler_CoreProtocol.swift | 5 +- .../InputHandler_TriageInput.swift | 10 +++ .../Typewriter/Session/InputSession.swift | 3 + .../Session/SessionCoreProtocol.swift | 3 + .../Typewriter/Session/SessionProtocol.swift | 7 ++ ...InputHandlerTests_AutoSwitchOnErrors.swift | 79 ++++++++++++++++--- .../MockedInputHandlerAndStates.swift | 11 +++ 8 files changed, 111 insertions(+), 33 deletions(-) diff --git a/Packages/vChewing_IMKUtils/Sources/IMKUtils/TISInputSourceExtension.swift b/Packages/vChewing_IMKUtils/Sources/IMKUtils/TISInputSourceExtension.swift index 09497112e..d8ad4da68 100644 --- a/Packages/vChewing_IMKUtils/Sources/IMKUtils/TISInputSourceExtension.swift +++ b/Packages/vChewing_IMKUtils/Sources/IMKUtils/TISInputSourceExtension.swift @@ -102,39 +102,21 @@ // 1. Check if current ASCII capable keyboard input source is selectable, activated, and not vChewing. if let currentASCII = TISCopyCurrentASCIICapableKeyboardInputSource()?.takeRetainedValue() { if !currentASCII.identifier.lowercased().contains("vchewing"), currentASCII.isSelectable, currentASCII.isActivated { - if currentASCII.select() { - DispatchQueue.main.async { - _ = currentASCII.select() - } - return true - } + return currentASCII.select() } } // 2. Search for com.apple.keylayout.ABC among activated and selectable sources. let allSources = rawTISInputSources(onlyASCII: true) if let abcSource = allSources.first(where: { $0.identifier == "com.apple.keylayout.ABC" && $0.isSelectable && $0.isActivated }) { - if abcSource.select() { - DispatchQueue.main.async { - _ = abcSource.select() - } - return true - } + return abcSource.select() } // 3. Fallback: Any selectable and activated ASCII layout that is not vChewing. if let anyASCII = allSources.first(where: { !$0.identifier.lowercased().contains("vchewing") && $0.isSelectable && $0.isActivated }) { - let result = anyASCII.select() - DispatchQueue.main.async { - _ = anyASCII.select() - } - return result + return anyASCII.select() } // 4. Last resort: Any selectable ABC layout even if not marked activated yet. if let abcSource = allSources.first(where: { $0.identifier == "com.apple.keylayout.ABC" && $0.isSelectable }) { - let result = abcSource.select() - DispatchQueue.main.async { - _ = abcSource.select() - } - return result + return abcSource.select() } return false } diff --git a/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_CoreProtocol.swift b/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_CoreProtocol.swift index d57ab1970..af6f3e8dd 100644 --- a/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_CoreProtocol.swift +++ b/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_CoreProtocol.swift @@ -1283,10 +1283,13 @@ extension InputHandlerProtocol { calligrapher.removeAll() assembler.clear() session.switchState(State.ofCommitting(textToCommit: textToCommit)) - session.isASCIIMode = true let switched = SessionHost.shared.switchToSystemABCInputSource() if switched { + session.isPassThroughUntilDeactivated = true + session.passThroughUntilDeactivatedTimestamp = Date() InputSession.isAutoSwitchedToABC = true + } else { + session.isASCIIMode = true } return true } diff --git a/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_TriageInput.swift b/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_TriageInput.swift index a5a5610be..8e2fd9d43 100644 --- a/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_TriageInput.swift +++ b/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler_TriageInput.swift @@ -9,11 +9,21 @@ /// 該檔案乃輸入調度模組當中「用來規定當 IMK 接受按鍵訊號時且首次交給輸入調度模組處理時、 /// 輸入調度模組要率先處理」的部分。據此判斷是否需要將按鍵處理委派給其它成員函式。 +import Foundation + // MARK: - § 根據狀態調度按鍵輸入 (Handle Input with States) * Triage extension InputHandlerProtocol { public func triageInput(event input: InputSignalProtocol) -> Bool { guard let session = session else { return false } + if session.isPassThroughUntilDeactivated { + if let timestamp = session.passThroughUntilDeactivatedTimestamp, + Date().timeIntervalSince(timestamp) < 2.0 { + return false + } + session.isPassThroughUntilDeactivated = false + session.passThroughUntilDeactivatedTimestamp = nil + } var state: State { session.state } currentLM.syncPrefs() diff --git a/Packages/vChewing_Typewriter/Sources/Typewriter/Session/InputSession.swift b/Packages/vChewing_Typewriter/Sources/Typewriter/Session/InputSession.swift index a372a90df..b443b9c51 100644 --- a/Packages/vChewing_Typewriter/Sources/Typewriter/Session/InputSession.swift +++ b/Packages/vChewing_Typewriter/Sources/Typewriter/Session/InputSession.swift @@ -83,6 +83,9 @@ public final class InputSession: @MainActor SessionProtocol, Sendable { public let id: UUID = .init() + public var isPassThroughUntilDeactivated: Bool = false + public var passThroughUntilDeactivatedTimestamp: Date? = nil + public var clientProxyObjectIdentifier: ObjectIdentifier? public var buzzer: (() -> ())? = { mainSync { SessionHost.shared.buzz() } } diff --git a/Packages/vChewing_Typewriter/Sources/Typewriter/Session/SessionCoreProtocol.swift b/Packages/vChewing_Typewriter/Sources/Typewriter/Session/SessionCoreProtocol.swift index 7dcc7f97b..44b9a36cf 100644 --- a/Packages/vChewing_Typewriter/Sources/Typewriter/Session/SessionCoreProtocol.swift +++ b/Packages/vChewing_Typewriter/Sources/Typewriter/Session/SessionCoreProtocol.swift @@ -32,6 +32,9 @@ public protocol SessionCoreProtocol: AnyObject & CtlCandidateDelegate { /// 用以記錄當前輸入法狀態的變數。(有 DidSet) var state: IMEState { get set } var isASCIIMode: Bool { get set } + /// 記錄是否剛觸發連續錯誤自動切換至 ABC,此時在 deactivateServer 前應將後續按鍵 pass-through 給系統。 + var isPassThroughUntilDeactivated: Bool { get set } + var passThroughUntilDeactivatedTimestamp: Date? { get set } var inputMode: Shared.InputMode { get } var clientMitigationLevel: Int { get } var ui: SessionUIProtocol? { get } diff --git a/Packages/vChewing_Typewriter/Sources/Typewriter/Session/SessionProtocol.swift b/Packages/vChewing_Typewriter/Sources/Typewriter/Session/SessionProtocol.swift index d362160fb..46962464c 100644 --- a/Packages/vChewing_Typewriter/Sources/Typewriter/Session/SessionProtocol.swift +++ b/Packages/vChewing_Typewriter/Sources/Typewriter/Session/SessionProtocol.swift @@ -193,6 +193,8 @@ extension SessionProtocol { } public func performServerDeactivation() { + isPassThroughUntilDeactivated = false + passThroughUntilDeactivatedTimestamp = nil guard Self.current?.id != id else { return } isActivated = false // `resetInputHandler()` 會自動搞定 Empty 狀態。 @@ -212,6 +214,8 @@ extension SessionProtocol { } public func performServerActivation() { + isPassThroughUntilDeactivated = false + passThroughUntilDeactivatedTimestamp = nil // 檢查若前次是因連續鍵入錯誤而自動切換至 ABC,在使用者重新切回唯音時應自動恢復為中文模式。 if Self.isAutoSwitchedToABC { Self.isAutoSwitchedToABC = false @@ -236,6 +240,9 @@ extension SessionProtocol { inputMode = resolvedInputMode } state = .ofEmpty() + if isASCIIMode, !SessionHost.shared.isKeyboardJIS() { + isASCIIMode = false + } lastAppliedKeyboardLayout = nil setKeyLayout() return diff --git a/Packages/vChewing_Typewriter/Tests/TypewriterTests/InputHandlerTests_AutoSwitchOnErrors.swift b/Packages/vChewing_Typewriter/Tests/TypewriterTests/InputHandlerTests_AutoSwitchOnErrors.swift index aa2cfa996..233fb40b0 100644 --- a/Packages/vChewing_Typewriter/Tests/TypewriterTests/InputHandlerTests_AutoSwitchOnErrors.swift +++ b/Packages/vChewing_Typewriter/Tests/TypewriterTests/InputHandlerTests_AutoSwitchOnErrors.swift @@ -45,7 +45,8 @@ extension InputHandlerTests { #expect(testSession.recentCommissions == ["great"]) #expect(switchedToABC == true) #expect(InputSession.isAutoSwitchedToABC == true) - #expect(testSession.isASCIIMode == true) + #expect(testSession.isPassThroughUntilDeactivated == true) + #expect(testSession.isASCIIMode == false) #expect(testHandler.composer.isEmpty) #expect(testHandler.assembler.isEmpty) } @@ -74,17 +75,19 @@ extension InputHandlerTests { // 當輸入到第 5 鍵(即 "cd .." 中的第二個 ".")時,累計達到 5 個錯誤鍵, // 即刻觸發自動切換至系統 ABC 輸入法,並將已鍵入的 5 個英數字元 "cd .." 遞交。 - // 同時唯音內部狀態亦切換為英數模式(isASCIIMode == true),確保尚未切離此 session 的後續按鍵(如第 6 鍵 "/") + // 同時標記 isPassThroughUntilDeactivated == true,確保尚未切離此 session 的後續按鍵(如第 6 鍵 "/") // 不會被唯音當成注音(大千鍵盤的 "ㄥ")攔截,而是直接 pass-through 由 OS 送出。 + // 唯音自身的 isASCIIMode 仍保持 false(中文模式),以確保切回唯音時能直接輸入中文。 typeSentence("cd ..") #expect(switchedToABC == true) #expect(InputSession.isAutoSwitchedToABC == true) #expect(testSession.recentCommissions == ["cd .."]) - #expect(testSession.isASCIIMode == true) + #expect(testSession.isPassThroughUntilDeactivated == true) + #expect(testSession.isASCIIMode == false) #expect(testHandler.composer.isEmpty) #expect(testHandler.assembler.isEmpty) - // 第 6 鍵 "/" 在英數模式下 pass-through 直接由系統處理,不被唯音攔截為注音 "ㄥ" + // 第 6 鍵 "/" 在 pass-through 模式下直接由系統處理,不被唯音攔截為注音 "ㄥ" let slashHandled = testHandler.triageInput(event: KBEvent.KeyEventData(chars: "/").asEvent) #expect(!slashHandled) } @@ -114,12 +117,13 @@ extension InputHandlerTests { _ = testHandler.triageInput(event: KBEvent.KeyEventData(chars: String(ch)).asEvent) } #expect(switchedToABC == true) - #expect(testSession.isASCIIMode == true) + #expect(testSession.isPassThroughUntilDeactivated == true) + #expect(testSession.isASCIIMode == false) #expect(testSession.recentCommissions == ["git l"]) #expect(testHandler.composer.isEmpty) #expect(testHandler.assembler.isEmpty) - // 後續按鍵在英數模式下 pass-through 直接交由 OS 送出 + // 後續按鍵在 pass-through 模式下直接交由 OS 送出 let oHandled = testHandler.triageInput(event: KBEvent.KeyEventData(chars: "o").asEvent) #expect(!oHandled) let gHandled = testHandler.triageInput(event: KBEvent.KeyEventData(chars: "g").asEvent) @@ -175,7 +179,8 @@ extension InputHandlerTests { // 鍵入到第 5 鍵空格時即觸發切換至 ABC 並遞交 "sudo "。 typeSentence("sudo ") #expect(switchedToABC == true) - #expect(testSession.isASCIIMode == true) + #expect(testSession.isPassThroughUntilDeactivated == true) + #expect(testSession.isASCIIMode == false) #expect(testSession.recentCommissions == ["sudo "]) #expect(testHandler.composer.isEmpty) #expect(testHandler.assembler.isEmpty) @@ -278,6 +283,15 @@ extension InputHandlerTests { testSession.recentCommissions.removeAll() testSession.resetInputHandler(forceComposerCleanup: true) + var switchedToABC = false + SessionHost.shared.switchToSystemABCInputSource = { + switchedToABC = true + return true + } + defer { + SessionHost.shared.switchToSystemABCInputSource = { false } + } + // Type a valid Chinese syllable first: ㄧㄡ ("u. ") -> forms a node in assembler typeSentence("u. ") #expect(!testHandler.assembler.isEmpty) @@ -287,7 +301,9 @@ extension InputHandlerTests { // The prior assembled sentence must be discarded, only "great" committed #expect(testSession.recentCommissions.last == "great") - #expect(testSession.isASCIIMode == true) + #expect(switchedToABC == true) + #expect(testSession.isPassThroughUntilDeactivated == true) + #expect(testSession.isASCIIMode == false) #expect(testHandler.assembler.isEmpty) #expect(testHandler.composer.isEmpty) } @@ -434,7 +450,8 @@ extension InputHandlerTests { typeSentence("mkdir") #expect(switchedToABC == true) - #expect(testSession.isASCIIMode == true) + #expect(testSession.isPassThroughUntilDeactivated == true) + #expect(testSession.isASCIIMode == false) #expect(testSession.recentCommissions == ["mkdir"]) #expect(testHandler.composer.isEmpty) #expect(testHandler.assembler.isEmpty) @@ -467,9 +484,51 @@ extension InputHandlerTests { typeSentence("git") #expect(switchedToABC == true) - #expect(testSession.isASCIIMode == true) + #expect(testSession.isPassThroughUntilDeactivated == true) + #expect(testSession.isASCIIMode == false) #expect(testSession.recentCommissions == ["git"]) #expect(testHandler.composer.isEmpty) #expect(testHandler.assembler.isEmpty) } + + /// 測試自動切換至 ABC 後,使用者重新切回唯音(performServerActivation)時, + /// 輸入法應已重置 pass-through 狀態並維持中文模式(isASCIIMode == false), + /// 且能立即正常鍵入中文。 + @Test + func test_AutoSwitchOnConsecutiveErrors_SwitchBackToVChewingEnablesChineseImmediately() throws { + guard let testHandler, let testSession else { + Issue.record("Test handler or session is nil.") + return + } + testHandler.prefs.autoSwitchToAlphanumericalOnConsecutiveErrors = true + testSession.inputMode = .imeModeCHT + testSession.isASCIIMode = false + testSession.recentCommissions.removeAll() + testSession.resetInputHandler(forceComposerCleanup: true) + + SessionHost.shared.switchToSystemABCInputSource = { true } + defer { + SessionHost.shared.switchToSystemABCInputSource = { false } + } + + // 1. 打 "cd .." 觸發自動切換至 ABC + typeSentence("cd ..") + #expect(testSession.recentCommissions == ["cd .."]) + #expect(testSession.isPassThroughUntilDeactivated == true) + #expect(testSession.isASCIIMode == false) + + // 2. 隨後的 "/" 仍在 pass-through,不被攔截 + let slashHandled = testHandler.triageInput(event: KBEvent.KeyEventData(chars: "/").asEvent) + #expect(!slashHandled) + + // 3. 模擬使用者透過 Cmd+Space 切回唯音:呼叫 performServerActivation + testSession.performServerActivation() + #expect(testSession.isPassThroughUntilDeactivated == false) + #expect(testSession.isASCIIMode == false) + + // 4. 切回唯音後,應立即能正常輸入中文,無須手動按 Shift 或再次切換 + // 在大千鍵盤上打 "su3" -> "你" (3聲) + typeSentence("su3") + #expect(testHandler.assembler.assembledSentence.values.joined() == "你") + } } diff --git a/Packages/vChewing_Typewriter/Tests/TypewriterTests/TestComponents/MockedInputHandlerAndStates.swift b/Packages/vChewing_Typewriter/Tests/TypewriterTests/TestComponents/MockedInputHandlerAndStates.swift index f4e5c5bc5..e727d8a16 100644 --- a/Packages/vChewing_Typewriter/Tests/TypewriterTests/TestComponents/MockedInputHandlerAndStates.swift +++ b/Packages/vChewing_Typewriter/Tests/TypewriterTests/TestComponents/MockedInputHandlerAndStates.swift @@ -103,6 +103,8 @@ public final class MockSession: @MainActor SessionCoreProtocol { public var state: IMEState = .init() public var inputHandler: MockInputHandler? public var isASCIIMode: Bool = false + public var isPassThroughUntilDeactivated: Bool = false + public var passThroughUntilDeactivatedTimestamp: Date? = nil public var inputMode: Shared.InputMode = .imeModeCHT public var clientMitigationLevel: Int = 0 /// 預設為 nil(候選窗不存在);測試需要模擬候選窗已顯示時才指派。 @@ -140,6 +142,15 @@ public final class MockSession: @MainActor SessionCoreProtocol { vCLog(logMessage) } + public func performServerActivation() { + isPassThroughUntilDeactivated = false + passThroughUntilDeactivatedTimestamp = nil + if isASCIIMode { + isASCIIMode = false + } + state = .ofEmpty() + } + public func getCandidate(at index: Int) -> CandidateInState? { guard state.candidates.indices.contains(index) else { return nil } return state.candidates[index] From 34621edb7447d929c0c019a7995f3cffa29f3cde Mon Sep 17 00:00:00 2001 From: Will Date: Sun, 6 Sep 2026 20:45:40 +0800 Subject: [PATCH 14/14] Shared // UserDef: Set default of kAutoSwitchToAlphanumericalOnConsecutiveErrors to true. --- Packages/vChewing_Shared/Sources/Shared/UserDef/UserDef.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/vChewing_Shared/Sources/Shared/UserDef/UserDef.swift b/Packages/vChewing_Shared/Sources/Shared/UserDef/UserDef.swift index 838d338cd..454debd8e 100644 --- a/Packages/vChewing_Shared/Sources/Shared/UserDef/UserDef.swift +++ b/Packages/vChewing_Shared/Sources/Shared/UserDef/UserDef.swift @@ -500,7 +500,7 @@ nonisolated extension UserDef { case .kFetchSuggestionsFromPerceptionOverrideModel: return .bool(true) case .kUseFixedCandidateOrderOnSelection: return .bool(false) case .kAutoCorrectReadingCombination: return .bool(true) - case .kAutoSwitchToAlphanumericalOnConsecutiveErrors: return .bool(false) + case .kAutoSwitchToAlphanumericalOnConsecutiveErrors: return .bool(true) case .kConsecutiveTypingErrorsThreshold: return .integer(5) case .kReadingNarrationCoverage: return .integer(0) case .kAlsoConfirmAssociatedCandidatesByEnter: return .bool(false)