diff --git a/default/input-methods/typing.py b/default/input-methods/typing.py index caaa40bd..5c0b494f 100644 --- a/default/input-methods/typing.py +++ b/default/input-methods/typing.py @@ -10,16 +10,30 @@ import sys import configure as setup -def controller(method): - return json.loads(setup.run(setup.CONTROLLER[:1] + ["--json=short"] + setup.CONTROLLER[1:] + [method]))["data"][0] - - def keyboard_catalog(): + # Read the same choices as the ISO and first-boot form, then convert console + # keymaps through systemd's mapping, just as localectl does during install. + choices = setup.run(["bash", "-c", 'source "$1"; printf "%s\\n" "$OMARCHY_KEYBOARD_LAYOUTS"', + "bash", str(setup.ROOT / "install/provisioning/setup-form.sh")]) + mappings = {} + for line in Path("/usr/share/systemd/kbd-model-map").read_text().splitlines(): + fields = line.split() + if not fields or fields[0].startswith("#"): + continue + layout = fields[1].split(",")[0] + variant = fields[3].split(",")[0] + mappings.setdefault(fields[0], layout + (":" + variant if variant != "-" else "")) + # These console names have no entry in systemd's conversion table. + mappings.update({"azerty": "az", "bg-cp1251": "bg", "colemak": "us:colemak", "kyrgyz": "kg", + "de_CH-latin1": "ch", "no-latin1": "no"}) catalog = {} - for layout, label, languages, variants in controller("AvailableKeyboardLayouts"): - catalog[layout] = label - for variant, description, languages in variants: - catalog[f"{layout}:{variant}"] = description + for choice in choices.splitlines(): + label, keymap, *input_settings = choice.split("|") + layout = input_settings[1] if len(input_settings) > 1 else mappings.get(keymap, keymap) + if label == "Lao": + layout = "la" + # Composition engines belong in Input Methods, not as duplicate US layouts. + catalog.setdefault(layout, label) return catalog @@ -39,11 +53,11 @@ def picker_rows(catalog, selected): return rows -def choose(title, catalog, selected, kind): +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)] + on_change = ["python", str(setup.ROOT / "default/input-methods/typing.py"), "apply", kind, json.dumps({"catalog": catalog, "overrides": overrides or {}})] args += ["--", "--multiple", "--width", "620", "--maxheight", "650", "--on-change", json.dumps(on_change)] for key in selected: args += ["--selected", rows[key][1:]] @@ -52,11 +66,6 @@ def choose(title, catalog, selected, kind): return None if result.returncode: raise RuntimeError(result.stderr.strip() or "The selection menu could not open") - values = json.loads(result.stdout) - valid = {row[1:]: key for key, row in rows.items()} - if not isinstance(values, list) or any(not isinstance(value, str) or value not in valid for value in values): - raise ValueError("The menu returned an invalid selection") - return list(dict.fromkeys(valid[value] for value in values)) def input_catalog(items): @@ -68,11 +77,11 @@ def input_catalog(items): return catalog, available -def input_items(items, selected): +def input_items(items, selected, overrides=None): keyboard = [item for item in items if item[0].startswith("keyboard-")] if not keyboard: raise RuntimeError("The current input group has no keyboard input") - existing = dict(items) + existing = {**(overrides or {}), **dict(items)} return keyboard + [[name, existing.get(name, "")] for name in selected] @@ -94,10 +103,10 @@ def configure_inputs(): group, layout, items = setup.live_group() catalog, available = input_catalog(items) current = [name for name, override in items if not name.startswith("keyboard-")] - choose("Input Methods", catalog, current, "input") + choose("Input Methods", catalog, current, "input", dict(items)) -def save_inputs(selected): +def save_inputs(selected, overrides=None): group, layout, items = setup.live_group() catalog, available = input_catalog(items) missing = [catalog.get(name, name) for name in selected if name not in available] @@ -107,7 +116,7 @@ def save_inputs(selected): for name in selected: if name in setup.PRESETS: setup.font_default(config_home, name) - set_inputs(group, layout, items, input_items(items, selected)) + set_inputs(group, layout, items, input_items(items, selected, overrides)) def keyboard_values(selected): @@ -171,16 +180,19 @@ def configure_keyboard(): choose("Keyboard Layouts", catalog, current, "keyboard") -def apply_selection(kind, catalog, values): +def apply_selection(kind, context, values): + catalog = context["catalog"] rows = picker_rows(catalog, []) valid = {row[1:]: key for key, row in rows.items()} if not isinstance(values, list) or any(not isinstance(value, str) or value not in valid for value in values): raise ValueError("The menu returned an invalid selection") selected = list(dict.fromkeys(valid[value] for value in values)) if kind == "input": - save_inputs(selected) + save_inputs(selected, context.get("overrides")) else: save_keyboard(selected) + selected = keyboard_selection() + print(json.dumps([rows[key][1:] for key in selected])) def main(): diff --git a/docs/input-methods.md b/docs/input-methods.md index 3c4dfa67..e6ee168e 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` reads the Fcitx keyboard catalog (including variants) and 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.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/manual/34-keyboard-mouse-trackpad.md b/manual/34-keyboard-mouse-trackpad.md index 1a86c49b..4ce37dd2 100644 --- a/manual/34-keyboard-mouse-trackpad.md +++ b/manual/34-keyboard-mouse-trackpad.md @@ -65,7 +65,7 @@ Typing starts in Latin mode. Press `Super + I` to cycle through configured input The keyboard indicator appears on the right of the bar when you have multiple keyboard layouts or input methods. Click it to switch input languages; Japanese displays あ for hiragana. 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. -Use _Setup > Typing > Keyboard Layouts_ to select layouts such as English and French, including keyboard variants. Use _Setup > Typing > Input Methods_ to select which composition engines are active. Both pickers open inside the Omarchy menu with your current selections checked. Click or press Enter to toggle a choice; each change applies immediately. Deselecting an input method removes it from the cycle without uninstalling it; the keyboard remains available. +Use _Setup > Typing > Keyboard Layouts_ to select layouts such as English and French, from the same choices offered during installation. Use _Setup > Typing > Input Methods_ to select which composition engines are active. Both pickers open inside the Omarchy menu with your current selections checked. Click or press Enter to toggle a choice; each change applies immediately. Deselecting an input method removes it from the cycle without uninstalling it; the keyboard remains available. Run `omarchy setup keyboard` or `omarchy setup input` to open the same pickers. To append one language from the command line, use `omarchy setup input mozc`, `hangul`, `pinyin`, or `chewing`. Your 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. diff --git a/shell/plugins/menu/Menu.qml b/shell/plugins/menu/Menu.qml index fdb5be8d..eb633a32 100644 --- a/shell/plugins/menu/Menu.qml +++ b/shell/plugins/menu/Menu.qml @@ -800,8 +800,9 @@ Item { var selected = MenuModel.toggleDmenuSelection(root.dmenuSelected, value) if (root.dmenuOnChange.length > 0) { if (selectionProc.running) return + selectionProc.collected = "" selectionProc.serial = root.requestSerial - selectionProc.selected = MenuModel.dmenuSelections(root.dmenuOptions, selected) + selectionProc.selected = MenuModel.dmenuSelections(root.dmenuOptions, selected, true) selectionProc.command = root.dmenuOnChange.concat([JSON.stringify(selectionProc.selected)]) selectionProc.running = true } else { @@ -912,7 +913,7 @@ Item { dmenuMultiple = mode === "select" && payload.multiple === true dmenuOnChange = dmenuMultiple && Array.isArray(payload.onChange) && payload.onChange.every(function(arg) { return typeof arg === "string" }) ? payload.onChange : [] - dmenuSelected = MenuModel.dmenuSelections(dmenuOptions, Array.isArray(payload.selected) ? payload.selected : []) + dmenuSelected = MenuModel.dmenuSelections(dmenuOptions, Array.isArray(payload.selected) ? payload.selected : [], dmenuOnChange.length > 0) selectionFile = String(payload.selectionFile || "") doneFile = String(payload.doneFile || "") requestActive = !!doneFile @@ -993,9 +994,18 @@ Item { id: selectionProc property int serial: 0 property var selected: [] + property string collected: "" + stdout: StdioCollector { onStreamFinished: selectionProc.collected = text } onExited: function(exitCode) { if (serial !== root.requestSerial || !root.dmenuActive) return - if (exitCode === 0) root.dmenuSelected = selected + if (exitCode === 0) { + var applied = selected + if (collected.trim()) { + try { applied = JSON.parse(collected) } catch (error) { return } + if (!Array.isArray(applied)) return + } + root.dmenuSelected = MenuModel.dmenuSelections(root.dmenuOptions, applied, true) + } root.updateDmenuChecks() } } @@ -1282,6 +1292,16 @@ Item { spacing: root.rowSpacing boundsBehavior: Flickable.StopAtBounds + WheelHandler { + target: null + onWheel: function(event) { + var delta = event.pixelDelta.y || event.angleDelta.y / 120 * root.rowHeightForDetail("") * 3 + var bottom = resultList.originY + Math.max(0, resultList.contentHeight - resultList.height) + resultList.contentY = Math.max(resultList.originY, Math.min(bottom, resultList.contentY - delta)) + event.accepted = true + } + } + section.property: "section" section.criteria: ViewSection.FullString section.delegate: Item { diff --git a/shell/plugins/menu/MenuModel.js b/shell/plugins/menu/MenuModel.js index 1cc604b3..a3a0b5c3 100644 --- a/shell/plugins/menu/MenuModel.js +++ b/shell/plugins/menu/MenuModel.js @@ -509,9 +509,10 @@ function dmenuValue(option) { return parts.join("\t") } -function dmenuSelections(options, selected) { - return options.map(dmenuValue).filter(function(value, index, values) { - return selected.indexOf(value) !== -1 && values.indexOf(value) === index +function dmenuSelections(options, selected, preserveOrder) { + var available = options.map(dmenuValue) + return (preserveOrder ? selected : available).filter(function(value, index, values) { + return available.indexOf(value) !== -1 && selected.indexOf(value) !== -1 && values.indexOf(value) === index }) } diff --git a/test/shell.d/typing-setup-checks.py b/test/shell.d/typing-setup-checks.py index 47fe2a61..a2181dd2 100644 --- a/test/shell.d/typing-setup-checks.py +++ b/test/shell.d/typing-setup-checks.py @@ -23,9 +23,22 @@ class TypingSetupTest(unittest.TestCase): with self.assertRaises(ValueError): typing.keyboard_values([]) - def test_keyboard_catalog_includes_variants(self): - with patch.object(typing, "controller", return_value=[["us", "English (US)", ["en"], [["intl", "English (US, international)", ["en"]]]]]): - self.assertEqual(typing.keyboard_catalog(), {"us": "English (US)", "us:intl": "English (US, international)"}) + def test_keyboard_catalog_uses_installer_choices(self): + catalog = typing.keyboard_catalog() + self.assertEqual(catalog["us"], "English (US)") + self.assertEqual(catalog["gb"], "English (UK)") + self.assertEqual(catalog["us:colemak"], "English (US, Colemak)") + self.assertEqual(catalog["ch"], "German (Switzerland)") + self.assertEqual(catalog["la"], "Lao") + self.assertEqual(catalog["latam"], "Spanish (Latin American)") + self.assertNotIn("us:intl", catalog) + self.assertLess(len(catalog), 60) + + def test_readding_input_keeps_its_original_override(self): + self.assertEqual(typing.input_items([["keyboard-us", ""]], ["mozc"], {"mozc": "jp"}), + [["keyboard-us", ""], ["mozc", "jp"]]) + self.assertEqual(typing.input_items([["keyboard-us", ""], ["mozc", "us"]], ["mozc"], {"mozc": "jp"}), + [["keyboard-us", ""], ["mozc", "us"]]) def test_input_deselection_keeps_keyboard_and_selected_overrides(self): items = [["keyboard-us", ""], ["mozc", "jp"], ["hangul", ""], ["custom", "de"]] @@ -48,23 +61,27 @@ class TypingSetupTest(unittest.TestCase): def test_picker_preselects_current_entries_and_returns_empty_selection(self): result = subprocess.CompletedProcess([], 0, stdout="[]") with patch.object(typing.subprocess, "run", return_value=result) as process: - self.assertEqual(typing.choose("Inputs", {"mozc": "Japanese", "hangul": "Korean"}, ["hangul"], "input"), []) + self.assertIsNone(typing.choose("Inputs", {"mozc": "Japanese", "hangul": "Korean"}, ["hangul"], "input")) args = process.call_args.args[0] self.assertEqual(args[2], "\tKorean") self.assertIn("--multiple", args) self.assertEqual(args[-2:], ["--selected", "Korean"]) - def test_picker_rejects_unknown_returned_values(self): - with patch.object(typing.subprocess, "run", return_value=subprocess.CompletedProcess([], 0, stdout='["unknown"]')): - with self.assertRaises(ValueError): - typing.choose("Inputs", {"mozc": "Japanese"}, [], "input") + def test_apply_rejects_unknown_values(self): + with self.assertRaises(ValueError): + typing.apply_selection("input", {"catalog": {"mozc": "Japanese"}}, ["unknown"]) def test_immediate_selection_removes_an_input_and_accepts_keyboard_only(self): - with patch.object(typing, "save_inputs") as save: - typing.apply_selection("input", {"mozc": "Japanese", "hangul": "Korean"}, ["Japanese"]) - save.assert_called_once_with(["mozc"]) - typing.apply_selection("input", {"mozc": "Japanese"}, []) - self.assertEqual(save.call_args.args, ([],)) + with patch.object(typing, "save_inputs") as save, patch("builtins.print"): + typing.apply_selection("input", {"catalog": {"mozc": "Japanese", "hangul": "Korean"}}, ["Japanese"]) + save.assert_called_once_with(["mozc"], None) + typing.apply_selection("input", {"catalog": {"mozc": "Japanese"}}, []) + self.assertEqual(save.call_args.args, ([], None)) + + def test_keyboard_checks_reflect_normalized_selection(self): + with patch.object(typing, "save_keyboard"), patch.object(typing, "keyboard_selection", return_value=["us", "ru"]), patch("builtins.print") as output: + typing.apply_selection("keyboard", {"catalog": {"us": "English", "ru": "Russian"}}, ["Russian"]) + self.assertEqual(json.loads(output.call_args.args[0]), ["English", "Russian"]) def test_keyboard_override_failure_preserves_symlink_and_restores_settings(self): with tempfile.TemporaryDirectory() as temporary: diff --git a/test/shell.d/typing-setup-test.sh b/test/shell.d/typing-setup-test.sh index 509681b8..91034375 100644 --- a/test/shell.d/typing-setup-test.sh +++ b/test/shell.d/typing-setup-test.sh @@ -10,6 +10,7 @@ const menu = requireFromRoot('shell/plugins/menu/MenuModel.js') const options = ['French', '\tJapanese (Mozc)', 'x\tSame label\tfirst', 'x\tSame label\tsecond'] assertEqual(menu.dmenuValue(options[2]), 'Same label\tfirst', 'multi-select keeps a stable subtext value without the icon') assertDeepEqual(menu.dmenuSelections(options, ['Same label\tsecond', 'French', 'stale']), ['French', 'Same label\tsecond'], 'multi-select preserves option order and excludes stale choices') +assertDeepEqual(menu.dmenuSelections(options, ['Same label\tsecond', 'French', 'stale'], true), ['Same label\tsecond', 'French'], 'immediate selections preserve switching order') assertDeepEqual(menu.toggleDmenuSelection(['French'], 'French'), [], 'deselecting the final option produces an empty selection') assertDeepEqual(menu.toggleDmenuSelection(['French'], 'Japanese (Mozc)'), ['French', 'Japanese (Mozc)'], 'selecting an input retains earlier choices') JS