让 Fcitx5 的分组切换更像 Windows:KDE Plasma 常驻「中 / EN」指示器

笔记

最终效果与适用环境

这套方案的目标很简单:保留 Fcitx 原生托盘图标显示“当前输入法”,再额外用一个常驻的 中 / EN 指示器显示“当前 Fcitx 分组”。

  • 适用环境:Debian Sid、KDE Plasma 6.7.4、Wayland、Fcitx5 5.1.x;其他使用 KDE Plasma + Fcitx5 的发行版也可以按文中的 D-Bus 接口和依赖调整。
  • 切换方式:Super + Space(Win + 空格)切换 Fcitx 分组。
  • 显示效果:默认 分组显示 中,english only 分组显示 EN;Fcitx 自己的 拼 / 键盘 图标继续独立工作。
  • 更新方式:正常切换使用事件驱动,不依赖高频轮询。
  • 实测延迟:从发出分组切换到自定义 indicator 发出图标更新,约 20.8 ms。
  • 自启动:使用 systemd --user,随 graphical-session.target 启动,并在异常退出时自动重启。

如果你想要的是接近 Windows 语言栏的“当前输入环境一眼可见”,而不是只在切换瞬间弹出一条通知,这套实现就是为这个需求准备的。

我在 Linux 上使用 Fcitx5 的“分组”功能来模拟 Windows 的输入语言行为。我有两个分组:

  • 默认:包含拼音和英文键盘;
  • english only:只有英文键盘。

我把 Super + Space(也就是 Win + 空格)绑定为切换输入法分组。功能上已经很接近 Windows,但是视觉反馈一直不够好:Fcitx 自己的托盘图标只告诉我“当前输入法是什么”,不能告诉我“当前处于哪个分组”。

例如,下面两个状态在 Fcitx 原生托盘里很难区分:

默认分组 + keyboard-us
english only 分组 + keyboard-us

它们的当前输入法都是英文键盘,但语义完全不同。前者按 Shift 可以回到拼音,后者就是一个严格的纯英文环境。

我想要的是 Windows 右下角语言指示器那种感觉:无论当前组内是什么输入法,都有一个独立的、常驻的状态告诉我目前是中文环境还是 English-only 环境。

最终效果是:

Fcitx 原生图标       自定义分组图标
[拼 / 键盘]          [中 / EN]

两者各司其职:原生 Fcitx 图标显示“当前输入法”,额外的 indicator 显示“当前分组”。

为什么最后自己写了一个,而不是直接装现成插件

这次配置过程中我首先尝试寻找现成方案,但没有找到一个成熟、直接针对 Fcitx5 Current Input Method Group 做常驻显示的 KDE 组件。

几个看起来接近的方案实际上解决的是不同问题:

  1. Fcitx5 自己的 Status Notifier:很好用,但它的图标主要表示当前输入法,例如拼音或键盘,不是当前输入法分组。
  2. KDE 的 Keyboard Layout 指示器:它表示的是 XKB 键盘布局,不等于 Fcitx 的 Input Method Group。
  3. KIMPanel / KDE Input Method Panel:更偏向输入法候选、状态和 UI 集成,并不是一个 Windows 风格的常驻“当前分组”标签。
  4. 自己写 Plasma plasmoid:我实际试过,但 Plasma 6 对 metadata、QML API、包结构都比较严格。第一次实验甚至让 plasmashell 退出,顶部和底部面板以及桌面一起消失。虽然可以继续把 plasmoid 写正确,但为了一个输入法状态指示器,把代码直接加载进 Plasma Shell 的风险收益比并不好。

因此最后选择了一个更隔离的结构:独立 StatusNotifier / AppIndicator 进程。

它即使崩溃,也只是自己的 中 / EN 图标消失,不会拖垮 Plasma 面板。

Fcitx5 本身已经提供了我们需要的控制接口

Fcitx5 的 D-Bus Controller 可以直接查询和切换当前分组:

gdbus call --session \
  --dest org.fcitx.Fcitx5 \
  --object-path /controller \
  --method org.fcitx.Fcitx.Controller1.CurrentInputMethodGroup

我的机器会返回:

('默认',)

切到 English-only:

gdbus call --session \
  --dest org.fcitx.Fcitx5 \
  --object-path /controller \
  --method org.fcitx.Fcitx.Controller1.SwitchInputMethodGroup \
  'english only'

因此 indicator 本身不需要模拟快捷键,也不需要解析 Fcitx 配置文件。

第一版的问题:300 ms 轮询仍然有明显割裂感

最初版本每 300 ms 调一次 CurrentInputMethodGroup():

每 300 ms询问一次 Fcitx
        ↓
发现分组变化
        ↓
更新托盘图标

技术上已经能用,但是在按下 Win + 空格后,屏幕上的 中 / EN 有时会晚一点才变。300 ms 看起来很短,放在一个需要即时反馈的输入状态 UI 上却足以让人感觉“不是系统原生行为”。

所以后面继续研究了 Fcitx 自己的事件链。

事件驱动:借用 Fcitx 官方托盘的 NewIcon 信号

Fcitx 内部存在 InputMethodGroupChanged 事件,而且 Fcitx 官方 Status Notifier 本来就会监听这个事件。当分组变化时,官方托盘会立即执行自己的图标/菜单更新,并在 D-Bus 上发出:

org.kde.StatusNotifierItem.NewIcon

于是可以把这个官方事件链当成我们的触发器:

Win + Space
   ↓
Fcitx 内部 InputMethodGroupChanged
   ↓
Fcitx 官方 StatusNotifier 发 NewIcon
   ↓
自定义 indicator 收到信号
   ↓
读取 CurrentInputMethodGroup
   ↓
立即更新 中 / EN

实测从发出分组切换到自定义 indicator 发出自己的 NewIcon,约为 20.8 ms。这已经基本属于按下快捷键就同步改变的体感。

同时代码仍然保留一个 10 秒一次的低频兜底同步,只用于 Fcitx 异常重启、托盘重新注册或极端情况下漏掉一次事件。正常切换不依赖这个定时器。

2026-08-15 修复说明: 初版代码中,rediscover_fcitx_sni() 作为 GLib.idle_add() 的回调时错误返回了 True。GLib 会把 True 解释为“保留该 idle source 并继续调度”,从而形成高频 D-Bus 查询并造成 Python、dbus-daemon、Fcitx 与 KDED 的异常 CPU 占用。现已改为在该回调完成一次重新发现后返回 False;正常分组变化仍由 Fcitx StatusNotifier 的 NewIcon 事件驱动,10 秒定时器仅作为低频兜底。

环境与依赖

这套配置是在下面的环境完成的:

Debian Sid
KDE Plasma 6.7.4
Wayland
Fcitx5 5.1.x
Python 3

Debian 上需要的包:

sudo apt install \
  python3-dbus \
  python3-gi \
  gir1.2-gtk-3.0 \
  gir1.2-ayatanaappindicator3-0.1

当前实现使用 AyatanaAppIndicator3。运行时可能会看到 libayatana-appindicator 的 deprecation warning;它不影响当前功能。以后如果要做成真正长期维护的小项目,可以改写成 Qt/KF6 原生 StatusNotifierItem。

最终目录结构

~/.local/share/fcitx-group-indicator/
├── indicator.py
└── icons/
    ├── index.theme
    └── scalable/apps/
        ├── fcitx-group-default.svg
        └── fcitx-group-english.svg

~/.config/systemd/user/
└── fcitx-group-indicator.service

下面是这台机器当前实际运行的完整代码。

indicator.py 完整内容

#!/usr/bin/python3
import os
import dbus
import gi
from dbus.mainloop.glib import DBusGMainLoop

DBusGMainLoop(set_as_default=True)
gi.require_version('Gtk', '3.0')
gi.require_version('AyatanaAppIndicator3', '0.1')
from gi.repository import Gtk, GLib, AyatanaAppIndicator3 as AppIndicator

BASE = os.path.dirname(os.path.abspath(__file__))
ZH_ICON = os.path.join(BASE, 'icons/scalable/apps/fcitx-group-default.svg')
EN_ICON = os.path.join(BASE, 'icons/scalable/apps/fcitx-group-english.svg')
FCITX_IFACE = 'org.fcitx.Fcitx.Controller1'
SNI_IFACE = 'org.kde.StatusNotifierItem'
WATCHER_IFACE = 'org.kde.StatusNotifierWatcher'
WATCHER_NAME = 'org.kde.StatusNotifierWatcher'
WATCHER_PATH = '/StatusNotifierWatcher'

bus = dbus.SessionBus()
fcitx_obj = bus.get_object('org.fcitx.Fcitx5', '/controller')
fcitx = dbus.Interface(fcitx_obj, FCITX_IFACE)

indicator = AppIndicator.Indicator.new(
    'fcitx-group-indicator', 'input-keyboard',
    AppIndicator.IndicatorCategory.SYSTEM_SERVICES)
indicator.set_status(AppIndicator.IndicatorStatus.ACTIVE)
last_group = None
fcitx_sni_match = None

def current_group():
    try:
        return str(fcitx.CurrentInputMethodGroup())
    except Exception:
        return '?'

def refresh(*_args):
    global last_group
    group = current_group()
    if group == last_group:
        return True
    last_group = group
    if group == '默认':
        icon, label = ZH_ICON, '中'
    elif group == 'english only':
        icon, label = EN_ICON, 'EN'
    else:
        icon, label = 'input-keyboard', '?'
    indicator.set_icon_full(icon, 'Fcitx 输入法分组')
    indicator.set_label(label, 'EN')
    state_item.set_label('当前分组:' + group)
    return True

def on_fcitx_new_icon(*_args):
    # Fcitx's own StatusNotifier emits this immediately on
    # EventType::InputMethodGroupChanged.
    refresh()

def split_registered_item(item):
    text = str(item)
    pos = text.find('/')
    if pos <= 0:
        return None, None
    return text[:pos], text[pos:]

def rediscover_fcitx_sni(*_args):
    global fcitx_sni_match
    try:
        watcher_obj = bus.get_object(WATCHER_NAME, WATCHER_PATH)
        props = dbus.Interface(watcher_obj, 'org.freedesktop.DBus.Properties')
        items = props.Get(WATCHER_IFACE, 'RegisteredStatusNotifierItems')
    except Exception:
        return False
    for item in items:
        service, path = split_registered_item(item)
        if not service:
            continue
        try:
            obj = bus.get_object(service, path)
            p = dbus.Interface(obj, 'org.freedesktop.DBus.Properties')
            if str(p.Get(SNI_IFACE, 'Id')) != 'Fcitx':
                continue
        except Exception:
            continue
        if fcitx_sni_match is not None:
            fcitx_sni_match.remove()
        fcitx_sni_match = bus.add_signal_receiver(
            on_fcitx_new_icon, signal_name='NewIcon',
            dbus_interface=SNI_IFACE, bus_name=service, path=path)
        refresh()
        return False
    return False

def toggle(_item=None):
    group = current_group()
    target = 'english only' if group == '默认' else '默认'
    try:
        fcitx.SwitchInputMethodGroup(target)
    finally:
        refresh()

menu = Gtk.Menu()
state_item = Gtk.MenuItem(label='当前分组:?')
state_item.set_sensitive(False)
menu.append(state_item)
switch_item = Gtk.MenuItem(label='切换分组')
switch_item.connect('activate', toggle)
menu.append(switch_item)
menu.append(Gtk.SeparatorMenuItem())
quit_item = Gtk.MenuItem(label='退出指示器')
quit_item.connect('activate', lambda _x: Gtk.main_quit())
menu.append(quit_item)
menu.show_all()
indicator.set_menu(menu)

bus.add_signal_receiver(
    lambda *_: GLib.idle_add(rediscover_fcitx_sni),
    signal_name='StatusNotifierItemRegistered',
    dbus_interface=WATCHER_IFACE, bus_name=WATCHER_NAME,
    path=WATCHER_PATH)
bus.add_signal_receiver(
    lambda *_: GLib.idle_add(rediscover_fcitx_sni),
    signal_name='StatusNotifierItemUnregistered',
    dbus_interface=WATCHER_IFACE, bus_name=WATCHER_NAME,
    path=WATCHER_PATH)

refresh()
rediscover_fcitx_sni()
# Low-frequency safety sync only; normal changes are event-driven.
GLib.timeout_add_seconds(10, refresh)
Gtk.main()

这份代码里有几个值得注意的点:

  • CurrentInputMethodGroup() 用于读取当前 Fcitx 分组;
  • SwitchInputMethodGroup() 用于菜单里的手动切换;
  • indicator 会在 Plasma 的 org.kde.StatusNotifierWatcher 中寻找 Id == Fcitx 的官方托盘项目;
  • 监听这个项目的 org.kde.StatusNotifierItem.NewIcon,把它作为分组变化的即时触发器;
  • 如果 Fcitx 自己的 StatusNotifier 被重新注册,代码会自动重新发现并重新绑定;
  • 10 秒定时器只是兜底,不参与正常实时更新。

两个 SVG 图标

fcitx-group-default.svg

<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
  <rect x="4" y="4" width="56" height="56" rx="12" fill="#2563eb" stroke="#ffffff" stroke-width="2"/>
  <text x="32" y="45" text-anchor="middle" font-family="Noto Sans CJK SC, Noto Sans, sans-serif" font-size="38" font-weight="700" fill="#ffffff">中</text>
</svg>

fcitx-group-english.svg

<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
  <rect x="4" y="4" width="56" height="56" rx="12" fill="#374151" stroke="#ffffff" stroke-width="2"/>
  <text x="32" y="42" text-anchor="middle" font-family="Noto Sans, sans-serif" font-size="25" font-weight="700" fill="#ffffff">EN</text>
</svg>

icons/index.theme

[Icon Theme]
Name=Fcitx Group Indicator
Comment=Icons for Fcitx group indicator
Directories=scalable/apps

[scalable/apps]
Size=64
Context=Applications
Type=Scalable
MinSize=16
MaxSize=128

目前真正显示托盘图标时使用的是 SVG 的绝对路径,这样可以绕过 Plasma/Qt 图标主题缓存和图标名 fallback 带来的不确定性。

systemd 用户服务

我没有把它放进 KDE 的 ~/.config/autostart/*.desktop,而是使用 systemd user service。原因是这个 indicator 本质上是一个和图形会话同生命周期的小后台进程,systemd 更方便做自动启动、异常重启、日志和生命周期管理。

当前实际 unit 内容如下:

[Unit]
Description=Fcitx input method group indicator
PartOf=graphical-session.target
After=graphical-session.target

[Service]
Type=simple
ExecStart=/usr/bin/python3 /home/iruanp/.local/share/fcitx-group-indicator/indicator.py
Restart=on-failure
RestartSec=2
Environment=GTK_MODULES=

[Install]
WantedBy=graphical-session.target

启用并立即启动:

systemctl --user daemon-reload
systemctl --user enable --now fcitx-group-indicator.service

检查:

systemctl --user status fcitx-group-indicator.service
systemctl --user is-enabled fcitx-group-indicator.service
systemctl --user is-active fcitx-group-indicator.service

我的当前状态应该是:

enabled
active

因为 unit 使用:

WantedBy=graphical-session.target
PartOf=graphical-session.target

它只在图形桌面会话中运行。退出 Plasma 会话后也会一起停止,不需要 loginctl enable-linger。

上面的 ExecStart 是我这台机器的实际路径。如果其他用户复制,需要把 /home/iruanp 换成自己的 home。更通用的 unit 也可以使用 systemd 的 %h specifier,例如 ExecStart=/usr/bin/python3 %h/.local/share/fcitx-group-indicator/indicator.py。

从零安装

先创建目录:

mkdir -p ~/.local/share/fcitx-group-indicator/icons/scalable/apps
mkdir -p ~/.config/systemd/user

分别把上文代码保存到:

~/.local/share/fcitx-group-indicator/indicator.py
~/.local/share/fcitx-group-indicator/icons/index.theme
~/.local/share/fcitx-group-indicator/icons/scalable/apps/fcitx-group-default.svg
~/.local/share/fcitx-group-indicator/icons/scalable/apps/fcitx-group-english.svg
~/.config/systemd/user/fcitx-group-indicator.service

给 Python 文件执行权限不是必须的,因为 unit 明确使用 /usr/bin/python3 启动,不过也可以:

chmod +x ~/.local/share/fcitx-group-indicator/indicator.py

然后:

systemctl --user daemon-reload
systemctl --user enable --now fcitx-group-indicator.service

如果你的 Fcitx 分组名和我的不同

当前代码是按我的两个组名写的:

默认
english only

映射为:

默认         -> 中
english only -> EN

如果你的组名不同,修改 refresh() 中:

if group == '默认':
    icon, label = ZH_ICON, '中'
elif group == 'english only':
    icon, label = EN_ICON, 'EN'

以及 toggle() 中的目标组即可。

也可以继续把它改造成配置驱动,例如:

[groups]
"默认" = "中"
"english only" = "EN"

这样代码本身就完全不需要知道具体分组名了。

恢复 Fcitx“已切换分组”通知

我之前还遇到过另一个问题:曾经在 Fcitx 的“已切换到分组……”通知上点过“不再显示”,后来怎么开 ShowInputMethodInformation 都不回来。

原因是 Fcitx 会单独记录隐藏的通知类型:

[HiddenNotifications]
0=enumerate-group

文件是:

~/.config/fcitx5/conf/notifications.conf

把 enumerate-group 从 HiddenNotifications 中移除,再让 Fcitx 重新加载配置,就可以恢复切组通知。

这和本文的常驻 中 / EN 指示器是两个独立功能:瞬时通知适合确认“刚刚切换了”,常驻指示器适合随时确认“现在在哪个组”。

为什么不直接修改 Fcitx 官方托盘图标

理论上也可以 fork Fcitx 的 notificationitem 模块,让它直接按当前 group 改图标。但这会把一个很个人化的 UI 偏好变成 Fcitx 模块补丁:

  • Fcitx 升级后需要维护补丁;
  • 需要重新编译相关模块;
  • 官方托盘图标本来还承担“当前输入法”的信息,改成 group 反而损失原本的信息。

保留两个状态项反而更清晰:

Fcitx:当前输入法
Indicator:当前分组

而且 indicator 和 Fcitx 是松耦合的:只依赖公开 D-Bus Controller 和官方托盘的标准 StatusNotifier 信号。

目前还可以继续改进的地方

当前版本已经把最明显的体验问题——轮询延迟——解决了,但它还不是一个正式发布的软件包。

后续如果继续打磨,我会考虑:

  • 改成 Qt6 / KF6 原生 StatusNotifierItem,让菜单、字体、DPI 和 Plasma 更统一;
  • 自动读取所有 Fcitx Groups,而不是硬编码两个组;
  • 用 TOML/JSON 配置分组到文字、图标的映射;
  • 菜单直接显示所有 group,并使用 radio item 标记当前项;
  • 做成 Debian package 或独立 Git 仓库;
  • 如果 Fcitx 未来公开“Current Group Changed”的 D-Bus 信号,直接监听它,不再借用官方托盘 NewIcon 作为桥梁。

就目前而言,这个独立 indicator 已经达到了我真正需要的目标:Win + 空格切换 Fcitx 分组后,KDE 面板几乎立即用 中 / EN 告诉我当前输入环境,同时又不破坏 Fcitx 原本的拼音/英文状态显示。

这比单纯恢复“已切换到分组……”的瞬时提示更接近 Windows 的输入语言体验。

附录:给其他 AI 工具的机器可读复现清单

下面这一段是特意写给 AI/自动化工具的。读取这篇文章的 AI 可以直接解析这个 YAML,然后按照 files、dependencies、group_mapping 和 commands 重建相同设置。

展开机器可读配置
artifact: fcitx5-group-indicator
purpose: "在 KDE Plasma 系统托盘中常驻显示 Fcitx5 当前输入法分组"
created_with:
  product: ChatGPT
  model: GPT-5.6 Sol
  date: 2026-08-15
  method: "根据真实 Debian Sid + KDE Plasma + Fcitx5 远程调试过程创建"

platform:
  distribution: Debian Sid
  desktop: KDE Plasma 6.7.4
  session: Wayland
  input_method_framework: Fcitx5

fcitx:
  dbus_service: org.fcitx.Fcitx5
  controller_path: /controller
  controller_interface: org.fcitx.Fcitx.Controller1
  read_group_method: CurrentInputMethodGroup
  switch_group_method: SwitchInputMethodGroup
  hotkey: Super+Space

group_mapping:
  "默认":
    display: "中"
    icon: fcitx-group-default.svg
  "english only":
    display: "EN"
    icon: fcitx-group-english.svg

event_strategy:
  normal_update: event_driven
  event_source: "Fcitx official StatusNotifierItem NewIcon"
  reason: "Fcitx emits NewIcon after its internal InputMethodGroupChanged event"
  measured_end_to_end_latency_ms: 20.8
  fallback_sync_seconds: 10

dependencies:
  - python3-dbus
  - python3-gi
  - gir1.2-gtk-3.0
  - gir1.2-ayatanaappindicator3-0.1

files:
  indicator: "~/.local/share/fcitx-group-indicator/indicator.py"
  icon_theme: "~/.local/share/fcitx-group-indicator/icons/index.theme"
  default_icon: "~/.local/share/fcitx-group-indicator/icons/scalable/apps/fcitx-group-default.svg"
  english_icon: "~/.local/share/fcitx-group-indicator/icons/scalable/apps/fcitx-group-english.svg"
  systemd_unit: "~/.config/systemd/user/fcitx-group-indicator.service"

systemd:
  scope: user
  wanted_by: graphical-session.target
  part_of: graphical-session.target
  restart: on-failure
  restart_sec: 2
  enable_command: "systemctl --user enable --now fcitx-group-indicator.service"

verification:
  - "gdbus call --session --dest org.fcitx.Fcitx5 --object-path /controller --method org.fcitx.Fcitx.Controller1.CurrentInputMethodGroup"
  - "systemctl --user is-enabled fcitx-group-indicator.service"
  - "systemctl --user is-active fcitx-group-indicator.service"

ai_instructions:
  - "读取本文的完整 indicator.py、SVG 和 systemd unit,不要自行省略代码。"
  - "如果用户分组名不同,先查询 InputMethodGroups 和 CurrentInputMethodGroup,再修改 group_mapping。"
  - "优先保留事件驱动逻辑,不要退回高频轮询。"
  - "不要用 Plasma plasmoid 替换此实现,除非已经针对用户当前 Plasma 版本验证兼容性。"
  - "修改前备份已有文件;安装后使用 systemctl --user 和 Fcitx D-Bus 验证。"

如果要让另一个 AI 直接执行,可以把这篇 Markdown 文件整个提供给它,并给出类似下面的指令:

读取这篇文章。按照“机器可读复现清单”和文中的完整代码,在我的 KDE Plasma + Fcitx5 环境里复现同样的分组指示器。先查询我的实际 Fcitx 分组名;不要假设一定叫“默认”和“english only”。修改任何现有文件前先备份,并在最后验证 systemd user service 和 Fcitx D-Bus 状态。

关于本文的生成说明

本文由 OpenAI GPT-5.6 Sol 在 ChatGPT 中根据一次真实的 Debian Sid + KDE Plasma + Fcitx5 远程配置与调试过程整理并生成。文中的最终代码取自实际正在运行的配置,而不是示意代码。

AI 在这篇文章中的角色主要是:参与排查可用方案、实际编写并迭代 indicator、根据本机 D-Bus 与 StatusNotifier 行为做验证和延迟测试,以及把最终可复现配置整理成文档。文章作者仍为站点 front matter 中标注的 Floppy Liu。

文章保留机器可读复现清单,是为了让其他 AI 工具在读取完整 Markdown 后,可以先检查用户自己的 Fcitx 分组名和系统环境,再安全地复现或修改同款设置。