Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
220 lines
7.8 KiB
Python
220 lines
7.8 KiB
Python
"""Live Fcitx state for the existing keyboard layout widget."""
|
|
|
|
from collections import Counter
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
|
|
from gi.repository import Gio, GLib
|
|
|
|
|
|
BUS = "org.fcitx.Fcitx5"
|
|
INTERFACE = "org.fcitx.Fcitx.Controller1"
|
|
|
|
|
|
def call(bus, method, args=None):
|
|
# Reading the bar must neither activate a stopped service nor wait indefinitely.
|
|
return bus.call_sync(BUS, "/controller", INTERFACE, method, args, None,
|
|
Gio.DBusCallFlags.NO_AUTO_START, 1000, None).unpack()
|
|
|
|
|
|
def snapshot(bus):
|
|
group = call(bus, "CurrentInputMethodGroup")[0]
|
|
methods = list(dict.fromkeys(item[0] for item in
|
|
call(bus, "InputMethodGroupInfo", GLib.Variant("(s)", (group,)))[1]))
|
|
info = call(bus, "CurrentInputMethodInfo")
|
|
return {"methods": methods, "current": info[0], "name": info[1], "label": info[4], "language": info[5]}
|
|
|
|
|
|
def cycle(bus, state=None):
|
|
state = state if state is not None else snapshot(bus)
|
|
methods = state["methods"]
|
|
if len(methods) > 1 and state["current"] in methods:
|
|
following = methods[(methods.index(state["current"]) + 1) % len(methods)]
|
|
call(bus, "SetCurrentIM", GLib.Variant("(s)", (following,)))
|
|
|
|
|
|
def layout_keyboard(keyboards):
|
|
typed = [keyboard for keyboard in keyboards if not re.match(
|
|
r"^(hl-virtual-keyboard|power-button|sleep-button|lid-switch|video-bus)", keyboard.get("name", ""))]
|
|
typed = [item for item in typed if len(item.get("layout", "").split(",")) > 1]
|
|
if not typed:
|
|
return None
|
|
# Consumer controls also carry the seat layout. Its frequency keeps an
|
|
# individually configured keyboard from taking over the desktop shortcut.
|
|
counts = Counter(item.get("layout", "") for item in keyboards)
|
|
layout = max((item["layout"] for item in typed), key=lambda layout: counts[layout])
|
|
keyboard = max((item for item in typed if item["layout"] == layout),
|
|
key=lambda item: item.get("active_layout_index", 0))
|
|
return keyboard
|
|
|
|
|
|
def layout_switches(keyboards, index=None):
|
|
keyboard = layout_keyboard(keyboards)
|
|
if keyboard is None:
|
|
return []
|
|
layout = keyboard["layout"]
|
|
following = index if index is not None else (keyboard.get("active_layout_index", 0) + 1) % len(layout.split(","))
|
|
return [["hyprctl", "switchxkblayout", item["name"], str(following)]
|
|
for item in keyboards if item.get("layout") == layout and item.get("name")]
|
|
|
|
|
|
def cycle_choice(state, keyboards):
|
|
methods = state.get("methods", [])
|
|
keyboard = layout_keyboard(keyboards)
|
|
primary = next((method for method in methods if method.startswith("keyboard-")), None)
|
|
if keyboard is None or primary is None:
|
|
current = state.get("current")
|
|
if len(methods) > 1 and current in methods:
|
|
return methods[(methods.index(current) + 1) % len(methods)], None
|
|
return None, None
|
|
# One direct-input method represents all compositor layouts. Composition
|
|
# engines follow them in the user's configured order.
|
|
choices = []
|
|
for method in methods:
|
|
if method == primary:
|
|
choices.extend((method, index) for index in range(len(keyboard["layout"].split(","))))
|
|
else:
|
|
choices.append((method, None))
|
|
current = state.get("current") or primary
|
|
position = (current, keyboard.get("active_layout_index", 0) if current == primary else None)
|
|
if position not in choices:
|
|
return None, None
|
|
return choices[(choices.index(position) + 1) % len(choices)]
|
|
|
|
|
|
def cycle_input():
|
|
try:
|
|
bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
|
|
state = snapshot(bus)
|
|
except GLib.Error:
|
|
state = {}
|
|
devices = subprocess.run(["hyprctl", "-j", "devices"], check=True, text=True, capture_output=True)
|
|
keyboards = json.loads(devices.stdout).get("keyboards", [])
|
|
if not state.get("methods"):
|
|
switches = layout_switches(keyboards)
|
|
else:
|
|
method, index = cycle_choice(state, keyboards)
|
|
if method is not None and method != state.get("current"):
|
|
call(bus, "SetCurrentIM", GLib.Variant("(s)", (method,)))
|
|
switches = layout_switches(keyboards, index) if index is not None else []
|
|
for command in switches:
|
|
subprocess.run(command, check=True, capture_output=True)
|
|
|
|
|
|
class Indicator:
|
|
def __init__(self, bus):
|
|
self.bus = bus
|
|
self.pending = ""
|
|
self.last = ""
|
|
self.entries = {}
|
|
|
|
def select_next(self):
|
|
devices = subprocess.run(["hyprctl", "-j", "devices"], check=True, text=True, capture_output=True)
|
|
keyboards = json.loads(devices.stdout).get("keyboards", [])
|
|
try:
|
|
state = snapshot(self.bus)
|
|
except GLib.Error:
|
|
# Without Fcitx, Super+I still cycles the compositor layouts.
|
|
for command in layout_switches(keyboards):
|
|
subprocess.run(command, check=True, capture_output=True)
|
|
return
|
|
methods = state["methods"]
|
|
current = self.pending or state["current"] or self.last
|
|
state["current"] = current if current in methods else next(iter(methods), "")
|
|
following, index = cycle_choice(state, keyboards)
|
|
if following is None:
|
|
return
|
|
self.pending = following
|
|
for command in layout_switches(keyboards, index) if index is not None else []:
|
|
subprocess.run(command, check=True, capture_output=True)
|
|
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 = 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, OSError, ValueError, subprocess.CalledProcessError):
|
|
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.
|
|
GLib.io_add_watch(sys.stdin, GLib.IO_IN | GLib.IO_HUP, command)
|
|
poll()
|
|
loop.run()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
if sys.argv[1:] == ["cycle"]:
|
|
cycle_input()
|
|
else:
|
|
watch(Gio.bus_get_sync(Gio.BusType.SESSION, None))
|
|
except (GLib.Error, OSError, ValueError, subprocess.CalledProcessError) as error:
|
|
raise SystemExit(str(error))
|