From 2841563e6ce8aac18cbd17dd7d6885ca2dd4b040 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 7 Oct 2026 17:17:02 +0200 Subject: [PATCH] Remember input selections before an app gains focus --- default/input-methods/indicator.py | 81 +++++++++++++++++++- shell/Commons/InputMethodState.qml | 36 +++++++++ shell/Commons/qmldir | 1 + shell/plugins/bar/widgets/KeyboardLayout.qml | 38 +-------- test/shell.d/input-indicator-test.sh | 32 ++++++++ 5 files changed, 148 insertions(+), 40 deletions(-) create mode 100644 shell/Commons/InputMethodState.qml diff --git a/default/input-methods/indicator.py b/default/input-methods/indicator.py index 95e022f4..b5e6dd6c 100644 --- a/default/input-methods/indicator.py +++ b/default/input-methods/indicator.py @@ -1,6 +1,7 @@ """Live Fcitx state for the existing keyboard layout widget.""" import json +import os import sys from gi.repository import Gio, GLib @@ -32,27 +33,99 @@ def cycle(bus): call(bus, "SetCurrentIM", GLib.Variant("(s)", (following,))) +class Indicator: + def __init__(self, bus): + self.bus = bus + self.pending = "" + self.last = "" + self.entries = {} + + def select_next(self): + state = snapshot(self.bus) + methods = state["methods"] + if len(methods) < 2: + return + current = self.pending or state["current"] or self.last or methods[0] + if current not in methods: + current = methods[0] + self.pending = methods[(methods.index(current) + 1) % len(methods)] + self.refresh() + + def refresh(self): + state = snapshot(self.bus) + methods = state["methods"] + if self.pending not in methods: + self.pending = "" + if self.pending and state["current"]: + # Before the first application gains focus Fcitx has no input context. + # Retain the click until it has one, without changing startup defaults. + call(self.bus, "SetCurrentIM", GLib.Variant("(s)", (self.pending,))) + state = snapshot(self.bus) + if state["current"] == self.pending: + self.pending = "" + if state["current"]: + self.last = state["current"] + else: + chosen = self.pending or (self.last if self.last in methods else "") or next(iter(methods), "") + if chosen not in self.entries: + self.entries = {entry[0]: entry for entry in call(self.bus, "AvailableInputMethods")[0]} + entry = self.entries.get(chosen) + if entry: + state.update(current=entry[0], name=entry[1], label=entry[4], language=entry[5]) + return state + + def watch(bus): previous = None + indicator = Indicator(bus) + loop = GLib.MainLoop() + buffer = "" + timer = 0 def refresh(): nonlocal previous try: - state = snapshot(bus) + state = indicator.refresh() except GLib.Error: + indicator.pending = "" + indicator.last = "" state = {"methods": [], "current": "", "name": "", "language": ""} line = json.dumps(state, ensure_ascii=False) if line != previous: print(line, flush=True) previous = line + + def poll(): + nonlocal timer + refresh() + timer = GLib.timeout_add(50 if indicator.pending else 500, poll) + return GLib.SOURCE_REMOVE + + def command(source, condition): + nonlocal buffer, timer + data = os.read(sys.stdin.fileno(), 4096) + if not data: + loop.quit() + return GLib.SOURCE_REMOVE + buffer += data.decode() + while "\n" in buffer: + line, buffer = buffer.split("\n", 1) + if line == "cycle": + try: + indicator.select_next() + except GLib.Error: + indicator.pending = "" + refresh() + GLib.source_remove(timer) + timer = GLib.timeout_add(50 if indicator.pending else 500, poll) return GLib.SOURCE_CONTINUE # Fcitx's controller has no current-method-changed signal. Keep one bus # connection open instead of spawning a command on every poll. Polling also # follows per-application input contexts and recovers after service restarts. - refresh() - GLib.timeout_add(500, refresh) - GLib.MainLoop().run() + GLib.io_add_watch(sys.stdin, GLib.IO_IN | GLib.IO_HUP, command) + poll() + loop.run() if __name__ == "__main__": diff --git a/shell/Commons/InputMethodState.qml b/shell/Commons/InputMethodState.qml new file mode 100644 index 00000000..bbcc757c --- /dev/null +++ b/shell/Commons/InputMethodState.qml @@ -0,0 +1,36 @@ +pragma Singleton +import QtQuick +import Quickshell +import Quickshell.Io + +Singleton { + id: root + property var state: ({}) + + function cycle() { + if (watcher.running) watcher.write("cycle\n") + } + + // One reader and pending selection for every monitor's keyboard widget. + Process { + id: watcher + command: ["/usr/bin/python", Quickshell.env("OMARCHY_PATH") + "/default/input-methods/indicator.py"] + running: true + stdinEnabled: true + stdout: SplitParser { + onRead: data => { + try { root.state = JSON.parse(data) } catch (e) {} + } + } + onExited: { + root.state = ({}) + restart.restart() + } + } + + Timer { + id: restart + interval: 5000 + onTriggered: watcher.running = true + } +} diff --git a/shell/Commons/qmldir b/shell/Commons/qmldir index 1c9101c1..f618d451 100644 --- a/shell/Commons/qmldir +++ b/shell/Commons/qmldir @@ -2,6 +2,7 @@ module qs.Commons singleton Border 1.0 Border.qml singleton Color 1.0 Color.qml singleton IpcRegistry 1.0 IpcRegistry.qml +singleton InputMethodState 1.0 InputMethodState.qml singleton Style 1.0 Style.qml singleton Util 1.0 Util.qml ShellIpc 1.0 ShellIpc.qml diff --git a/shell/plugins/bar/widgets/KeyboardLayout.qml b/shell/plugins/bar/widgets/KeyboardLayout.qml index 82d77c46..4cb8e5d7 100644 --- a/shell/plugins/bar/widgets/KeyboardLayout.qml +++ b/shell/plugins/bar/widgets/KeyboardLayout.qml @@ -36,7 +36,7 @@ BarWidget { // xkb's own table rather than maintained by hand. property var layoutBriefs: ({}) readonly property string layoutLabel: KeyboardLayoutModel.shortLabel(layoutFull, layoutBriefs) - property var inputState: ({}) + readonly property var inputState: InputMethodState.state readonly property bool multipleInputs: (inputState.methods || []).length > 1 readonly property string inputLabel: KeyboardLayoutModel.inputLabel(inputState, layoutLabel) @@ -231,39 +231,6 @@ BarWidget { onTriggered: root.refresh() } - Process { - id: inputWatcher - command: ["/usr/bin/python", Quickshell.env("OMARCHY_PATH") + "/default/input-methods/indicator.py"] - running: true - stdout: SplitParser { - onRead: data => { - try { root.inputState = JSON.parse(data) } catch (e) {} - } - } - onExited: { - root.inputState = ({}) - inputRestart.restart() - } - } - - Timer { - id: inputRestart - interval: 5000 - onTriggered: inputWatcher.running = true - } - - Process { - id: inputCycle - property int pending: 0 - command: ["/usr/bin/python", Quickshell.env("OMARCHY_PATH") + "/default/input-methods/indicator.py", "cycle"] - onExited: { - if (pending > 0) { - pending-- - running = true - } - } - } - visible: inputLabel !== "" && KeyboardLayoutModel.showIndicator(layoutLabel, multipleLayouts, multipleInputs) implicitWidth: button.implicitWidth implicitHeight: button.implicitHeight @@ -278,8 +245,7 @@ BarWidget { tooltipText: KeyboardLayoutModel.inputTooltip(root.inputState, root.layoutFull, root.multipleLayouts) onPressed: function(button) { if (root.multipleInputs && button === Qt.LeftButton) { - if (inputCycle.running) inputCycle.pending++ - else inputCycle.running = true + InputMethodState.cycle() } else if (root.multipleLayouts) { root.cycleLayout() } else if (!root.multipleInputs && root.bar) { diff --git a/test/shell.d/input-indicator-test.sh b/test/shell.d/input-indicator-test.sh index 91c8e175..7c3804f1 100755 --- a/test/shell.d/input-indicator-test.sh +++ b/test/shell.d/input-indicator-test.sh @@ -39,4 +39,36 @@ check_cycle("keyboard-us", ["keyboard-us"], None) check_cycle("removed", ["keyboard-us", "mozc"], None) check_cycle("", [], None) print("ok - input cycling covers Latin, multiple engines, single modes, and stale contexts") + +entries = [("keyboard-us", "English", "", "input-keyboard", "en", "en", True), + ("pinyin", "Pinyin", "", "fcitx-pinyin", "拼", "zh_CN", True)] +live = {"methods": ["keyboard-us", "pinyin"], "current": "", "name": "", "label": "", "language": ""} +selected = [] +def fake_call(bus, method, args=None): + if method == "AvailableInputMethods": + return (entries,) + if method == "SetCurrentIM": + selected.append(args.unpack()[0]) + live["current"] = selected[-1] + return () + raise AssertionError(method) + +with patch.object(indicator, "snapshot", side_effect=lambda bus: dict(live)), patch.object(indicator, "call", side_effect=fake_call): + reader = indicator.Indicator(None) + assert reader.refresh()["current"] == "keyboard-us" + reader.select_next() + assert reader.refresh()["current"] == "pinyin" + assert reader.pending == "pinyin" and selected == [] + reader.select_next() + assert reader.pending == "keyboard-us" + reader.select_next() + live["current"] = "keyboard-us" + assert reader.refresh()["current"] == "pinyin" + assert reader.pending == "" and selected == ["pinyin"] + live["current"] = "" + reader.select_next() + live["methods"] = ["pinyin"] + reader.refresh() + assert reader.pending == "" +print("ok - desktop clicks update the label, cycle pending choices, and apply once a text context exists") PY