From f28a65234720d4d88a94973be39d9cecdec647cb Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Fri, 9 Oct 2026 17:00:14 +0200 Subject: [PATCH] Avoid shadowing Python typing and retain pending mixed input selections --- bin/omarchy-setup-input | 2 +- bin/omarchy-setup-keyboard | 2 +- default/input-methods/indicator.py | 13 +++++---- .../{typing.py => typing_setup.py} | 2 +- docs/input-methods.md | 2 +- shell/plugins/bar/widgets/KeyboardLayout.qml | 4 +-- test/shell.d/input-indicator-test.sh | 28 ++++++++++++++++++- test/shell.d/typing-setup-checks.py | 2 +- 8 files changed, 41 insertions(+), 14 deletions(-) rename default/input-methods/{typing.py => typing_setup.py} (98%) diff --git a/bin/omarchy-setup-input b/bin/omarchy-setup-input index 43f2287d..c11f7484 100755 --- a/bin/omarchy-setup-input +++ b/bin/omarchy-setup-input @@ -14,7 +14,7 @@ fi method=${1:-} if [[ -z $method ]]; then - python "$OMARCHY_PATH/default/input-methods/typing.py" input + python "$OMARCHY_PATH/default/input-methods/typing_setup.py" input exit fi diff --git a/bin/omarchy-setup-keyboard b/bin/omarchy-setup-keyboard index 87183735..ff7cee75 100755 --- a/bin/omarchy-setup-keyboard +++ b/bin/omarchy-setup-keyboard @@ -4,4 +4,4 @@ set -euo pipefail -python "$OMARCHY_PATH/default/input-methods/typing.py" keyboard +python "$OMARCHY_PATH/default/input-methods/typing_setup.py" keyboard diff --git a/default/input-methods/indicator.py b/default/input-methods/indicator.py index 6e6cfdec..be6451ac 100644 --- a/default/input-methods/indicator.py +++ b/default/input-methods/indicator.py @@ -114,12 +114,15 @@ class Indicator: def select_next(self): state = snapshot(self.bus) methods = state["methods"] - if len(methods) < 2: + state["current"] = self.pending or state["current"] or self.last or next(iter(methods), "") + devices = subprocess.run(["hyprctl", "-j", "devices"], check=True, text=True, capture_output=True) + keyboards = json.loads(devices.stdout).get("keyboards", []) + following, index = cycle_choice(state, keyboards) + if following is None: return - current = self.pending or state["current"] or self.last or methods[0] - if current not in methods: - current = methods[0] - self.pending = methods[(methods.index(current) + 1) % len(methods)] + self.pending = following + for command in layout_switches(keyboards, index) if index is not None else []: + subprocess.run(command, check=True, capture_output=True) self.refresh() def refresh(self): diff --git a/default/input-methods/typing.py b/default/input-methods/typing_setup.py similarity index 98% rename from default/input-methods/typing.py rename to default/input-methods/typing_setup.py index 93c376cd..4b226a69 100644 --- a/default/input-methods/typing.py +++ b/default/input-methods/typing_setup.py @@ -57,7 +57,7 @@ def choose(title, catalog, selected, kind, overrides=None): catalog = {**catalog, **{key: catalog.get(key, key) for key in selected}} rows = picker_rows(catalog, selected) args = ["omarchy-menu-select", title] + list(rows.values()) - on_change = ["python", str(setup.ROOT / "default/input-methods/typing.py"), "apply", kind, json.dumps({"catalog": catalog, "overrides": overrides or {}})] + on_change = ["python", str(setup.ROOT / "default/input-methods/typing_setup.py"), "apply", kind, json.dumps({"catalog": catalog, "overrides": overrides or {}})] args += ["--", "--multiple", "--width", "620", "--maxheight", "650", "--on-change", json.dumps(on_change), "--change-key", "typing:" + kind] for key in selected: args += ["--selected", rows[key][1:]] diff --git a/docs/input-methods.md b/docs/input-methods.md index bd8f41a0..51b45525 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. -`Setup > Typing > Keyboard Layouts` and `Input Methods` use the native menu's multi-select mode, with current entries checked and changes applied immediately. `default/input-methods/typing.py` uses the installer’s keyboard choices, converted to desktop layouts through systemd’s keymap table, and reads the current input group. Input selection replaces only the non-keyboard group items and retains selected engines' layout overrides; deselection does not uninstall packages. The standard composition engines are already shipped in the base package set. Closing a picker keeps changes already applied. Failed updates leave its checked state unchanged. +`Setup > Typing > Keyboard Layouts` and `Input Methods` use the native menu's multi-select mode, with current entries checked and changes applied immediately. `default/input-methods/typing_setup.py` uses the installer’s keyboard choices, converted to desktop layouts through systemd’s keymap table, and reads the current input group. Input selection replaces only the non-keyboard group items and retains selected engines' layout overrides; deselection does not uninstall packages. The standard composition engines are already shipped in the base package set. Closing a picker keeps changes already applied. Failed updates leave its checked state unchanged. 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. diff --git a/shell/plugins/bar/widgets/KeyboardLayout.qml b/shell/plugins/bar/widgets/KeyboardLayout.qml index 8256a578..f6db826d 100644 --- a/shell/plugins/bar/widgets/KeyboardLayout.qml +++ b/shell/plugins/bar/widgets/KeyboardLayout.qml @@ -244,9 +244,7 @@ BarWidget { horizontalMargin: 6 tooltipText: KeyboardLayoutModel.inputTooltip(root.inputState, root.layoutFull, root.multipleLayouts) onPressed: function(button) { - if (root.multipleLayouts && button === Qt.LeftButton) { - root.bar.run("omarchy-input-method cycle") - } else if (root.multipleInputs && button === Qt.LeftButton) { + if ((root.multipleInputs || root.multipleLayouts) && button === Qt.LeftButton) { InputMethodState.cycle() } else if (root.multipleLayouts) { root.cycleLayout() diff --git a/test/shell.d/input-indicator-test.sh b/test/shell.d/input-indicator-test.sh index d7abb6ed..dfadff2f 100755 --- a/test/shell.d/input-indicator-test.sh +++ b/test/shell.d/input-indicator-test.sh @@ -10,6 +10,9 @@ from unittest.mock import patch import json import subprocess +# Script-directory imports must not shadow Python's standard typing module. +subprocess.run(["/usr/bin/python", "-c", "import sys; sys.path.insert(0, sys.argv[1]); import typing; from gi.repository import Gio; assert hasattr(typing, 'TYPE_CHECKING')", os.environ["OMARCHY_PATH"] + "/default/input-methods"], check=True) + spec = importlib.util.spec_from_file_location("indicator", os.environ["OMARCHY_PATH"] + "/default/input-methods/indicator.py") indicator = importlib.util.module_from_spec(spec) spec.loader.exec_module(indicator) @@ -55,7 +58,7 @@ def fake_call(bus, method, args=None): return () raise AssertionError(method) -with patch.object(indicator, "snapshot", side_effect=lambda bus: dict(live)), patch.object(indicator, "call", side_effect=fake_call): +with patch.object(indicator, "snapshot", side_effect=lambda bus: dict(live)), patch.object(indicator, "call", side_effect=fake_call), patch.object(indicator.subprocess, "run", return_value=subprocess.CompletedProcess([], 0, stdout='{"keyboards": []}')): reader = indicator.Indicator(None) assert reader.refresh()["current"] == "keyboard-us" reader.select_next() @@ -127,4 +130,27 @@ with patch.object(indicator.Gio, "bus_get_sync", return_value=None), patch.objec 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") +keyboards = [{"name": "physical", "layout": "us,dk", "active_layout_index": 0}] +live = {"methods": ["keyboard-us", "pinyin"], "current": "", "name": "", "label": "", "language": ""} +selected.clear() +def fake_process(command, **kwargs): + if command[:3] == ["hyprctl", "-j", "devices"]: + return subprocess.CompletedProcess(command, 0, stdout=json.dumps({"keyboards": keyboards})) + keyboards[0]["active_layout_index"] = int(command[-1]) + return subprocess.CompletedProcess(command, 0, stdout="") +with patch.object(indicator, "snapshot", side_effect=lambda bus: dict(live)), patch.object(indicator, "call", side_effect=fake_call), patch.object(indicator.subprocess, "run", side_effect=fake_process): + reader = indicator.Indicator(None) + reader.select_next() + assert keyboards[0]["active_layout_index"] == 1 + reader.select_next() + assert reader.pending == "pinyin" and selected == [] + assert reader.refresh()["current"] == "pinyin" + live["current"] = "keyboard-us" + reader.refresh() + assert selected == ["pinyin"] and reader.pending == "" + reader.select_next() + assert keyboards[0]["active_layout_index"] == 0 + assert selected[-1] == "keyboard-us" +print("ok - mixed-layout bar clicks queue an engine without focus and apply it when a text field gains focus") + PY diff --git a/test/shell.d/typing-setup-checks.py b/test/shell.d/typing-setup-checks.py index 58e78dbd..782c20bb 100644 --- a/test/shell.d/typing-setup-checks.py +++ b/test/shell.d/typing-setup-checks.py @@ -10,7 +10,7 @@ from unittest.mock import patch ROOT = Path(os.environ["OMARCHY_PATH"]) sys.path.insert(0, str(ROOT / "default/input-methods")) -spec = importlib.util.spec_from_file_location("typing_setup", ROOT / "default/input-methods/typing.py") +spec = importlib.util.spec_from_file_location("typing_setup", ROOT / "default/input-methods/typing_setup.py") typing = importlib.util.module_from_spec(spec) spec.loader.exec_module(typing)