Apply typing selections immediately from the menu
This commit is contained in:
1 parent
58d7429f53
commit
9421e961ea
9 files changed
+126
-39
No files matched your search
+14
-2
@@ -13,6 +13,8 @@
|
||||
# with same-named rows get the subtext back as the stable key.
|
||||
|
||||
# With --multiple, clicking/Enter toggles rows and Apply returns a JSON array.
|
||||
# --on-change takes a JSON command array; each toggle runs it with the selection
|
||||
# JSON appended, applies immediately, and needs no Apply row.
|
||||
# --selected takes a returned value (label plus any subtext), and can be repeated.
|
||||
|
||||
set -euo pipefail
|
||||
@@ -29,6 +31,7 @@ options=()
|
||||
menu_width=""
|
||||
menu_maxheight=""
|
||||
multiple=0
|
||||
on_change="[]"
|
||||
selected=()
|
||||
|
||||
while (( $# > 0 )); do
|
||||
@@ -36,6 +39,14 @@ while (( $# > 0 )); do
|
||||
shift
|
||||
while (( $# > 0 )); do
|
||||
case "$1" in
|
||||
--on-change)
|
||||
shift
|
||||
if (( $# == 0 )); then
|
||||
echo "omarchy-menu-select: --on-change requires a JSON command array" >&2
|
||||
exit 1
|
||||
fi
|
||||
on_change="$1"
|
||||
;;
|
||||
--multiple)
|
||||
multiple=1
|
||||
;;
|
||||
@@ -97,15 +108,16 @@ payload=$(perl -MEncode=decode -MJSON::PP=encode_json,decode_json -e '
|
||||
options => decode_json($ARGV[1]),
|
||||
multiple => $ARGV[6] ? JSON::PP::true : JSON::PP::false,
|
||||
selected => decode_json($ARGV[7]),
|
||||
onChange => decode_json($ARGV[8]),
|
||||
selectionFile => decode("UTF-8", $ARGV[2]),
|
||||
doneFile => decode("UTF-8", $ARGV[3])
|
||||
};
|
||||
$payload->{width} = int($ARGV[4]) if length($ARGV[4] // "");
|
||||
$payload->{maxHeight} = int($ARGV[5]) if length($ARGV[5] // "");
|
||||
print encode_json($payload)
|
||||
' "$prompt" "$options_json" "$selection_file" "$done_file" "$menu_width" "$menu_maxheight" "$multiple" "$selected_json")
|
||||
' "$prompt" "$options_json" "$selection_file" "$done_file" "$menu_width" "$menu_maxheight" "$multiple" "$selected_json" "$on_change")
|
||||
|
||||
omarchy-shell shell summon omarchy.menu "$payload" >/dev/null
|
||||
omarchy-shell shell summon omarchy.menu "$payload" >/dev/null || exit 2
|
||||
|
||||
while [[ ! -e $done_file ]]; do
|
||||
sleep 0.05
|
||||
|
||||
@@ -28,6 +28,17 @@ if non_latin_layouts:find(" " .. kb_layout:match("^[^,]*") .. " ", 1, true) then
|
||||
kb_options = kb_options .. ",grp:alts_toggle"
|
||||
end
|
||||
|
||||
-- Saved selections already include their Latin-leading layout. Keep the
|
||||
-- installer's layout-switching shortcut when a non-Latin layout remains.
|
||||
if not kb_options:find("grp:alts_toggle", 1, true) then
|
||||
for layout in kb_layout:gmatch("[^,]+") do
|
||||
if non_latin_layouts:find(" " .. layout .. " ", 1, true) then
|
||||
kb_options = kb_options .. ",grp:alts_toggle"
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
hl.config({
|
||||
input = {
|
||||
kb_layout = kb_layout,
|
||||
|
||||
@@ -30,14 +30,21 @@ def keyboard_selection():
|
||||
for index, layout in enumerate(layouts)]
|
||||
|
||||
|
||||
def choose(title, catalog, selected):
|
||||
def picker_rows(catalog, selected):
|
||||
# Current entries stay first, in switching order, followed by the catalog.
|
||||
keys = list(dict.fromkeys(selected + sorted(catalog, key=lambda key: catalog[key])))
|
||||
labels = {key: catalog.get(key, key) for key in keys}
|
||||
counts = Counter(labels.values())
|
||||
rows = {key: "\t" + labels[key] + (f" ({key})" if counts[labels[key]] > 1 else "") for key in keys}
|
||||
return rows
|
||||
|
||||
|
||||
def choose(title, catalog, selected, kind):
|
||||
catalog = {**catalog, **{key: catalog.get(key, key) for key in selected}}
|
||||
rows = picker_rows(catalog, selected)
|
||||
args = ["omarchy-menu-select", title] + list(rows.values())
|
||||
args += ["--", "--multiple", "--width", "620", "--maxheight", "650"]
|
||||
on_change = ["python", str(setup.ROOT / "default/input-methods/typing.py"), "apply", kind, json.dumps(catalog)]
|
||||
args += ["--", "--multiple", "--width", "620", "--maxheight", "650", "--on-change", json.dumps(on_change)]
|
||||
for key in selected:
|
||||
args += ["--selected", rows[key][1:]]
|
||||
result = subprocess.run(args, text=True, capture_output=True)
|
||||
@@ -87,21 +94,20 @@ 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-")]
|
||||
selected = choose("Input Methods", catalog, current)
|
||||
if selected is None:
|
||||
return False
|
||||
missing = [catalog[name] for name in selected if name not in available]
|
||||
choose("Input Methods", catalog, current, "input")
|
||||
|
||||
|
||||
def save_inputs(selected):
|
||||
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]
|
||||
if missing:
|
||||
raise RuntimeError("Restart input after updating Omarchy to use: " + ", ".join(missing))
|
||||
# Abort rather than overwrite a group changed while the picker was open.
|
||||
if setup.live_group() != (group, layout, items):
|
||||
raise RuntimeError("Input settings changed while the menu was open. Please try again.")
|
||||
config_home = Path(os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config"))
|
||||
for name in selected:
|
||||
if name in setup.PRESETS:
|
||||
setup.font_default(config_home, name)
|
||||
set_inputs(group, layout, items, input_items(items, selected))
|
||||
return True
|
||||
|
||||
|
||||
def keyboard_values(selected):
|
||||
@@ -110,7 +116,7 @@ def keyboard_values(selected):
|
||||
parts = [value.split(":", 1) for value in selected]
|
||||
# Preserve the installer's Latin-leading rule for desktop keybindings.
|
||||
if parts[0][0] in setup.NON_LATIN:
|
||||
parts.insert(0, ["us"])
|
||||
parts = [["us"]] + [part for part in parts if part != ["us"]]
|
||||
layouts = ",".join(part[0] for part in parts)
|
||||
variants = ",".join(part[1] if len(part) > 1 else "" for part in parts)
|
||||
return layouts, variants
|
||||
@@ -123,16 +129,15 @@ def save_keyboard(selected):
|
||||
original = setup.read(path) if path.exists() else None
|
||||
group, old_layout, before = setup.live_group()
|
||||
group_changed = False
|
||||
previous_errors = setup.run(["hyprctl", "configerrors"])
|
||||
try:
|
||||
setup.atomic_write(path, f"XKBLAYOUT={layouts}\nXKBVARIANT={variants}\n")
|
||||
setup.run(["hyprctl", "reload"])
|
||||
errors = setup.run(["hyprctl", "configerrors"])
|
||||
if errors:
|
||||
if errors and errors != previous_errors:
|
||||
raise RuntimeError(errors)
|
||||
expected = [part[0] + (":" + part[1] if len(part) > 1 and part[1] else "")
|
||||
for part in [value.split(":", 1) for value in selected]]
|
||||
if selected[0].split(":", 1)[0] in setup.NON_LATIN:
|
||||
expected.insert(0, "us")
|
||||
expected = [layout + (":" + variant if variant else "")
|
||||
for layout, variant in zip(layouts.split(","), variants.split(","))]
|
||||
if keyboard_selection() != expected:
|
||||
raise RuntimeError("Your personal keyboard layout override takes precedence over this selection")
|
||||
first_layout = layouts.split(",")[0]
|
||||
@@ -152,36 +157,48 @@ def save_keyboard(selected):
|
||||
path.resolve().unlink(missing_ok=True)
|
||||
else:
|
||||
setup.atomic_write(path, original)
|
||||
setup.run(["hyprctl", "reload"])
|
||||
if group_changed:
|
||||
setup.live_set(group, old_layout, before)
|
||||
try:
|
||||
setup.run(["hyprctl", "reload"])
|
||||
finally:
|
||||
if group_changed:
|
||||
setup.live_set(group, old_layout, before)
|
||||
raise
|
||||
|
||||
|
||||
def configure_keyboard():
|
||||
current = keyboard_selection()
|
||||
catalog = keyboard_catalog()
|
||||
selected = choose("Keyboard Layouts", catalog, current)
|
||||
if selected is None:
|
||||
return False
|
||||
if keyboard_selection() != current:
|
||||
raise RuntimeError("Keyboard settings changed while the menu was open. Please try again.")
|
||||
save_keyboard(selected)
|
||||
return True
|
||||
choose("Keyboard Layouts", catalog, current, "keyboard")
|
||||
|
||||
|
||||
def apply_selection(kind, catalog, values):
|
||||
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)
|
||||
else:
|
||||
save_keyboard(selected)
|
||||
|
||||
|
||||
def main():
|
||||
kind = sys.argv[1] if len(sys.argv) == 2 else ""
|
||||
applying = len(sys.argv) == 5 and sys.argv[1] == "apply"
|
||||
kind = sys.argv[2] if applying else sys.argv[1] if len(sys.argv) == 2 else ""
|
||||
if kind not in ("input", "keyboard"):
|
||||
raise ValueError("Choose keyboard or input setup")
|
||||
title = "Input methods" if kind == "input" else "Keyboard layouts"
|
||||
try:
|
||||
applied = configure_inputs() if kind == "input" else configure_keyboard()
|
||||
if applying:
|
||||
apply_selection(kind, json.loads(sys.argv[3]), json.loads(sys.argv[4]))
|
||||
elif kind == "input":
|
||||
configure_inputs()
|
||||
else:
|
||||
configure_keyboard()
|
||||
except (OSError, ValueError, RuntimeError, subprocess.CalledProcessError) as error:
|
||||
subprocess.run(["omarchy-notification-send", title + " could not be updated", str(error)], check=True)
|
||||
raise SystemExit(1)
|
||||
if applied:
|
||||
subprocess.run(["omarchy-notification-send", title + " updated", "Use Super + I to switch inputs"], check=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -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 an explicit Apply row. `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. Cancellation does not change configuration.
|
||||
`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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -168,3 +168,5 @@ how the pickers behind menu actions (`omarchy-menu-plugin`,
|
||||
|
||||
|
||||
Pass `-- --multiple` to `omarchy-menu-select` for a multi-select request. Repeat `--selected <value>` to preselect returned values (labels plus any subtext, without icons). Clicking or pressing Enter toggles an option without closing the menu; Apply returns a JSON array in option order, including `[]` when all choices are deselected. Cancellation still exits 1. Selections survive filtering.
|
||||
|
||||
With `--on-change <JSON command array>`, each toggle runs that command with the proposed selection JSON appended as its final argument, and no Apply row is shown. The checkbox changes only after the command succeeds; a failed command retains the previous selection. Changes persist when the menu closes. The typing selectors use this mode.
|
||||
@@ -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, then select Apply to save. 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, 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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ Item {
|
||||
property var dmenuOptions: []
|
||||
property bool dmenuMultiple: false
|
||||
property var dmenuSelected: []
|
||||
property var dmenuOnChange: []
|
||||
property string selectionFile: ""
|
||||
property string doneFile: ""
|
||||
property int dmenuWidth: 300
|
||||
@@ -565,7 +566,7 @@ Item {
|
||||
return
|
||||
}
|
||||
|
||||
if (root.dmenuMultiple) {
|
||||
if (root.dmenuMultiple && root.dmenuOnChange.length === 0) {
|
||||
displayModel.append({
|
||||
itemId: "dmenu.apply", disabled: false, kind: "dmenu", icon: "", iconFont: "",
|
||||
appIcon: "", appId: "", label: "Apply", target: "",
|
||||
@@ -616,6 +617,18 @@ Item {
|
||||
})
|
||||
}
|
||||
|
||||
function updateDmenuChecks() {
|
||||
for (var i = 0; i < displayModel.count; i++) {
|
||||
var row = displayModel.get(i)
|
||||
if (row.itemId === "dmenu.apply") {
|
||||
displayModel.setProperty(i, "detail", root.dmenuSelected.length + " selected")
|
||||
} else if (row.kind === "dmenu") {
|
||||
var value = MenuModel.dmenuValue(root.dmenuOptions[Number(row.itemId.substring(6))])
|
||||
displayModel.setProperty(i, "icon", root.dmenuSelected.indexOf(value) !== -1 ? "✓" : "○")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rebuildDisplay() {
|
||||
if (root.dmenuActive) {
|
||||
root.rebuildDmenuDisplay()
|
||||
@@ -784,8 +797,17 @@ Item {
|
||||
root.applyDmenuSelection(JSON.stringify(MenuModel.dmenuSelections(root.dmenuOptions, root.dmenuSelected)))
|
||||
} else {
|
||||
var value = MenuModel.dmenuValue(root.dmenuOptions[Number(picked.itemId.substring(6))])
|
||||
root.dmenuSelected = MenuModel.toggleDmenuSelection(root.dmenuSelected, value)
|
||||
root.rebuildDmenuDisplay()
|
||||
var selected = MenuModel.toggleDmenuSelection(root.dmenuSelected, value)
|
||||
if (root.dmenuOnChange.length > 0) {
|
||||
if (selectionProc.running) return
|
||||
selectionProc.serial = root.requestSerial
|
||||
selectionProc.selected = MenuModel.dmenuSelections(root.dmenuOptions, selected)
|
||||
selectionProc.command = root.dmenuOnChange.concat([JSON.stringify(selectionProc.selected)])
|
||||
selectionProc.running = true
|
||||
} else {
|
||||
root.dmenuSelected = selected
|
||||
root.updateDmenuChecks()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
root.applyDmenuSelection(picked.detail ? picked.label + "\t" + picked.detail : picked.label)
|
||||
@@ -888,6 +910,8 @@ Item {
|
||||
dmenuPrompt = String(payload.prompt || (mode === "input" ? "Input" : "Select"))
|
||||
dmenuOptions = Array.isArray(payload.options) ? payload.options : []
|
||||
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 : [])
|
||||
selectionFile = String(payload.selectionFile || "")
|
||||
doneFile = String(payload.doneFile || "")
|
||||
@@ -965,6 +989,17 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: selectionProc
|
||||
property int serial: 0
|
||||
property var selected: []
|
||||
onExited: function(exitCode) {
|
||||
if (serial !== root.requestSerial || !root.dmenuActive) return
|
||||
if (exitCode === 0) root.dmenuSelected = selected
|
||||
root.updateDmenuChecks()
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: resultProc
|
||||
onExited: {
|
||||
|
||||
@@ -153,3 +153,5 @@ TEST_INPUT_PREFERENCE=$'INPUT_METHOD=hangul\nXKB_LAYOUT=kr\n' assert_input "a la
|
||||
|
||||
TEST_KEYBOARD_SELECTION=$'XKBLAYOUT=us,fr\nXKBVARIANT=intl,\n' assert_input "menu selections replace installer desktop defaults" "[us,fr] [intl,] [$base_options]" $'XKBLAYOUT=jp\n'
|
||||
TEST_KEYBOARD_SELECTION=$'XKBLAYOUT=us,fr\nXKBVARIANT=intl,\n' assert_greeter_input "menu selections leave the greeter keyboard unchanged" "[jp] [] []" $'XKBLAYOUT=jp\n'
|
||||
|
||||
TEST_KEYBOARD_SELECTION=$'XKBLAYOUT=us,ru\nXKBVARIANT=,phonetic\n' assert_input "saved non-Latin selections retain the layout shortcut" "[us,ru] [,phonetic] [$toggle_options]" $'XKBLAYOUT=us\n'
|
||||
@@ -19,6 +19,7 @@ class TypingSetupTest(unittest.TestCase):
|
||||
def test_variants_and_installer_binding_safety(self):
|
||||
self.assertEqual(typing.keyboard_values(["us:intl", "fr"]), ("us,fr", "intl,"))
|
||||
self.assertEqual(typing.keyboard_values(["ru:phonetic"]), ("us,ru", ",phonetic"))
|
||||
self.assertEqual(typing.keyboard_values(["ru:phonetic", "us"]), ("us,ru", ",phonetic"))
|
||||
with self.assertRaises(ValueError):
|
||||
typing.keyboard_values([])
|
||||
|
||||
@@ -47,7 +48,7 @@ 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"]), [])
|
||||
self.assertEqual(typing.choose("Inputs", {"mozc": "Japanese", "hangul": "Korean"}, ["hangul"], "input"), [])
|
||||
args = process.call_args.args[0]
|
||||
self.assertEqual(args[2], "\tKorean")
|
||||
self.assertIn("--multiple", args)
|
||||
@@ -56,7 +57,14 @@ class TypingSetupTest(unittest.TestCase):
|
||||
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"}, [])
|
||||
typing.choose("Inputs", {"mozc": "Japanese"}, [], "input")
|
||||
|
||||
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, ([],))
|
||||
|
||||
def test_keyboard_override_failure_preserves_symlink_and_restores_settings(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
|
||||
Reference in new issue
Block a user