From 73c0660fded2c54a5212e5a7180dccbabd457936 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 9 Oct 2026 15:22:47 +0200 Subject: [PATCH] Unify input method and keyboard layout switching --- bin/omarchy-input-method | 10 ++-- default/hypr/bindings/input-method.lua | 10 +--- default/input-methods/indicator.py | 51 +++++++++++++++++--- docs/input-methods.md | 2 +- manual/34-keyboard-mouse-trackpad.md | 4 +- shell/plugins/bar/widgets/KeyboardLayout.qml | 10 ---- test/shell.d/hyprland-jis-bindings-test.sh | 12 +++-- test/shell.d/input-indicator-test.sh | 34 +++++++++++++ 8 files changed, 99 insertions(+), 34 deletions(-) diff --git a/bin/omarchy-input-method b/bin/omarchy-input-method index a299756f..36004c2f 100755 --- a/bin/omarchy-input-method +++ b/bin/omarchy-input-method @@ -1,9 +1,13 @@ #!/bin/bash -# omarchy:summary=Configure or inspect the input method selected at installation -# omarchy:args= +# omarchy:summary=Configure, inspect, or switch input methods +# omarchy:args= # omarchy:hidden=true set -euo pipefail -python "$OMARCHY_PATH/default/input-methods/configure.py" "$@" +if [[ ${1:-} == "cycle" ]]; then + python "$OMARCHY_PATH/default/input-methods/indicator.py" cycle +else + python "$OMARCHY_PATH/default/input-methods/configure.py" "$@" +fi diff --git a/default/hypr/bindings/input-method.lua b/default/hypr/bindings/input-method.lua index 2518a779..12b513a2 100644 --- a/default/hypr/bindings/input-method.lua +++ b/default/hypr/bindings/input-method.lua @@ -34,14 +34,8 @@ local function has_input_method() end -- Keep the desktop shortcut independent of Fcitx's custom switching keys. --- Checking the profile on demand lets live setup work without a reload. -o.bind("SUPER + I", "Switch input language", function() - if has_input_method() then - hl.exec_cmd("fcitx5-remote --check -t") - else - hl.exec_cmd("omarchy-shell -q omarchy.keyboard-layout cycle") - end -end) +-- Read the live Fcitx group so setup works without a compositor reload. +o.bind("SUPER + I", "Switch input language", "omarchy-input-method cycle") -- 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 b5e6dd6c..84bf752c 100644 --- a/default/input-methods/indicator.py +++ b/default/input-methods/indicator.py @@ -1,7 +1,10 @@ """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 @@ -25,14 +28,51 @@ def snapshot(bus): return {"methods": methods, "current": info[0], "name": info[1], "label": info[4], "language": info[5]} -def cycle(bus): - state = snapshot(bus) +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_switches(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 [] + # 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)) + layout = keyboard.get("layout", "") + count = len(layout.split(",")) + if count < 2: + return [] + following = (keyboard.get("active_layout_index", 0) + 1) % count + return [["hyprctl", "switchxkblayout", item["name"], str(following)] + for item in keyboards if item.get("layout") == layout and item.get("name")] + + +def cycle_input(): + # Fcitx keyboard entries count as input methods too. With just one method, + # switch the compositor layouts even if the bar widget has been removed. + try: + bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) + state = snapshot(bus) + except GLib.Error: + state = {} + if len(state.get("methods", [])) > 1: + cycle(bus, state) + else: + devices = subprocess.run(["hyprctl", "-j", "devices"], check=True, text=True, capture_output=True) + for command in layout_switches(json.loads(devices.stdout).get("keyboards", [])): + subprocess.run(command, check=True, capture_output=True) + + class Indicator: def __init__(self, bus): self.bus = bus @@ -130,10 +170,9 @@ def watch(bus): if __name__ == "__main__": try: - bus = Gio.bus_get_sync(Gio.BusType.SESSION, None) if sys.argv[1:] == ["cycle"]: - cycle(bus) + cycle_input() else: - watch(bus) - except GLib.Error as error: + 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/docs/input-methods.md b/docs/input-methods.md index 1b056285..4bbeb1d7 100644 --- a/docs/input-methods.md +++ b/docs/input-methods.md @@ -10,7 +10,7 @@ The ISO carries the extra fields in `omarchy_install.input_method` and `omarchy_ `omarchy setup input` installs the selected engine, restarts the supervised Fcitx unit so it discovers new addons, and appends the engine through `InputMethodGroupInfo`, `SetInputMethodGroupInfo`, and `Save`. It keeps the group name, layout, existing methods, and their layout overrides. There is no parallel unsupervised daemon. A per-user D-Bus activation file delegates `org.fcitx.Fcitx5` to `omarchy-fcitx5.service`; it is loaded before live restart. -Shortcut defaults reserve Ctrl+Space for Tmux/Herdr, Super+Space for the menu, Super+Shift+Space for the bar, and Super+grave for the console. Hyprland owns Super+I to toggle input independently of Fcitx switching keys. With keyboard layouts alone, the same shortcut cycles the layouts through the bar widget, keeping matching devices in sync. The widget is enabled in the default right-hand bar layout and appears only when more than one input method or keyboard layout is configured. Fcitx handles Zenkaku/Hankaku and Hangul; Henkan activates and Muhenkan deactivates. An existing configured list, including an empty list, is preserved. Stock generated lists are repaired only while the user still has the single-keyboard default profile. A terminal Ctrl+Space returns to direct input before the following key, with a 300 ms timeout and no D-Bus activation; a keyboard-only profile skips this step. Wayland and XCB addons do not override the compositor's XKB settings. +Shortcut defaults reserve Ctrl+Space for Tmux/Herdr, Super+Space for the menu, Super+Shift+Space for the bar, and Super+grave for the console. Hyprland owns Super+I to cycle input independently of Fcitx switching keys. With keyboard layouts alone, the same shortcut cycles the compositor layouts, keeping matching devices in sync even without the bar widget. The widget is enabled in the default right-hand bar layout and appears only when more than one input method or keyboard layout is configured. Fcitx handles Zenkaku/Hankaku and Hangul; Henkan activates and Muhenkan deactivates. An existing configured list, including an empty list, is preserved. Stock generated lists are repaired only while the user still has the single-keyboard default profile. A terminal Ctrl+Space returns to direct input before the following key, with a 300 ms timeout and no D-Bus activation; a keyboard-only profile skips this step. Wayland and XCB addons do not override the compositor's XKB settings. The migration installs the standard engines and applies conservative defaults. It aligns an untouched Fcitx keyboard profile with the install layout, using the live controller when Fcitx is running. It leaves existing multilingual profiles and working trigger keys alone. Atomic file writes follow configuration symlinks, preserve file modes, and roll back on a failed seed or live setup so a retry can complete. Live rollback stops Fcitx before restoring disk, then reloads activation and restarts the unit. Migration bus errors defer profile alignment to the next login and do not restart input. If a pre-update daemon has not discovered a new engine, the migration aligns only its keyboard and prints the setup command. diff --git a/manual/34-keyboard-mouse-trackpad.md b/manual/34-keyboard-mouse-trackpad.md index a88f78f5..8a107386 100644 --- a/manual/34-keyboard-mouse-trackpad.md +++ b/manual/34-keyboard-mouse-trackpad.md @@ -61,13 +61,13 @@ 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 switch input on or off. 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. 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 in the menu bar appears for non-English keyboards and whenever you have multiple layouts or input modes. Click it to switch input languages; Japanese displays あ for hiragana. With only one input mode, clicking opens input language setup. 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. To add another language later, use _Setup > Input Language_ or run `omarchy setup input` (or `omarchy setup input mozc`, `hangul`, `pinyin`, or `chewing`). Your existing input methods and 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 toggle input. 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. 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/plugins/bar/widgets/KeyboardLayout.qml b/shell/plugins/bar/widgets/KeyboardLayout.qml index 0593632a..f9fe0781 100644 --- a/shell/plugins/bar/widgets/KeyboardLayout.qml +++ b/shell/plugins/bar/widgets/KeyboardLayout.qml @@ -91,14 +91,6 @@ BarWidget { // // Keyboards given their own kb_layout hold a different list and are left out: // an index into this list would not mean the same layout to them. - ShellIpc { - target: "omarchy.keyboard-layout" - - function cycle(): void { - root.cycleLayout() - } - } - function cycleLayout() { if (!root.bar || root.layoutCount < 2 || root.syncNames.length === 0) return const next = (root.layoutIndex + 1) % root.layoutCount @@ -256,8 +248,6 @@ BarWidget { InputMethodState.cycle() } else if (root.multipleLayouts) { root.cycleLayout() - } else if (!root.multipleInputs && root.bar) { - root.bar.run("omarchy-launch-floating-terminal-with-presentation 'omarchy-setup-input'") } } } diff --git a/test/shell.d/hyprland-jis-bindings-test.sh b/test/shell.d/hyprland-jis-bindings-test.sh index cd8c16ba..cef85f8f 100755 --- a/test/shell.d/hyprland-jis-bindings-test.sh +++ b/test/shell.d/hyprland-jis-bindings-test.sh @@ -77,7 +77,11 @@ local bind = binds[os.getenv("TEST_KEYS")] if bind.options.non_consuming then print("non_consuming") end -bind.dispatcher() +if type(bind.dispatcher) == "string" then + print("exec " .. bind.dispatcher) +else + bind.dispatcher() +end LUA } @@ -127,7 +131,7 @@ done TEST_KEYBOARD_ONLY=true TEST_FCITX5_CONFIG=$freed assert_fires "keyboard-only profiles skip the terminal controller call" \ "non_consuming" "$prefix" "foot" "terminal" -assert_fires "Super + I toggles configured input independently of custom Fcitx keys" \ - "exec fcitx5-remote --check -t" "SUPER + I" "chromium" +assert_fires "Super + I cycles configured input independently of custom Fcitx keys" \ + "exec omarchy-input-method cycle" "SUPER + I" "chromium" TEST_KEYBOARD_ONLY=true assert_fires "Super + I cycles keyboard layouts without an input engine" \ - "exec omarchy-shell -q omarchy.keyboard-layout cycle" "SUPER + I" "foot" "terminal" + "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 7c3804f1..aee0e478 100755 --- a/test/shell.d/input-indicator-test.sh +++ b/test/shell.d/input-indicator-test.sh @@ -71,4 +71,38 @@ with patch.object(indicator, "snapshot", side_effect=lambda bus: dict(live)), pa reader.refresh() 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") as process: + indicator.cycle_input() + assert call.call_args.args[2].unpack() == ("keyboard-fr",) + process.assert_not_called() +print("ok - the shortcut cycles Fcitx keyboard methods without requiring a bar widget") + +keyboards = [ + {"name": "power-button", "layout": "us,fr", "active_layout_index": 1}, + {"name": "physical keyboard", "layout": "us,fr", "active_layout_index": 0}, + {"name": "consumer-control", "layout": "us,fr", "active_layout_index": 0}, + {"name": "custom", "layout": "de", "active_layout_index": 0}, +] +expected = [["hyprctl", "switchxkblayout", item["name"], "1"] for item in keyboards[:3]] +assert indicator.layout_switches(keyboards) == expected +assert indicator.layout_switches([keyboards[-1]] + keyboards[:-1]) == expected +assert indicator.layout_switches([{ "name": "custom-first", "layout": "us,de,fr", "active_layout_index": 2 }] + keyboards) == expected +keyboards[1]["active_layout_index"] = 1 +assert all(command[-1] == "0" for command in indicator.layout_switches(keyboards)) +assert indicator.layout_switches([{ "name": "physical", "layout": "fr" }]) == [] +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"]}), 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) +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") PY