Files
omarchy/default/input-methods/indicator.py
T

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))