Unify input method and keyboard layout switching

This commit is contained in:
David Heinemeier Hansson committed 2026-10-09 15:22:47 +02:00
1 parent e8b1a1f874
commit 73c0660fde
8 files changed
+99 -34

No files matched your search

+7 -3
View File
@@ -1,9 +1,13 @@
#!/bin/bash #!/bin/bash
# omarchy:summary=Configure or inspect the input method selected at installation # omarchy:summary=Configure, inspect, or switch input methods
# omarchy:args=<status|configure [--seed|--defaults] [none|mozc|hangul|pinyin|chewing]> # omarchy:args=<status|cycle|configure [--seed|--defaults] [none|mozc|hangul|pinyin|chewing]>
# omarchy:hidden=true # omarchy:hidden=true
set -euo pipefail 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
+2 -8
View File
@@ -34,14 +34,8 @@ local function has_input_method()
end end
-- Keep the desktop shortcut independent of Fcitx's custom switching keys. -- Keep the desktop shortcut independent of Fcitx's custom switching keys.
-- Checking the profile on demand lets live setup work without a reload. -- Read the live Fcitx group so setup works without a compositor reload.
o.bind("SUPER + I", "Switch input language", function() o.bind("SUPER + I", "Switch input language", "omarchy-input-method cycle")
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)
-- Preserve custom Ctrl + Space triggers. Without a -- Preserve custom Ctrl + Space triggers. Without a
-- [Hotkey/TriggerKeys] list in the config, fcitx5 uses its default, which has -- [Hotkey/TriggerKeys] list in the config, fcitx5 uses its default, which has
+45 -6
View File
@@ -1,7 +1,10 @@
"""Live Fcitx state for the existing keyboard layout widget.""" """Live Fcitx state for the existing keyboard layout widget."""
from collections import Counter
import json import json
import os import os
import re
import subprocess
import sys import sys
from gi.repository import Gio, GLib 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]} return {"methods": methods, "current": info[0], "name": info[1], "label": info[4], "language": info[5]}
def cycle(bus): def cycle(bus, state=None):
state = snapshot(bus) state = state if state is not None else snapshot(bus)
methods = state["methods"] methods = state["methods"]
if len(methods) > 1 and state["current"] in methods: if len(methods) > 1 and state["current"] in methods:
following = methods[(methods.index(state["current"]) + 1) % len(methods)] following = methods[(methods.index(state["current"]) + 1) % len(methods)]
call(bus, "SetCurrentIM", GLib.Variant("(s)", (following,))) 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: class Indicator:
def __init__(self, bus): def __init__(self, bus):
self.bus = bus self.bus = bus
@@ -130,10 +170,9 @@ def watch(bus):
if __name__ == "__main__": if __name__ == "__main__":
try: try:
bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
if sys.argv[1:] == ["cycle"]: if sys.argv[1:] == ["cycle"]:
cycle(bus) cycle_input()
else: else:
watch(bus) watch(Gio.bus_get_sync(Gio.BusType.SESSION, None))
except GLib.Error as error: except (GLib.Error, OSError, ValueError, subprocess.CalledProcessError) as error:
raise SystemExit(str(error)) raise SystemExit(str(error))
+1 -1
View File
@@ -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. `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. 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.
+2 -2
View File
@@ -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. 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. 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. 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 ### Use ALT as SUPER
@@ -91,14 +91,6 @@ BarWidget {
// //
// Keyboards given their own kb_layout hold a different list and are left out: // 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. // 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() { function cycleLayout() {
if (!root.bar || root.layoutCount < 2 || root.syncNames.length === 0) return if (!root.bar || root.layoutCount < 2 || root.syncNames.length === 0) return
const next = (root.layoutIndex + 1) % root.layoutCount const next = (root.layoutIndex + 1) % root.layoutCount
@@ -256,8 +248,6 @@ BarWidget {
InputMethodState.cycle() InputMethodState.cycle()
} else if (root.multipleLayouts) { } else if (root.multipleLayouts) {
root.cycleLayout() root.cycleLayout()
} else if (!root.multipleInputs && root.bar) {
root.bar.run("omarchy-launch-floating-terminal-with-presentation 'omarchy-setup-input'")
} }
} }
} }
+8 -4
View File
@@ -77,7 +77,11 @@ local bind = binds[os.getenv("TEST_KEYS")]
if bind.options.non_consuming then if bind.options.non_consuming then
print("non_consuming") print("non_consuming")
end end
bind.dispatcher() if type(bind.dispatcher) == "string" then
print("exec " .. bind.dispatcher)
else
bind.dispatcher()
end
LUA LUA
} }
@@ -127,7 +131,7 @@ done
TEST_KEYBOARD_ONLY=true TEST_FCITX5_CONFIG=$freed assert_fires "keyboard-only profiles skip the terminal controller call" \ TEST_KEYBOARD_ONLY=true TEST_FCITX5_CONFIG=$freed assert_fires "keyboard-only profiles skip the terminal controller call" \
"non_consuming" "$prefix" "foot" "terminal" "non_consuming" "$prefix" "foot" "terminal"
assert_fires "Super + I toggles configured input independently of custom Fcitx keys" \ assert_fires "Super + I cycles configured input independently of custom Fcitx keys" \
"exec fcitx5-remote --check -t" "SUPER + I" "chromium" "exec omarchy-input-method cycle" "SUPER + I" "chromium"
TEST_KEYBOARD_ONLY=true assert_fires "Super + I cycles keyboard layouts without an input engine" \ 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"
+34
View File
@@ -71,4 +71,38 @@ with patch.object(indicator, "snapshot", side_effect=lambda bus: dict(live)), pa
reader.refresh() reader.refresh()
assert reader.pending == "" assert reader.pending == ""
print("ok - desktop clicks update the label, cycle pending choices, and apply once a text context exists") 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 PY