Remember input selections before an app gains focus

This commit is contained in:
David Heinemeier Hansson committed 2026-10-07 17:17:02 +02:00
1 parent cd73dc6cfd
commit 2841563e6c
5 files changed
+148 -40

No files matched your search

+77 -4
View File
@@ -1,6 +1,7 @@
"""Live Fcitx state for the existing keyboard layout widget.""" """Live Fcitx state for the existing keyboard layout widget."""
import json import json
import os
import sys import sys
from gi.repository import Gio, GLib from gi.repository import Gio, GLib
@@ -32,27 +33,99 @@ def cycle(bus):
call(bus, "SetCurrentIM", GLib.Variant("(s)", (following,))) 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): def watch(bus):
previous = None previous = None
indicator = Indicator(bus)
loop = GLib.MainLoop()
buffer = ""
timer = 0
def refresh(): def refresh():
nonlocal previous nonlocal previous
try: try:
state = snapshot(bus) state = indicator.refresh()
except GLib.Error: except GLib.Error:
indicator.pending = ""
indicator.last = ""
state = {"methods": [], "current": "", "name": "", "language": ""} state = {"methods": [], "current": "", "name": "", "language": ""}
line = json.dumps(state, ensure_ascii=False) line = json.dumps(state, ensure_ascii=False)
if line != previous: if line != previous:
print(line, flush=True) print(line, flush=True)
previous = line 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 return GLib.SOURCE_CONTINUE
# Fcitx's controller has no current-method-changed signal. Keep one bus # Fcitx's controller has no current-method-changed signal. Keep one bus
# connection open instead of spawning a command on every poll. Polling also # connection open instead of spawning a command on every poll. Polling also
# follows per-application input contexts and recovers after service restarts. # follows per-application input contexts and recovers after service restarts.
refresh() GLib.io_add_watch(sys.stdin, GLib.IO_IN | GLib.IO_HUP, command)
GLib.timeout_add(500, refresh) poll()
GLib.MainLoop().run() loop.run()
if __name__ == "__main__": if __name__ == "__main__":
+36
View File
@@ -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
}
}
+1
View File
@@ -2,6 +2,7 @@ module qs.Commons
singleton Border 1.0 Border.qml singleton Border 1.0 Border.qml
singleton Color 1.0 Color.qml singleton Color 1.0 Color.qml
singleton IpcRegistry 1.0 IpcRegistry.qml singleton IpcRegistry 1.0 IpcRegistry.qml
singleton InputMethodState 1.0 InputMethodState.qml
singleton Style 1.0 Style.qml singleton Style 1.0 Style.qml
singleton Util 1.0 Util.qml singleton Util 1.0 Util.qml
ShellIpc 1.0 ShellIpc.qml ShellIpc 1.0 ShellIpc.qml
+2 -36
View File
@@ -36,7 +36,7 @@ BarWidget {
// xkb's own table rather than maintained by hand. // xkb's own table rather than maintained by hand.
property var layoutBriefs: ({}) property var layoutBriefs: ({})
readonly property string layoutLabel: KeyboardLayoutModel.shortLabel(layoutFull, 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 bool multipleInputs: (inputState.methods || []).length > 1
readonly property string inputLabel: KeyboardLayoutModel.inputLabel(inputState, layoutLabel) readonly property string inputLabel: KeyboardLayoutModel.inputLabel(inputState, layoutLabel)
@@ -231,39 +231,6 @@ BarWidget {
onTriggered: root.refresh() 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) visible: inputLabel !== "" && KeyboardLayoutModel.showIndicator(layoutLabel, multipleLayouts, multipleInputs)
implicitWidth: button.implicitWidth implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight implicitHeight: button.implicitHeight
@@ -278,8 +245,7 @@ BarWidget {
tooltipText: KeyboardLayoutModel.inputTooltip(root.inputState, root.layoutFull, root.multipleLayouts) tooltipText: KeyboardLayoutModel.inputTooltip(root.inputState, root.layoutFull, root.multipleLayouts)
onPressed: function(button) { onPressed: function(button) {
if (root.multipleInputs && button === Qt.LeftButton) { if (root.multipleInputs && button === Qt.LeftButton) {
if (inputCycle.running) inputCycle.pending++ InputMethodState.cycle()
else inputCycle.running = true
} else if (root.multipleLayouts) { } else if (root.multipleLayouts) {
root.cycleLayout() root.cycleLayout()
} else if (!root.multipleInputs && root.bar) { } else if (!root.multipleInputs && root.bar) {
+32
View File
@@ -39,4 +39,36 @@ check_cycle("keyboard-us", ["keyboard-us"], None)
check_cycle("removed", ["keyboard-us", "mozc"], None) check_cycle("removed", ["keyboard-us", "mozc"], None)
check_cycle("", [], None) check_cycle("", [], None)
print("ok - input cycling covers Latin, multiple engines, single modes, and stale contexts") 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 PY