Select keyboard layouts and input methods in the Typing menu
This commit is contained in:
1 parent
73c0660fde
commit
58d7429f53
15 files changed
+407
-15
No files matched your search
+22
-3
@@ -1,9 +1,9 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Pick one option from a menu
|
||||
# omarchy:summary=Pick one or multiple options from a menu
|
||||
# omarchy:group=menu
|
||||
# omarchy:name=select
|
||||
# omarchy:args=prompt [option...] [-- menu args...]
|
||||
# omarchy:args=prompt [option...] [-- --multiple --selected <value> --width <pixels> --maxheight <pixels>]
|
||||
# omarchy:examples=omarchy menu select Format jpg png|omarchy-menu-select Resolution 4k 1080p 720p -- --width 400
|
||||
|
||||
# An option may lead with an icon, as "<glyph><TAB><label>", and may trail a
|
||||
@@ -12,6 +12,9 @@
|
||||
# alone; an option with a subtext returns "<label><TAB><subtext>", so callers
|
||||
# with same-named rows get the subtext back as the stable key.
|
||||
|
||||
# With --multiple, clicking/Enter toggles rows and Apply returns a JSON array.
|
||||
# --selected takes a returned value (label plus any subtext), and can be repeated.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if (( $# < 1 )); then
|
||||
@@ -25,12 +28,25 @@ shift
|
||||
options=()
|
||||
menu_width=""
|
||||
menu_maxheight=""
|
||||
multiple=0
|
||||
selected=()
|
||||
|
||||
while (( $# > 0 )); do
|
||||
if [[ $1 == "--" ]]; then
|
||||
shift
|
||||
while (( $# > 0 )); do
|
||||
case "$1" in
|
||||
--multiple)
|
||||
multiple=1
|
||||
;;
|
||||
--selected)
|
||||
shift
|
||||
if (( $# == 0 )); then
|
||||
echo "omarchy-menu-select: --selected requires a value" >&2
|
||||
exit 1
|
||||
fi
|
||||
selected+=("$1")
|
||||
;;
|
||||
--width)
|
||||
shift
|
||||
if (( $# == 0 )); then
|
||||
@@ -73,18 +89,21 @@ rm -f "$done_file"
|
||||
trap 'rm -f "$selection_file" "$done_file"' EXIT
|
||||
|
||||
options_json=$(perl -MEncode=decode -MJSON::PP=encode_json -e 'print encode_json([map { decode("UTF-8", $_) } @ARGV])' "${options[@]}")
|
||||
selected_json=$(perl -MEncode=decode -MJSON::PP=encode_json -e 'print encode_json([map { decode("UTF-8", $_) } @ARGV])' "${selected[@]}")
|
||||
payload=$(perl -MEncode=decode -MJSON::PP=encode_json,decode_json -e '
|
||||
my $payload = {
|
||||
mode => "select",
|
||||
prompt => decode("UTF-8", $ARGV[0]),
|
||||
options => decode_json($ARGV[1]),
|
||||
multiple => $ARGV[6] ? JSON::PP::true : JSON::PP::false,
|
||||
selected => decode_json($ARGV[7]),
|
||||
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")
|
||||
' "$prompt" "$options_json" "$selection_file" "$done_file" "$menu_width" "$menu_maxheight" "$multiple" "$selected_json")
|
||||
|
||||
omarchy-shell shell summon omarchy.menu "$payload" >/dev/null
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Set up Japanese, Korean, or Chinese typing
|
||||
# omarchy:summary=Select input methods for Japanese, Korean, or Chinese typing
|
||||
# omarchy:args=[mozc|hangul|pinyin|chewing]
|
||||
# omarchy:examples=omarchy setup input | omarchy setup input mozc
|
||||
# omarchy:requires-sudo=true
|
||||
@@ -14,9 +14,8 @@ fi
|
||||
|
||||
method=${1:-}
|
||||
if [[ -z $method ]]; then
|
||||
choice=$(jq -r 'to_entries[] | select(.key != "none") | .value.label' "$OMARCHY_PATH/default/input-methods/presets.json" |
|
||||
gum choose --header "Select input language")
|
||||
method=$(jq -r --arg label "$choice" 'to_entries[] | select(.value.label == $label) | .key' "$OMARCHY_PATH/default/input-methods/presets.json")
|
||||
python "$OMARCHY_PATH/default/input-methods/typing.py" input
|
||||
exit
|
||||
fi
|
||||
|
||||
case "$method" in
|
||||
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Select the keyboard layouts available for switching
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
python "$OMARCHY_PATH/default/input-methods/typing.py" keyboard
|
||||
@@ -7,7 +7,7 @@ local keyboard = require("default.hypr.keyboard")
|
||||
local non_latin_layouts =
|
||||
" af am ara bd bg by et ge gr il in iq ir kg kh kz la lk mk mm mn mv np rs ru sy th tj ua "
|
||||
|
||||
local vconsole = keyboard.installed()
|
||||
local vconsole = keyboard.selected()
|
||||
|
||||
local kb_layout = vconsole.XKBLAYOUT or "us"
|
||||
local kb_variant = vconsole.XKBVARIANT or ""
|
||||
|
||||
@@ -25,6 +25,13 @@ local function read_values(path)
|
||||
return values
|
||||
end
|
||||
|
||||
function keyboard.selected()
|
||||
local paths = require("default.hypr.paths")
|
||||
local values = read_values(paths.config_home .. "/omarchy/keyboard-layouts")
|
||||
if values.XKBLAYOUT then return values end
|
||||
return keyboard.installed()
|
||||
end
|
||||
|
||||
function keyboard.vconsole()
|
||||
return read_values("/etc/vconsole.conf")
|
||||
end
|
||||
@@ -42,7 +49,7 @@ end
|
||||
-- Japanese (JIS) keyboards put +, ^, and the Henkan/Muhenkan keys where US
|
||||
-- keyboards don't, so a few defaults only apply when that layout leads.
|
||||
function keyboard.jis()
|
||||
local layout = keyboard.installed().XKBLAYOUT or "us"
|
||||
local layout = keyboard.selected().XKBLAYOUT or "us"
|
||||
return layout:match("^[^,]*") == "jp"
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Native menu selectors for keyboard layouts and active input methods."""
|
||||
|
||||
from collections import Counter
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
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():
|
||||
catalog = {}
|
||||
for layout, label, languages, variants in controller("AvailableKeyboardLayouts"):
|
||||
catalog[layout] = label
|
||||
for variant, description, languages in variants:
|
||||
catalog[f"{layout}:{variant}"] = description
|
||||
return catalog
|
||||
|
||||
|
||||
def keyboard_selection():
|
||||
layouts = json.loads(setup.run(["hyprctl", "-j", "getoption", "input:kb_layout"]))["str"].split(",")
|
||||
variants = json.loads(setup.run(["hyprctl", "-j", "getoption", "input:kb_variant"]))["str"].split(",")
|
||||
return [layout + (":" + variants[index] if index < len(variants) and variants[index] else "")
|
||||
for index, layout in enumerate(layouts)]
|
||||
|
||||
|
||||
def choose(title, 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}
|
||||
args = ["omarchy-menu-select", title] + list(rows.values())
|
||||
args += ["--", "--multiple", "--width", "620", "--maxheight", "650"]
|
||||
for key in selected:
|
||||
args += ["--selected", rows[key][1:]]
|
||||
result = subprocess.run(args, text=True, capture_output=True)
|
||||
if result.returncode == 1:
|
||||
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):
|
||||
available = {item[0]: item[1] for item in setup.available_methods()}
|
||||
catalog = {key: preset["label"] for key, preset in setup.PRESETS.items() if key != "none"}
|
||||
for name, layout in items:
|
||||
if not name.startswith("keyboard-"):
|
||||
catalog.setdefault(name, available.get(name, name))
|
||||
return catalog, available
|
||||
|
||||
|
||||
def input_items(items, selected):
|
||||
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)
|
||||
return keyboard + [[name, existing.get(name, "")] for name in selected]
|
||||
|
||||
|
||||
def set_inputs(group, layout, before, after):
|
||||
if before == after:
|
||||
return
|
||||
try:
|
||||
# Removing the active engine must return typing to the keyboard.
|
||||
setup.run(["fcitx5-remote", "-c"])
|
||||
setup.live_set(group, layout, after)
|
||||
if setup.live_group() != (group, layout, after):
|
||||
raise RuntimeError("Fcitx did not retain the input selection")
|
||||
except BaseException:
|
||||
setup.live_set(group, layout, before)
|
||||
raise
|
||||
|
||||
|
||||
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]
|
||||
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):
|
||||
if not selected:
|
||||
raise ValueError("Select at least one keyboard layout")
|
||||
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"])
|
||||
layouts = ",".join(part[0] for part in parts)
|
||||
variants = ",".join(part[1] if len(part) > 1 else "" for part in parts)
|
||||
return layouts, variants
|
||||
|
||||
|
||||
def save_keyboard(selected):
|
||||
layouts, variants = keyboard_values(selected)
|
||||
config_home = Path(os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config"))
|
||||
path = config_home / "omarchy/keyboard-layouts"
|
||||
original = setup.read(path) if path.exists() else None
|
||||
group, old_layout, before = setup.live_group()
|
||||
group_changed = False
|
||||
try:
|
||||
setup.atomic_write(path, f"XKBLAYOUT={layouts}\nXKBVARIANT={variants}\n")
|
||||
setup.run(["hyprctl", "reload"])
|
||||
errors = setup.run(["hyprctl", "configerrors"])
|
||||
if 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")
|
||||
if keyboard_selection() != expected:
|
||||
raise RuntimeError("Your personal keyboard layout override takes precedence over this selection")
|
||||
first_layout = layouts.split(",")[0]
|
||||
first_variant = variants.split(",")[0]
|
||||
group_layout = first_layout + ("-" + first_variant if first_variant else "")
|
||||
primary = next((item[0] for item in before if item[0].startswith("keyboard-")), None)
|
||||
if primary is None:
|
||||
raise RuntimeError("The current input group has no keyboard input")
|
||||
keyboard = "keyboard-" + group_layout
|
||||
after = [[keyboard, ""]] + [item for item in before if item[0] not in (primary, keyboard)]
|
||||
group_changed = True
|
||||
setup.live_set(group, group_layout, after)
|
||||
if setup.live_group() != (group, group_layout, after):
|
||||
raise RuntimeError("Fcitx did not retain the keyboard selection")
|
||||
except BaseException:
|
||||
if original is None:
|
||||
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)
|
||||
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
|
||||
|
||||
|
||||
def main():
|
||||
kind = 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()
|
||||
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__":
|
||||
main()
|
||||
@@ -128,7 +128,9 @@
|
||||
"setup.monitors": {"icon":"","label":"Monitors","action":"omarchy-launch-config-editor \"$HOME/.config/hypr/monitors.lua\""},
|
||||
"setup.keybindings": {"icon":"","label":"Keybindings","when":"[[ -f ~/.config/hypr/bindings.lua ]]","action":"omarchy-launch-config-editor \"$HOME/.config/hypr/bindings.lua\""},
|
||||
"setup.input": {"icon":"","label":"Input","when":"[[ -f ~/.config/hypr/input.lua ]]","action":"omarchy-launch-config-editor \"$HOME/.config/hypr/input.lua\""},
|
||||
"setup.input-method": {"icon":"","label":"Input Language","action":"omarchy-launch-floating-terminal-with-presentation 'omarchy-setup-input'"},
|
||||
"setup.typing": {"icon":"","label":"Typing"},
|
||||
"setup.typing.keyboard": {"icon":"","label":"Keyboard Layouts","action":"omarchy-setup-keyboard"},
|
||||
"setup.typing.input": {"icon":"","label":"Input Methods","action":"omarchy-setup-input"},
|
||||
"setup.network": {"icon":"","label":"Network","aliases":["network"]},
|
||||
"setup.network.dns": {"icon":"","label":"DNS","aliases":["dns"]},
|
||||
"setup.network.dns.dhcp": {"icon":"","label":"DHCP","checked":"[[ \"$(omarchy-dns)\" == \"DHCP\" ]]","action":"omarchy-dns DHCP"},
|
||||
|
||||
@@ -8,7 +8,11 @@ The ISO carries the extra fields in `omarchy_install.input_method` and `omarchy_
|
||||
|
||||
`default/input-methods/configure.py` owns the shared configuration behavior. Offline seeding replaces only a missing profile or Fcitx's untouched single `keyboard-us` guess. The group layout matches Hyprland's effective leading layout and variant; non-Latin layouts retain the US-leading behavior used for desktop bindings. The selected composition engine is the group's `DefaultIM`, with `ActiveByDefault=False` so initial typing remains Latin. Existing multilingual profiles and explicitly chosen keyboard layouts are preserved.
|
||||
|
||||
`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.
|
||||
`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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -165,3 +165,6 @@ renders under the label, filters with it, and comes back as
|
||||
`label\tsubtext` so callers with same-named rows get a stable key. This is
|
||||
how the pickers behind menu actions (`omarchy-menu-plugin`,
|
||||
`omarchy-menu-timezone`, ...) present lists without owning any UI.
|
||||
|
||||
|
||||
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.
|
||||
@@ -63,9 +63,11 @@ Select Japanese, Korean, Chinese (Simplified, Pinyin), or Chinese (Traditional,
|
||||
|
||||
Typing starts in Latin mode. Press `Super + I` to cycle through configured input methods. On Japanese keyboards, 全角/半角 toggles input, 変換 turns it on, and 無変換 turns it off. On a Korean keyboard layout, dedicated 한/영 and 한자 keys switch Hangul and convert to Hanja. `Ctrl + Space` stays available for Tmux and Herdr; CapsLock stays the compose key.
|
||||
|
||||
The keyboard indicator in the menu bar appears for non-English keyboards and whenever you have multiple layouts or input modes. Click it to switch input languages; Japanese displays あ for hiragana. With only one input mode, clicking opens input language setup. 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.
|
||||
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.
|
||||
|
||||
To add another language later, use _Setup > Input Language_ or run `omarchy setup input` (or `omarchy setup input mozc`, `hangul`, `pinyin`, or `chewing`). Your existing input methods and 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.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
Use `Super + I` to cycle through input methods or keyboard layouts. The input indicator appears on the right of the bar only when more than one input method or layout is configured. This desktop binding works alongside your existing Fcitx switching keys; you can override it in `~/.config/hypr/bindings.lua`. Tapping Shift alone is not an input switch: Omarchy uses both Shift keys for CapsLock, which changes how input methods see Shift release. Input engines and the CapsLock compose sequences use the [Fcitx5](https://fcitx-im.org/) service already running in every session.
|
||||
|
||||
|
||||
@@ -57,6 +57,8 @@ Item {
|
||||
readonly property bool dmenuActive: mode === "select" || mode === "input"
|
||||
property string dmenuPrompt: ""
|
||||
property var dmenuOptions: []
|
||||
property bool dmenuMultiple: false
|
||||
property var dmenuSelected: []
|
||||
property string selectionFile: ""
|
||||
property string doneFile: ""
|
||||
property int dmenuWidth: 300
|
||||
@@ -563,6 +565,14 @@ Item {
|
||||
return
|
||||
}
|
||||
|
||||
if (root.dmenuMultiple) {
|
||||
displayModel.append({
|
||||
itemId: "dmenu.apply", disabled: false, kind: "dmenu", icon: "", iconFont: "",
|
||||
appIcon: "", appId: "", label: "Apply", target: "",
|
||||
detail: root.dmenuSelected.length + " selected", path: "", childCount: 0,
|
||||
action: "", provider: "", score: -1, section: ""
|
||||
})
|
||||
}
|
||||
var query = root.filterText.trim().toLowerCase()
|
||||
for (var i = 0; i < root.dmenuOptions.length; i++) {
|
||||
// An option is "<label>", "<glyph>\t<label>", or
|
||||
@@ -579,7 +589,7 @@ Item {
|
||||
itemId: "dmenu." + i,
|
||||
disabled: false,
|
||||
kind: "dmenu",
|
||||
icon: icon,
|
||||
icon: root.dmenuMultiple ? (root.dmenuSelected.indexOf(MenuModel.dmenuValue(root.dmenuOptions[i])) !== -1 ? "✓" : "○") : icon,
|
||||
iconFont: "",
|
||||
appIcon: "",
|
||||
appId: "",
|
||||
@@ -769,7 +779,17 @@ Item {
|
||||
}
|
||||
if (index < 0 || index >= displayModel.count) return
|
||||
var picked = displayModel.get(index)
|
||||
root.applyDmenuSelection(picked.detail ? picked.label + "\t" + picked.detail : picked.label)
|
||||
if (root.dmenuMultiple) {
|
||||
if (picked.itemId === "dmenu.apply") {
|
||||
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()
|
||||
}
|
||||
} else {
|
||||
root.applyDmenuSelection(picked.detail ? picked.label + "\t" + picked.detail : picked.label)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -867,6 +887,8 @@ Item {
|
||||
mode = payload.mode === "input" ? "input" : "select"
|
||||
dmenuPrompt = String(payload.prompt || (mode === "input" ? "Input" : "Select"))
|
||||
dmenuOptions = Array.isArray(payload.options) ? payload.options : []
|
||||
dmenuMultiple = mode === "select" && payload.multiple === true
|
||||
dmenuSelected = MenuModel.dmenuSelections(dmenuOptions, Array.isArray(payload.selected) ? payload.selected : [])
|
||||
selectionFile = String(payload.selectionFile || "")
|
||||
doneFile = String(payload.doneFile || "")
|
||||
requestActive = !!doneFile
|
||||
|
||||
@@ -501,6 +501,25 @@ function summonAction(action) {
|
||||
return { id: match[1], payload: match[2] || "{}" }
|
||||
}
|
||||
|
||||
|
||||
// Multi-select requests return stable values, without icons, in option order.
|
||||
function dmenuValue(option) {
|
||||
var parts = String(option || "").split("\t")
|
||||
if (parts.length > 1) parts.shift()
|
||||
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 toggleDmenuSelection(selected, value) {
|
||||
return selected.indexOf(value) === -1 ? selected.concat([value])
|
||||
: selected.filter(function(item) { return item !== value })
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined") {
|
||||
module.exports = {
|
||||
summonAction: summonAction,
|
||||
@@ -531,6 +550,9 @@ if (typeof module !== "undefined") {
|
||||
descriptionTextMatches: descriptionTextMatches,
|
||||
matchesQuery: matchesQuery,
|
||||
searchScore: searchScore,
|
||||
dmenuValue: dmenuValue,
|
||||
dmenuSelections: dmenuSelections,
|
||||
toggleDmenuSelection: toggleDmenuSelection,
|
||||
displayRow: displayRow
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,15 @@ local vconsole = os.getenv("OMARCHY_VCONSOLE")
|
||||
local real_open = io.open
|
||||
|
||||
io.open = function(path, mode)
|
||||
if path:match("/omarchy/keyboard%-layouts$") then
|
||||
local selected = os.getenv("TEST_KEYBOARD_SELECTION")
|
||||
if not selected then return nil end
|
||||
local file = io.tmpfile()
|
||||
file:write(selected)
|
||||
file:seek("set")
|
||||
return file
|
||||
end
|
||||
|
||||
if path == "/etc/omarchy/input-method" then
|
||||
local preference = os.getenv("TEST_INPUT_PREFERENCE")
|
||||
if not preference then return nil end
|
||||
@@ -141,3 +150,6 @@ pass "greeter non-latin layout list stays in sync with the initramfs hook"
|
||||
TEST_INPUT_PREFERENCE=$'INPUT_METHOD=hangul\n XKB_LAYOUT="kr" # desktop only\n' assert_input "Korean selection overrides its installer US layout" "[kr] [] [$base_options]" $'XKBLAYOUT=us\n'
|
||||
TEST_INPUT_PREFERENCE=$'INPUT_METHOD=hangul\nXKB_LAYOUT=kr\n' assert_input "a later US variant overrides the Korean preference" "[us] [intl] [$base_options]" $'XKBLAYOUT=us\nXKBVARIANT=intl\n'
|
||||
TEST_INPUT_PREFERENCE=$'INPUT_METHOD=hangul\nXKB_LAYOUT=kr\n' assert_input "a later layout overrides the Korean preference" "[de] [nodeadkeys] [$base_options]" $'XKBLAYOUT=de\nXKBVARIANT=nodeadkeys\n'
|
||||
|
||||
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'
|
||||
@@ -0,0 +1,90 @@
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
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")
|
||||
typing = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(typing)
|
||||
|
||||
|
||||
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"))
|
||||
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_input_deselection_keeps_keyboard_and_selected_overrides(self):
|
||||
items = [["keyboard-us", ""], ["mozc", "jp"], ["hangul", ""], ["custom", "de"]]
|
||||
self.assertEqual(typing.input_items(items, ["custom", "mozc"]), [["keyboard-us", ""], ["custom", "de"], ["mozc", "jp"]])
|
||||
self.assertEqual(typing.input_items(items, []), [["keyboard-us", ""]])
|
||||
|
||||
def test_cancellation_does_not_change_input_settings(self):
|
||||
with patch.object(typing.setup, "live_group", return_value=("Default", "us", [["keyboard-us", ""]])), patch.object(typing, "input_catalog", return_value=({"mozc": "Japanese"}, {"mozc": "Japanese"})), patch.object(typing, "choose", return_value=None), patch.object(typing.setup, "live_set") as setter:
|
||||
self.assertFalse(typing.configure_inputs())
|
||||
setter.assert_not_called()
|
||||
|
||||
def test_failed_input_save_restores_the_original_group(self):
|
||||
before = [["keyboard-us", ""], ["mozc", "jp"]]
|
||||
after = [["keyboard-us", ""]]
|
||||
with patch.object(typing.setup, "run"), patch.object(typing.setup, "live_set") as setter, patch.object(typing.setup, "live_group", return_value=("Default", "us", before)):
|
||||
with self.assertRaises(RuntimeError):
|
||||
typing.set_inputs("Default", "us", before, after)
|
||||
self.assertEqual(setter.call_args_list[-1].args, ("Default", "us", before))
|
||||
|
||||
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"]), [])
|
||||
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"}, [])
|
||||
|
||||
def test_keyboard_override_failure_preserves_symlink_and_restores_settings(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
config = Path(temporary)
|
||||
target = config / "actual-layouts"
|
||||
target.write_text("XKBLAYOUT=de\n")
|
||||
target.chmod(0o600)
|
||||
path = config / "omarchy/keyboard-layouts"
|
||||
path.parent.mkdir()
|
||||
path.symlink_to(target)
|
||||
before = ("Default", "us", [["keyboard-us", ""], ["mozc", "jp"]])
|
||||
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config)}), patch.object(typing.setup, "live_group", return_value=before), patch.object(typing.setup, "run", return_value=""), patch.object(typing, "keyboard_selection", return_value=["de"]), patch.object(typing.setup, "live_set") as setter:
|
||||
with self.assertRaisesRegex(RuntimeError, "override"):
|
||||
typing.save_keyboard(["us", "fr"])
|
||||
self.assertTrue(path.is_symlink())
|
||||
self.assertEqual(target.read_text(), "XKBLAYOUT=de\n")
|
||||
self.assertEqual(target.stat().st_mode & 0o777, 0o600)
|
||||
setter.assert_not_called()
|
||||
|
||||
def test_keyboard_save_aligns_fcitx_and_preserves_language_overrides(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
before = ("Default", "us", [["keyboard-us", ""], ["mozc", "jp"]])
|
||||
after = ("Default", "fr", [["keyboard-fr", ""], ["mozc", "jp"]])
|
||||
with patch.dict(os.environ, {"XDG_CONFIG_HOME": temporary}), patch.object(typing.setup, "live_group", side_effect=[before, after]), patch.object(typing.setup, "run", return_value=""), patch.object(typing, "keyboard_selection", return_value=["fr", "us:intl"]), patch.object(typing.setup, "live_set") as setter:
|
||||
typing.save_keyboard(["fr", "us:intl"])
|
||||
self.assertEqual(setter.call_args.args, after)
|
||||
self.assertEqual((Path(temporary) / "omarchy/keyboard-layouts").read_text(), "XKBLAYOUT=fr,us\nXKBVARIANT=,intl\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh"
|
||||
|
||||
OMARCHY_PATH="$ROOT" python "$ROOT/test/shell.d/typing-setup-checks.py"
|
||||
|
||||
run_node_test <<'JS'
|
||||
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.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
|
||||
Reference in new issue
Block a user