diff --git a/.envrc.example b/.envrc.example index 28f2944..8e0756b 100644 --- a/.envrc.example +++ b/.envrc.example @@ -26,7 +26,7 @@ export XCODE_VERSION="16.0" export MACOS_DEPLOYMENT_TARGET="13.0" # Version (managed by Version.xcconfig, but can be overridden here) -export VERSION="1.1.6" +export VERSION="1.1.7" export BUILD_NUMBER="1" # Optional: Set specific Xcode version diff --git a/ModSwitchIME.xcodeproj/project.pbxproj b/ModSwitchIME.xcodeproj/project.pbxproj index 343c659..8b59ff5 100644 --- a/ModSwitchIME.xcodeproj/project.pbxproj +++ b/ModSwitchIME.xcodeproj/project.pbxproj @@ -12,6 +12,8 @@ 001A7B322C2F3A1A00E5B4C8 /* ImeController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001A7B312C2F3A1A00E5B4C8 /* ImeController.swift */; }; 001A7B342C2F3A1A00E5B4C8 /* Preferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001A7B332C2F3A1A00E5B4C8 /* Preferences.swift */; }; 001A7B362C2F3A1A00E5B4C8 /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001A7B352C2F3A1A00E5B4C8 /* PreferencesView.swift */; }; + 001A7B442C2F3A1A00E5B4C8 /* InputSourcePickerViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001A7B452C2F3A1A00E5B4C8 /* InputSourcePickerViews.swift */; }; + 001A7B462C2F3A1A00E5B4C8 /* ImeController+StateMonitoring.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001A7B472C2F3A1A00E5B4C8 /* ImeController+StateMonitoring.swift */; }; 001A7B382C2F3A1A00E5B4C8 /* ModSwitchIMEError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 001A7B372C2F3A1A00E5B4C8 /* ModSwitchIMEError.swift */; }; 001A7B3A2C2F3A1A00E5B4C8 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 001A7B392C2F3A1A00E5B4C8 /* Assets.xcassets */; }; 001A7B3D2C2F3A1A00E5B4C8 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 001A7B3C2C2F3A1A00E5B4C8 /* Preview Assets.xcassets */; }; @@ -39,6 +41,8 @@ 001A7B312C2F3A1A00E5B4C8 /* ImeController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImeController.swift; sourceTree = ""; }; 001A7B332C2F3A1A00E5B4C8 /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = ""; }; 001A7B352C2F3A1A00E5B4C8 /* PreferencesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesView.swift; sourceTree = ""; }; + 001A7B452C2F3A1A00E5B4C8 /* InputSourcePickerViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputSourcePickerViews.swift; sourceTree = ""; }; + 001A7B472C2F3A1A00E5B4C8 /* ImeController+StateMonitoring.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ImeController+StateMonitoring.swift"; sourceTree = ""; }; 001A7B372C2F3A1A00E5B4C8 /* ModSwitchIMEError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModSwitchIMEError.swift; sourceTree = ""; }; 001A7B392C2F3A1A00E5B4C8 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 001A7B3C2C2F3A1A00E5B4C8 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; @@ -100,8 +104,10 @@ 001A7B2B2C2F3A1A00E5B4C8 /* App.swift */, 001A7B2F2C2F3A1A00E5B4C8 /* MenuBarApp.swift */, 001A7B312C2F3A1A00E5B4C8 /* ImeController.swift */, + 001A7B472C2F3A1A00E5B4C8 /* ImeController+StateMonitoring.swift */, 001A7B332C2F3A1A00E5B4C8 /* Preferences.swift */, 001A7B352C2F3A1A00E5B4C8 /* PreferencesView.swift */, + 001A7B452C2F3A1A00E5B4C8 /* InputSourcePickerViews.swift */, 001A7B372C2F3A1A00E5B4C8 /* ModSwitchIMEError.swift */, 001A7B402C2F3A1A00E5B4C8 /* KeyMonitor.swift */, 001A7B422C2F3A1A00E5B4C8 /* Logger.swift */, @@ -232,8 +238,10 @@ 001A7B2C2C2F3A1A00E5B4C8 /* App.swift in Sources */, 001A7B302C2F3A1A00E5B4C8 /* MenuBarApp.swift in Sources */, 001A7B322C2F3A1A00E5B4C8 /* ImeController.swift in Sources */, + 001A7B462C2F3A1A00E5B4C8 /* ImeController+StateMonitoring.swift in Sources */, 001A7B342C2F3A1A00E5B4C8 /* Preferences.swift in Sources */, 001A7B362C2F3A1A00E5B4C8 /* PreferencesView.swift in Sources */, + 001A7B442C2F3A1A00E5B4C8 /* InputSourcePickerViews.swift in Sources */, 001A7B382C2F3A1A00E5B4C8 /* ModSwitchIMEError.swift in Sources */, 001A7B412C2F3A1A00E5B4C8 /* KeyMonitor.swift in Sources */, 001A7B432C2F3A1A00E5B4C8 /* Logger.swift in Sources */, diff --git a/ModSwitchIME/Config/Version.xcconfig b/ModSwitchIME/Config/Version.xcconfig index 51dfb6a..e1c249c 100644 --- a/ModSwitchIME/Config/Version.xcconfig +++ b/ModSwitchIME/Config/Version.xcconfig @@ -2,7 +2,7 @@ // Centralized version configuration for ModSwitchIME // Marketing version shown to users (e.g., 1.0.0, 1.2.3) -MARKETING_VERSION = 1.1.6 +MARKETING_VERSION = 1.1.7 // Build number (incremented for each build) // Can be set to $(CURRENT_PROJECT_VERSION) for automatic increment diff --git a/ModSwitchIME/ImeController+StateMonitoring.swift b/ModSwitchIME/ImeController+StateMonitoring.swift new file mode 100644 index 0000000..5cc0961 --- /dev/null +++ b/ModSwitchIME/ImeController+StateMonitoring.swift @@ -0,0 +1,227 @@ +import Foundation +import Carbon +import Cocoa + +extension ImeController { + func initializeCache() { + if Thread.isMainThread { + buildCacheSync() + } else { + let semaphore = DispatchSemaphore(value: 0) + DispatchQueue.main.async { [weak self] in + self?.buildCacheSync() + semaphore.signal() + } + _ = semaphore.wait(timeout: .now() + 2.0) + } + } + + func buildCacheSync() { + guard let cfInputSources = TISCreateInputSourceList(nil, false) else { + Logger.error("TISCreateInputSourceList returned nil", category: .ime) + return + } + + let inputSources = cfInputSources.takeRetainedValue() as? [TISInputSource] ?? [] + + if inputSources.isEmpty { + Logger.warning("No input sources found", category: .ime) + return + } + + var newCache: [String: TISInputSource] = [:] + + for inputSource in inputSources { + if let sourceID = TISGetInputSourceProperty(inputSource, kTISPropertyInputSourceID) { + let id = Unmanaged.fromOpaque(sourceID).takeUnretainedValue() as String + newCache[id] = inputSource + } + } + + cacheQueue.sync { + inputSourceCache = newCache + } + Logger.debug("IME cache initialized with \(newCache.count) input sources", category: .ime) + } + + func refreshInputSourceCache() { + DispatchQueue.main.async { [weak self] in + self?.buildCacheSync() + } + } + + func refreshCacheSync() { + if Thread.isMainThread { + buildCacheSync() + return + } + + let semaphore = DispatchSemaphore(value: 0) + DispatchQueue.main.async { [weak self] in + self?.buildCacheSync() + semaphore.signal() + } + _ = semaphore.wait(timeout: .now() + 1.0) + } + + // MARK: - IME Change Monitoring + + func startMonitoringIMEChanges() { + DistributedNotificationCenter.default().addObserver( + self, + selector: #selector(inputSourcesChanged), + name: NSNotification.Name("com.apple.Carbon.TISNotifyEnabledKeyboardInputSourcesChanged"), + object: nil + ) + + DistributedNotificationCenter.default().addObserver( + self, + selector: #selector(inputSourcesChanged), + name: NSNotification.Name("com.apple.Carbon.TISNotifySelectedKeyboardInputSourceChanged"), + object: nil + ) + + let notificationCenter = NSWorkspace.shared.notificationCenter + + notificationCenter.addObserver( + self, + selector: #selector(systemWillSleep), + name: NSWorkspace.willSleepNotification, + object: nil + ) + + notificationCenter.addObserver( + self, + selector: #selector(systemDidWake), + name: NSWorkspace.didWakeNotification, + object: nil + ) + } + + @objc func inputSourcesChanged(_ notification: Notification) { + Logger.debug("Input sources changed, refreshing cache", category: .ime) + syncLastNotifiedIMEWithCurrentInputSource() + refreshInputSourceCache() + } + + @objc func systemWillSleep(_ notification: Notification) { + Logger.info("System will sleep - preparing IME cache", category: .ime) + } + + @objc func systemDidWake(_ notification: Notification) { + Logger.info("System did wake - refreshing IME cache", category: .ime) + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in + self?.refreshInputSourceCache() + Logger.debug("IME cache refreshed after system wake", category: .ime) + } + } + + // MARK: - Application Focus Monitoring + + func startMonitoringApplicationFocus() { + NSWorkspace.shared.notificationCenter.addObserver( + self, + selector: #selector(applicationDidActivate), + name: NSWorkspace.didActivateApplicationNotification, + object: nil + ) + } + + @objc func applicationDidActivate(_ notification: Notification) { + guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { + return + } + + let appName = app.localizedName ?? "Unknown" + Logger.debug("Application activated: \(appName)", category: .ime) + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in + self?.verifyIMEStateAfterAppSwitch() + } + } + + func verifyIMEStateAfterAppSwitch() { + let actualIME = getCurrentInputSource() + let expectedIME = lastSwitchedIMEQueue.sync { lastSwitchedIME } + + if let expected = expectedIME, actualIME != expected { + Logger.warning( + "IME state mismatch after app switch: expected=\(expected), actual=\(actualIME)", + category: .ime + ) + + refreshInputSourceCache() + + Logger.debug("Reapplying IME after focus change: \(expected)", category: .ime) + switchToSpecificIME(expected, fromUser: false) + } else { + Logger.debug("IME state verified after app switch: \(actualIME)", category: .ime) + } + } + + func setLastSwitchedIME(_ imeId: String) { + lastSwitchedIMEQueue.sync { + lastSwitchedIME = imeId + } + } + + func getLastSwitchedIME() -> String? { + return lastSwitchedIMEQueue.sync { lastSwitchedIME } + } +} + +extension ImeController { + func postIMESwitchNotification(_ imeId: String, isRetry: Bool = false) { + var shouldNotify = false + let currentIME = isRetry ? getCurrentInputSource() : "" + + notificationQueue.sync { + if lastNotifiedIME != imeId { + lastNotifiedIME = imeId + shouldNotify = true + } else if isRetry && currentIME == imeId { + shouldNotify = true + } + } + + guard shouldNotify else { + Logger.debug("Skipping duplicate notification for: \(imeId)", category: .ime) + return + } + + DispatchQueue.main.async { + NotificationCenter.default.post( + name: NSNotification.Name("ModSwitchIME.didSwitchIME"), + object: nil, + userInfo: ["imeId": imeId] + ) + } + } + + func postUIRefreshNotification() { + syncLastNotifiedIMEWithCurrentInputSource() + DispatchQueue.main.async { + NotificationCenter.default.post( + name: NSNotification.Name("ModSwitchIME.shouldRefreshUI"), + object: nil + ) + } + } + + func syncLastNotifiedIMEWithCurrentInputSource() { + let currentIME = getCurrentInputSource() + notificationQueue.sync { + lastNotifiedIME = currentIME + } + } + + #if DEBUG + func debugGetLastSwitchedIME() -> String? { getLastSwitchedIME() } + func debugGetLastNotifiedIME() -> String { notificationQueue.sync { lastNotifiedIME } } + func debugSetLastNotifiedIME(_ imeId: String) { + notificationQueue.sync { lastNotifiedIME = imeId } + } + func debugSyncLastNotifiedIMEWithCurrentInputSource() { syncLastNotifiedIMEWithCurrentInputSource() } + #endif +} diff --git a/ModSwitchIME/ImeController.swift b/ModSwitchIME/ImeController.swift index 78f6b12..1964564 100644 --- a/ModSwitchIME/ImeController.swift +++ b/ModSwitchIME/ImeController.swift @@ -10,7 +10,6 @@ protocol IMEControlling { func forceAscii() } -// swiftlint:disable:next type_body_length final class ImeController: ErrorHandler, IMEControlling { // Singleton instance static let shared = ImeController() @@ -26,12 +25,12 @@ final class ImeController: ErrorHandler, IMEControlling { var onError: ((ModSwitchIMEError) -> Void)? // Thread-safe cache for input sources - private var inputSourceCache: [String: TISInputSource] = [:] - private let cacheQueue = DispatchQueue(label: "com.modswitchime.cache") + var inputSourceCache: [String: TISInputSource] = [:] + let cacheQueue = DispatchQueue(label: "com.modswitchime.cache") // Track last switched IME for app focus verification - private var lastSwitchedIME: String? - private let lastSwitchedIMEQueue = DispatchQueue(label: "com.modswitchime.lastIME") + var lastSwitchedIME: String? + let lastSwitchedIMEQueue = DispatchQueue(label: "com.modswitchime.lastIME") // Track last switch timings separately for user-triggered and internal operations private struct SwitchThrottleState { @@ -44,7 +43,16 @@ final class ImeController: ErrorHandler, IMEControlling { private let switchThrottleQueue = DispatchQueue(label: "com.modswitchime.switchthrottle", attributes: .concurrent) private let userThrottleInterval: CFAbsoluteTime = 0.1 private let internalThrottleInterval: CFAbsoluteTime = 0.05 + private let verificationRetryDelay: TimeInterval = 0.05 + private let asciiFallbackDelay: TimeInterval = 0.2 + private var asciiFallbackGeneration: UInt64 = 0 + private let asciiFallbackQueue = DispatchQueue(label: "com.modswitchime.asciifallback") + private var switchGeneration: UInt64 = 0 + private let switchGenerationQueue = DispatchQueue(label: "com.modswitchime.switchgeneration") // Throttle state balances rapid duplicate prevention with user retry needs + + var lastNotifiedIME: String = "" + let notificationQueue = DispatchQueue(label: "com.modswitchime.notification") private init() { // Initialize cache on startup for immediate availability @@ -55,85 +63,37 @@ final class ImeController: ErrorHandler, IMEControlling { startMonitoringApplicationFocus() } - private func initializeCache() { - // Build cache synchronously on initialization for immediate availability - if Thread.isMainThread { - buildCacheSync() - } else { - // For test environments, wait for cache to be built - let semaphore = DispatchSemaphore(value: 0) - DispatchQueue.main.async { [weak self] in - self?.buildCacheSync() - semaphore.signal() - } - // Wait with timeout to prevent deadlock - _ = semaphore.wait(timeout: .now() + 2.0) - } - } - - private func buildCacheSync() { - guard let cfInputSources = TISCreateInputSourceList(nil, false) else { - Logger.error("TISCreateInputSourceList returned nil", category: .ime) - return - } - - let inputSources = cfInputSources.takeRetainedValue() as? [TISInputSource] ?? [] - - if inputSources.isEmpty { - Logger.warning("No input sources found", category: .ime) - return - } - - var newCache: [String: TISInputSource] = [:] - - for inputSource in inputSources { - if let sourceID = TISGetInputSourceProperty(inputSource, kTISPropertyInputSourceID) { - let id = Unmanaged.fromOpaque(sourceID).takeUnretainedValue() as String - - // Cache all input sources, not just enabled ones - // This allows switching to disabled IMEs if needed - newCache[id] = inputSource - } - } - - // Update cache atomically - cacheQueue.async { [weak self] in - self?.inputSourceCache = newCache - } - Logger.debug("IME cache initialized with \(newCache.count) input sources", category: .ime) - } - - private func refreshInputSourceCache() { - // Refresh cache in background - DispatchQueue.main.async { [weak self] in - self?.buildCacheSync() - } - } - func forceAscii(fromUser: Bool = true) { let englishSources = ["com.apple.keylayout.ABC", "com.apple.keylayout.US"] + var availableSources = availableInputSourceIDs(from: englishSources) - // Try to switch to the first available English source - // Don't check immediately as the switch is asynchronous - for sourceID in englishSources { - // Check if this source exists in our cache - var sourceExists = false - cacheQueue.sync { - sourceExists = inputSourceCache[sourceID] != nil - } - - if sourceExists { - // Use switchToSpecificIME - switchToSpecificIME(sourceID, fromUser: fromUser) - return // Don't check immediately, trust the switch will work - } + if availableSources.isEmpty { + refreshCacheSync() + availableSources = availableInputSourceIDs(from: englishSources) } - // If we get here, no English sources are available - let error = ModSwitchIMEError.inputSourceNotFound("English input source") - handleError(error) + guard let primarySource = availableSources.first else { + // If we get here, no English sources are available + let error = ModSwitchIMEError.inputSourceNotFound("English input source") + handleError(error) + return + } + + let fallbackToken = createAsciiFallbackToken() + + switchToSpecificIME( + primarySource, + fromUser: fromUser, + invalidatesPendingAsciiFallback: false + ) + scheduleAsciiFallbackIfNeeded( + primarySource: primarySource, + availableSources: availableSources, + fromUser: fromUser, + fallbackToken: fallbackToken + ) } - + // Wrapper for compatibility func forceAscii() { forceAscii(fromUser: true) @@ -141,60 +101,40 @@ final class ImeController: ErrorHandler, IMEControlling { // Removed toggleByCmd - no longer used after architecture changes - func switchToSpecificIME(_ imeId: String, fromUser: Bool = true) { + func switchToSpecificIME( + _ imeId: String, + fromUser: Bool = true, + invalidatesPendingAsciiFallback: Bool = true + ) { // Validate IME ID guard isValidIMEId(imeId) else { Logger.warning("Invalid IME ID provided: \(imeId)", category: .ime) handleError(ModSwitchIMEError.invalidConfiguration) return } - - let currentIME = getCurrentInputSource() - let now = CFAbsoluteTimeGetCurrent() - - if fromUser { - var shouldSkip = false - switchThrottleQueue.sync { - shouldSkip = switchThrottleState.lastUserTarget == imeId && - (now - switchThrottleState.lastUserRequestTime) < userThrottleInterval - } - if shouldSkip { - Logger.debug("Skipping rapid duplicate user switch: \(imeId)", category: .ime) - postUIRefreshNotification() - return - } - } else { - var shouldSkip = false - if currentIME == imeId { - Logger.debug("Already on target IME (internal): \(imeId)", category: .ime) - return - } + if invalidatesPendingAsciiFallback { + invalidateAsciiFallbackToken() + } - switchThrottleQueue.sync { - shouldSkip = switchThrottleState.lastInternalTarget == imeId && - (now - switchThrottleState.lastInternalRequestTime) < internalThrottleInterval - } + let currentIME = getCurrentInputSource() + let now = CFAbsoluteTimeGetCurrent() - if shouldSkip { - Logger.debug("Skipping throttled internal switch: \(imeId)", category: .ime) - return - } + if shouldSkipSwitch( + targetIME: imeId, + fromUser: fromUser, + currentIME: currentIME, + now: now + ) { + return } - switchThrottleQueue.async(flags: .barrier) { - if fromUser { - self.switchThrottleState.lastUserTarget = imeId - self.switchThrottleState.lastUserRequestTime = now - } else { - self.switchThrottleState.lastInternalTarget = imeId - self.switchThrottleState.lastInternalRequestTime = now - } - } + let switchToken = createSwitchToken() + recordSwitchThrottle(targetIME: imeId, fromUser: fromUser, now: now) // Execute IME switch do { - try selectInputSource(imeId) + try selectInputSource(imeId, switchToken: switchToken) } catch { let imeError = ModSwitchIMEError.inputSourceNotFound(imeId) handleError(imeError) @@ -235,23 +175,7 @@ final class ImeController: ErrorHandler, IMEControlling { return true } - // Choose an alternate IME to force a visible state change before selecting the target again. - private func chooseAlternateIME(for target: String) -> String? { - var cache: [String: TISInputSource] = [:] - cacheQueue.sync { cache = inputSourceCache } - let englishCandidates = ["com.apple.keylayout.ABC", "com.apple.keylayout.US"] - let available = englishCandidates.filter { cache[$0] != nil } - guard !available.isEmpty else { return nil } - if target == "com.apple.keylayout.ABC" { - return available.first { $0 == "com.apple.keylayout.US" } ?? available.first - } - if target == "com.apple.keylayout.US" { - return available.first { $0 == "com.apple.keylayout.ABC" } ?? available.first - } - return available.first - } - - func selectInputSource(_ inputSourceID: String) throws { + func selectInputSource(_ inputSourceID: String, switchToken: UInt64) throws { // Validate input guard isValidIMEId(inputSourceID) else { throw ModSwitchIMEError.invalidInputSource("Invalid IME ID format: \(inputSourceID)") @@ -266,7 +190,8 @@ final class ImeController: ErrorHandler, IMEControlling { try selectWithRetries(source: source, expectedIME: inputSourceID, currentIME: currentIME, - refreshed: refreshed) + refreshed: refreshed, + switchToken: switchToken) return } throw ModSwitchIMEError.inputSourceNotFound(inputSourceID) @@ -293,14 +218,15 @@ final class ImeController: ErrorHandler, IMEControlling { source: TISInputSource, expectedIME: String, currentIME: String, - refreshed: Bool + refreshed: Bool, + switchToken: UInt64 ) throws { var lastError: Error? for attempt in 0..<3 { + guard isCurrentSwitchToken(switchToken) else { return } let result = TISSelectInputSource(source) if result == noErr { - performHybridIMESwitch(expectedIME: expectedIME, currentIME: currentIME) - setLastSwitchedIME(expectedIME) + performHybridIMESwitch(expectedIME: expectedIME, currentIME: currentIME, switchToken: switchToken) return } lastError = ModSwitchIMEError.inputMethodSwitchFailed( @@ -316,56 +242,76 @@ final class ImeController: ErrorHandler, IMEControlling { throw lastError ?? ModSwitchIMEError.inputMethodSwitchFailed("Unknown error") } - private func performHybridIMESwitch(expectedIME: String, currentIME: String) { + private func performHybridIMESwitch(expectedIME: String, currentIME: String, switchToken: UInt64) { // Hybrid approach: Check actual switch after a short delay before notifying DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) { [weak self] in guard let self = self else { return } + guard self.isCurrentSwitchToken(switchToken) else { return } let actualIME = self.getCurrentInputSource() if actualIME == expectedIME { // Success - notify UI update Logger.debug("IME switch confirmed: \(currentIME) -> \(actualIME)", category: .ime) + self.setLastSwitchedIME(expectedIME) self.postIMESwitchNotification(expectedIME) // Schedule additional verification for edge cases - self.scheduleAdditionalVerification(expectedIME: expectedIME, delay: 0.05) + self.scheduleAdditionalVerification(expectedIME: expectedIME, delay: 0.05, switchToken: switchToken) } else if actualIME == currentIME { // Not switched yet - schedule another check Logger.debug("IME not switched yet, scheduling verification", category: .ime) // Prevent infinite recursion by limiting retry depth - self.verifyIMESwitchWithLimit(expectedIME: expectedIME, currentIME: currentIME, retryCount: 1) + self.verifyIMESwitchWithLimit( + expectedIME: expectedIME, + currentIME: currentIME, + retryCount: 1, + switchToken: switchToken + ) } else { // Switched to unexpected IME Logger.warning( "IME switched to unexpected: \(actualIME) (expected: \(expectedIME))", category: .ime ) - // Notify UI with actual state - self.postUIRefreshNotification() + // Reapply the requested IME before falling back to showing actual state. + self.retryIMESwitchWithLimit( + targetIME: expectedIME, + currentIME: actualIME, + retryCount: 1, + switchToken: switchToken + ) } } } - private func scheduleAdditionalVerification(expectedIME: String, delay: TimeInterval) { + private func scheduleAdditionalVerification(expectedIME: String, delay: TimeInterval, switchToken: UInt64) { DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in guard let self = self else { return } + guard self.isCurrentSwitchToken(switchToken) else { return } let actualIME = self.getCurrentInputSource() if actualIME != expectedIME { Logger.warning("Additional verification: IME mismatch detected", category: .ime) - // Correct the UI state - self.postUIRefreshNotification() + // The system or focused app overrode the switch after confirmation. Try once more. + self.retryIMESwitchWithLimit( + targetIME: expectedIME, + currentIME: actualIME, + retryCount: 1, + switchToken: switchToken + ) } } } - private func verifyIMESwitch(expectedIME: String, currentIME: String) { - verifyIMESwitchWithLimit(expectedIME: expectedIME, currentIME: currentIME, retryCount: 1) - } - - private func verifyIMESwitchWithLimit(expectedIME: String, currentIME: String, retryCount: Int) { + private func verifyIMESwitchWithLimit( + expectedIME: String, + currentIME: String, + retryCount: Int, + switchToken: UInt64 + ) { + guard isCurrentSwitchToken(switchToken) else { return } guard retryCount <= 3 else { Logger.warning("Max retry attempts reached for IME switch verification", category: .ime) postUIRefreshNotification() @@ -375,10 +321,12 @@ final class ImeController: ErrorHandler, IMEControlling { // Verify the switch after a short delay (reduced from 0.1 to 0.05) DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { [weak self] in guard let self = self else { return } + guard self.isCurrentSwitchToken(switchToken) else { return } let newIME = self.getCurrentInputSource() if newIME == expectedIME { Logger.debug("IME switch verified: \(currentIME) -> \(newIME)", category: .ime) + self.setLastSwitchedIME(expectedIME) // Check if notification already sent to prevent duplicates var needsNotification = false self.notificationQueue.sync { @@ -391,228 +339,80 @@ final class ImeController: ErrorHandler, IMEControlling { Logger.warning("IME switch may have failed: still at current IME", category: .ime) // Retry with incremented count if retryCount < 3 { - self.retryIMESwitchWithLimit(targetIME: expectedIME, retryCount: retryCount + 1, fromUser: false) + self.retryIMESwitchWithLimit( + targetIME: expectedIME, + currentIME: currentIME, + retryCount: retryCount + 1, + switchToken: switchToken + ) } else { self.postUIRefreshNotification() } } else { Logger.warning("IME switched to unexpected: \(newIME) (expected: \(expectedIME))", category: .ime) - // Refresh UI with actual state - self.postUIRefreshNotification() + if retryCount < 3 { + self.retryIMESwitchWithLimit( + targetIME: expectedIME, + currentIME: newIME, + retryCount: retryCount + 1, + switchToken: switchToken + ) + } else { + self.postUIRefreshNotification() + } } } } - private func retryIMESwitch(targetIME: String) { - retryIMESwitchWithLimit(targetIME: targetIME, retryCount: 1, fromUser: false) - } - - private func retryIMESwitchWithLimit(targetIME: String, retryCount: Int, fromUser: Bool = false) { + private func retryIMESwitchWithLimit( + targetIME: String, + currentIME: String, + retryCount: Int, + switchToken: UInt64 + ) { + guard isCurrentSwitchToken(switchToken) else { return } guard retryCount <= 3 else { Logger.warning("Max retry attempts reached for IME switch", category: .ime) postUIRefreshNotification() return } - DispatchQueue.main.async { [weak self] in + DispatchQueue.main.asyncAfter(deadline: .now() + verificationRetryDelay) { [weak self] in guard let self = self else { return } + guard self.isCurrentSwitchToken(switchToken) else { return } - // Use switchToSpecificIME with fromUser parameter for retry - self.switchToSpecificIME(targetIME, fromUser: fromUser) - } - } - - private func findFreshInputSource(_ inputSourceID: String) -> TISInputSource? { - guard let cfInputSources = TISCreateInputSourceList(nil, false) else { - return nil - } - - let inputSources = cfInputSources.takeRetainedValue() as? [TISInputSource] ?? [] - - for inputSource in inputSources { - if let sourceID = TISGetInputSourceProperty(inputSource, kTISPropertyInputSourceID) { - let id = Unmanaged.fromOpaque(sourceID).takeUnretainedValue() as String - if id == inputSourceID { - return inputSource - } + guard let (source, refreshed) = self.getInputSourceFromCacheOrRefresh(targetIME) else { + Logger.warning("Retry IME source not found: \(targetIME)", category: .ime) + self.postUIRefreshNotification() + return } - } - - return nil - } - - private func refreshCacheSync() { - // Synchronous cache refresh for critical operations - let semaphore = DispatchSemaphore(value: 0) - - DispatchQueue.main.async { [weak self] in - self?.buildCacheSync() - semaphore.signal() - } - - // Wait for cache refresh to complete (with timeout) - _ = semaphore.wait(timeout: .now() + 1.0) - } - - // MARK: - IME Change Monitoring - - private func startMonitoringIMEChanges() { - // Monitor for input source changes (removed NSTextInputContext - not available) - - // Also monitor for system notifications about input method changes - DistributedNotificationCenter.default().addObserver( - self, - selector: #selector(inputSourcesChanged), - name: NSNotification.Name("com.apple.Carbon.TISNotifyEnabledKeyboardInputSourcesChanged"), - object: nil - ) - - DistributedNotificationCenter.default().addObserver( - self, - selector: #selector(inputSourcesChanged), - name: NSNotification.Name("com.apple.Carbon.TISNotifySelectedKeyboardInputSourceChanged"), - object: nil - ) - - // Monitor system sleep/wake events - let notificationCenter = NSWorkspace.shared.notificationCenter - - notificationCenter.addObserver( - self, - selector: #selector(systemWillSleep), - name: NSWorkspace.willSleepNotification, - object: nil - ) - - notificationCenter.addObserver( - self, - selector: #selector(systemDidWake), - name: NSWorkspace.didWakeNotification, - object: nil - ) - } - - @objc private func inputSourcesChanged(_ notification: Notification) { - Logger.debug("Input sources changed, refreshing cache", category: .ime) - // Refresh cache when system IMEs change - refreshInputSourceCache() - } - - @objc private func systemWillSleep(_ notification: Notification) { - Logger.info("System will sleep - preparing IME cache", category: .ime) - // Cache might become stale during sleep, mark for refresh - } - - @objc private func systemDidWake(_ notification: Notification) { - Logger.info("System did wake - refreshing IME cache", category: .ime) - - // Delay cache refresh to ensure system is fully awake - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in - self?.refreshInputSourceCache() - Logger.debug("IME cache refreshed after system wake", category: .ime) - } - } - - // MARK: - Application Focus Monitoring - - private func startMonitoringApplicationFocus() { - NSWorkspace.shared.notificationCenter.addObserver( - self, - selector: #selector(applicationDidActivate), - name: NSWorkspace.didActivateApplicationNotification, - object: nil - ) - } - - @objc private func applicationDidActivate(_ notification: Notification) { - guard let app = notification.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication else { - return - } - - let appName = app.localizedName ?? "Unknown" - Logger.debug("Application activated: \(appName)", category: .ime) - - // Verify IME state after a short delay to ensure app is fully focused - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in - self?.verifyIMEStateAfterAppSwitch() - } - } - - private func verifyIMEStateAfterAppSwitch() { - let actualIME = getCurrentInputSource() - - // Get expected IME from last switch - let expectedIME = lastSwitchedIMEQueue.sync { lastSwitchedIME } - - if let expected = expectedIME, actualIME != expected { - Logger.warning( - "IME state mismatch after app switch: expected=\(expected), actual=\(actualIME)", - category: .ime - ) - // Optionally refresh cache to ensure accuracy - refreshInputSourceCache() - - // Request UI to refresh to reflect the actual current IME - // Do not force switch (some apps intentionally change IME) - postUIRefreshNotification() - } else { - Logger.debug("IME state verified after app switch: \(actualIME)", category: .ime) - } - } - - private func setLastSwitchedIME(_ imeId: String) { - lastSwitchedIMEQueue.sync { [weak self] in - self?.lastSwitchedIME = imeId - } - } - - private func getLastSwitchedIME() -> String? { - return lastSwitchedIMEQueue.sync { lastSwitchedIME } - } - - // MARK: - Thread-safe Notification Helpers - - // Track last notified IME to prevent duplicates - private var lastNotifiedIME: String = "" - private let notificationQueue = DispatchQueue(label: "com.modswitchime.notification") - - private func postIMESwitchNotification(_ imeId: String, isRetry: Bool = false) { - // Prevent duplicate notifications - var shouldNotify = false - let currentIME = isRetry ? getCurrentInputSource() : "" // Get current IME outside of sync block - - notificationQueue.sync { - if lastNotifiedIME != imeId { - lastNotifiedIME = imeId - shouldNotify = true - } else if isRetry && currentIME == imeId { - // Allow retry notification if actually switched - shouldNotify = true + let result = TISSelectInputSource(source) + if result == noErr { + let ctx = refreshed ? "fresh source" : "cached source" + Logger.debug("Retrying IME switch (\(ctx)) attempt \(retryCount): \(targetIME)", category: .ime) + self.verifyIMESwitchWithLimit( + expectedIME: targetIME, + currentIME: currentIME, + retryCount: retryCount, + switchToken: switchToken + ) + } else if retryCount < 3 { + Logger.warning( + "Retry IME switch attempt \(retryCount) failed with code: \(result)", + category: .ime + ) + self.retryIMESwitchWithLimit( + targetIME: targetIME, + currentIME: currentIME, + retryCount: retryCount + 1, + switchToken: switchToken + ) + } else { + Logger.warning("Retry IME switch failed with code: \(result)", category: .ime) + self.postUIRefreshNotification() } } - - guard shouldNotify else { - Logger.debug("Skipping duplicate notification for: \(imeId)", category: .ime) - return - } - - DispatchQueue.main.async { - NotificationCenter.default.post( - name: NSNotification.Name("ModSwitchIME.didSwitchIME"), - object: nil, - userInfo: ["imeId": imeId] - ) - } - } - - private func postUIRefreshNotification() { - DispatchQueue.main.async { - NotificationCenter.default.post( - name: NSNotification.Name("ModSwitchIME.shouldRefreshUI"), - object: nil - ) - } } // Removed performSwitch and related methods - no longer needed after simplification @@ -658,3 +458,146 @@ final class ImeController: ErrorHandler, IMEControlling { DistributedNotificationCenter.default().removeObserver(self) } } + +private extension ImeController { + func availableInputSourceIDs(from candidateIDs: [String]) -> [String] { + var available: [String] = [] + cacheQueue.sync { + available = candidateIDs.filter { inputSourceCache[$0] != nil } + } + return available + } + + func scheduleAsciiFallbackIfNeeded( + primarySource: String, + availableSources: [String], + fromUser: Bool, + fallbackToken: UInt64 + ) { + guard availableSources.count > 1 else { + return + } + + DispatchQueue.main.asyncAfter(deadline: .now() + asciiFallbackDelay) { [weak self] in + self?.applyAsciiFallbackIfNeeded( + primarySource: primarySource, + availableSources: availableSources, + fromUser: fromUser, + fallbackToken: fallbackToken + ) + } + } + + func applyAsciiFallbackIfNeeded( + primarySource: String, + availableSources: [String], + fromUser: Bool, + fallbackToken: UInt64 + ) { + guard isCurrentAsciiFallbackToken(fallbackToken) else { return } + + let actualIME = getCurrentInputSource() + guard actualIME != primarySource, !availableSources.contains(actualIME) else { + return + } + + guard let fallbackSource = availableSources.first(where: { $0 != primarySource }) else { + return + } + + Logger.warning( + "ASCII switch did not apply, trying fallback: \(primarySource) -> \(fallbackSource)", + category: .ime + ) + switchToSpecificIME(fallbackSource, fromUser: fromUser) + } + + func createAsciiFallbackToken() -> UInt64 { + return asciiFallbackQueue.sync { + asciiFallbackGeneration &+= 1 + return asciiFallbackGeneration + } + } + + func invalidateAsciiFallbackToken() { + asciiFallbackQueue.sync { + asciiFallbackGeneration &+= 1 + } + } + + func isCurrentAsciiFallbackToken(_ token: UInt64) -> Bool { + return asciiFallbackQueue.sync { + asciiFallbackGeneration == token + } + } + + func createSwitchToken() -> UInt64 { + return switchGenerationQueue.sync { + switchGeneration &+= 1 + return switchGeneration + } + } + + func isCurrentSwitchToken(_ token: UInt64) -> Bool { + return switchGenerationQueue.sync { + switchGeneration == token + } + } + + func shouldSkipSwitch( + targetIME: String, + fromUser: Bool, + currentIME: String, + now: CFAbsoluteTime + ) -> Bool { + if fromUser { + return shouldSkipUserSwitch(targetIME: targetIME, currentIME: currentIME, now: now) + } + return shouldSkipInternalSwitch(targetIME: targetIME, currentIME: currentIME, now: now) + } + + func shouldSkipUserSwitch(targetIME: String, currentIME: String, now: CFAbsoluteTime) -> Bool { + var shouldSkip = false + switchThrottleQueue.sync { + shouldSkip = switchThrottleState.lastUserTarget == targetIME && + (now - switchThrottleState.lastUserRequestTime) < userThrottleInterval && + currentIME == targetIME + } + + if shouldSkip { + Logger.debug("Skipping rapid duplicate user switch: \(targetIME)", category: .ime) + postUIRefreshNotification() + } + return shouldSkip + } + + func shouldSkipInternalSwitch(targetIME: String, currentIME: String, now: CFAbsoluteTime) -> Bool { + if currentIME == targetIME { + Logger.debug("Already on target IME (internal): \(targetIME)", category: .ime) + return true + } + + var shouldSkip = false + switchThrottleQueue.sync { + shouldSkip = switchThrottleState.lastInternalTarget == targetIME && + (now - switchThrottleState.lastInternalRequestTime) < internalThrottleInterval + } + + if shouldSkip { + Logger.debug("Skipping throttled internal switch: \(targetIME)", category: .ime) + } + return shouldSkip + } + + func recordSwitchThrottle(targetIME: String, fromUser: Bool, now: CFAbsoluteTime) { + switchThrottleQueue.async(flags: .barrier) { + if fromUser { + self.switchThrottleState.lastUserTarget = targetIME + self.switchThrottleState.lastUserRequestTime = now + } else { + self.switchThrottleState.lastInternalTarget = targetIME + self.switchThrottleState.lastInternalRequestTime = now + } + } + } +} diff --git a/ModSwitchIME/InputSourcePickerViews.swift b/ModSwitchIME/InputSourcePickerViews.swift new file mode 100644 index 0000000..b5beb12 --- /dev/null +++ b/ModSwitchIME/InputSourcePickerViews.swift @@ -0,0 +1,474 @@ +import SwiftUI + +// MARK: - Modifier Key Input Source Picker + +struct ModifierKeyInputSourcePicker: View { + let modifierKey: ModifierKey + @Binding var selectedSourceId: String + @Binding var isPresented: Bool + @State private var searchText = "" + @State private var selectedLanguage: String? + @State private var showDisabledSources = false + + private var groupedInputSources: [String: [Preferences.InputSource]] { + // Use Preferences.getAllInputSources (back to working implementation) + let cachedSources = Preferences.getAllInputSources(includeDisabled: showDisabledSources) + + let filtered = searchText.isEmpty ? cachedSources : cachedSources.filter { + $0.localizedName.localizedCaseInsensitiveContains(searchText) || + $0.sourceId.localizedCaseInsensitiveContains(searchText) + } + + return Dictionary(grouping: filtered) { source in + Preferences.getInputSourceLanguage(source.sourceId) + } + } + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + Text("Select IME for \(modifierKey.displayName)") + .font(.headline) + Spacer() + Toggle("Show disabled sources", isOn: $showDisabledSources) + .toggleStyle(.checkbox) + .font(.caption) + Button("Cancel") { + isPresented = false + } + .keyboardShortcut(.escape) + } + .padding() + + // Clear selection button + if !selectedSourceId.isEmpty { + Button("Remove Assignment") { + selectedSourceId = "" + isPresented = false + } + .padding(.horizontal) + .padding(.bottom, 8) + } + + // Search field + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.secondary) + TextField("Search", text: $searchText) + .textFieldStyle(.plain) + } + .padding(8) + .background(Color(NSColor.controlBackgroundColor)) + .cornerRadius(6) + .padding(.horizontal) + .padding(.bottom, 8) + + Divider() + + // Language list or Input source list + if selectedLanguage == nil { + // Language selection screen + ScrollView { + VStack(spacing: 0) { + ForEach(groupedInputSources.keys.sorted(), id: \.self) { language in + LanguageRowView( + language: language, + count: groupedInputSources[language]?.count ?? 0 + ) { selectedLanguage = language } + + let sortedKeys = groupedInputSources.keys.sorted() + if language != sortedKeys.last { + Divider() + } + } + } + .padding(.vertical, 8) + } + } else { + // Input source selection screen + VStack(spacing: 0) { + // Back button + HStack { + Button { + selectedLanguage = nil + } label: { + HStack(spacing: 4) { + Image(systemName: "chevron.left") + Text("Languages") + } + .foregroundColor(.blue) + } + .buttonStyle(.plain) + + Spacer() + + Text(selectedLanguage ?? "") + .font(.headline) + + Spacer() + + // Keep spacing + Text("").frame(width: 50) + } + .padding(.horizontal) + .padding(.vertical, 8) + + Divider() + + ScrollView { + VStack(spacing: 0) { + if let sources = groupedInputSources[selectedLanguage ?? ""] { + ForEach(sources) { source in + InputSourceRowView( + source: source, + isSelected: source.sourceId == selectedSourceId + ) { + selectedSourceId = source.sourceId + isPresented = false + } + + if source.id != sources.last?.id { + Divider() + .padding(.leading, 52) + } + } + } + } + .padding(.vertical, 8) + } + } + } + } + .frame(width: 400, height: 500) + .background(Color(NSColor.windowBackgroundColor)) + } +} + +// MARK: - Input Source Row View + +struct InputSourceRowView: View { + let source: Preferences.InputSource + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: { + // Only allow selection if the source is enabled + if source.isEnabled { + action() + } + }, label: { + HStack(spacing: 12) { + // Flag Icon - display larger + Text(Preferences.getInputSourceIcon(source.sourceId) ?? "⌨️") + .font(.system(size: 20)) + .frame(width: 28, height: 28) + + // Name only (no source ID) + Text(getDisplayName()) + .font(.system(size: 13)) + .foregroundColor(source.isEnabled ? .primary : .secondary) + + Spacer() + + // Checkmark + if isSelected { + Image(systemName: "checkmark") + .foregroundColor(.blue) + .font(.system(size: 12, weight: .medium)) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 6) + .contentShape(Rectangle()) + }) + .buttonStyle(.plain) + .background(isSelected ? Color.blue.opacity(0.1) : Color.clear) + .disabled(!source.isEnabled) // Disable interaction for disabled sources + .opacity(source.isEnabled ? 1.0 : 0.5) // Disabled sources are shown with reduced opacity + } + + private func getDisplayName() -> String { + if let googleName = getGoogleInputDisplayName() { + return googleName + } + + if let atokName = getATOKDisplayName() { + return atokName + } + + if let kotoeriName = getKotoeriDisplayName() { + return kotoeriName + } + + if let keyboardName = getKeyboardLayoutDisplayName() { + return keyboardName + } + + return source.localizedName + } + + private func getGoogleInputDisplayName() -> String? { + guard source.sourceId.contains("com.google.inputmethod.Japanese") else { return nil } + + if source.sourceId.contains("Hiragana") { + return "Hiragana (Google)" + } else if source.sourceId.contains("Katakana") { + return "Katakana (Google)" + } else if source.sourceId.contains("FullWidthRoman") { + return "Full-width Alphanumeric (Google)" + } else if source.sourceId.contains("HalfWidthKana") { + return "Half-width Katakana (Google)" + } else if source.sourceId.contains("Roman") { + return "Alphanumeric (Google)" + } + return nil + } + + private func getATOKDisplayName() -> String? { + guard source.sourceId.contains("ATOK") else { return nil } + + if source.sourceId.contains("Japanese.Katakana") { + return "Katakana (ATOK)" + } else if source.sourceId.contains("Japanese.FullWidthRoman") { + return "Full-width Alphanumeric (ATOK)" + } else if source.sourceId.contains("Japanese.HalfWidthEiji") { + return "Half-width Alphanumeric (ATOK)" + } else if source.sourceId.contains("Roman") { + return "Alphanumeric (ATOK)" + } else if source.sourceId.hasSuffix(".Japanese") { + return "Hiragana (ATOK)" + } + return nil + } + + private func getKotoeriDisplayName() -> String? { + guard source.sourceId.contains("com.apple.inputmethod.Kotoeri") else { return nil } + + if source.sourceId.contains("Hiragana") { + return "Hiragana" + } else if source.sourceId.contains("Katakana") { + return "Katakana" + } else if source.sourceId.contains("FullWidthRoman") { + return "Full-width Alphanumeric" + } else if source.sourceId.contains("HalfWidthKana") { + return "Half-width Katakana" + } else if source.sourceId.contains("Roman") { + return "Alphanumeric" + } + return nil + } + + private func getKeyboardLayoutDisplayName() -> String? { + if source.sourceId == "com.apple.keylayout.ABC" { + return "ABC" + } else if source.sourceId == "com.apple.keylayout.US" { + return "US" + } + return nil + } +} + +// MARK: - Idle IME Picker + +struct IdleIMEPicker: View { + @Binding var selectedSourceId: String + @Binding var isPresented: Bool + @State private var searchText = "" + @State private var selectedLanguage: String? + @State private var showDisabledSources = false + + private var groupedInputSources: [String: [Preferences.InputSource]] { + // Use Preferences.getAllInputSources (back to working implementation) + let cachedSources = Preferences.getAllInputSources(includeDisabled: showDisabledSources) + + let filtered = searchText.isEmpty ? cachedSources : cachedSources.filter { + $0.localizedName.localizedCaseInsensitiveContains(searchText) || + $0.sourceId.localizedCaseInsensitiveContains(searchText) + } + + return Dictionary(grouping: filtered) { source in + Preferences.getInputSourceLanguage(source.sourceId) + } + } + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + Text("Select IME for Idle Return") + .font(.headline) + Spacer() + Toggle("Show disabled sources", isOn: $showDisabledSources) + .toggleStyle(.checkbox) + .font(.caption) + Button("Cancel") { + isPresented = false + } + .keyboardShortcut(.escape) + } + .padding() + + // Clear selection button + Button("Reset to English") { + selectedSourceId = "" + isPresented = false + } + .padding(.horizontal) + .padding(.bottom, 8) + + // Search field + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.secondary) + TextField("Search", text: $searchText) + .textFieldStyle(.plain) + } + .padding(8) + .background(Color(NSColor.controlBackgroundColor)) + .cornerRadius(6) + .padding(.horizontal) + .padding(.bottom, 8) + + Divider() + + // Language list or Input source list + if selectedLanguage == nil { + // Language selection screen + ScrollView { + VStack(spacing: 0) { + ForEach(groupedInputSources.keys.sorted(), id: \.self) { language in + LanguageRowView( + language: language, + count: groupedInputSources[language]?.count ?? 0 + ) { selectedLanguage = language } + + let sortedKeys = groupedInputSources.keys.sorted() + if language != sortedKeys.last { + Divider() + } + } + } + .padding(.vertical, 8) + } + } else { + // Input source selection screen + VStack(spacing: 0) { + // Back button + HStack { + Button { + selectedLanguage = nil + } label: { + HStack(spacing: 4) { + Image(systemName: "chevron.left") + Text("Languages") + } + .foregroundColor(.blue) + } + .buttonStyle(.plain) + + Spacer() + + Text(selectedLanguage ?? "") + .font(.headline) + + Spacer() + + // Keep spacing + Text("").frame(width: 50) + } + .padding(.horizontal) + .padding(.vertical, 8) + + Divider() + + ScrollView { + VStack(spacing: 0) { + if let sources = groupedInputSources[selectedLanguage ?? ""] { + ForEach(sources) { source in + InputSourceRowView( + source: source, + isSelected: source.sourceId == selectedSourceId + ) { + selectedSourceId = source.sourceId + isPresented = false + } + + if source.id != sources.last?.id { + Divider() + .padding(.leading, 52) + } + } + } + } + .padding(.vertical, 8) + } + } + } + } + .frame(width: 400, height: 500) + .background(Color(NSColor.windowBackgroundColor)) + } +} + +// MARK: - Language Row View + +struct LanguageRowView: View { + let language: String + let count: Int + let action: () -> Void + + var body: some View { + Button(action: action) { + HStack { + // Language icon + Text(getLanguageIcon()) + .font(.system(size: 24)) + .frame(width: 32, height: 32) + + // Language name + Text(language) + .font(.system(size: 14)) + .foregroundColor(.primary) + + Spacer() + + // Number of input sources + Text("\(count)") + .font(.caption) + .foregroundColor(.secondary) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(Color.gray.opacity(0.2)) + .cornerRadius(10) + + // Arrow + Image(systemName: "chevron.right") + .font(.system(size: 12)) + .foregroundColor(.secondary) + } + .padding(.horizontal, 16) + .padding(.vertical, 10) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .background(Color.clear) + } + + private func getLanguageIcon() -> String { + switch language { + case "Japanese": return "🇯🇵" + case "Chinese": return "🇨🇳" + case "Korean": return "🇰🇷" + case "Vietnamese": return "🇻🇳" + case "Arabic": return "🇸🇦" + case "Hebrew": return "🇮🇱" + case "Thai": return "🇹🇭" + case "Indic Languages": return "🇮🇳" + case "Cyrillic Scripts": return "🇷🇺" + case "European Languages": return "🇪🇺" + default: return "🌐" + } + } +} diff --git a/ModSwitchIME/KeyMonitor.swift b/ModSwitchIME/KeyMonitor.swift index 58c1377..c4806f3 100644 --- a/ModSwitchIME/KeyMonitor.swift +++ b/ModSwitchIME/KeyMonitor.swift @@ -145,9 +145,7 @@ final class KeyMonitor { cancellables.removeAll() // Clear state - keyStates.removeAll() - lastPressedKey = nil - nonModifierKeyPressed = false + resetKeyState() Logger.info("KeyMonitor stopped", category: .keyboard) } @@ -173,12 +171,15 @@ final class KeyMonitor { } } case .keyUp: - // Only update if we were tracking non-modifier key press - if nonModifierKeyPressed { - nonModifierKeyPressed = false + stateQueue.sync { + // Only update if we were tracking non-modifier key press + if nonModifierKeyPressed { + nonModifierKeyPressed = false + } } case .tapDisabledByTimeout: Logger.error("Event tap disabled by timeout", category: .keyboard) + resetKeyState() if let eventTap = eventTap { CGEvent.tapEnable(tap: eventTap, enable: true) // Attempt to recreate if re-enable failed @@ -197,6 +198,7 @@ final class KeyMonitor { } case .tapDisabledByUserInput: Logger.error("Event tap disabled by user input", category: .keyboard) + resetKeyState() onError?(.eventTapDisabled(automatic: false)) DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in if let eventTap = self?.eventTap { @@ -223,7 +225,6 @@ final class KeyMonitor { return Unmanaged.passUnretained(event) } - // swiftlint:disable:next cyclomatic_complexity function_body_length private func handleFlagsChanged(event: CGEvent) { let keyCode = event.getIntegerValueField(.keyboardEventKeycode) let flags = event.flags @@ -231,25 +232,33 @@ final class KeyMonitor { guard let modifierKey = ModifierKey.from(keyCode: keyCode) else { return } + + let targetIME = stateQueue.sync { + processFlagsChanged(modifierKey: modifierKey, flags: flags) + } + + if let targetIME = targetIME { + imeController.switchToSpecificIME(targetIME) + } + } + + // swiftlint:disable:next cyclomatic_complexity function_body_length + private func processFlagsChanged(modifierKey: ModifierKey, flags: CGEventFlags) -> String? { // IMPORTANT: For left/right keys that share the same flag mask (like leftCommand/rightCommand), // we need to determine press/release based on the presence in keyStates let wasAlreadyPressed = keyStates[modifierKey] != nil let currentlyPressed = flags.contains(modifierKey.flagMask) - // For Command keys (and other pairs that share flagMask), we need special handling - let isSharedFlagKey = (modifierKey.flagMask == .maskCommand || - modifierKey.flagMask == .maskShift || - modifierKey.flagMask == .maskControl || - modifierKey.flagMask == .maskAlternate) + let isSharedFlagKey = isSharedFlagKey(modifierKey) let isKeyDown: Bool if isSharedFlagKey && wasAlreadyPressed { // For shared flag keys, if the key was already pressed and we get an event for it, // it's likely a release (since macOS sends events for both press and release) // We'll determine based on the current state and other pressed keys - let otherPressedKeysWithSameFlag = keyStates.keys.filter { - $0 != modifierKey && $0.flagMask == modifierKey.flagMask + let otherPressedKeysWithSameFlag = keyStates.keys.filter { + $0 != modifierKey && $0.flagMask == modifierKey.flagMask } if otherPressedKeysWithSameFlag.isEmpty { @@ -267,10 +276,12 @@ final class KeyMonitor { isKeyDown = false } else if currentlyPressed && wasAlreadyPressed { // Key is still down (no change) - return // No action needed + return nil } else { // Key is still up (no change) - return // No action needed + cleanupReleasedSharedFlagKeys(for: modifierKey, flags: flags) + clearLastPressedKeyIfNeeded() + return nil } let now = CFAbsoluteTimeGetCurrent() @@ -291,7 +302,7 @@ final class KeyMonitor { // Count other CURRENTLY pressed keys that have IME configured (excluding current key) // IMPORTANT: Only consider keys that are actually still pressed (in keyStates) - let otherPressedKeys = stateQueue.sync { keyStates.filter { $0.key != modifierKey } } + let otherPressedKeys = keyStates.filter { $0.key != modifierKey } var otherKeysWithIME: [ModifierKey] = [] for (key, _) in otherPressedKeys { if preferences.getIME(for: key) != nil && preferences.isKeyEnabled(key) { @@ -322,7 +333,7 @@ final class KeyMonitor { "Multi-key IME switch: \(modifierKey.displayName) -> \(targetIME)", category: .keyboard ) - imeController.switchToSpecificIME(targetIME) + return targetIME } } else { // Not a multi-key press scenario @@ -339,32 +350,24 @@ final class KeyMonitor { let keyState = keyStates[modifierKey] keyStates.removeValue(forKey: modifierKey) - handleKeyRelease(modifierKey: modifierKey, keyState: keyState, event: event) + let targetIME = targetIMEForKeyRelease(modifierKey: modifierKey, keyState: keyState) - // IMPORTANT: For left/right Command keys that share the same flagMask, - // we need to ensure both are removed when flags indicate no Command keys are pressed - if modifierKey.flagMask == .maskCommand && !flags.contains(.maskCommand) { - // No Command keys are pressed according to flags, remove both if they exist - let commandKeysToRemove = keyStates.keys.filter { $0.flagMask == .maskCommand } - for key in commandKeysToRemove { - keyStates.removeValue(forKey: key) - } - } + cleanupReleasedSharedFlagKeys(for: modifierKey, flags: flags) // Clear lastPressedKey when all keys are released - if keyStates.isEmpty { - lastPressedKey = nil - // All keys released - clear all state - } + clearLastPressedKeyIfNeeded() + return targetIME } + + return nil } - private func handleKeyRelease(modifierKey: ModifierKey, keyState: ModifierKeyState?, event: CGEvent) { + private func targetIMEForKeyRelease(modifierKey: ModifierKey, keyState: ModifierKeyState?) -> String? { // Check if IME is configured for this key guard let targetIME = preferences.getIME(for: modifierKey), preferences.isKeyEnabled(modifierKey) else { // No IME configured or disabled for this key - return + return nil } // Check if we have a valid state @@ -373,7 +376,7 @@ final class KeyMonitor { "Key release without corresponding press: \(modifierKey.displayName)", category: .keyboard ) - return + return nil } // Check if other keys are currently pressed @@ -387,7 +390,41 @@ final class KeyMonitor { ) // Direct switch without checking current IME for better performance - imeController.switchToSpecificIME(targetIME) + return targetIME + } + + return nil + } + + private func isSharedFlagKey(_ modifierKey: ModifierKey) -> Bool { + return modifierKey.flagMask == .maskCommand || + modifierKey.flagMask == .maskShift || + modifierKey.flagMask == .maskControl || + modifierKey.flagMask == .maskAlternate + } + + private func cleanupReleasedSharedFlagKeys(for modifierKey: ModifierKey, flags: CGEventFlags) { + guard isSharedFlagKey(modifierKey), !flags.contains(modifierKey.flagMask) else { + return + } + + let keysToRemove = keyStates.keys.filter { $0.flagMask == modifierKey.flagMask } + for key in keysToRemove { + keyStates.removeValue(forKey: key) + } + } + + private func clearLastPressedKeyIfNeeded() { + if keyStates.isEmpty { + lastPressedKey = nil + } + } + + private func resetKeyState() { + stateQueue.sync { + keyStates.removeAll() + lastPressedKey = nil + nonModifierKeyPressed = false } } @@ -628,12 +665,14 @@ final class KeyMonitor { // Compatibility method for tests func getModifierKeyStates() -> [ModifierKey: (isDown: Bool, downTime: CFAbsoluteTime)] { - // Convert current key states to the expected format - var states: [ModifierKey: (isDown: Bool, downTime: CFAbsoluteTime)] = [:] - for (key, state) in keyStates { - states[key] = (isDown: true, downTime: state.downTime) + return stateQueue.sync { + // Convert current key states to the expected format + var states: [ModifierKey: (isDown: Bool, downTime: CFAbsoluteTime)] = [:] + for (key, state) in keyStates { + states[key] = (isDown: true, downTime: state.downTime) + } + return states } - return states } private func verifyEventTapEnabled() -> Bool { @@ -651,7 +690,7 @@ final class KeyMonitor { stop() // Ensure all resources are cleaned up cancellables.removeAll() - keyStates.removeAll() + resetKeyState() cancelRetryTimer() stopIdleTimer() } diff --git a/ModSwitchIME/MenuBarApp.swift b/ModSwitchIME/MenuBarApp.swift index c1706c2..cd33aa2 100644 --- a/ModSwitchIME/MenuBarApp.swift +++ b/ModSwitchIME/MenuBarApp.swift @@ -31,6 +31,9 @@ final class MenuBarApp: NSObject, ObservableObject, NSApplicationDelegate { private var imeDisplayNameCache: [String: String] = [:] // Debounced icon refresh work item to avoid flicker and race with TIS private var iconRefreshWorkItem: DispatchWorkItem? + // Periodic reconciliation catches missed system notifications during window/Space changes + private var imeReconciliationTimer: Timer? + private var lastDisplayedIME: String? // Shared ImeController instance to avoid duplication // Note: This ImeController will also monitor IME changes for cache updates, @@ -444,6 +447,8 @@ final class MenuBarApp: NSObject, ObservableObject, NSApplicationDelegate { // Stop KeyMonitor keyMonitor?.stop() keyMonitor = nil + imeReconciliationTimer?.invalidate() + imeReconciliationTimer = nil } private func showErrorAlert(error: Error) { @@ -591,9 +596,35 @@ final class MenuBarApp: NSObject, ObservableObject, NSApplicationDelegate { name: NSNotification.Name("ModSwitchIME.shouldRefreshUI"), object: nil ) + + setupFocusContextMonitoring() // Initial icon update refreshIconDebounced() + startIMEStateReconciliationTimer() + } + + private func setupFocusContextMonitoring() { + NSWorkspace.shared.notificationCenter.addObserver( + self, + selector: #selector(handleFocusContextChanged), + name: NSWorkspace.didActivateApplicationNotification, + object: nil + ) + + NSWorkspace.shared.notificationCenter.addObserver( + self, + selector: #selector(handleFocusContextChanged), + name: NSWorkspace.activeSpaceDidChangeNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleFocusContextChanged), + name: NSApplication.didChangeScreenParametersNotification, + object: nil + ) } @objc private func imeStateChanged(_ notification: Notification) { @@ -621,6 +652,21 @@ final class MenuBarApp: NSObject, ObservableObject, NSApplicationDelegate { // Always update based on actual current IME state refreshIconDebounced() } + + @objc private func handleFocusContextChanged(_ notification: Notification) { + Logger.debug("Focus context changed: \(notification.name.rawValue)", category: .main) + refreshIconDebounced(delay: 0.12) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self] in + self?.refreshIconDebounced(delay: 0) + } + } + + private func startIMEStateReconciliationTimer() { + imeReconciliationTimer?.invalidate() + imeReconciliationTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + self?.refreshIconIfIMEChanged() + } + } @objc private func systemWillSleep(_ notification: Notification) { // System is going to sleep @@ -764,6 +810,7 @@ final class MenuBarApp: NSObject, ObservableObject, NSApplicationDelegate { keyMonitor?.stop() // Cancel any pending icon refresh iconRefreshWorkItem?.cancel() + imeReconciliationTimer?.invalidate() // Remove all notification observers to prevent memory leaks windowCloseObservers.forEach { NotificationCenter.default.removeObserver($0) } windowCloseObservers.removeAll() @@ -787,6 +834,12 @@ extension MenuBarApp { DispatchQueue.main.asyncAfter(deadline: .now() + d, execute: item) } + private func refreshIconIfIMEChanged() { + let currentIME = getCurrentIME() + guard currentIME != lastDisplayedIME else { return } + refreshIconDebounced() + } + private func updateIconWithCurrentIME() { // Ensure UI updates happen on main thread DispatchQueue.main.async { [weak self] in @@ -806,6 +859,7 @@ extension MenuBarApp { private func updateIconForIME(_ imeId: String) { guard let button = statusBarItem?.button else { return } + lastDisplayedIME = imeId let displayName = getIMEDisplayName(imeId) let tooltip = "\(displayName) (\(imeId))" // Always use SF Symbol globe for the menu bar icon. diff --git a/ModSwitchIME/PreferencesView.swift b/ModSwitchIME/PreferencesView.swift index 2236130..3c5fb1a 100644 --- a/ModSwitchIME/PreferencesView.swift +++ b/ModSwitchIME/PreferencesView.swift @@ -272,480 +272,7 @@ struct ModifierKeyRow: View { } } -// MARK: - Modifier Key Input Source Picker - -struct ModifierKeyInputSourcePicker: View { - let modifierKey: ModifierKey - @Binding var selectedSourceId: String - @Binding var isPresented: Bool - @State private var searchText = "" - @State private var selectedLanguage: String? - @State private var showDisabledSources = false - - private var groupedInputSources: [String: [Preferences.InputSource]] { - // Use Preferences.getAllInputSources (back to working implementation) - let cachedSources = Preferences.getAllInputSources(includeDisabled: showDisabledSources) - - let filtered = searchText.isEmpty ? cachedSources : cachedSources.filter { - $0.localizedName.localizedCaseInsensitiveContains(searchText) || - $0.sourceId.localizedCaseInsensitiveContains(searchText) - } - - return Dictionary(grouping: filtered) { source in - Preferences.getInputSourceLanguage(source.sourceId) - } - } - - var body: some View { - VStack(spacing: 0) { - // Header - HStack { - Text("Select IME for \(modifierKey.displayName)") - .font(.headline) - Spacer() - Toggle("Show disabled sources", isOn: $showDisabledSources) - .toggleStyle(.checkbox) - .font(.caption) - Button("Cancel") { - isPresented = false - } - .keyboardShortcut(.escape) - } - .padding() - - // Clear selection button - if !selectedSourceId.isEmpty { - Button("Remove Assignment") { - selectedSourceId = "" - isPresented = false - } - .padding(.horizontal) - .padding(.bottom, 8) - } - - // Search field - HStack { - Image(systemName: "magnifyingglass") - .foregroundColor(.secondary) - TextField("Search", text: $searchText) - .textFieldStyle(.plain) - } - .padding(8) - .background(Color(NSColor.controlBackgroundColor)) - .cornerRadius(6) - .padding(.horizontal) - .padding(.bottom, 8) - - Divider() - - // Language list or Input source list - if selectedLanguage == nil { - // Language selection screen - ScrollView { - VStack(spacing: 0) { - ForEach(groupedInputSources.keys.sorted(), id: \.self) { language in - LanguageRowView( - language: language, - count: groupedInputSources[language]?.count ?? 0 - ) { selectedLanguage = language } - - let sortedKeys = groupedInputSources.keys.sorted() - if language != sortedKeys.last { - Divider() - } - } - } - .padding(.vertical, 8) - } - } else { - // Input source selection screen - VStack(spacing: 0) { - // Back button - HStack { - Button { - selectedLanguage = nil - } label: { - HStack(spacing: 4) { - Image(systemName: "chevron.left") - Text("Languages") - } - .foregroundColor(.blue) - } - .buttonStyle(.plain) - - Spacer() - - Text(selectedLanguage ?? "") - .font(.headline) - - Spacer() - - // Keep spacing - Text("").frame(width: 50) - } - .padding(.horizontal) - .padding(.vertical, 8) - - Divider() - - ScrollView { - VStack(spacing: 0) { - if let sources = groupedInputSources[selectedLanguage ?? ""] { - ForEach(sources) { source in - InputSourceRowView( - source: source, - isSelected: source.sourceId == selectedSourceId - ) { - selectedSourceId = source.sourceId - isPresented = false - } - - if source.id != sources.last?.id { - Divider() - .padding(.leading, 52) - } - } - } - } - .padding(.vertical, 8) - } - } - } - } - .frame(width: 400, height: 500) - .background(Color(NSColor.windowBackgroundColor)) - } -} - // Make ModifierKey conform to Identifiable for sheet presentation extension ModifierKey: Identifiable { var id: String { rawValue } } - -// MARK: - Input Source Row View - -struct InputSourceRowView: View { - let source: Preferences.InputSource - let isSelected: Bool - let action: () -> Void - - var body: some View { - Button(action: { - // Only allow selection if the source is enabled - if source.isEnabled { - action() - } - }, label: { - HStack(spacing: 12) { - // Flag Icon - display larger - Text(Preferences.getInputSourceIcon(source.sourceId) ?? "⌨️") - .font(.system(size: 20)) - .frame(width: 28, height: 28) - - // Name only (no source ID) - Text(getDisplayName()) - .font(.system(size: 13)) - .foregroundColor(source.isEnabled ? .primary : .secondary) - - Spacer() - - // Checkmark - if isSelected { - Image(systemName: "checkmark") - .foregroundColor(.blue) - .font(.system(size: 12, weight: .medium)) - } - } - .padding(.horizontal, 16) - .padding(.vertical, 6) - .contentShape(Rectangle()) - }) - .buttonStyle(.plain) - .background(isSelected ? Color.blue.opacity(0.1) : Color.clear) - .disabled(!source.isEnabled) // Disable interaction for disabled sources - .opacity(source.isEnabled ? 1.0 : 0.5) // Disabled sources are shown with reduced opacity - } - - private func getDisplayName() -> String { - if let googleName = getGoogleInputDisplayName() { - return googleName - } - - if let atokName = getATOKDisplayName() { - return atokName - } - - if let kotoeriName = getKotoeriDisplayName() { - return kotoeriName - } - - if let keyboardName = getKeyboardLayoutDisplayName() { - return keyboardName - } - - return source.localizedName - } - - private func getGoogleInputDisplayName() -> String? { - guard source.sourceId.contains("com.google.inputmethod.Japanese") else { return nil } - - if source.sourceId.contains("Hiragana") { - return "Hiragana (Google)" - } else if source.sourceId.contains("Katakana") { - return "Katakana (Google)" - } else if source.sourceId.contains("FullWidthRoman") { - return "Full-width Alphanumeric (Google)" - } else if source.sourceId.contains("HalfWidthKana") { - return "Half-width Katakana (Google)" - } else if source.sourceId.contains("Roman") { - return "Alphanumeric (Google)" - } - return nil - } - - private func getATOKDisplayName() -> String? { - guard source.sourceId.contains("ATOK") else { return nil } - - if source.sourceId.contains("Japanese.Katakana") { - return "Katakana (ATOK)" - } else if source.sourceId.contains("Japanese.FullWidthRoman") { - return "Full-width Alphanumeric (ATOK)" - } else if source.sourceId.contains("Japanese.HalfWidthEiji") { - return "Half-width Alphanumeric (ATOK)" - } else if source.sourceId.contains("Roman") { - return "Alphanumeric (ATOK)" - } else if source.sourceId.hasSuffix(".Japanese") { - return "Hiragana (ATOK)" - } - return nil - } - - private func getKotoeriDisplayName() -> String? { - guard source.sourceId.contains("com.apple.inputmethod.Kotoeri") else { return nil } - - if source.sourceId.contains("Hiragana") { - return "Hiragana" - } else if source.sourceId.contains("Katakana") { - return "Katakana" - } else if source.sourceId.contains("FullWidthRoman") { - return "Full-width Alphanumeric" - } else if source.sourceId.contains("HalfWidthKana") { - return "Half-width Katakana" - } else if source.sourceId.contains("Roman") { - return "Alphanumeric" - } - return nil - } - - private func getKeyboardLayoutDisplayName() -> String? { - if source.sourceId == "com.apple.keylayout.ABC" { - return "ABC" - } else if source.sourceId == "com.apple.keylayout.US" { - return "US" - } - return nil - } -} - -// MARK: - Idle IME Picker - -struct IdleIMEPicker: View { - @Binding var selectedSourceId: String - @Binding var isPresented: Bool - @State private var searchText = "" - @State private var selectedLanguage: String? - @State private var showDisabledSources = false - - private var groupedInputSources: [String: [Preferences.InputSource]] { - // Use Preferences.getAllInputSources (back to working implementation) - let cachedSources = Preferences.getAllInputSources(includeDisabled: showDisabledSources) - - let filtered = searchText.isEmpty ? cachedSources : cachedSources.filter { - $0.localizedName.localizedCaseInsensitiveContains(searchText) || - $0.sourceId.localizedCaseInsensitiveContains(searchText) - } - - return Dictionary(grouping: filtered) { source in - Preferences.getInputSourceLanguage(source.sourceId) - } - } - - var body: some View { - VStack(spacing: 0) { - // Header - HStack { - Text("Select IME for Idle Return") - .font(.headline) - Spacer() - Toggle("Show disabled sources", isOn: $showDisabledSources) - .toggleStyle(.checkbox) - .font(.caption) - Button("Cancel") { - isPresented = false - } - .keyboardShortcut(.escape) - } - .padding() - - // Clear selection button - Button("Reset to English") { - selectedSourceId = "" - isPresented = false - } - .padding(.horizontal) - .padding(.bottom, 8) - - // Search field - HStack { - Image(systemName: "magnifyingglass") - .foregroundColor(.secondary) - TextField("Search", text: $searchText) - .textFieldStyle(.plain) - } - .padding(8) - .background(Color(NSColor.controlBackgroundColor)) - .cornerRadius(6) - .padding(.horizontal) - .padding(.bottom, 8) - - Divider() - - // Language list or Input source list - if selectedLanguage == nil { - // Language selection screen - ScrollView { - VStack(spacing: 0) { - ForEach(groupedInputSources.keys.sorted(), id: \.self) { language in - LanguageRowView( - language: language, - count: groupedInputSources[language]?.count ?? 0 - ) { selectedLanguage = language } - - let sortedKeys = groupedInputSources.keys.sorted() - if language != sortedKeys.last { - Divider() - } - } - } - .padding(.vertical, 8) - } - } else { - // Input source selection screen - VStack(spacing: 0) { - // Back button - HStack { - Button { - selectedLanguage = nil - } label: { - HStack(spacing: 4) { - Image(systemName: "chevron.left") - Text("Languages") - } - .foregroundColor(.blue) - } - .buttonStyle(.plain) - - Spacer() - - Text(selectedLanguage ?? "") - .font(.headline) - - Spacer() - - // Keep spacing - Text("").frame(width: 50) - } - .padding(.horizontal) - .padding(.vertical, 8) - - Divider() - - ScrollView { - VStack(spacing: 0) { - if let sources = groupedInputSources[selectedLanguage ?? ""] { - ForEach(sources) { source in - InputSourceRowView( - source: source, - isSelected: source.sourceId == selectedSourceId - ) { - selectedSourceId = source.sourceId - isPresented = false - } - - if source.id != sources.last?.id { - Divider() - .padding(.leading, 52) - } - } - } - } - .padding(.vertical, 8) - } - } - } - } - .frame(width: 400, height: 500) - .background(Color(NSColor.windowBackgroundColor)) - } -} - -// MARK: - Language Row View - -struct LanguageRowView: View { - let language: String - let count: Int - let action: () -> Void - - var body: some View { - Button(action: action) { - HStack { - // Language icon - Text(getLanguageIcon()) - .font(.system(size: 24)) - .frame(width: 32, height: 32) - - // Language name - Text(language) - .font(.system(size: 14)) - .foregroundColor(.primary) - - Spacer() - - // Number of input sources - Text("\(count)") - .font(.caption) - .foregroundColor(.secondary) - .padding(.horizontal, 8) - .padding(.vertical, 2) - .background(Color.gray.opacity(0.2)) - .cornerRadius(10) - - // Arrow - Image(systemName: "chevron.right") - .font(.system(size: 12)) - .foregroundColor(.secondary) - } - .padding(.horizontal, 16) - .padding(.vertical, 10) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .background(Color.clear) - } - - private func getLanguageIcon() -> String { - switch language { - case "Japanese": return "🇯🇵" - case "Chinese": return "🇨🇳" - case "Korean": return "🇰🇷" - case "Vietnamese": return "🇻🇳" - case "Arabic": return "🇸🇦" - case "Hebrew": return "🇮🇱" - case "Thai": return "🇹🇭" - case "Indic Languages": return "🇮🇳" - case "Cyrillic Scripts": return "🇷🇺" - case "European Languages": return "🇪🇺" - default: return "🌐" - } - } -} diff --git a/ModSwitchIMETests/ImeControllerSkipLogicTests2.swift b/ModSwitchIMETests/ImeControllerSkipLogicTests2.swift index a4741cd..a5ba45d 100644 --- a/ModSwitchIMETests/ImeControllerSkipLogicTests2.swift +++ b/ModSwitchIMETests/ImeControllerSkipLogicTests2.swift @@ -114,8 +114,12 @@ final class ImeControllerSkipLogicTests2: XCTestCase { XCTAssertTrue(true, "Chattering prevention should work with 100ms threshold") } - func testDuplicateUserSwitchPostsRefreshNotification() { - let targetIME = "com.apple.keylayout.ABC" + func testDuplicateUserSwitchPostsRefreshNotification() throws { + let targetIME = controller.getCurrentInputSource() + if targetIME == "Unknown" { + throw XCTSkip("Current input source is unavailable in this environment") + } + let refreshExpectation = expectation(description: "Duplicate user switch triggers UI refresh") var secondCallStarted = false @@ -139,4 +143,28 @@ final class ImeControllerSkipLogicTests2: XCTestCase { wait(for: [refreshExpectation], timeout: 1.0) } + + func testLastNotifiedIMESyncsToActualInputSource() { + let actualIME = controller.getCurrentInputSource() + controller.debugSetLastNotifiedIME("com.apple.keylayout.StaleTestIME") + + controller.debugSyncLastNotifiedIMEWithCurrentInputSource() + + XCTAssertEqual(controller.debugGetLastNotifiedIME(), actualIME) + } + + func testLastSwitchedIMEUpdatesAfterConfirmedSwitch() throws { + let currentIME = controller.getCurrentInputSource() + if currentIME == "Unknown" { + throw XCTSkip("Current input source is unavailable in this environment") + } + + controller.switchToSpecificIME(currentIME, fromUser: true) + + let exp = expectation(description: "wait for hybrid switch confirmation") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { exp.fulfill() } + wait(for: [exp], timeout: 1.0) + + XCTAssertEqual(controller.debugGetLastSwitchedIME(), currentIME) + } } diff --git a/ModSwitchIMETests/KeyMonitorSimpleTest.swift b/ModSwitchIMETests/KeyMonitorSimpleTest.swift index 42c5358..5686d14 100644 --- a/ModSwitchIMETests/KeyMonitorSimpleTest.swift +++ b/ModSwitchIMETests/KeyMonitorSimpleTest.swift @@ -152,6 +152,27 @@ class KeyMonitorSimpleTest: XCTestCase { XCTAssertEqual(mockImeController.switchToSpecificIMECalls.count, 0) #endif } + + func testStaleSharedFlagStateIsClearedForOptionKeys() { + #if DEBUG + keyMonitor.simulateFlagsChanged( + keyCode: ModifierKey.leftOption.keyCode, + flags: ModifierKey.leftOption.flagMask + ) + + XCTAssertTrue(keyMonitor.getKeyPressTimestamps().keys.contains(.leftOption)) + + keyMonitor.simulateFlagsChanged( + keyCode: ModifierKey.rightOption.keyCode, + flags: [] + ) + + XCTAssertTrue( + keyMonitor.getKeyPressTimestamps().isEmpty, + "Stale left/right Option state should be cleared when Option flag is not present" + ) + #endif + } // MARK: - Event Tap Health Monitoring Tests diff --git a/ModSwitchIMETests/MenuBarIconDebounceTests.swift b/ModSwitchIMETests/MenuBarIconDebounceTests.swift index 8fcf761..2aec09d 100644 --- a/ModSwitchIMETests/MenuBarIconDebounceTests.swift +++ b/ModSwitchIMETests/MenuBarIconDebounceTests.swift @@ -1,18 +1,22 @@ import XCTest +import AppKit @testable import ModSwitchIME final class MenuBarIconDebounceTests: XCTestCase { - func testInternalNotificationDebouncedToSingleUpdate() { - // Given: a MenuBarApp instance + private func makeInitializedApp() -> MenuBarApp { let app = MenuBarApp() - // Allow async initialization + initial icon update to complete let initExp = expectation(description: "wait init") DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { initExp.fulfill() } wait(for: [initExp], timeout: 1.0) - // Reset debug counter and record baseline MenuBarApp.debugResetIconUpdateCount() + return app + } + + func testInternalNotificationDebouncedToSingleUpdate() { + // Given: a MenuBarApp instance + let app = makeInitializedApp() let before = MenuBarApp.debugIconUpdateCount // When: fire multiple internal IME switch notifications rapidly @@ -34,4 +38,46 @@ final class MenuBarIconDebounceTests: XCTestCase { XCTAssertLessThan(delta, 3, "Debounce should coalesce rapid notifications to a small number of updates") _ = app // keep reference alive for test duration } + + func testApplicationActivationRefreshesIconTwiceForSettledIMEState() { + let app = makeInitializedApp() + let before = MenuBarApp.debugIconUpdateCount + let userInfo: [AnyHashable: Any] = [NSWorkspace.applicationUserInfoKey: NSRunningApplication.current] + + NSWorkspace.shared.notificationCenter.post( + name: NSWorkspace.didActivateApplicationNotification, + object: nil, + userInfo: userInfo + ) + + let exp = expectation(description: "wait focus refresh") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { exp.fulfill() } + wait(for: [exp], timeout: 1.0) + + let delta = MenuBarApp.debugIconUpdateCount - before + XCTAssertGreaterThanOrEqual(delta, 2, "Focus changes should refresh once quickly and once after settling") + _ = app + } + + func testSpaceAndScreenChangesRefreshIcon() { + let app = makeInitializedApp() + let before = MenuBarApp.debugIconUpdateCount + + NSWorkspace.shared.notificationCenter.post( + name: NSWorkspace.activeSpaceDidChangeNotification, + object: nil + ) + NotificationCenter.default.post( + name: NSApplication.didChangeScreenParametersNotification, + object: nil + ) + + let exp = expectation(description: "wait context refresh") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { exp.fulfill() } + wait(for: [exp], timeout: 1.0) + + let delta = MenuBarApp.debugIconUpdateCount - before + XCTAssertGreaterThanOrEqual(delta, 2, "Space and screen changes should refresh the IME icon") + _ = app + } } diff --git a/VERSION b/VERSION index 0664a8f..2bf1ca5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.6 +1.1.7