Remember input selections before an app gains focus
This commit is contained in:
1 parent
cd73dc6cfd
commit
2841563e6c
5 files changed
+148
-40
No files matched your search
@@ -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__":
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in new issue
Block a user