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 <noreply@anthropic.com>
This commit is contained in:
David Heinemeier HanssonandClaude Opus 5.5 committed 2026-10-11 07:52:51 +09:00
1 parent 8b5ebc57bf
commit b0af02555e
3 files changed
+30 -76

No files matched your search

+4 -4
View File
@@ -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" "$@"
+1 -31
View File
@@ -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))
+25 -41
View File
@@ -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"