diff --git a/Packages/vChewing_IMKUtils/Sources/IMKUtils/TISInputSourceExtension.swift b/Packages/vChewing_IMKUtils/Sources/IMKUtils/TISInputSourceExtension.swift index 13ac234ed..d8ad4da68 100644 --- a/Packages/vChewing_IMKUtils/Sources/IMKUtils/TISInputSourceExtension.swift +++ b/Packages/vChewing_IMKUtils/Sources/IMKUtils/TISInputSourceExtension.swift @@ -97,6 +97,30 @@ 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 { + 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 }) { + 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 }) { + 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 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_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_SettingsUI/Sources/SettingsUI/SettingsCocoa/VwrSettingsPaneCocoaGeneral.swift b/Packages/vChewing_SettingsUI/Sources/SettingsUI/SettingsCocoa/VwrSettingsPaneCocoaGeneral.swift index 4e5de54f0..2f1944155 100644 --- a/Packages/vChewing_SettingsUI/Sources/SettingsUI/SettingsCocoa/VwrSettingsPaneCocoaGeneral.swift +++ b/Packages/vChewing_SettingsUI/Sources/SettingsUI/SettingsCocoa/VwrSettingsPaneCocoaGeneral.swift @@ -64,6 +64,14 @@ extension SettingsPanesCocoa { fixWidth: contentWidth, prefUITab: .tabGeneral ) + UserDef.kAutoSwitchToAlphanumericalOnConsecutiveErrors.renderCocoa( + 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 e4ac44ef9..1d69261dd 100644 --- a/Packages/vChewing_SettingsUI/Sources/SettingsUI/SettingsUI/VwrSettingsPaneGeneral.swift +++ b/Packages/vChewing_SettingsUI/Sources/SettingsUI/SettingsUI/VwrSettingsPaneGeneral.swift @@ -64,6 +64,8 @@ public struct VwrSettingsPaneGeneral: View { SpeechSputnik.shared.refreshStatus() } 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 cad3b3f0f..b2c6ddff7 100644 --- a/Packages/vChewing_Shared/Sources/Shared/PrefMgr_Core.swift +++ b/Packages/vChewing_Shared/Sources/Shared/PrefMgr_Core.swift @@ -168,6 +168,12 @@ public final class PrefMgr: PrefMgrProtocol, Sendable { @AppProperty(userDef: .kAutoCorrectReadingCombination) public var autoCorrectReadingCombination: Bool + @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 e95c92204..711b11c4b 100644 --- a/Packages/vChewing_Shared/Sources/Shared/Protocols/PrefMgrProtocol.swift +++ b/Packages/vChewing_Shared/Sources/Shared/Protocols/PrefMgrProtocol.swift @@ -54,6 +54,8 @@ public protocol PrefMgrProtocol { var reducePOMLifetimeToNoMoreThan12Hours: Bool { get set } 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 c89988b98..454debd8e 100644 --- a/Packages/vChewing_Shared/Sources/Shared/UserDef/UserDef.swift +++ b/Packages/vChewing_Shared/Sources/Shared/UserDef/UserDef.swift @@ -80,6 +80,8 @@ nonisolated public enum UserDef: String, CaseIterable, Identifiable, Sendable { case kFetchSuggestionsFromPerceptionOverrideModel = "FetchSuggestionsFromPerceptionOverrideModel" case kUseFixedCandidateOrderOnSelection = "UseFixedCandidateOrderOnSelection" case kAutoCorrectReadingCombination = "AutoCorrectReadingCombination" + case kAutoSwitchToAlphanumericalOnConsecutiveErrors = "AutoSwitchToAlphanumericalOnConsecutiveErrors" + case kConsecutiveTypingErrorsThreshold = "ConsecutiveTypingErrorsThreshold" case kReadingNarrationCoverage = "ReadingNarrationCoverage" case kAlsoConfirmAssociatedCandidatesByEnter = "AlsoConfirmAssociatedCandidatesByEnter" case kKeepReadingUponCompositionError = "KeepReadingUponCompositionError" @@ -374,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 @@ -497,6 +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 .kConsecutiveTypingErrorsThreshold: return .integer(5) case .kReadingNarrationCoverage: return .integer(0) case .kAlsoConfirmAssociatedCandidatesByEnter: return .bool(false) case .kKeepReadingUponCompositionError: return .bool(false) @@ -898,6 +903,22 @@ 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 .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_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 26c447478..1a47a29ca 100644 --- a/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler.swift +++ b/Packages/vChewing_Typewriter/Sources/Typewriter/InputHandler/InputHandler.swift @@ -76,6 +76,8 @@ public final class InputHandler: @MainActor InputHandlerProtocol { public var strCodePointBuffer = "" // 內碼輸入專用組碼區 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 f9f4b856b..af6f3e8dd 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,8 @@ 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 inFlightComposerKeys: [String] { get set } // 當前注拼槽對應的鍵入字元暫存區 var furiousTrail: [String] { get set } // 狂拼模式:自動 chop/空格固化提交鍵對應的拼音字母 blob trail var furiousHighlightOverride: CandidateInState? { get set } // 狂拼 copilot 窗高亮候選(當拍消費) var furiousCoSegmentedOffers: [FuriousCoSegmentedOffer] { get set @@ -431,6 +434,8 @@ extension InputHandlerProtocol { public func clearComposerAndCalligrapher() { calligrapher.removeAll() composer.clear() + consecutiveTypingErrors.removeAll() + inFlightComposerKeys.removeAll() mixedAlphanumericalBuffer.removeAll() strCodePointBuffer.removeAll() } @@ -1057,3 +1062,235 @@ 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. 試驗吸收後的聲介韻組合是否在國語注音合法音節集合內。 + // 若注拼槽已有音位,且追加此鍵後的聲介韻組合不屬於任何合法音節或合法音節前綴,直接判定為錯誤鍵。 + 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 { + 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 + } + + // 2d. 聲/介/韻/調 槽位順序違反判定: + // 韻母後輸入聲母 + 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 + } + + // 2e. 破壞既有已鍵入音位判定(例如自動糾錯移除掉已有的介母、韻母或聲母): + 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.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.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() + inFlightComposerKeys.removeAll() + } + return nil + } + + if let keyCodeType = KeyCode(rawValue: input.keyCode) { + switch keyCodeType { + case .kSymbolMenuPhysicalKeyIntl, .kSymbolMenuPhysicalKeyJIS, .kContextMenu: + consecutiveTypingErrors.removeAll() + inFlightComposerKeys.removeAll() + return nil + default: break + } + } + + let threshold = max(3, prefs.consecutiveTypingErrorsThreshold) + + // 當已處於連續錯誤狀態(已累積 >= 2 個錯誤鍵)時,Space 被視為英數輸入的一環(如 "cd .."、"ls -la"、"git status"), + // 應計入連續鍵擊並阻斷注音一聲/送字;若未處於錯誤狀態,Space 仍為正常的注音一聲或送字。 + if input.isSpace { + if consecutiveTypingErrors.count >= 2 { + consecutiveTypingErrors.append(" ") + composer.clear() + calligrapher.removeAll() + inFlightComposerKeys.removeAll() + if consecutiveTypingErrors.count >= threshold { + return commitConsecutiveErrorsAndSwitchToABC(session: session) + } + return true + } else { + consecutiveTypingErrors.removeAll() + inFlightComposerKeys.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 .." 中的 "." 與 "/"、"git log" 中的字母),持續累積直到滿門檻自動切換。 + if consecutiveTypingErrors.count >= 2 { + consecutiveTypingErrors.append(charToRecord) + composer.clear() + calligrapher.removeAll() + inFlightComposerKeys.removeAll() + if consecutiveTypingErrors.count >= threshold { + return commitConsecutiveErrorsAndSwitchToABC(session: session) + } + 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 { + // 若在累積錯誤鍵之前注拼槽內已有尚未固化/提交的鍵入字元(如合法的聲母或介母,但因後續鍵入破壞注音結構而判定為英文打字), + // 將這些尚未成為完整漢字的按鍵一併作為錯誤鍵序列納入 + if !inFlightComposerKeys.isEmpty { + consecutiveTypingErrors = inFlightComposerKeys + [charToRecord] + inFlightComposerKeys.removeAll() + } else { + consecutiveTypingErrors.append(charToRecord) + } + composer.clear() + calligrapher.removeAll() + if consecutiveTypingErrors.count >= threshold { + return commitConsecutiveErrorsAndSwitchToABC(session: session) + } + return true + } else { + if composer.isEmpty { + inFlightComposerKeys = [charToRecord] + consecutiveTypingErrors = [charToRecord] + } else { + inFlightComposerKeys.append(charToRecord) + } + } + return nil + } + + @discardableResult + private func commitConsecutiveErrorsAndSwitchToABC(session: Session) -> Bool { + let textToCommit = consecutiveTypingErrors.joined() + consecutiveTypingErrors.removeAll() + inFlightComposerKeys.removeAll() + composer.clear() + calligrapher.removeAll() + assembler.clear() + session.switchState(State.ofCommitting(textToCommit: textToCommit)) + 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_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..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() @@ -48,6 +58,14 @@ 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: + consecutiveTypingErrors.removeAll() + inFlightComposerKeys.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/InputSession.swift b/Packages/vChewing_Typewriter/Sources/Typewriter/Session/InputSession.swift index 2abe7a414..b443b9c51 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 池 @@ -79,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 511f6a424..44b9a36cf 100644 --- a/Packages/vChewing_Typewriter/Sources/Typewriter/Session/SessionCoreProtocol.swift +++ b/Packages/vChewing_Typewriter/Sources/Typewriter/Session/SessionCoreProtocol.swift @@ -31,7 +31,11 @@ public protocol SessionCoreProtocol: AnyObject & CtlCandidateDelegate { var id: UUID { get } /// 用以記錄當前輸入法狀態的變數。(有 DidSet) var state: IMEState { get set } - var isASCIIMode: Bool { get } + 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 } @@ -156,6 +160,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/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/Sources/Typewriter/Session/SessionProtocol.swift b/Packages/vChewing_Typewriter/Sources/Typewriter/Session/SessionProtocol.swift index ade70a378..46962464c 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 } /// 上一個被處理過的鍵盤事件。 @@ -191,6 +193,8 @@ extension SessionProtocol { } public func performServerDeactivation() { + isPassThroughUntilDeactivated = false + passThroughUntilDeactivatedTimestamp = nil guard Self.current?.id != id else { return } isActivated = false // `resetInputHandler()` 會自動搞定 Empty 狀態。 @@ -210,6 +214,17 @@ extension SessionProtocol { } public func performServerActivation() { + isPassThroughUntilDeactivated = false + passThroughUntilDeactivatedTimestamp = nil + // 檢查若前次是因連續鍵入錯誤而自動切換至 ABC,在使用者重新切回唯音時應自動恢復為中文模式。 + if Self.isAutoSwitchedToABC { + Self.isAutoSwitchedToABC = false + isASCIIMode = false + Self.isASCIIModeForAllClients = false + isASCIIModeForThisClient = false + Self.isASCIIModeForEachClient.removeAll() + } + // MARK: 快速路徑 — 最佳化 CapsLock 中英頻繁切換的場景。 /// 每次 activateServer 都是一次全新的啟用事件, @@ -225,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/Sources/Typewriter/Typewriter/Typewriter_BPMFFullMatch.swift b/Packages/vChewing_Typewriter/Sources/Typewriter/Typewriter/Typewriter_BPMFFullMatch.swift index dbfeb5256..48456fd77 100644 --- a/Packages/vChewing_Typewriter/Sources/Typewriter/Typewriter/Typewriter_BPMFFullMatch.swift +++ b/Packages/vChewing_Typewriter/Sources/Typewriter/Typewriter/Typewriter_BPMFFullMatch.swift @@ -324,6 +324,8 @@ 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()) @@ -354,6 +356,8 @@ public struct BPMFFullMatchTypewriter: Typewriter let textToCommit = handler.commitOverflownComposition 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 new file mode 100644 index 000000000..233fb40b0 --- /dev/null +++ b/Packages/vChewing_Typewriter/Tests/TypewriterTests/InputHandlerTests_AutoSwitchOnErrors.swift @@ -0,0 +1,534 @@ +// (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_BasicSwitchToABC() 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 } + InputSession.isAutoSwitchedToABC = false + } + + // Dachen: g=ㄕ, r=ㄐ, e=ㄍ, a=ㄇ, t=ㄔ (5 consonants -> 5 consecutive errors) + typeSentence("great") + + #expect(testSession.recentCommissions == ["great"]) + #expect(switchedToABC == true) + #expect(InputSession.isAutoSwitchedToABC == true) + #expect(testSession.isPassThroughUntilDeactivated == true) + #expect(testSession.isASCIIMode == false) + #expect(testHandler.composer.isEmpty) + #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 } + InputSession.isAutoSwitchedToABC = false + } + + // 當輸入到第 5 鍵(即 "cd .." 中的第二個 ".")時,累計達到 5 個錯誤鍵, + // 即刻觸發自動切換至系統 ABC 輸入法,並將已鍵入的 5 個英數字元 "cd .." 遞交。 + // 同時標記 isPassThroughUntilDeactivated == true,確保尚未切離此 session 的後續按鍵(如第 6 鍵 "/") + // 不會被唯音當成注音(大千鍵盤的 "ㄥ")攔截,而是直接 pass-through 由 OS 送出。 + // 唯音自身的 isASCIIMode 仍保持 false(中文模式),以確保切回唯音時能直接輸入中文。 + typeSentence("cd ..") + #expect(switchedToABC == true) + #expect(InputSession.isAutoSwitchedToABC == true) + #expect(testSession.recentCommissions == ["cd .."]) + #expect(testSession.isPassThroughUntilDeactivated == true) + #expect(testSession.isASCIIMode == false) + #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_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.isPassThroughUntilDeactivated == true) + #expect(testSession.isASCIIMode == false) + #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_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 { + 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.isPassThroughUntilDeactivated == true) + #expect(testSession.isASCIIMode == false) + #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 { + 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 { + 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") + + #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) + + 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) + + // 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(switchedToABC == true) + #expect(testSession.isPassThroughUntilDeactivated == true) + #expect(testSession.isASCIIMode == false) + #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_MkdirCommand() 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 } + } + + typeSentence("mkdir") + + #expect(switchedToABC == true) + #expect(testSession.isPassThroughUntilDeactivated == true) + #expect(testSession.isASCIIMode == false) + #expect(testSession.recentCommissions == ["mkdir"]) + #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.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 03753f0c7..e727d8a16 100644 --- a/Packages/vChewing_Typewriter/Tests/TypewriterTests/TestComponents/MockedInputHandlerAndStates.swift +++ b/Packages/vChewing_Typewriter/Tests/TypewriterTests/TestComponents/MockedInputHandlerAndStates.swift @@ -67,6 +67,8 @@ public final class MockInputHandler: @MainActor InputHandlerProtocol { public var strCodePointBuffer = "" 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 @@ -101,6 +103,9 @@ 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(候選窗不存在);測試需要模擬候選窗已顯示時才指派。 public var mockCandidateController: MockCandidateController? @@ -137,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] diff --git a/Sources/vChewingIME_macOS/Resources/en.lproj/Localizable.strings b/Sources/vChewingIME_macOS/Resources/en.lproj/Localizable.strings index ea3c526e0..c5aff164f 100644 --- a/Sources/vChewingIME_macOS/Resources/en.lproj/Localizable.strings +++ b/Sources/vChewingIME_macOS/Resources/en.lproj/Localizable.strings @@ -345,6 +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 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 6599d7c5e..a349e780a 100644 --- a/Sources/vChewingIME_macOS/Resources/ja.lproj/Localizable.strings +++ b/Sources/vChewingIME_macOS/Resources/ja.lproj/Localizable.strings @@ -345,6 +345,10 @@ "i18n:UserDef.kAutoCompositeWithLongestPossibleCassetteKey.shortTitle" = "最長可能キーができた場合、文字を組み立つ"; "i18n:UserDef.kAutoCorrectReadingCombination.description" = "互換性のため、中英混在入力のフォールバックがONの場合、この機能は不作動。"; "i18n:UserDef.kAutoCorrectReadingCombination.shortTitle" = "入力中で打ち間違った発音組み合わせを自動的に訂正する"; +"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 7ef81633b..1f16b9b49 100644 --- a/Sources/vChewingIME_macOS/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/vChewingIME_macOS/Resources/zh-Hans.lproj/Localizable.strings @@ -345,6 +345,10 @@ "i18n:UserDef.kAutoCompositeWithLongestPossibleCassetteKey.shortTitle" = "在已经敲出最长可能码的时候自动组字"; "i18n:UserDef.kAutoCorrectReadingCombination.description" = "该功能在中英混合输入回退时不起作用,以确保相容性。"; "i18n:UserDef.kAutoCorrectReadingCombination.shortTitle" = "敲字时自动纠正读音组合"; +"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 5a159fdd8..3bb1d2f6f 100644 --- a/Sources/vChewingIME_macOS/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/vChewingIME_macOS/Resources/zh-Hant.lproj/Localizable.strings @@ -345,6 +345,10 @@ "i18n:UserDef.kAutoCompositeWithLongestPossibleCassetteKey.shortTitle" = "在已經敲出最長可能碼的時候自動組字"; "i18n:UserDef.kAutoCorrectReadingCombination.description" = "該功能在中英混合輸入回退時不起作用,以確保相容性。"; "i18n:UserDef.kAutoCorrectReadingCombination.shortTitle" = "敲字時自動糾正讀音組合"; +"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" = "靜音";