From f3697a13a71a3e5b7b66e912cf1295cdfd408b74 Mon Sep 17 00:00:00 2001 From: Bo Feng Date: Thu, 11 Jun 2026 23:25:57 -0400 Subject: [PATCH 1/3] Finalize stranded composition when input source is switched away programmatically macOS 26 does not call deactivateServer when another process switches the input source via TISSelectInputSource() (as macism, Input Source Pro and im-select-style vim tools do): the pending composition is stranded and the candidate panel is left orphaned on screen, while subsequent keystrokes already go to the new input source. Switching via the menu bar Input menu still works correctly. The input-source-changed distributed notification is still delivered on that path, so hook the existing kTISNotifySelectedKeyboardInputSourceChanged observer: when the selected source is no longer Squirrel and a composition is pending, commit the raw input and hide the panel, exactly as deactivateServer would have. When deactivateServer did run (menu bar switching), the composition is already empty and this is a no-op. Fixes #1140 Co-Authored-By: Claude Fable 5 --- sources/SquirrelApplicationDelegate.swift | 14 ++++++++++++++ sources/SquirrelInputController.swift | 12 ++++++++++++ 2 files changed, 26 insertions(+) diff --git a/sources/SquirrelApplicationDelegate.swift b/sources/SquirrelApplicationDelegate.swift index 840b6c9ac..5b864b1f0 100644 --- a/sources/SquirrelApplicationDelegate.swift +++ b/sources/SquirrelApplicationDelegate.swift @@ -245,6 +245,7 @@ final class SquirrelApplicationDelegate: NSObject, NSApplicationDelegate, SPUSta notifCenter.addObserver(forName: .init("SquirrelGetASCIIModeNotification"), object: nil, queue: nil, using: rimeGetASCIIMode) notifCenter.addObserver(forName: .init(kTISNotifySelectedKeyboardInputSourceChanged as String), object: nil, queue: .main) { [weak self] _ in self?.updateStatusItemVisibility() + self?.finalizeStrandedComposition() } } @@ -361,6 +362,19 @@ private extension SquirrelApplicationDelegate { statusItem.isVisible = id.hasPrefix("im.rime.inputmethod.Squirrel") } + // macOS 26 does not call deactivateServer when the input source is switched + // away by another process via TISSelectInputSource() (e.g. macism, Input + // Source Pro): the pending composition is stranded and the candidate panel + // is left orphaned on screen (#1140). The input-source-changed notification + // is still delivered, so finalize the composition here as a fallback. + // Switching via the menu bar calls deactivateServer first, making this a + // no-op. + func finalizeStrandedComposition() { + let id = SquirrelInstaller.currentInputSourceID() ?? "" + guard !id.hasPrefix("im.rime.inputmethod.Squirrel") else { return } + panel?.inputController?.finalizeStrandedComposition() + } + func applyStatusIcon(asciiMode: Bool, schemaLabel: String?) { guard let button = statusItem?.button else { return } if let schemaLabel = schemaLabel, !schemaLabel.isEmpty { diff --git a/sources/SquirrelInputController.swift b/sources/SquirrelInputController.swift index 889443e08..e713cb9e5 100644 --- a/sources/SquirrelInputController.swift +++ b/sources/SquirrelInputController.swift @@ -237,6 +237,18 @@ final class SquirrelInputController: IMKInputController { client = nil } + /// Commit the pending composition and hide the panel, mirroring + /// `deactivateServer`. macOS 26 does not call `deactivateServer` when the + /// input source is switched away by another process via + /// `TISSelectInputSource()` (#1140), so this is invoked from the + /// input-source-changed notification as a fallback. No-op when nothing is + /// being composed. + func finalizeStrandedComposition() { + guard session != 0, let input = rimeAPI.get_input(session), input.pointee != 0 else { return } + hidePalettes() + commitComposition(nil) + } + override func hidePalettes() { NSApp.squirrelAppDelegate.panel?.hide() super.hidePalettes() From 404bacd52bcee1104753bbe441991fd4a694f9b4 Mon Sep 17 00:00:00 2001 From: LEO Yoon Tsaw Date: Fri, 12 Jun 2026 10:32:51 -0400 Subject: [PATCH 2/3] Use deactivateServer instead of creating a new function --- sources/SquirrelApplicationDelegate.swift | 6 +++-- sources/SquirrelInputController.swift | 33 +++++++---------------- 2 files changed, 13 insertions(+), 26 deletions(-) diff --git a/sources/SquirrelApplicationDelegate.swift b/sources/SquirrelApplicationDelegate.swift index 5b864b1f0..d710d070e 100644 --- a/sources/SquirrelApplicationDelegate.swift +++ b/sources/SquirrelApplicationDelegate.swift @@ -372,7 +372,9 @@ private extension SquirrelApplicationDelegate { func finalizeStrandedComposition() { let id = SquirrelInstaller.currentInputSourceID() ?? "" guard !id.hasPrefix("im.rime.inputmethod.Squirrel") else { return } - panel?.inputController?.finalizeStrandedComposition() + if let inputController = panel?.inputController { + inputController.deactivateServer(inputController.client()) + } } func applyStatusIcon(asciiMode: Bool, schemaLabel: String?) { @@ -407,7 +409,7 @@ private extension SquirrelApplicationDelegate { func rimeToggleASCIIMode(_ notification: Notification) { guard let mode = notification.object as? String else { return } let enableASCII = mode == "ascii" - + if enableASCII { NotificationCenter.default.post(name: .init("SquirrelSetASCIIModeNotification"), object: true) } else { diff --git a/sources/SquirrelInputController.swift b/sources/SquirrelInputController.swift index e713cb9e5..75b7ebb27 100644 --- a/sources/SquirrelInputController.swift +++ b/sources/SquirrelInputController.swift @@ -197,7 +197,7 @@ final class SquirrelInputController: IMKInputController { preedit = "" // Update menu bar icon if session != 0 { - let state = rimeAPI.get_option(session, "ascii_mode"); + let state = rimeAPI.get_option(session, "ascii_mode") let label = rimeAPI.get_state_label_abbreviated(session, "ascii_mode", state, true).asString NSApp.squirrelAppDelegate.updateStatusIcon(asciiMode: state, schemaLabel: label) } @@ -208,7 +208,7 @@ final class SquirrelInputController: IMKInputController { // print("[DEBUG] initWithServer: \(server ?? .init()) delegate: \(delegate ?? "nil") client:\(client ?? "nil")") super.init(server: server, delegate: delegate, client: client) createSession() - + // Listen for ASCII mode toggle notifications NotificationCenter.default.addObserver( forName: .init("SquirrelSetASCIIModeNotification"), @@ -217,7 +217,7 @@ final class SquirrelInputController: IMKInputController { ) { [weak self] notification in self?.handleASCIIModeToggle(notification) } - + // Listen for ASCII mode status requests NotificationCenter.default.addObserver( forName: .init("SquirrelReportASCIIModeNotification"), @@ -226,8 +226,6 @@ final class SquirrelInputController: IMKInputController { ) { [weak self] notification in self?.reportASCIIMode(notification) } - - } override func deactivateServer(_ sender: Any!) { @@ -237,18 +235,6 @@ final class SquirrelInputController: IMKInputController { client = nil } - /// Commit the pending composition and hide the panel, mirroring - /// `deactivateServer`. macOS 26 does not call `deactivateServer` when the - /// input source is switched away by another process via - /// `TISSelectInputSource()` (#1140), so this is invoked from the - /// input-source-changed notification as a fallback. No-op when nothing is - /// being composed. - func finalizeStrandedComposition() { - guard session != 0, let input = rimeAPI.get_input(session), input.pointee != 0 else { return } - hidePalettes() - commitComposition(nil) - } - override func hidePalettes() { NSApp.squirrelAppDelegate.panel?.hide() super.hidePalettes() @@ -649,27 +635,26 @@ private extension SquirrelInputController { private func handleASCIIModeToggle(_ notification: Notification) { guard let enableASCII = notification.object as? Bool else { return } guard session != 0 && rimeAPI.find_session(session) else { return } - + rimeAPI.set_option(session, "ascii_mode", enableASCII) - + // Force update the UI to reflect the mode change rimeUpdate() } - + private func reportASCIIMode(_: Notification) { // Only active input controller should respond guard let client = client else { return } guard session != 0 && rimeAPI.find_session(session) else { return } - + let isASCIIMode = rimeAPI.get_option(session, "ascii_mode") let status = isASCIIMode ? "ascii" : "nascii" - + // Directly respond with the status DistributedNotificationCenter.default().postNotificationName( - .init("SquirrelASCIIModeResponse"), + .init("SquirrelASCIIModeResponse"), object: status ) } - } From 66d080d4bc06f4fd35ac9df28f1478119384a6a1 Mon Sep 17 00:00:00 2001 From: LEO Yoon Tsaw Date: Fri, 12 Jun 2026 10:39:57 -0400 Subject: [PATCH 3/3] rename a variable --- sources/SquirrelApplicationDelegate.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/sources/SquirrelApplicationDelegate.swift b/sources/SquirrelApplicationDelegate.swift index d710d070e..27f5f02ae 100644 --- a/sources/SquirrelApplicationDelegate.swift +++ b/sources/SquirrelApplicationDelegate.swift @@ -358,8 +358,8 @@ private extension SquirrelApplicationDelegate { func updateStatusItemVisibility() { guard let statusItem = statusItem else { return } - let id = SquirrelInstaller.currentInputSourceID() ?? "" - statusItem.isVisible = id.hasPrefix("im.rime.inputmethod.Squirrel") + let currentInputSourceID = SquirrelInstaller.currentInputSourceID() ?? "" + statusItem.isVisible = currentInputSourceID.hasPrefix("im.rime.inputmethod.Squirrel") } // macOS 26 does not call deactivateServer when the input source is switched @@ -370,8 +370,8 @@ private extension SquirrelApplicationDelegate { // Switching via the menu bar calls deactivateServer first, making this a // no-op. func finalizeStrandedComposition() { - let id = SquirrelInstaller.currentInputSourceID() ?? "" - guard !id.hasPrefix("im.rime.inputmethod.Squirrel") else { return } + let currentInputSourceID = SquirrelInstaller.currentInputSourceID() ?? "" + guard !currentInputSourceID.hasPrefix("im.rime.inputmethod.Squirrel") else { return } if let inputController = panel?.inputController { inputController.deactivateServer(inputController.client()) }