Cycle keyboard layouts alongside composition engines
This commit is contained in:
1 parent
99f32458d6
commit
639cf3ee5e
6 files changed
+76
-19
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
@@ -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')
|
||||
|
||||
Reference in new issue
Block a user