From d579157881b2765d74e7720777127a97bfe137ed Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Sun, 11 Oct 2026 07:39:50 +0900 Subject: [PATCH] Cycle input methods back with Super+Shift+I Co-Authored-By: Claude Opus 5.5 --- bin/omarchy-input-method | 11 +++++--- default/hypr/bindings/input-method.lua | 1 + default/input-methods/indicator.py | 30 +++++++++++----------- docs/omarchy-shell.md | 2 +- manual/34-keyboard-mouse-trackpad.md | 4 +-- shell/Commons/InputMethodState.qml | 4 +-- shell/shell.qml | 4 +-- test/shell.d/hyprland-jis-bindings-test.sh | 2 ++ test/shell.d/input-indicator-test.sh | 22 ++++++++++++++-- 9 files changed, 53 insertions(+), 27 deletions(-) diff --git a/bin/omarchy-input-method b/bin/omarchy-input-method index a3dcb653..0e4f9d86 100755 --- a/bin/omarchy-input-method +++ b/bin/omarchy-input-method @@ -1,16 +1,21 @@ #!/bin/bash # omarchy:summary=Configure, inspect, or switch input methods -# omarchy:args= +# omarchy:args= # omarchy:hidden=true set -euo pipefail if [[ ${1:-} == "cycle" ]]; then + direction=${2:-next} + if [[ $direction != "next" && $direction != "back" ]]; 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 2>/dev/null) != "ok" ]]; then - python "$OMARCHY_PATH/default/input-methods/indicator.py" cycle + if [[ $(omarchy-shell shell cycleInput "$direction" 2>/dev/null) != "ok" ]]; then + python "$OMARCHY_PATH/default/input-methods/indicator.py" cycle "$direction" fi else python "$OMARCHY_PATH/default/input-methods/configure.py" "$@" diff --git a/default/hypr/bindings/input-method.lua b/default/hypr/bindings/input-method.lua index 12b513a2..a78c2052 100644 --- a/default/hypr/bindings/input-method.lua +++ b/default/hypr/bindings/input-method.lua @@ -36,6 +36,7 @@ end -- Keep the desktop shortcut independent of Fcitx's custom switching keys. -- Read the live Fcitx group so setup works without a compositor reload. o.bind("SUPER + I", "Switch input language", "omarchy-input-method cycle") +o.bind("SUPER + SHIFT + I", "Switch input language back", "omarchy-input-method cycle back") -- Preserve custom Ctrl + Space triggers. Without a -- [Hotkey/TriggerKeys] list in the config, fcitx5 uses its default, which has diff --git a/default/input-methods/indicator.py b/default/input-methods/indicator.py index bb6703db..513551c6 100644 --- a/default/input-methods/indicator.py +++ b/default/input-methods/indicator.py @@ -51,24 +51,24 @@ def layout_keyboard(keyboards): return keyboard -def layout_switches(keyboards, index=None): +def layout_switches(keyboards, index=None, step=1): 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(",")) + following = index if index is not None else (keyboard.get("active_layout_index", 0) + step) % 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): +def cycle_choice(state, keyboards, step=1): 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 methods[(methods.index(current) + step) % len(methods)], None return None, None # One direct-input method represents all compositor layouts. Composition # engines follow them in the user's configured order. @@ -82,10 +82,10 @@ def cycle_choice(state, keyboards): 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)] + return choices[(choices.index(position) + step) % len(choices)] -def cycle_input(): +def cycle_input(step=1): try: bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) state = snapshot(bus) @@ -94,9 +94,9 @@ def cycle_input(): 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) + switches = layout_switches(keyboards, step=step) else: - method, index = cycle_choice(state, keyboards) + 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 [] @@ -111,20 +111,20 @@ class Indicator: self.last = "" self.entries = {} - def select_next(self): + def select_next(self, step=1): 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): + for command in layout_switches(keyboards, step=step): 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) + following, index = cycle_choice(state, keyboards, step) if following is None: return self.pending = following @@ -191,9 +191,9 @@ def watch(bus): buffer += data.decode() while "\n" in buffer: line, buffer = buffer.split("\n", 1) - if line == "cycle": + if line in ("cycle", "cycle back"): try: - indicator.select_next() + indicator.select_next(-1 if line == "cycle back" else 1) except (GLib.Error, OSError, ValueError, subprocess.CalledProcessError): indicator.pending = "" refresh() @@ -211,8 +211,8 @@ def watch(bus): if __name__ == "__main__": try: - if sys.argv[1:] == ["cycle"]: - cycle_input() + 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)) except (GLib.Error, OSError, ValueError, subprocess.CalledProcessError) as error: diff --git a/docs/omarchy-shell.md b/docs/omarchy-shell.md index b900a24a..8a220a8d 100644 --- a/docs/omarchy-shell.md +++ b/docs/omarchy-shell.md @@ -122,7 +122,7 @@ or `omarchy.power`. There is no `bar` target. | `reloadConfig` | reload shell.json | | `applyTheme ` | push theme colors + shell.toml | | `toggleBarTransparency` | flip the bar background between solid and transparent | -| `cycleInput` | switch to the next input method or layout (Super+I); `not running` without the input reader | +| `cycleInput ` | switch to the next or previous input method or layout (Super+I, Super+Shift+I); `not running` without the input reader | | `setPluginEnabled <"true"\|…>` | flip enabled bit (`ok` / `unknown`) | | `enablePlugin ` | enable and place in one mutation | | `putBarWidget ` | place a widget only where absent (`omarchy bar put`) | diff --git a/manual/34-keyboard-mouse-trackpad.md b/manual/34-keyboard-mouse-trackpad.md index 4ce37dd2..a6a03db8 100644 --- a/manual/34-keyboard-mouse-trackpad.md +++ b/manual/34-keyboard-mouse-trackpad.md @@ -61,7 +61,7 @@ On Dell XPS laptops with a haptic touchpad, you can also set the click strength Select Japanese, Korean, Chinese (Simplified, Pinyin), or Chinese (Traditional, Zhuyin) when the installer asks for your keyboard. Omarchy configures the keyboard and matching input engine automatically, including during offline first-boot setup. Japanese uses Mozc, Korean uses standard two-set Hangul, Simplified Chinese uses Pinyin, and Traditional Chinese uses Chewing's standard Zhuyin arrangement. Japanese (US keyboard) provides Mozc with a US keyboard layout. -Typing starts in Latin mode. Press `Super + I` to cycle through configured input methods. On Japanese keyboards, 全角/半角 toggles input, 変換 turns it on, and 無変換 turns it off. On a Korean keyboard layout, dedicated 한/영 and 한자 keys switch Hangul and convert to Hanja. `Ctrl + Space` stays available for Tmux and Herdr; CapsLock stays the compose key. +Typing starts in Latin mode. Press `Super + I` to cycle through configured input methods, and `Super + Shift + I` to cycle back. On Japanese keyboards, 全角/半角 toggles input, 変換 turns it on, and 無変換 turns it off. On a Korean keyboard layout, dedicated 한/영 and 한자 keys switch Hangul and convert to Hanja. `Ctrl + Space` stays available for Tmux and Herdr; CapsLock stays the compose key. The keyboard indicator appears on the right of the bar when you have multiple keyboard layouts or input methods. Click it to switch input languages; Japanese displays あ for hiragana. For Japanese character choices, switch to Japanese, type a word such as `nihongo`, then press Space twice. Press Enter to accept your choice. If you also have multiple keyboard layouts, right-click the indicator to cycle those. @@ -69,7 +69,7 @@ Use _Setup > Typing > Keyboard Layouts_ to select layouts such as English and Fr Run `omarchy setup keyboard` or `omarchy setup input` to open the same pickers. To append one language from the command line, use `omarchy setup input mozc`, `hangul`, `pinyin`, or `chewing`. Your custom switching keys are preserved. For alternative engine layouts or keys, install `fcitx5-configtool` with `omarchy pkg add fcitx5-configtool` and launch it from a terminal. Selecting an input language does not change the desktop language. -Use `Super + I` to cycle through input methods or keyboard layouts. The input indicator appears on the right of the bar only when more than one input method or layout is configured. This desktop binding works alongside your existing Fcitx switching keys; you can override it in `~/.config/hypr/bindings.lua`. Tapping Shift alone is not an input switch: Omarchy uses both Shift keys for CapsLock, which changes how input methods see Shift release. Input engines and the CapsLock compose sequences use the [Fcitx5](https://fcitx-im.org/) service already running in every session. +Use `Super + I` to cycle through input methods or keyboard layouts, and `Super + Shift + I` to go back the other way, so with three configured you can move between two neighbours. The input indicator appears on the right of the bar only when more than one input method or layout is configured. This desktop binding works alongside your existing Fcitx switching keys; you can override it in `~/.config/hypr/bindings.lua`. Tapping Shift alone is not an input switch: Omarchy uses both Shift keys for CapsLock, which changes how input methods see Shift release. Input engines and the CapsLock compose sequences use the [Fcitx5](https://fcitx-im.org/) service already running in every session. ### Use ALT as SUPER diff --git a/shell/Commons/InputMethodState.qml b/shell/Commons/InputMethodState.qml index 7535055c..cf6a3b21 100644 --- a/shell/Commons/InputMethodState.qml +++ b/shell/Commons/InputMethodState.qml @@ -7,9 +7,9 @@ Singleton { id: root property var state: ({}) - function cycle() { + function cycle(back) { if (!watcher.running) return false - watcher.write("cycle\n") + watcher.write(back ? "cycle back\n" : "cycle\n") return true } diff --git a/shell/shell.qml b/shell/shell.qml index d2c9ef1d..19ad7fcf 100644 --- a/shell/shell.qml +++ b/shell/shell.qml @@ -1781,8 +1781,8 @@ ShellRoot { // Super+I goes through the bar's reader, which keeps a choice made before // any text field has focus until one does. - function cycleInput(): string { - return Commons.InputMethodState.cycle() ? "ok" : "not running" + function cycleInput(direction: string): string { + return Commons.InputMethodState.cycle(direction === "back") ? "ok" : "not running" } function rescanPlugins(): void { diff --git a/test/shell.d/hyprland-jis-bindings-test.sh b/test/shell.d/hyprland-jis-bindings-test.sh index cef85f8f..ad3dde15 100755 --- a/test/shell.d/hyprland-jis-bindings-test.sh +++ b/test/shell.d/hyprland-jis-bindings-test.sh @@ -133,5 +133,7 @@ TEST_KEYBOARD_ONLY=true TEST_FCITX5_CONFIG=$freed assert_fires "keyboard-only pr assert_fires "Super + I cycles configured input independently of custom Fcitx keys" \ "exec omarchy-input-method cycle" "SUPER + I" "chromium" +assert_fires "Super + Shift + I cycles configured input back" \ + "exec omarchy-input-method cycle back" "SUPER + SHIFT + I" "chromium" TEST_KEYBOARD_ONLY=true assert_fires "Super + I cycles keyboard layouts without an input engine" \ "exec omarchy-input-method cycle" "SUPER + I" "foot" "terminal" diff --git a/test/shell.d/input-indicator-test.sh b/test/shell.d/input-indicator-test.sh index ac73df73..131e01d3 100755 --- a/test/shell.d/input-indicator-test.sh +++ b/test/shell.d/input-indicator-test.sh @@ -176,6 +176,16 @@ with patch.object(indicator, "snapshot", side_effect=indicator.GLib.Error("no fc assert keyboards[0]["active_layout_index"] == 1 print("ok - the shortcut reader cycles compositor layouts when Fcitx is unavailable") +keyboards = [{"name": "physical", "layout": "us,dk", "active_layout_index": 0}] +state = {"methods": ["keyboard-us", "mozc", "hangul"], "current": "keyboard-us"} +assert indicator.cycle_choice(state, keyboards) == ("keyboard-us", 1) +assert indicator.cycle_choice(state, keyboards, -1) == ("hangul", None) +state["current"] = "mozc" +assert indicator.cycle_choice(state, keyboards, -1) == ("keyboard-us", 1) +assert indicator.cycle_choice({"methods": ["keyboard-us", "mozc", "hangul"], "current": "keyboard-us"}, [], -1) == ("hangul", None) +assert indicator.layout_switches(keyboards, step=-1)[0][-1] == "1" +print("ok - cycling back walks the same choices in reverse") + PY stubs=$(mktemp -d) @@ -193,10 +203,18 @@ SH chmod +x "$stubs/omarchy-shell" "$stubs/python" CALLS=$calls SHELL_REPLY=ok PATH="$stubs:$PATH" OMARCHY_PATH="$ROOT" "$ROOT/bin/omarchy-input-method" cycle -[[ $(<"$calls") == "shell shell cycleInput" ]] || fail "Super+I goes through the shell's reader" "$(<"$calls")" +[[ $(<"$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\npython indicator.py cycle' ]] || fail "Super+I switches directly without the shell" "$(<"$calls")" +[[ $(<"$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" + +: >"$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")" +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"