Input method switching with fcitx5-remote
十一月 04, 2026
Tags:
#emacs
#meow
#elisp
Background
If you read other articles on this blog, you would notice that I frequently type in a considerable number of Chinese characters. And I do most of the typing in GNU Emacs. And I try to do more and more of it in Emacs.
A detour to Helix editor some years ago led me to use Meow, a modal editing mode in Emacs. Similar to Vi, Meow has a Normal Mode for issuing editing commands, and a Insert Mode for content manipulation that are too contingent, complex and unpredictable to be abstracted to a command or function — in other words, (prose) writing.
I use Pinyin, a compositional input method (IM), i.e. one uses several keystrokes to compose the code representing a Chinese character or phrase. Since the Chinese text is ideographic with many different characters sharing the same pronunciation, while the pinyin is a phonetic coding, the Pinyin IM user almost always needs to select the character they want from a candidate list. Therefore, almost all pinyin-based IM implementation make inputting a 2-phase process: when the IM is active, it intercepts your keystrokes to compose the character, and you need to select one from the candidates before the IM actually inserts that character or phrase into your editor app.
The issue
This creates a problem when I write prose in Chinese in Emacs. If the IM is active while I exit from Meow's Insert Mode to Normal Mode, usually to issue editor commands indicated by letters, I am often unpleasantly surprised as when I press a key, it is not received by Meow but the still active IM. Sure, I can just press Enter to make the IM bypass the composing phase and send the key directly to Emacs, or just remember to press Shift after pressing Esc. But they are still friction. And this can happen dozens of times a day. So it soon becomes annoying enough that I want to do something 'bout it.
The first take
Deactivating a composing IM when switching to Normal Mode is a problem at least as old as Gvim. So the basic idea is already quite well formulated: save the IM state (active/inactive) when one exits Insert Mode, and restore it when one exits Normal Mode. That said, one does not simply write a vimscript and call it a day. Neither Windows or Mac provides any straightforward way to let you set input method status from your editor. In its defence, that's a security feature, but like many others, 99.99% of the time it just ruins a user's day. So people write tools for the two major OSes so they can seamlessly switch between Normal and Insert modes. Luckily, I use fcitx5 in Linux. It provides fcitx5-remote to help us send messages to DBus. So my functions will use it to obtain IM state as well as set the active IM.
(defun my/get-im-state ()
"Obtain fcitx5 input method state with `fcitx5-remote -n' command."
(car (process-lines "fcitx5-remote" "-n")))
(defun my/set-im-state (im)
"Set input method with `fcitx-remote -s <IM>'.
Known input method names:
- keyboard-us
- shuangpin"
(unless (equal
(call-process "fcitx5-remote" nil nil nil "-s" im) 0)
(message "Error: IM switching failed.")))
Put the two functions to meow-insert-exit-hook and meow-normal-mode-hook respectively:
(defvar my-v/direct-im-state "keyboard-us"
"The input method allowing direct input of characters.")
(defvar my-v/cur-im-state my-v/direct-im-state
"Current IM state.")
(add-hook 'meow-normal-mode-hook
(lambda ()
(if (equal meow-normal-mode t) ;; see Note
(progn
(setq my-v/cur-im-state (my/get-im-state))
(my/set-im-state my-v/direct-im-state)))))
(add-hook 'meow-insert-enter-hook
(lambda ()
(my/set-im-state my-v/cur-im-state)))
Note: meow-normal-mode-hook seems to be called both when Emacs enters and exits Normal Mode. The if predicate makes sure my IM controlling code is only run when switching into Normal Mode, since the hook is run after the mode switching is done.
There is also meow-insert-exit-hook, but I was not sure if I need to set IM state when exiting from other modes, so I used the more general Normal Mode hook. This is problematic, as we will see.
The second, Eglot issue
Apart from its selection-based editing, another feature of Helix is out-of-box LSP support. Soon after I switch back to Emacs, I set up Eglot for Markdown buffers to have LSP goodies such as hinting linked page in the echo area, etc.
Now I set up my IM auto-switching, I enjoyed it vastly in text-mode. But a few days later when I open a Markdown buffer (and Eglot fires up automatically as I set it up to do), I find that whenever I write something in the buffer, in a few seconds my IM is switched back to keyboard-us.
It's not hard to guess and verify that Eglot causes this. As to how, I guess it's because Eglot switches away from the current buffer while editing, and Meow somehow switches to Normal Mode then back to Insert. This happens too quickly for the user to perceive but still causes the meow-normal-mode-hook run and switches my IM to English. When I get to sit down and investigate, I read Eglot docs first to see what part may be responsible for buffer switching.
In the Eglot documentation, it mentions that Eglot utilizes many of Emacs' built-in features such as xref and eldoc. It occurs to me that I can turn off these features one by one to narrow down which part is responsible for the IM switching.
I start with eldoc, as it involves showing "program docs" in the echo area, i.e. another buffer. Sure enough, after I disable it but not other Eglot features, my IM stays put.
But eldoc is quite a sophisticated package. How do I find out which function makes Meow to enter Normal Mode? There are a lot of entry points in the eldoc source, any of them may be the cause. I tried shadowing eldoc-print-current-symbol-info and the functions it calls to no avail.
To try one by one would be too time consuming. I searched the Web for how to print the callstack in Emacs without throwing error, after finding out that throwing error in meow-normal-mode-hook shut you out of find-file (who'd have thought?) in my current Emacs configuration. From the good old Emacs Stackexchange I learned that one can use debug-on-entry to get a callstack on virtually any function.
So, I use it on my/set-im-state and get a nice backtrace about what happened when my IM is deactivated:
Debugger entered--entering a function:
* my/set-im-state("keyboard-us")
(progn (setq my-v/cur-im-state (my/get-im-state)) (my/set-im-state my-v/direct-im-state))
(if (equal meow-normal-mode t) (progn (setq my-v/cur-im-state (my/get-im-state)) (my/set-im-state my-v/direct-im-state)))
#f(lambda () [t] (if (equal meow-normal-mode t) (progn (setq my-v/cur-im-state (my/get-im-state)) (my/set-im-state my-v/direct-im-state))))()
meow-normal-mode(1)
meow--switch-state(normal t)
meow--mode-guess-state()
meow--mode-get-state()
meow--enable()
meow-mode(1)
meow-global-mode-enable-in-buffer()
run-hooks(after-change-major-mode-hook)
run-mode-hooks(special-mode-hook)
special-mode()
eldoc--format-doc-buffer(nil)
eldoc-display-in-buffer(nil nil)
run-hook-with-args(eldoc-display-in-buffer nil nil)
It confirms that eldoc alone triggers the problem. But eldoc is not the direct cause. It switches to another buffer and set that buffer to special-mode, this mode-switching makes meow try to switch to a reasonable default mode, and that is Normal. The meow-normal-mode-hook only knows the buffer now successfully enter the Normal Mode but have no idea it comes from another mode (Motion Mode) in another buffer. So it sets IM state to when the eldoc buffer is created, i.e. inactive.
The second take
It is not practical to make eldoc not switching buffer. It needs to set that buffer to special-mode so Meow will always switch to Normal Mode. Changing that can break so many things that it is not worthy considering. To circumvent that problem, I reckon, I should store IM state in Insert Mode for each buffer, and write a restore function that look up which state it should set for the current buffer.
(defvar my-v/im-state-by-buffer '((compo nil) (direct nil))
"Alist storing input method state for each buffer.")
(defvar my-v/direct-im-state "keyboard-us"
"The input method allowing direct input of characters.")
(defvar my-v/composing-im-state "shuangpin"
"The input method requiring composing before input.")
(defvar my-v/cur-im-state my-v/direct-im-state
"Current IM state.")
(defun my/record-im-state ()
"Save the current input method state to `my-v/buffer-im-state'.
Each buffer has at most 1 entry in the list. New IM state overwrites the
old one."
(let ((bn (buffer-name (current-buffer)))
(im-state (car (process-lines "fcitx5-remote" "-n")))
(sl my-v/im-state-by-buffer))
(cond ((equal im-state my-v/direct-im-state)
(progn
(unless (member bn (cdr (assoc 'direct sl)))
(setf (alist-get 'direct sl)
(cons (buffer-name (current-buffer)) (cdr (assoc 'direct sl)))))
(if (member bn (cdr (assoc 'compo sl)))
(setf (alist-get 'compo sl)
(delete bn (cdr (assoc 'compo sl)))))))
((equal im-state my-v/composing-im-state)
(progn
(unless (member bn (cdr (assoc 'compo sl)))
(setf (alist-get 'compo sl)
(cons (buffer-name (current-buffer)) (cdr (assoc 'compo sl)))))
(if (member bn (cdr (assoc 'direct sl)))
(setf (alist-get 'direct sl)
(delete bn (cdr (assoc 'direct sl))))))))))
(defun my/restore-im-state ()
"Restore the IM state in current buffer."
(let ((bn (buffer-name (current-buffer)))
(dbs (cdr (assoc 'direct my-v/im-state-by-buffer)))
(cbs (cdr (assoc 'compo my-v/im-state-by-buffer))))
(cond ((member bn dbs)
(setq my-v/cur-im-state my-v/direct-im-state))
((member bn cbs)
(setq my-v/cur-im-state my-v/composing-im-state))
(t
(setq my-v/cur-im-state my-v/direct-im-state)))
(unless (equal
(call-process "fcitx5-remote" nil nil nil "-s" my-v/cur-im-state) 0)
(message "Error: IM switching failed."))))
The 'record' function is more complex than I first contemplated. During coding, I realized that the buffer list should contain unique elements, and the same buffer should not appear in both 'direct' and 'compo' lists. Thus, the 'record' function contains some relatively complicated logic to make sure the two lists are clean after each store operation.
my-v/composing-im-state is now explicitly defined, mainly to make the code less verbose.
Use the two functions in hooks and replace my/set-im-state and my/get-im-state:
(add-hook 'meow-normal-mode-hook
(lambda ()
(if (equal meow-normal-mode t)
(progn
(my/record-im-state)
(call-process "fcitx5-remote"
nil nil nil "-s" my-v/direct-im-state)))))
(add-hook 'meow-insert-enter-hook
'my/restore-im-state)
Suddenly I realized that in Meow, you cannot write with keyboard in a buffer in any mode but Insert Mode. So perhaps I can use meow-insert-exit-hook to replace meow-normal-mode-hook?
It works!
Now that my/record-im-state and my/set-im-state won't be called when switching to special-mode as eldoc does, can I just use my/set-im-state in meow-insert-exit-mode?
It works, again! Turns out I could fix the problem by just changing a line (which hook to put the function in).
So did I waste a evening?
In my current use case, the new my/record-im-state function still brings a few quality-of-life improvements. For example, if after I write my prose in Chinese, I need to use magit to commit the changes and write the commit message in English, in my previous setup I need to switch back to English in the commit message buffer, and need to switch to Chinese again if I want to resume writing my prose since my-v/cur-im-state is changed. Now each buffer remembers my respective IM states when I was writing.
Besides, the new IM state read and write functions and variables can be easily extended to accommodate more IMs. If you write not only in Chinese and English but also, say, in Tibetan or Georgian languages, my current setup can still make sure you don't have to switch IM when switching between tasks and buffers.