From f03a0ea6a9729f700a41480f1752c314aaf2d935 Mon Sep 17 00:00:00 2001 From: Will Date: Thu, 27 Aug 2026 22:14:28 +0800 Subject: [PATCH] fix(indicator): don't flash indicator when app switch keeps the same input source Switching between two apps that resolve to the same input source (via a cached keyboard, a per-app specified keyboard, or the system-wide default) still called TISSelectInputSource and popped up the switch indicator every time, even though the input method never actually changed. Users going from an app in EN state to another app in EN state saw the indicator flash on every app switch. Root cause: IndicatorVM always selected the resolved keyboard on app changes, and stateChangesPublisher emitted .appChanges for every app-kind change, which IndicatorWindowController unconditionally treated as "show the indicator". Changes: - ActivateEvent.appChanges now carries inputSourceDidChange so the indicator layer can tell real keyboard switches from no-op ones; add isAppChangesWithUnchangedInputSource helper - IndicatorVM skips the redundant TIS select (and the CJKV fix dance) when the target keyboard equals the current one and marks the state as .noChanges; keyboard cache learning (restore previously used keyboard) still runs for the new app - IndicatorWindowController routes unchanged-keyboard app switches to the hide path instead of showing the indicator - autoShowPublisher suppresses its initial activation for unchanged keyboards while keeping the freshly rebuilt focus-detection watcher active, so focus-based show/hide keeps working - Add ActivateEventInputSourceChangeTests covering the new event flag Behavior notes: - Different-keyboard app switches still announce the indicator as before - The always-on indicator keeps receiving .appChanges, so it no longer flickers on same-keyboard app switches - isHideWhenSwitchAppWithForceKeyboard now consistently hides the indicator when the keyboard is unchanged (previously "cached" sources would still show); nothing switched, so there is nothing to announce - No new preference keys, no localization or settings backup changes Fixes #126 --- Input Source Pro.xcodeproj/project.pbxproj | 4 ++ ...IndicatorWindowController+Activation.swift | 6 +++ .../IndicatorWindowController.swift | 4 ++ .../Models/IndicatorVM+Triggers.swift | 23 ++++++++--- Input Source Pro/Models/IndicatorVM.swift | 11 +++++ .../ActivateEventInputSourceChangeTests.swift | 41 +++++++++++++++++++ 6 files changed, 84 insertions(+), 5 deletions(-) create mode 100644 Tests/ActivateEventInputSourceChangeTests.swift diff --git a/Input Source Pro.xcodeproj/project.pbxproj b/Input Source Pro.xcodeproj/project.pbxproj index 1c845b2..9c303ef 100644 --- a/Input Source Pro.xcodeproj/project.pbxproj +++ b/Input Source Pro.xcodeproj/project.pbxproj @@ -175,6 +175,7 @@ D60000622F20000000000062 /* AppURLAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60000612F20000000000061 /* AppURLAction.swift */; }; D60000722F20000000000072 /* AppURLActionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60000712F20000000000071 /* AppURLActionTests.swift */; }; D60000822F20000000000082 /* URLActivationSuppressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60000812F20000000000081 /* URLActivationSuppressionTests.swift */; }; + D60001922F20000000000192 /* ActivateEventInputSourceChangeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60001912F20000000000191 /* ActivateEventInputSourceChangeTests.swift */; }; D60001022F20000000000102 /* AppKindComparisonTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D60001012F20000000000101 /* AppKindComparisonTests.swift */; }; /* End PBXBuildFile section */ @@ -366,6 +367,7 @@ D60000612F20000000000061 /* AppURLAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppURLAction.swift; sourceTree = ""; }; D60000712F20000000000071 /* AppURLActionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppURLActionTests.swift; sourceTree = ""; }; D60000812F20000000000081 /* URLActivationSuppressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLActivationSuppressionTests.swift; sourceTree = ""; }; + D60001912F20000000000191 /* ActivateEventInputSourceChangeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivateEventInputSourceChangeTests.swift; sourceTree = ""; }; D60001012F20000000000101 /* AppKindComparisonTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppKindComparisonTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -746,6 +748,7 @@ D60000022F20000000000002 /* TestHarnessTests.swift */, D60000712F20000000000071 /* AppURLActionTests.swift */, D60000812F20000000000081 /* URLActivationSuppressionTests.swift */, + D60001912F20000000000191 /* ActivateEventInputSourceChangeTests.swift */, ); path = Tests; sourceTree = ""; @@ -1066,6 +1069,7 @@ D60000032F20000000000003 /* TestHarnessTests.swift in Sources */, D60000722F20000000000072 /* AppURLActionTests.swift in Sources */, D60000822F20000000000082 /* URLActivationSuppressionTests.swift in Sources */, + D60001922F20000000000192 /* ActivateEventInputSourceChangeTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Input Source Pro/Controllers/IndicatorWindowController+Activation.swift b/Input Source Pro/Controllers/IndicatorWindowController+Activation.swift index aef6659..ffb881c 100644 --- a/Input Source Pro/Controllers/IndicatorWindowController+Activation.swift +++ b/Input Source Pro/Controllers/IndicatorWindowController+Activation.swift @@ -63,6 +63,12 @@ extension IndicatorWindowController { let application = app.getApplication(preferencesVM: preferencesVM) let needActivateAtFirstTime = { + // App switch that keeps the same input source: don't announce it, + // let focus detection decide whether the indicator should be visible. + if event.isAppChangesWithUnchangedInputSource { + return false + } + if preferencesVM.preferences.isActiveWhenSwitchApp { return true } diff --git a/Input Source Pro/Controllers/IndicatorWindowController.swift b/Input Source Pro/Controllers/IndicatorWindowController.swift index 2a42081..a8866c5 100644 --- a/Input Source Pro/Controllers/IndicatorWindowController.swift +++ b/Input Source Pro/Controllers/IndicatorWindowController.swift @@ -73,6 +73,10 @@ class IndicatorWindowController: FloatWindowController { return self.alwaysOnPublisher(event: event, inputSource: inputSource, appKind: appKind) } else if preferencesVM.needDetectFocusedFieldChanges(app: app) { return self.autoShowPublisher(event: event, inputSource: inputSource, appKind: appKind) + } else if event.isAppChangesWithUnchangedInputSource { + // App switch that keeps the same input source: nothing switched, + // so don't pop the indicator up. + return self.justHidePublisher() } else { return self.autoHidePublisher(event: event, inputSource: inputSource, appKind: appKind) } diff --git a/Input Source Pro/Models/IndicatorVM+Triggers.swift b/Input Source Pro/Models/IndicatorVM+Triggers.swift index 5d950c1..6debf9f 100644 --- a/Input Source Pro/Models/IndicatorVM+Triggers.swift +++ b/Input Source Pro/Models/IndicatorVM+Triggers.swift @@ -8,13 +8,13 @@ extension IndicatorVM { enum ActivateEvent { case justHide case longMouseDown - case appChanges(current: AppKind?, prev: AppKind?) + case appChanges(current: AppKind?, prev: AppKind?, inputSourceDidChange: Bool) case inputSourceChanges(InputSource, InputSourceChangeReason) case functionKeyModeChanges(FKeyMode) func isAppChangesWithSameAppOrWebsite() -> Bool { switch self { - case let .appChanges(current, prev): + case let .appChanges(current, prev, _): return current?.isSameAppOrWebsite(with: prev) == true case .inputSourceChanges: return false @@ -27,6 +27,15 @@ extension IndicatorVM { } } + var isAppChangesWithUnchangedInputSource: Bool { + switch self { + case let .appChanges(_, _, inputSourceDidChange): + return !inputSourceDidChange + default: + return false + } + } + var isJustHide: Bool { switch self { case .justHide: return true @@ -73,7 +82,11 @@ extension IndicatorVM { .map { [weak self] previous, current -> ActivateEvent in if let preferencesVM = self?.preferencesVM { if previous?.appKind?.getId() != current.appKind?.getId() { - let event = ActivateEvent.appChanges(current: current.appKind, prev: previous?.appKind) + let event = ActivateEvent.appChanges( + current: current.appKind, + prev: previous?.appKind, + inputSourceDidChange: previous?.inputSource.persistentIdentifier != current.inputSource.persistentIdentifier + ) if preferencesVM.preferences.isActiveWhenSwitchApp || preferencesVM.preferences.isActiveWhenFocusedElementChangesEnabled { if preferencesVM.preferences.isHideWhenSwitchAppWithForceKeyboard { @@ -116,8 +129,8 @@ extension IndicatorVM { extension IndicatorVM.ActivateEvent: @preconcurrency CustomStringConvertible { var description: String { switch self { - case let .appChanges(current, prev): - return "appChanges(\(String(describing: current)), \(String(describing: prev))" + case let .appChanges(current, prev, inputSourceDidChange): + return "appChanges(\(String(describing: current)), \(String(describing: prev)), inputSourceDidChange: \(inputSourceDidChange))" case .inputSourceChanges: return "inputSourceChanges" case let .functionKeyModeChanges(mode): diff --git a/Input Source Pro/Models/IndicatorVM.swift b/Input Source Pro/Models/IndicatorVM.swift index fed9918..ca74f87 100644 --- a/Input Source Pro/Models/IndicatorVM.swift +++ b/Input Source Pro/Models/IndicatorVM.swift @@ -242,6 +242,17 @@ extension IndicatorVM { return state case let .appChanged(appKind): if let status = preferencesVM.getAppAutoSwitchKeyboard(appKind) { + // The target keyboard is already active: skip the redundant + // TIS select (and CJKV fix) so nothing "switches", and mark + // the reason as .noChanges so the indicator won't announce it. + if status.inputSource.persistentIdentifier == state.inputSource.persistentIdentifier { + return updateState( + appKind: appKind, + inputSource: state.inputSource, + inputSourceChangeReason: .noChanges + ) + } + inputSourceVM.select(inputSource: status.inputSource, app: appKind.getApp()) return updateState( diff --git a/Tests/ActivateEventInputSourceChangeTests.swift b/Tests/ActivateEventInputSourceChangeTests.swift new file mode 100644 index 0000000..b60527c --- /dev/null +++ b/Tests/ActivateEventInputSourceChangeTests.swift @@ -0,0 +1,41 @@ +import AppKit +import XCTest +@testable import Input_Source_Pro + +@MainActor +final class ActivateEventInputSourceChangeTests: XCTestCase { + private let app = NSRunningApplication.current + + private func appKind() -> AppKind { + .normal(app: app, info: (focusedElement: nil, isFocusOnInputContainer: true)) + } + + func testAppChangesWithUnchangedInputSourceIsFlagged() { + let event = IndicatorVM.ActivateEvent.appChanges( + current: appKind(), + prev: appKind(), + inputSourceDidChange: false + ) + + XCTAssertTrue(event.isAppChangesWithUnchangedInputSource) + XCTAssertTrue(event.isAppChangesWithSameAppOrWebsite()) + XCTAssertFalse(event.isJustHide) + } + + func testAppChangesWithChangedInputSourceIsNotFlagged() { + let event = IndicatorVM.ActivateEvent.appChanges( + current: appKind(), + prev: appKind(), + inputSourceDidChange: true + ) + + XCTAssertFalse(event.isAppChangesWithUnchangedInputSource) + XCTAssertTrue(event.isAppChangesWithSameAppOrWebsite()) + XCTAssertFalse(event.isJustHide) + } + + func testNonAppChangeEventsAreNotFlagged() { + XCTAssertFalse(IndicatorVM.ActivateEvent.justHide.isAppChangesWithUnchangedInputSource) + XCTAssertFalse(IndicatorVM.ActivateEvent.longMouseDown.isAppChangesWithUnchangedInputSource) + } +} \ No newline at end of file