From b0af02555eacceaa9e9f3a9c06915134a15c4d8b Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 11 Oct 2026 07:52:51 +0900 Subject: [PATCH] Cycle input only through the shell's reader Without the shell there is no bar or menu either, so the one-shot cycling path only duplicated the reader's logic. Co-Authored-By: Claude Opus 5.5 --- bin/omarchy-input-method | 8 ++-- default/input-methods/indicator.py | 32 +------------- test/shell.d/input-indicator-test.sh | 66 +++++++++++----------------- 3 files changed, 30 insertions(+), 76 deletions(-) diff --git a/bin/omarchy-input-method b/bin/omarchy-input-method index 0e4f9d86..d6222799 100755 --- a/bin/omarchy-input-method +++ b/bin/omarchy-input-method @@ -12,10 +12,10 @@ if [[ ${1:-} == "cycle" ]]; then echo "Usage: omarchy-input-method cycle [back]" >&2 exit 1 fi - # The shell's reader retains a choice made while nothing has focus. Without - # the shell, switch directly. - if [[ $(omarchy-shell shell cycleInput "$direction" 2>/dev/null) != "ok" ]]; then - python "$OMARCHY_PATH/default/input-methods/indicator.py" cycle "$direction" + # The shell's reader retains a choice made while nothing has focus. + if [[ $(omarchy-shell shell cycleInput "$direction") != "ok" ]]; then + echo "The shell's input reader is not running" >&2 + exit 1 fi else python "$OMARCHY_PATH/default/input-methods/configure.py" "$@" diff --git a/default/input-methods/indicator.py b/default/input-methods/indicator.py index 513551c6..d26d10cd 100644 --- a/default/input-methods/indicator.py +++ b/default/input-methods/indicator.py @@ -28,14 +28,6 @@ def snapshot(bus): 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", ""))] @@ -85,25 +77,6 @@ def cycle_choice(state, keyboards, step=1): return choices[(choices.index(position) + step) % len(choices)] -def cycle_input(step=1): - 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, step=step) - else: - method, index = cycle_choice(state, keyboards, step) - 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 @@ -211,9 +184,6 @@ def watch(bus): if __name__ == "__main__": try: - if sys.argv[1:] in (["cycle"], ["cycle", "next"], ["cycle", "back"]): - cycle_input(-1 if sys.argv[2:] == ["back"] else 1) - else: - watch(Gio.bus_get_sync(Gio.BusType.SESSION, None)) + watch(Gio.bus_get_sync(Gio.BusType.SESSION, None)) except (GLib.Error, OSError, ValueError, subprocess.CalledProcessError) as error: raise SystemExit(str(error)) diff --git a/test/shell.d/input-indicator-test.sh b/test/shell.d/input-indicator-test.sh index 131e01d3..cb8efdfa 100755 --- a/test/shell.d/input-indicator-test.sh +++ b/test/shell.d/input-indicator-test.sh @@ -27,14 +27,7 @@ with patch.object(indicator, "call", side_effect=[ print("ok - live state deduplicates modes and reads language independently of the engine label") def check_cycle(current, methods, expected): - state = {"current": current, "methods": methods} - with patch.object(indicator, "snapshot", return_value=state), patch.object(indicator, "call") as call: - indicator.cycle(None) - if expected is None: - call.assert_not_called() - else: - assert call.call_args.args[1] == "SetCurrentIM" - assert call.call_args.args[2].unpack() == (expected,) + assert indicator.cycle_choice({"current": current, "methods": methods}, [])[0] == expected check_cycle("keyboard-us", ["keyboard-us", "mozc"], "mozc") check_cycle("mozc", ["keyboard-us", "mozc"], "keyboard-us") @@ -77,9 +70,9 @@ with patch.object(indicator, "snapshot", side_effect=lambda bus: dict(live)), pa assert reader.pending == "" print("ok - desktop clicks update the label, cycle pending choices, and apply once a text context exists") -with patch.object(indicator.Gio, "bus_get_sync", return_value=None), patch.object(indicator, "snapshot", return_value={"methods": ["keyboard-us", "keyboard-fr"], "current": "keyboard-us"}), patch.object(indicator, "call") as call, patch.object(indicator.subprocess, "run", return_value=subprocess.CompletedProcess([], 0, stdout='{"keyboards": []}')) as process: - indicator.cycle_input() - assert call.call_args.args[2].unpack() == ("keyboard-fr",) +with patch.object(indicator, "snapshot", return_value={"methods": ["keyboard-us", "keyboard-fr"], "current": "keyboard-us"}), patch.object(indicator, "call") as call, patch.object(indicator.subprocess, "run", return_value=subprocess.CompletedProcess([], 0, stdout='{"keyboards": []}')) as process: + indicator.Indicator(None).select_next() + assert call.call_args_list[0].args[2].unpack() == ("keyboard-fr",) assert process.call_count == 1 print("ok - the shortcut cycles Fcitx keyboard methods without requiring a bar widget") @@ -100,34 +93,29 @@ assert indicator.layout_switches([]) == [] print("ok - layout switching synchronizes matching devices, wraps, and preserves device-specific layouts") import json -import subprocess -with patch.object(indicator.Gio, "bus_get_sync", return_value=None), patch.object(indicator, "snapshot", return_value={"methods": ["keyboard-us"], "current": "keyboard-us"}), patch.object(indicator.subprocess, "run", return_value=subprocess.CompletedProcess([], 0, stdout=json.dumps({"keyboards": keyboards}))) as process: - indicator.cycle_input() - assert process.call_args_list[0].args[0] == ["hyprctl", "-j", "devices"] - assert [call.args[0] for call in process.call_args_list[1:]] == indicator.layout_switches(keyboards) +def reader_cycle(state, keyboards): + with patch.object(indicator, "snapshot", return_value=dict(state)), patch.object(indicator, "call") as call, patch.object(indicator.subprocess, "run", return_value=subprocess.CompletedProcess([], 0, stdout=json.dumps({"keyboards": keyboards}))) as process: + indicator.Indicator(None).select_next() + return call, [command.args[0] for command in process.call_args_list[1:]] + +call, commands = reader_cycle({"methods": ["keyboard-us"], "current": "keyboard-us"}, keyboards) +assert commands == indicator.layout_switches(keyboards) print("ok - keyboard-only input falls back to compositor layouts") -with patch.object(indicator.Gio, "bus_get_sync", side_effect=indicator.GLib.Error("no bus")), patch.object(indicator.subprocess, "run", return_value=subprocess.CompletedProcess([], 0, stdout=json.dumps({"keyboards": keyboards}))) as process: - indicator.cycle_input() - assert [call.args[0] for call in process.call_args_list[1:]] == indicator.layout_switches(keyboards) -print("ok - compositor layouts switch even when the Fcitx bus is unavailable") state = {"methods": ["keyboard-us", "mozc", "hangul"], "current": "keyboard-us"} keyboards[1]["active_layout_index"] = 0 assert indicator.cycle_choice(state, keyboards) == ("keyboard-us", 1) -with patch.object(indicator.Gio, "bus_get_sync", return_value=None), patch.object(indicator, "snapshot", return_value=state), patch.object(indicator, "call") as call, patch.object(indicator.subprocess, "run", return_value=subprocess.CompletedProcess([], 0, stdout=json.dumps({"keyboards": keyboards}))) as process: - indicator.cycle_input() - call.assert_not_called() - assert all(command.args[0][-1] == "1" for command in process.call_args_list[1:]) - assert len(process.call_args_list) > 1 +call, commands = reader_cycle(state, keyboards) +assert commands and all(command[-1] == "1" for command in commands) +assert all(item.args[2].unpack() == ("keyboard-us",) for item in call.call_args_list if item.args[1] == "SetCurrentIM") keyboards[1]["active_layout_index"] = 1 assert indicator.cycle_choice(state, keyboards) == ("mozc", None) state["current"] = "mozc" assert indicator.cycle_choice(state, keyboards) == ("hangul", None) state["current"] = "hangul" assert indicator.cycle_choice(state, keyboards) == ("keyboard-us", 0) -with patch.object(indicator.Gio, "bus_get_sync", return_value=None), patch.object(indicator, "snapshot", return_value=state), patch.object(indicator, "call") as call, patch.object(indicator.subprocess, "run", return_value=subprocess.CompletedProcess([], 0, stdout=json.dumps({"keyboards": keyboards}))) as process: - indicator.cycle_input() - assert call.call_args.args[2].unpack() == ("keyboard-us",) - assert all(command.args[0][-1] == "0" for command in process.call_args_list[1:]) +call, commands = reader_cycle(state, keyboards) +assert call.call_args_list[0].args[2].unpack() == ("keyboard-us",) +assert all(command[-1] == "0" for command in commands) print("ok - layouts and composition engines share one cycle and returning to direct input resets the layout") keyboards = [{"name": "physical", "layout": "us,dk", "active_layout_index": 0}] @@ -196,25 +184,21 @@ cat >"$stubs/omarchy-shell" <<'SH' echo "shell $*" >>"$CALLS" echo "$SHELL_REPLY" SH -cat >"$stubs/python" <<'SH' -#!/bin/bash -echo "python ${*##*/}" >>"$CALLS" -SH -chmod +x "$stubs/omarchy-shell" "$stubs/python" +chmod +x "$stubs/omarchy-shell" CALLS=$calls SHELL_REPLY=ok PATH="$stubs:$PATH" OMARCHY_PATH="$ROOT" "$ROOT/bin/omarchy-input-method" cycle [[ $(<"$calls") == "shell shell cycleInput next" ]] || fail "Super+I goes through the shell's reader" "$(<"$calls")" pass "Super+I goes through the shell's reader, which retains a choice made without focus" -: >"$calls" -CALLS=$calls SHELL_REPLY="" PATH="$stubs:$PATH" OMARCHY_PATH="$ROOT" "$ROOT/bin/omarchy-input-method" cycle -[[ $(<"$calls") == $'shell shell cycleInput next\npython indicator.py cycle next' ]] || fail "Super+I switches directly without the shell" "$(<"$calls")" -pass "Super+I switches directly when the shell does not answer" +if CALLS=$calls SHELL_REPLY="not running" PATH="$stubs:$PATH" OMARCHY_PATH="$ROOT" "$ROOT/bin/omarchy-input-method" cycle 2>/dev/null; then + fail "Super+I reports a missing input reader" +fi +pass "Super+I reports a missing input reader instead of silently doing nothing" : >"$calls" -CALLS=$calls SHELL_REPLY="" PATH="$stubs:$PATH" OMARCHY_PATH="$ROOT" "$ROOT/bin/omarchy-input-method" cycle back -[[ $(<"$calls") == $'shell shell cycleInput back\npython indicator.py cycle back' ]] || fail "Super+Shift+I cycles back" "$(<"$calls")" +CALLS=$calls SHELL_REPLY=ok PATH="$stubs:$PATH" OMARCHY_PATH="$ROOT" "$ROOT/bin/omarchy-input-method" cycle back +[[ $(<"$calls") == "shell shell cycleInput back" ]] || fail "Super+Shift+I cycles back" "$(<"$calls")" if CALLS=$calls PATH="$stubs:$PATH" OMARCHY_PATH="$ROOT" "$ROOT/bin/omarchy-input-method" cycle sideways 2>/dev/null; then fail "an unknown cycle direction is rejected" fi -pass "Super+Shift+I cycles back through the shell or directly, and other directions are rejected" +pass "Super+Shift+I cycles back through the shell, and other directions are rejected"