From 639cf3ee5e3005ac4c374aaf4b5ff984f3f43af0 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 9 Oct 2026 16:57:31 +0200 Subject: [PATCH] Cycle keyboard layouts alongside composition engines --- default/input-methods/indicator.py | 57 ++++++++++++++----- docs/input-methods.md | 2 +- shell/plugins/bar/widgets/KeyboardLayout.qml | 4 +- .../bar/widgets/KeyboardLayoutModel.js | 3 +- test/shell.d/input-indicator-test.sh | 28 ++++++++- test/shell.d/keyboard-layout-test.sh | 1 + 6 files changed, 76 insertions(+), 19 deletions(-) diff --git a/default/input-methods/indicator.py b/default/input-methods/indicator.py index 84bf752c..6e6cfdec 100644 --- a/default/input-methods/indicator.py +++ b/default/input-methods/indicator.py @@ -36,41 +36,72 @@ def cycle(bus, state=None): call(bus, "SetCurrentIM", GLib.Variant("(s)", (following,))) -def layout_switches(keyboards): +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 [] + 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)) - layout = keyboard.get("layout", "") - count = len(layout.split(",")) - if count < 2: + return keyboard + + +def layout_switches(keyboards, index=None): + keyboard = layout_keyboard(keyboards) + if keyboard is None: return [] - following = (keyboard.get("active_layout_index", 0) + 1) % count + 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(): - # 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) + 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: - 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) + 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: diff --git a/docs/input-methods.md b/docs/input-methods.md index e6ee168e..bd8f41a0 100644 --- a/docs/input-methods.md +++ b/docs/input-methods.md @@ -14,7 +14,7 @@ The ISO carries the extra fields in `omarchy_install.input_method` and `omarchy_ Keyboard selections are persisted as data in `~/.config/omarchy/keyboard-layouts`; `default.hypr.keyboard.selected()` reads them before falling back to installation defaults. Personal Hyprland overrides still take precedence. A save reloads and validates the compositor, aligns the Fcitx group's primary keyboard with the leading layout/variant, and restores the previous configuration and group on failure. Non-Latin selections keep the installer's Latin-leading rule for desktop shortcuts. System console, disk-unlock, and greeter layouts are unchanged. -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. +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 all selected compositor layouts followed by the configured Fcitx input methods, 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/shell/plugins/bar/widgets/KeyboardLayout.qml b/shell/plugins/bar/widgets/KeyboardLayout.qml index f9fe0781..8256a578 100644 --- a/shell/plugins/bar/widgets/KeyboardLayout.qml +++ b/shell/plugins/bar/widgets/KeyboardLayout.qml @@ -244,7 +244,9 @@ BarWidget { horizontalMargin: 6 tooltipText: KeyboardLayoutModel.inputTooltip(root.inputState, root.layoutFull, root.multipleLayouts) onPressed: function(button) { - if (root.multipleInputs && button === Qt.LeftButton) { + if (root.multipleLayouts && button === Qt.LeftButton) { + root.bar.run("omarchy-input-method cycle") + } else if (root.multipleInputs && button === Qt.LeftButton) { InputMethodState.cycle() } else if (root.multipleLayouts) { root.cycleLayout() diff --git a/shell/plugins/bar/widgets/KeyboardLayoutModel.js b/shell/plugins/bar/widgets/KeyboardLayoutModel.js index daefaf04..3376ca89 100644 --- a/shell/plugins/bar/widgets/KeyboardLayoutModel.js +++ b/shell/plugins/bar/widgets/KeyboardLayoutModel.js @@ -120,7 +120,8 @@ function inputLabel(state, fallback) { function inputTooltip(state, layout, multipleLayouts) { if (!state || !state.methods || state.methods.length < 2) return layout + (multipleLayouts ? " · Super + I" : "") - return (state.name || layout).replace(/^Keyboard - /, "") + " · Super + I" + var name = state.current && state.current.indexOf("keyboard-") === 0 ? layout : (state.name || layout) + return name.replace(/^Keyboard - /, "") + " · Super + I" } function showIndicator(layoutLabel, multipleLayouts, multipleInputs) { diff --git a/test/shell.d/input-indicator-test.sh b/test/shell.d/input-indicator-test.sh index aee0e478..d7abb6ed 100755 --- a/test/shell.d/input-indicator-test.sh +++ b/test/shell.d/input-indicator-test.sh @@ -7,6 +7,8 @@ OMARCHY_PATH="$ROOT" /usr/bin/python - <<'PY' import importlib.util import os from unittest.mock import patch +import json +import subprocess spec = importlib.util.spec_from_file_location("indicator", os.environ["OMARCHY_PATH"] + "/default/input-methods/indicator.py") indicator = importlib.util.module_from_spec(spec) @@ -72,10 +74,10 @@ 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") as process: +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",) - process.assert_not_called() + assert process.call_count == 1 print("ok - the shortcut cycles Fcitx keyboard methods without requiring a bar widget") keyboards = [ @@ -96,7 +98,7 @@ print("ok - layout switching synchronizes matching devices, wraps, and preserves 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: +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) @@ -105,4 +107,24 @@ with patch.object(indicator.Gio, "bus_get_sync", side_effect=indicator.GLib.Erro 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 +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:]) +print("ok - layouts and composition engines share one cycle and returning to direct input resets the layout") + PY diff --git a/test/shell.d/keyboard-layout-test.sh b/test/shell.d/keyboard-layout-test.sh index 5b55a933..89cfe6be 100755 --- a/test/shell.d/keyboard-layout-test.sh +++ b/test/shell.d/keyboard-layout-test.sh @@ -17,6 +17,7 @@ assertEqual(model.inputLabel({ current: 'custom', language: 'vi' }, 'EN'), 'VI', assertEqual(model.inputTooltip(japanese, 'English (US)', false), 'Mozc · Super + I', 'input tooltip shows only its name and shortcut') assertEqual(model.inputTooltip(japanese, 'English (US)', true), 'Mozc · Super + I', 'multiple layouts keep the input tooltip compact') assertEqual(model.inputTooltip({ ...japanese, current: 'keyboard-us', name: 'Keyboard - English (US)' }, 'English (US)', false), 'English (US) · Super + I', 'Latin input omits the redundant keyboard prefix') +assertEqual(model.inputTooltip({ ...japanese, current: 'keyboard-us', name: 'Keyboard - English (US)' }, 'Danish', true), 'Danish · Super + I', 'direct input tooltip follows the active compositor layout') assertEqual(model.inputTooltip({ methods: ['keyboard-fr'] }, 'French', false), 'French', 'a single layout tooltip stays compact') assertEqual(model.showIndicator('EN', false, false), false, 'English alone stays hidden') assertEqual(model.showIndicator('ENG', false, false), false, 'English before the brief table loads stays hidden')