Use installer keyboard choices and keep typing selections accurate

This commit is contained in:
David Heinemeier Hansson committed 2026-10-09 16:45:46 +02:00
1 parent 9421e961ea
commit e210b47e33
7 files changed
+94 -43

No files matched your search

+34 -22
View File
@@ -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():
+1 -1
View File
@@ -10,7 +10,7 @@ The ISO carries the extra fields in `omarchy_install.input_method` and `omarchy_
`omarchy setup input <engine>` 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.
+1 -1
View File
@@ -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.
+23 -3
View File
@@ -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 {
+4 -3
View File
@@ -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
})
}
+30 -13
View File
@@ -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:
+1
View File
@@ -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