From 9fe6a047153e14465c16e282f5bd69c60a8596e9 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Wed, 7 Oct 2026 12:55:33 +0200 Subject: [PATCH] Unify input methods with the installer keyboard selection --- bin/omarchy-input-method | 9 + bin/omarchy-provision-owner | 1 + bin/omarchy-restart-xcompose | 23 +- bin/omarchy-setup-input | 30 ++ bin/omarchy-upgrade-to-quattro | 2 + config/fcitx5/conf/quickphrase.conf | 2 + config/fcitx5/conf/wayland.conf | 1 + config/fcitx5/config | 20 + default/hypr/bindings/input-method.lua | 66 +++ default/hypr/bindings/jis.lua | 43 ++ default/hypr/input.lua | 23 +- default/hypr/keyboard.lua | 47 +++ default/hypr/omarchy.lua | 2 + default/input-methods/configure.py | 378 ++++++++++++++++++ .../input-methods/org.fcitx.Fcitx5.service | 5 + default/input-methods/presets.json | 7 + default/omarchy/omarchy-menu.jsonc | 1 + default/systemd/user/omarchy-fcitx5.service | 5 +- docs/input-methods.md | 21 + install/omarchy-base.packages | 4 + install/provisioning/setup-form.sh | 27 +- install/user/all.sh | 1 + install/user/first-run/enable-user-units.sh | 2 + install/user/input-method.sh | 1 + manual/34-keyboard-mouse-trackpad.md | 8 +- migrations/1791367434.sh | 4 + test/acceptance.d/input-methods-test.sh | 83 ++++ test/shell.d/hyprland-jis-bindings-test.sh | 128 ++++++ test/shell.d/hyprland-keyboard-layout-test.sh | 11 + test/shell.d/input-method-checks.py | 251 ++++++++++++ test/shell.d/input-method-test.sh | 6 + test/shell.d/owe-integration-test.sh | 9 +- test/shell.d/setup-form-test.sh | 15 + test/shell.d/systemd-test.sh | 2 +- 34 files changed, 1207 insertions(+), 31 deletions(-) create mode 100755 bin/omarchy-input-method create mode 100755 bin/omarchy-setup-input create mode 100644 config/fcitx5/conf/quickphrase.conf create mode 100644 config/fcitx5/conf/wayland.conf create mode 100644 config/fcitx5/config create mode 100644 default/hypr/bindings/input-method.lua create mode 100644 default/hypr/bindings/jis.lua create mode 100644 default/hypr/keyboard.lua create mode 100644 default/input-methods/configure.py create mode 100644 default/input-methods/org.fcitx.Fcitx5.service create mode 100644 default/input-methods/presets.json create mode 100644 docs/input-methods.md create mode 100644 install/user/input-method.sh create mode 100644 migrations/1791367434.sh create mode 100644 test/acceptance.d/input-methods-test.sh create mode 100755 test/shell.d/hyprland-jis-bindings-test.sh create mode 100644 test/shell.d/input-method-checks.py create mode 100644 test/shell.d/input-method-test.sh diff --git a/bin/omarchy-input-method b/bin/omarchy-input-method new file mode 100755 index 00000000..a299756f --- /dev/null +++ b/bin/omarchy-input-method @@ -0,0 +1,9 @@ +#!/bin/bash + +# omarchy:summary=Configure or inspect the input method selected at installation +# omarchy:args= +# omarchy:hidden=true + +set -euo pipefail + +python "$OMARCHY_PATH/default/input-methods/configure.py" "$@" diff --git a/bin/omarchy-provision-owner b/bin/omarchy-provision-owner index a1a22559..d4166b45 100755 --- a/bin/omarchy-provision-owner +++ b/bin/omarchy-provision-owner @@ -599,6 +599,7 @@ keyboard_form() { done apply_keyboard "$keyboard" + omarchy_persist_input_selection } confirm_reboot() { diff --git a/bin/omarchy-restart-xcompose b/bin/omarchy-restart-xcompose index db65bd4e..26ef110c 100755 --- a/bin/omarchy-restart-xcompose +++ b/bin/omarchy-restart-xcompose @@ -2,9 +2,30 @@ # omarchy:summary=Restart the XCompose input method service (fcitx5) to apply new compose key settings. +set -euo pipefail + # A fcitx5 running outside the unit owns org.fcitx.Fcitx5, so the unit's # instance would exit on arrival and the stale process would keep serving the # old ~/.XCompose -- a restart that reports success and changes nothing. systemctl --user stop omarchy-fcitx5.service -pkill -x fcitx5 >/dev/null 2>&1 || true +pkill -u "$UID" -x fcitx5 >/dev/null 2>&1 || true +for attempt in {1..50}; do + if ! pgrep -u "$UID" -x fcitx5 >/dev/null; then + break + fi + sleep 0.1 +done +if pgrep -u "$UID" -x fcitx5 >/dev/null; then + # An activation concurrent with stop may have restarted the unit. + systemctl --user stop omarchy-fcitx5.service + pkill -u "$UID" -x fcitx5 >/dev/null 2>&1 || true + for attempt in {1..50}; do + if ! pgrep -u "$UID" -x fcitx5 >/dev/null; then break; fi + sleep 0.1 + done +fi +if pgrep -u "$UID" -x fcitx5 >/dev/null; then + echo "Fcitx did not stop; input service was not restarted." >&2 + exit 1 +fi systemctl --user start omarchy-fcitx5.service diff --git a/bin/omarchy-setup-input b/bin/omarchy-setup-input new file mode 100755 index 00000000..ff756b04 --- /dev/null +++ b/bin/omarchy-setup-input @@ -0,0 +1,30 @@ +#!/bin/bash + +# omarchy:summary=Set up Japanese, Korean, or Chinese typing +# omarchy:args=[mozc|hangul|pinyin|chewing] +# omarchy:examples=omarchy setup input | omarchy setup input mozc +# omarchy:requires-sudo=true + +set -euo pipefail + +if (( $# > 1 )); then + echo "Usage: omarchy setup input [mozc|hangul|pinyin|chewing]" >&2 + exit 1 +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") +fi + +case "$method" in + mozc | hangul | pinyin | chewing) ;; + *) echo "Unknown input method: $method" >&2; exit 1 ;; +esac + +mapfile -t packages < <(jq -r --arg method "$method" '.[$method].packages[]' "$OMARCHY_PATH/default/input-methods/presets.json") +omarchy-pkg-add "${packages[@]}" +omarchy-input-method configure "$method" +echo "Input method added. The default switch is Ctrl+Shift+Space or the Japanese/Korean input key; existing custom keys are preserved." diff --git a/bin/omarchy-upgrade-to-quattro b/bin/omarchy-upgrade-to-quattro index 9f4a8ae7..28131750 100755 --- a/bin/omarchy-upgrade-to-quattro +++ b/bin/omarchy-upgrade-to-quattro @@ -1802,6 +1802,8 @@ copy_missing_config_defaults() { while IFS= read -r -d '' source_path; do rel="${source_path#$source_dir/}" is_retired_config_file "$rel" && continue + # The input migration preserves working shortcuts in existing IME profiles. + [[ $rel == "fcitx5/config" ]] && continue target_path="$target_dir/$rel" [[ -e $target_path || -L $target_path ]] && continue diff --git a/config/fcitx5/conf/quickphrase.conf b/config/fcitx5/conf/quickphrase.conf new file mode 100644 index 00000000..34905ac6 --- /dev/null +++ b/config/fcitx5/conf/quickphrase.conf @@ -0,0 +1,2 @@ +[TriggerKey] +0=Super+semicolon diff --git a/config/fcitx5/conf/wayland.conf b/config/fcitx5/conf/wayland.conf new file mode 100644 index 00000000..90f53161 --- /dev/null +++ b/config/fcitx5/conf/wayland.conf @@ -0,0 +1 @@ +Allow Overriding System XKB Settings=False diff --git a/config/fcitx5/config b/config/fcitx5/config new file mode 100644 index 00000000..3609ca02 --- /dev/null +++ b/config/fcitx5/config @@ -0,0 +1,20 @@ +# Leave Ctrl+Space to tmux/Herdr and the Super shortcuts to the desktop. +[Hotkey/TriggerKeys] +0=Control+Shift+space +1=Zenkaku_Hankaku +2=Hangul + +[Hotkey/ActivateKeys] +0=Henkan + +[Hotkey/DeactivateKeys] +0=Muhenkan + +[Hotkey/AltTriggerKeys] + +[Hotkey/EnumerateGroupForwardKeys] + +[Hotkey/EnumerateGroupBackwardKeys] + +[Behavior] +ActiveByDefault=False diff --git a/default/hypr/bindings/input-method.lua b/default/hypr/bindings/input-method.lua new file mode 100644 index 00000000..2fe813e5 --- /dev/null +++ b/default/hypr/bindings/input-method.lua @@ -0,0 +1,66 @@ +local paths = require("default.hypr.paths") + +-- Same terminal tag test as clipboard.lua. +local function active_window_is_terminal() + local window = hl.get_active_window() + if not window then + return false + end + + for _, tag in ipairs(window.tags or {}) do + if tag:gsub("%*$", "") == "terminal" then + return true + end + end + + return false +end + +-- Read on demand so adding an engine in a live desktop needs no reload. +local function has_input_method() + local file = io.open(paths.config_home .. "/fcitx5/profile", "r") + if not file then return false end + local in_item, found = false, false + for line in file:lines() do + if line:match("^%[") then + in_item = line:match("^%[Groups/%d+/Items/%d+%]$") ~= nil + elseif in_item then + local name = line:match("^Name=(.+)$") + if name and not name:match("^keyboard%-") then found = true end + end + end + file:close() + return found +end + +-- Preserve custom Ctrl + Space triggers. Without a +-- [Hotkey/TriggerKeys] list in the config, fcitx5 uses its default, which has +-- Ctrl + Space. +local function fcitx5_toggles_on_ctrl_space() + local file = io.open(paths.config_home .. "/fcitx5/config", "r") + if not file then + return true + end + + local in_triggers, has_triggers, toggles = false, false, false + for line in file:lines() do + if line:match("^%[") then + in_triggers = line == "[Hotkey/TriggerKeys]" + has_triggers = has_triggers or in_triggers + elseif in_triggers and line:match("^%d+=Control%+space$") then + toggles = true + end + end + + file:close() + return toggles or not has_triggers +end + +-- Pass the tmux/Herdr prefix through and return to direct input, so the next +-- key reaches the multiplexer instead of the engine's composition. +o.bind("CTRL + SPACE", "Direct input for the terminal prefix", function() + if active_window_is_terminal() and has_input_method() and not fcitx5_toggles_on_ctrl_space() then + -- Wait for the local controller reply before dispatching the next key. + o.shell_succeeds("timeout 0.3 fcitx5-remote --check -c") + end +end, { non_consuming = true }) diff --git a/default/hypr/bindings/jis.lua b/default/hypr/bindings/jis.lua new file mode 100644 index 00000000..a2b9ab6d --- /dev/null +++ b/default/hypr/bindings/jis.lua @@ -0,0 +1,43 @@ +-- Defaults for Japanese (JIS) keyboards, which only apply when the installed +-- layout is jp. See default/hypr/keyboard.lua. + +local keyboard = require("default.hypr.keyboard") + +if not keyboard.jis() then + return +end + +-- Same down/up split as the universal clipboard shortcuts in clipboard.lua. +local function send_shortcut_once(mods, key) + hl.dispatch(hl.dsp.send_key_state({ mods = mods, key = key, state = "down" })) + + hl.timer(function() + hl.dispatch(hl.dsp.send_key_state({ mods = mods, key = key, state = "up" })) + end, { timeout = 50, type = "oneshot" }) +end + +local function active_window_class() + local window = hl.get_active_window() + return ((window and window.class) or ""):lower() +end + +-- On JIS, + is Shift + ;, so apps see Ctrl + Shift + semicolon and never zoom +-- in. Hand them Ctrl + keypad plus instead, which Chromium, Electron apps, +-- VS Code, and Firefox read as zoom in. Obsidian only listens for Ctrl + ^, +-- which JIS types without Shift. +local zoom_in_overrides = { + obsidian = { "CTRL", "asciicircum" }, +} + +o.bind("CTRL + SHIFT + semicolon", "Zoom in (JIS + key)", function() + local class = active_window_class() + + for pattern, chord in pairs(zoom_in_overrides) do + if class:find(pattern, 1, true) then + send_shortcut_once(chord[1], chord[2]) + return + end + end + + send_shortcut_once("CTRL", "KP_Add") +end) diff --git a/default/hypr/input.lua b/default/hypr/input.lua index a87ad994..3657abb3 100644 --- a/default/hypr/input.lua +++ b/default/hypr/input.lua @@ -1,32 +1,13 @@ -- https://wiki.hypr.land/Configuring/Basics/Variables/#input -local function read_vconsole() - local values = {} - local file = io.open("/etc/vconsole.conf", "r") - if not file then - return values - end - - for line in file:lines() do - local key, value = line:match("^%s*([%w_]+)%s*=%s*(.-)%s*$") - if key and value then - value = value:gsub("%s+#.*$", "") - value = value:gsub('^"(.*)"$', "%1") - value = value:gsub("^'(.*)'$", "%1") - values[key] = value - end - end - - file:close() - return values -end +local keyboard = require("default.hypr.keyboard") -- Layouts that can't type Latin letters. Keep in sync with the list in -- etc/mkinitcpio.conf.d/omarchy_hooks.conf. 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 = read_vconsole() +local vconsole = keyboard.installed() local kb_layout = vconsole.XKBLAYOUT or "us" local kb_variant = vconsole.XKBVARIANT or "" diff --git a/default/hypr/keyboard.lua b/default/hypr/keyboard.lua new file mode 100644 index 00000000..46b9a4a5 --- /dev/null +++ b/default/hypr/keyboard.lua @@ -0,0 +1,47 @@ +-- The keyboard layout picked at install time. The installer and first-boot +-- setup persist it to /etc/vconsole.conf, so Hyprland reads it from there +-- instead of rewriting every user's config. + +local keyboard = {} + +local function read_values(path) + local values = {} + local file = io.open(path, "r") + if not file then + return values + end + + for line in file:lines() do + local key, value = line:match("^%s*([%w_]+)%s*=%s*(.-)%s*$") + if key and value then + value = value:gsub("%s+#.*$", "") + value = value:gsub('^"(.*)"$', "%1") + value = value:gsub("^'(.*)'$", "%1") + values[key] = value + end + end + + file:close() + return values +end + +function keyboard.vconsole() + return read_values("/etc/vconsole.conf") +end + +function keyboard.installed() + local values = keyboard.vconsole() + if read_values("/etc/omarchy/input-method").XKB_LAYOUT == "kr" then + values.XKBLAYOUT, values.XKBVARIANT = "kr", "" + end + return values +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" + return layout:match("^[^,]*") == "jp" +end + +return keyboard diff --git a/default/hypr/omarchy.lua b/default/hypr/omarchy.lua index abc7c4e0..1e84058a 100644 --- a/default/hypr/omarchy.lua +++ b/default/hypr/omarchy.lua @@ -8,6 +8,8 @@ require("default.hypr.autostart") if _G.omarchy_default_bindings ~= false then require("default.hypr.bindings.media") require("default.hypr.bindings.clipboard") + require("default.hypr.bindings.input-method") + require("default.hypr.bindings.jis") require("default.hypr.bindings.tiling") require("default.hypr.bindings.utilities") require("default.hypr.bindings.voxtype") diff --git a/default/input-methods/configure.py b/default/input-methods/configure.py new file mode 100644 index 00000000..0ec5751b --- /dev/null +++ b/default/input-methods/configure.py @@ -0,0 +1,378 @@ +"""One Fcitx setup path for offline finalization and a running desktop.""" + +import argparse +from contextlib import contextmanager +import json +import os +from pathlib import Path +import re +import subprocess +import tempfile +import sys +import time + + +ROOT = Path(os.environ["OMARCHY_PATH"]) +PRESETS = json.loads((ROOT / "default/input-methods/presets.json").read_text()) +NON_LATIN = set("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".split()) +CONTROLLER = ["busctl", "--user", "call", "org.fcitx.Fcitx5", "/controller", "org.fcitx.Fcitx.Controller1"] + + +def run(args, **kwargs): + return subprocess.run(args, check=True, text=True, capture_output=True, **kwargs).stdout.strip() + + +def read(path): + return path.read_text() if path.exists() else "" + + +def values(text): + result = {} + for line in text.splitlines(): + match = re.match(r"^\s*([\w ]+)\s*=\s*(.*?)\s*(?:\s+#.*)?$", line) + if match: + result[match[1].strip()] = match[2].strip("\"'") + return result + + +def sections(text): + result = {} + current = "" + for line in text.splitlines(): + match = re.match(r"^\s*\[([^]]+)\]\s*$", line) + if match: + current = match[1] + result.setdefault(current, {}) + elif current: + result[current].update(values(line)) + return result + + +def atomic_write(path, text): + # Follow a user's config symlink rather than replacing the link itself. + path = path.resolve() + if read(path) == text: + return False + path.parent.mkdir(parents=True, exist_ok=True) + mode = path.stat().st_mode & 0o777 if path.exists() else 0o644 + fd, temporary = tempfile.mkstemp(prefix=".omarchy-input-", dir=path.parent) + try: + with os.fdopen(fd, "w") as file: + file.write(text) + os.chmod(temporary, mode) + os.replace(temporary, path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + return True + + +def key_list(entries): + # Fcitx canonicalizes modifiers (e.g. Shift+Super instead of Super+Shift). + return {frozenset(value.split("+")) for value in entries.values()} + + +def defaults(config_home, fresh=False): + changed = False + files = ["config", "conf/quickphrase.conf", "conf/wayland.conf", "conf/xcb.conf"] + for name in files: + target = config_home / "fcitx5" / name + original = read(target) + shipped = read(ROOT / "config/fcitx5" / name) + if name == "config" or name == "conf/quickphrase.conf": + existing = sections(original) + additions = [] + for section, entries in sections(shipped).items(): + if not fresh and section == "Hotkey/TriggerKeys": + continue + # Existing lists (including explicitly empty lists) are user choices. + # A fresh profile has no working IME shortcut to preserve. + stock_lists = { + "Hotkey/TriggerKeys": {"0": "Control+space", "1": "Zenkaku_Hankaku", "2": "Hangul"}, + "Hotkey/ActivateKeys": {"0": "Hangul_Hanja"}, + "Hotkey/DeactivateKeys": {"0": "Hangul_Romaja"}, + "Hotkey/AltTriggerKeys": {"0": "Shift_L"}, + "Hotkey/EnumerateGroupForwardKeys": {"0": "Super+space"}, + "Hotkey/EnumerateGroupBackwardKeys": {"0": "Super+Shift+space"}, + "TriggerKey": {"0": "Super+grave", "1": "Super+semicolon"}, + } + if fresh and section in existing and section in stock_lists and (key_list(existing[section]) == key_list(stock_lists[section]) or (not existing[section] and section in {"Hotkey/ActivateKeys", "Hotkey/DeactivateKeys"})): + original = re.sub(r"(?ms)^\[" + re.escape(section) + r"\]\s*\n.*?(?=^\[|\Z)", "", original) + existing.pop(section) + if section not in existing: + additions.append(f"[{section}]\n" + "".join(f"{key}={value}\n" for key, value in entries.items())) + if additions: + changed |= atomic_write(target, original.rstrip() + ("\n\n" if original.strip() else "") + "\n".join(additions)) + elif not target.exists() and not target.is_symlink(): + changed |= atomic_write(target, shipped) + return changed + + +def activation_default(): + data_home = Path(os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local/share")) + path = data_home / "dbus-1/services/org.fcitx.Fcitx5.service" + original = read(path) + # Repair the package's plain activation entry, but retain custom launchers. + packaged = values(original).get("Exec") in ("/usr/bin/fcitx5", "/usr/bin/fcitx5 -d") + if not original or (packaged and "SystemdService" not in values(original)): + return atomic_write(path, read(ROOT / "default/input-methods/org.fcitx.Fcitx5.service")) + return False + + +@contextmanager +def transaction(config_home, live=False): + data_home = Path(os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local/share")) + paths = [config_home / "fcitx5" / name for name in ["profile", "config", "conf/quickphrase.conf", "conf/wayland.conf", "conf/xcb.conf"]] + paths += [config_home / "fontconfig/conf.d/50-omarchy-input-method.conf", data_home / "dbus-1/services/org.fcitx.Fcitx5.service"] + before = {path.resolve(): path.read_text() if path.exists() else None for path in paths} + try: + yield + except BaseException as setup_error: + if live: + # Stop before restoring disk: a graceful stop saves the in-memory profile. + try: + run(["systemctl", "--user", "stop", "omarchy-fcitx5.service"]) + subprocess.run(["pkill", "-u", str(os.getuid()), "-x", "fcitx5"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + for attempt in range(50): + if subprocess.run(["pgrep", "-u", str(os.getuid()), "-x", "fcitx5"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode != 0: + break + time.sleep(0.1) + else: + raise RuntimeError("Fcitx did not stop for rollback; previous settings could not safely be restored") + except (OSError, subprocess.CalledProcessError, RuntimeError) as error: + raise RuntimeError(f"Could not stop Fcitx for input rollback: {error} (setup failed: {setup_error})") from error + for path, original in before.items(): + if original is None: + path.unlink(missing_ok=True) + else: + atomic_write(path, original) + if live: + # Restore memory as well as disk; otherwise Fcitx saves the failed change + # again on exit. Bus activation caches also need to forget the new entry. + reload_bus() + try: + run(["omarchy-restart-xcompose"]) + run(CONTROLLER + ["ReloadConfig"]) + except (OSError, subprocess.CalledProcessError, RuntimeError) as error: + print(f"Could not reload restored input settings: {error}", file=sys.stderr) + raise + + +def selection(): + vconsole = values(read(Path(os.environ.get("OMARCHY_VCONSOLE", "/etc/vconsole.conf")))) + preference = values(read(Path(os.environ.get("OMARCHY_INPUT_SELECTION", "/etc/omarchy/input-method")))) + layout = vconsole.get("XKBLAYOUT", "us").split(",")[0] or "us" + variant = vconsole.get("XKBVARIANT", "").split(",")[0] + if preference.get("XKB_LAYOUT") == "kr": + layout, variant = "kr", "" + method = preference.get("INPUT_METHOD", "mozc" if layout == "jp" else "none") + if method not in PRESETS: + raise ValueError(f"Unknown installed input method: {method}") + if layout in NON_LATIN: + layout, variant = "us", "" + group_layout = layout + (f"-{variant}" if variant else "") + if not re.fullmatch(r"[A-Za-z0-9_-]+", group_layout): + raise ValueError("Invalid installed keyboard layout") + return method, group_layout + + +def stock_profile(text): + if not text.strip(): + return True + groups = sections(text) + items = [data.get("Name", "") for key, data in groups.items() if re.fullmatch(r"Groups/\d+/Items/\d+", key)] + group = groups.get("Groups/0", {}) + return (items == ["keyboard-us"] + and list(key for key in groups if re.fullmatch(r"Groups/\d+", key)) == ["Groups/0"] + and group.get("Name", "Default") == "Default" + and group.get("Default Layout", "us") == "us" + and not groups.get("Groups/0/Items/0", {}).get("Layout")) + + +def profile_text(layout, method): + default_im = method if method != "none" else f"keyboard-{layout}" + text = f"[Groups/0]\nName=Default\nDefault Layout={layout}\nDefaultIM={default_im}\n\n[Groups/0/Items/0]\nName=keyboard-{layout}\nLayout=\n" + if method != "none": + text += f"\n[Groups/0/Items/1]\nName={method}\nLayout=\n" + return text + "\n[GroupOrder]\n0=Default\n" + + +def font_default(config_home, method): + variant = PRESETS[method]["font"] + path = config_home / "fontconfig/conf.d/50-omarchy-input-method.conf" + if not variant or path.exists() or path.is_symlink(): + return + # Only CJK fallback changes; retain the interface and terminal's Latin font. + rules = [] + for families, face in [("sans-serif|Liberation Sans", "Sans"), ("serif|Liberation Serif", "Serif"), ("monospace|JetBrainsMono Nerd Font", "Sans Mono")]: + for family in families.split("|"): + rules.append(f'{family}Noto {face} CJK {variant}') + atomic_write(path, '\n\n\n' + "\n".join(rules) + "\n\n") + + +def live_group(): + group = run(["fcitx5-remote", "-q"]) + info = json.loads(run(CONTROLLER[:1] + ["--json=short"] + CONTROLLER[1:] + ["InputMethodGroupInfo", "s", group]))["data"] + layout, items = info[:2] + return group, layout, items + + +def live_set(group, layout, items): + args = [group, layout, str(len(items))] + for name, item_layout in items: + args.extend([name, item_layout]) + run(CONTROLLER + ["SetInputMethodGroupInfo", "ssa(ss)"] + args) + run(CONTROLLER + ["Save"]) + + +def reload_bus(): + # Offline finalization and updates over SSH may have no session bus. + try: + run(["busctl", "--user", "call", "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", "ReloadConfig"]) + except (OSError, subprocess.CalledProcessError, RuntimeError) as error: + print(f"Input activation reload deferred until login: {error}", file=sys.stderr) + + +def available_methods(): + return json.loads(run(CONTROLLER[:1] + ["--json=short"] + CONTROLLER[1:] + ["AvailableInputMethods"]))["data"][0] + + +def wait_ready(method): + for attempt in range(100): + try: + available = available_methods() + if any(item[0] == method for item in available) and live_group()[2]: + return + except (OSError, ValueError, KeyError, IndexError, subprocess.CalledProcessError): + pass + time.sleep(0.1) + raise RuntimeError(f"Fcitx did not load the {method} engine after restart") + + +def live_append(method): + group, layout, items = live_group() + if any(item[0] == method for item in items): + return + try: + live_set(group, layout, items + [[method, ""]]) + if not any(item[0] == method for item in live_group()[2]): + raise RuntimeError(f"Fcitx did not retain the {method} engine") + except BaseException: + live_set(group, layout, items) + raise + + +def select_live_method(method, profile): + # Clients reconnect asynchronously after restart. SetCurrentIM silently does + # nothing without a focused input context, so verify the saved group default. + for attempt in range(30): + run(["fcitx5-remote", "-s", method]) + run(["fcitx5-remote", "-c"]) + run(CONTROLLER + ["Save"]) + group = run(["fcitx5-remote", "-q"]) + for section, entries in sections(read(profile)).items(): + if re.fullmatch(r"Groups/\d+", section) and entries.get("Name") == group and entries.get("DefaultIM") == method: + return + time.sleep(0.1) + print(f"Input method added; select {method} in Fcitx settings to switch to it.", file=sys.stderr) + + +def configure(args): + config_home = Path(os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")) + running = subprocess.run(["pgrep", "-u", str(os.getuid()), "-x", "fcitx5"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0 + # Finalization can be retried after login. A live daemon owns its profile. + if args.seed and running: + return + with transaction(config_home, live=running and not args.defaults): + configure_inner(args, config_home, running) + + +def configure_inner(args, config_home, running): + if args.seed or args.defaults: + method, layout = selection() + else: + method, layout = args.method or selection()[0], None + if args.method: + method = args.method + profile = config_home / "fcitx5/profile" + original = read(profile) + if args.seed and not stock_profile(original): + return + if not args.seed and not args.defaults and not running: + raise RuntimeError("Log in to an Omarchy desktop before changing input methods") + if not args.seed and not args.defaults and method == "none": + raise ValueError("Choose an input engine to add") + + if args.seed: + atomic_write(profile, profile_text(layout, method)) + defaults(config_home, fresh=True) + activation_default() + font_default(config_home, method) + elif args.defaults: + fresh = stock_profile(original) + changed = defaults(config_home, fresh=fresh) + if activation_default(): + reload_bus() + # Upgrades can run through sudo/SSH without access to the live user's bus. + # Never overwrite its profile or restart input in that context. + if fresh: + if running: + try: + group, old_layout, items = live_group() + if old_layout == "us" and items == [["keyboard-us", ""]]: + new_items = [[f"keyboard-{layout}", ""]] + if method != "none": + if any(item[0] == method for item in available_methods()): + new_items.append([method, ""]) + else: + print(f"Finish input setup after login: omarchy setup input {method}", file=sys.stderr) + live_set(group, layout, new_items) + if live_group()[1:] != (layout, new_items): + live_set(group, old_layout, items) + raise RuntimeError("Fcitx did not retain the installed keyboard settings") + except (OSError, ValueError, KeyError, IndexError, RuntimeError, subprocess.CalledProcessError) as error: + print(f"Input profile alignment deferred until login: {error}", file=sys.stderr) + else: + atomic_write(profile, profile_text(layout, method)) + if running and changed: + try: + run(CONTROLLER + ["ReloadConfig"]) + except (OSError, subprocess.CalledProcessError) as error: + print(f"Input defaults reload deferred until login: {error}", file=sys.stderr) + else: + # New addons are discovered only at startup. Supervision remains with the + # existing unit; never spawn a second fcitx instance ourselves. + activation_default() + defaults(config_home, fresh=stock_profile(original)) + reload_bus() + run(["omarchy-restart-xcompose"]) + wait_ready(method) + live_append(method) + # The selected language becomes the next active engine; leave Latin input + # active until the user deliberately switches. Keep all other group items. + select_live_method(method, profile) + font_default(config_home, method) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + commands.add_parser("status") + configure_parser = commands.add_parser("configure") + mode = configure_parser.add_mutually_exclusive_group() + mode.add_argument("--seed", action="store_true") + mode.add_argument("--defaults", action="store_true") + configure_parser.add_argument("method", nargs="?", choices=PRESETS) + args = parser.parse_args() + if args.command == "status": + method, layout = selection() + print(json.dumps({"installed_method": method, "layout": layout})) + else: + configure(args) + + +if __name__ == "__main__": + try: + main() + except (OSError, ValueError, RuntimeError, subprocess.CalledProcessError) as error: + raise SystemExit(f"Input method setup failed: {error}") diff --git a/default/input-methods/org.fcitx.Fcitx5.service b/default/input-methods/org.fcitx.Fcitx5.service new file mode 100644 index 00000000..6be99d23 --- /dev/null +++ b/default/input-methods/org.fcitx.Fcitx5.service @@ -0,0 +1,5 @@ +# Installed per-user so D-Bus activation uses the supervised Omarchy unit. +[D-BUS Service] +Name=org.fcitx.Fcitx5 +Exec=/usr/bin/fcitx5 +SystemdService=omarchy-fcitx5.service diff --git a/default/input-methods/presets.json b/default/input-methods/presets.json new file mode 100644 index 00000000..1ea1855f --- /dev/null +++ b/default/input-methods/presets.json @@ -0,0 +1,7 @@ +{ + "none": {"label": "Keyboard only", "packages": [], "font": ""}, + "mozc": {"label": "Japanese (Mozc)", "packages": ["fcitx5-mozc"], "font": "JP"}, + "hangul": {"label": "Korean (Hangul)", "packages": ["fcitx5-hangul"], "font": "KR"}, + "pinyin": {"label": "Chinese (Simplified, Pinyin)", "packages": ["fcitx5-chinese-addons"], "font": "SC"}, + "chewing": {"label": "Chinese (Traditional, Zhuyin)", "packages": ["fcitx5-chewing"], "font": "TC"} +} diff --git a/default/omarchy/omarchy-menu.jsonc b/default/omarchy/omarchy-menu.jsonc index 736cba1a..ac239452 100644 --- a/default/omarchy/omarchy-menu.jsonc +++ b/default/omarchy/omarchy-menu.jsonc @@ -128,6 +128,7 @@ "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.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"}, diff --git a/default/systemd/user/omarchy-fcitx5.service b/default/systemd/user/omarchy-fcitx5.service index 672c67bf..db6eb578 100644 --- a/default/systemd/user/omarchy-fcitx5.service +++ b/default/systemd/user/omarchy-fcitx5.service @@ -1,5 +1,5 @@ [Unit] -Description=Fcitx5 input method (XCompose sequences) +Description=Fcitx5 input methods and XCompose sequences # fcitx5 turns the CapsLock compose sequences in ~/.XCompose into text for # Wayland clients. # @@ -19,7 +19,8 @@ PartOf=graphical-session.target ConditionEnvironment=WAYLAND_DISPLAY [Service] -Type=simple +Type=dbus +BusName=org.fcitx.Fcitx5 # notificationitem duplicates the tray entry omarchy already renders itself. ExecStart=/usr/bin/fcitx5 --disable notificationitem # always, not on-failure: fcitx5 exits 0 when it detects another instance owning diff --git a/docs/input-methods.md b/docs/input-methods.md new file mode 100644 index 00000000..af1ca8c2 --- /dev/null +++ b/docs/input-methods.md @@ -0,0 +1,21 @@ +# Input methods + +The install keyboard picker selects a console keymap plus an optional Fcitx engine. `install/provisioning/setup-form.sh` is shared by the ISO configurator and deferred first-boot owner setup. Its rows are `label|console keymap|input method|desktop XKB override`; the last two fields are optional, so ordinary keyboard choices keep their existing meaning. + +The ISO carries the extra fields in `omarchy_install.input_method` and `omarchy_install.input_xkb_layout`. Both installation paths persist them in `/etc/omarchy/input-method` as `INPUT_METHOD` and `XKB_LAYOUT`. This file is parsed as data, never sourced. Korean has a real US console keymap and a `kr` desktop override; Japanese uses `jp106`, or US when the US-keyboard choice is selected. Console and disk-unlock input remain ordinary keymaps. + +`install/user/input-method.sh` calls `omarchy-input-method configure --seed` during user finalization, before graphical login. The standard engines are in the base package set so deferred provisioning and factory resets do not need network access to configure typing. `default/input-methods/presets.json` is the engine catalog used by the post-install selector. + +`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. + +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. Input toggles on Ctrl+Shift+Space, Zenkaku/Hankaku, or 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. + +The migration installs the standard engines and applies conservative defaults. It aligns an untouched Fcitx keyboard profile with the install layout, using the live controller when Fcitx is running. It leaves existing multilingual profiles and working trigger keys alone. Atomic file writes follow configuration symlinks, preserve file modes, and roll back on a failed seed or live setup so a retry can complete. Live rollback stops Fcitx before restoring disk, then reloads activation and restarts the unit. Migration bus errors defer profile alignment to the next login and do not restart input. If a pre-update daemon has not discovered a new engine, the migration aligns only its keyboard and prints the setup command. + +Input selection does not change the system locale, repository region, desktop language, or Latin interface font. A new language setup seeds a CJK fallback preference once; additional languages do not overwrite it. + +Focused checks live in `test/shell.d/input-method-test.sh`, `setup-form-test.sh`, `hyprland-keyboard-layout-test.sh`, and `hyprland-jis-bindings-test.sh`. `test/acceptance.d/input-methods-test.sh` exercises real composition in a disposable installed VM and restores the original user configuration. The ISO repository's keyboard tests cover transport and target persistence. Browser input must be verified against the shipped browser versions; the language selector is not a place to guess flags from older guides. + +Default key values are checked against [Fcitx global configuration](https://github.com/fcitx/fcitx5/blob/master/src/lib/fcitx/globalconfig.cpp). diff --git a/install/omarchy-base.packages b/install/omarchy-base.packages index 1981d883..fc991a09 100644 --- a/install/omarchy-base.packages +++ b/install/omarchy-base.packages @@ -34,7 +34,11 @@ eza fakeroot fastfetch fcitx5 +fcitx5-chewing +fcitx5-chinese-addons fcitx5-gtk +fcitx5-hangul +fcitx5-mozc fcitx5-qt fd ffmpeg diff --git a/install/provisioning/setup-form.sh b/install/provisioning/setup-form.sh index f4265d1d..8b82a77e 100644 --- a/install/provisioning/setup-form.sh +++ b/install/provisioning/setup-form.sh @@ -18,7 +18,8 @@ # so it never reaches the shell as SIGINT. Act on the status, never on a trap. # # Callers supply `notice ` for validation feedback, and set -# the variables these prompts write: keyboard, keyboard_label, username, +# the variables these prompts write: keyboard, keyboard_label, +# keyboard_input_method, keyboard_xkb_layout, username, # password, password_confirmation, full_name, email_address, hostname, timezone. OMARCHY_FORM_BACK=1 @@ -37,6 +38,8 @@ Azerbaijani|azerty Belarusian|by Belgian|be-latin1 Bulgarian|bg-cp1251 +Chinese (Simplified, Pinyin)|us|pinyin +Chinese (Traditional, Zhuyin)|us|chewing Croatian|croat Czech|cz Danish|dk-latin1 @@ -55,8 +58,10 @@ Hungarian|hu Icelandic|is-latin1 Irish|ie Italian|it -Japanese|jp106 +Japanese|jp106|mozc +Japanese (US keyboard)|us|mozc Kazakh|kazakh +Korean|us|hangul|kr Kyrgyz|kyrgyz Lao|la-latin1 Latvian|lv @@ -97,8 +102,22 @@ omarchy_prompt_keyboard() { gum choose --height 10 --selected "English (US)" --header "Select keyboard layout") && status=0 || status=$? ((status == 0)) || return $status - keyboard_label="$choice" - keyboard=$(printf '%s\n' "$OMARCHY_KEYBOARD_LAYOUTS" | awk -F'|' -v c="$choice" '$1==c{print $2; exit}') + local selection + selection=$(printf '%s\n' "$OMARCHY_KEYBOARD_LAYOUTS" | awk -F'|' -v c="$choice" '$1==c{print; exit}') + [[ -n $selection ]] || return 1 + IFS='|' read -r keyboard_label keyboard keyboard_input_method keyboard_xkb_layout <<< "$selection" + keyboard_input_method=${keyboard_input_method:-none} + keyboard_xkb_layout=${keyboard_xkb_layout:-} +} + +# Root-side persistence. Console keymaps remain real keymaps; the input +# preference survives separately for every user of the installed system. +omarchy_persist_input_selection() { + local target_root=${1:-} method=${keyboard_input_method:-none} layout=${keyboard_xkb_layout:-} + case "$method" in none | mozc | hangul | pinyin | chewing) ;; *) return 1 ;; esac + case "$layout" in "" | kr) ;; *) return 1 ;; esac + mkdir -p "$target_root/etc/omarchy" + printf 'INPUT_METHOD=%s\nXKB_LAYOUT=%s\n' "$method" "$layout" > "$target_root/etc/omarchy/input-method" } omarchy_prompt_username() { diff --git a/install/user/all.sh b/install/user/all.sh index d81c3e75..3f41caa3 100644 --- a/install/user/all.sh +++ b/install/user/all.sh @@ -2,6 +2,7 @@ run_logged "$OMARCHY_INSTALL/user/theme.sh" run_logged "$OMARCHY_INSTALL/user/chromium.sh" run_logged "$OMARCHY_INSTALL/user/git.sh" run_logged "$OMARCHY_INSTALL/user/xcompose.sh" +run_logged "$OMARCHY_INSTALL/user/input-method.sh" run_logged "$OMARCHY_INSTALL/user/mise-work.sh" run_logged "$OMARCHY_INSTALL/user/hardware/asus/fix-audio-mixer.sh" diff --git a/install/user/first-run/enable-user-units.sh b/install/user/first-run/enable-user-units.sh index e08885c9..3728e653 100755 --- a/install/user/first-run/enable-user-units.sh +++ b/install/user/first-run/enable-user-units.sh @@ -11,6 +11,8 @@ set -euo pipefail +omarchy-input-method configure --defaults || echo "Input method defaults deferred; run omarchy setup input after login." >&2 + systemctl --user daemon-reload systemctl --user enable --now \ bt-agent.service \ diff --git a/install/user/input-method.sh b/install/user/input-method.sh new file mode 100644 index 00000000..f4901f77 --- /dev/null +++ b/install/user/input-method.sh @@ -0,0 +1 @@ +omarchy-input-method configure --seed diff --git a/manual/34-keyboard-mouse-trackpad.md b/manual/34-keyboard-mouse-trackpad.md index 648e7713..202f46ad 100644 --- a/manual/34-keyboard-mouse-trackpad.md +++ b/manual/34-keyboard-mouse-trackpad.md @@ -59,7 +59,13 @@ On Dell XPS laptops with a haptic touchpad, you can also set the click strength ### Typing in Chinese, Japanese, and other languages -Omarchy runs the [fcitx5](https://fcitx-im.org/) input method framework as part of every session — it's what powers the CapsLock compose sequences. That means the plumbing for non-Latin input is already in place: install an input engine like `fcitx5-mozc` (Japanese) or `fcitx5-chinese-addons` (Chinese) with `omarchy pkg add`, plus `fcitx5-configtool` to add the engine to your input methods and set the key that switches between them. +Select Japanese, Korean, Chinese (Simplified, Pinyin), or Chinese (Traditional, Zhuyin) when the installer asks for your keyboard. Omarchy configures the keyboard and matching input engine automatically, including during offline first-boot setup. Japanese uses Mozc, Korean uses standard two-set Hangul, Simplified Chinese uses Pinyin, and Traditional Chinese uses Chewing's standard Zhuyin arrangement. Japanese (US keyboard) provides Mozc with a US keyboard layout. + +Typing starts in Latin mode. Press `Ctrl + Shift + Space` to switch input on or off. 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. + +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. + +On keyboards without dedicated input keys, use `Ctrl + Shift + Space`. This input shortcut takes precedence over app shortcuts such as code completion; you can change it in `fcitx5-configtool`. 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. ### Use ALT as SUPER diff --git a/migrations/1791367434.sh b/migrations/1791367434.sh new file mode 100644 index 00000000..84b5c255 --- /dev/null +++ b/migrations/1791367434.sh @@ -0,0 +1,4 @@ +echo "Configure shared input-method defaults and free desktop shortcuts" + +omarchy-pkg-add fcitx5-mozc fcitx5-hangul fcitx5-chinese-addons fcitx5-chewing +omarchy-input-method configure --defaults diff --git a/test/acceptance.d/input-methods-test.sh b/test/acceptance.d/input-methods-test.sh new file mode 100644 index 00000000..c147e894 --- /dev/null +++ b/test/acceptance.d/input-methods-test.sh @@ -0,0 +1,83 @@ +#!/bin/bash + +# VM-only: exercises the shared setup command and real composition for every +# shipped engine. Restore the profile, shortcut settings, and font preference. +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +work=$(mktemp -d) +config_home=${XDG_CONFIG_HOME:-$HOME/.config} +data_home=${XDG_DATA_HOME:-$HOME/.local/share} +paths=("$config_home/fcitx5" "$config_home/fontconfig/conf.d/50-omarchy-input-method.conf" "$data_home/dbus-1/services/org.fcitx.Fcitx5.service") +for (( i = 0; i < ${#paths[@]}; i++ )); do + if [[ -e ${paths[i]} || -L ${paths[i]} ]]; then + cp -a "${paths[i]}" "$work/backup-$i" + fi +done + +cleanup() { + close_windows '^org\.omarchy\.ime-test$' + systemctl --user stop omarchy-fcitx5.service + for (( i = 0; i < ${#paths[@]}; i++ )); do + rm -rf "${paths[i]}" + if [[ -e $work/backup-$i || -L $work/backup-$i ]]; then + mkdir -p "$(dirname "${paths[i]}")" + cp -a "$work/backup-$i" "${paths[i]}" + fi + done + busctl --user call org.freedesktop.DBus /org/freedesktop/DBus org.freedesktop.DBus ReloadConfig >/dev/null + systemctl --user start omarchy-fcitx5.service + rm -rf "$work" +} +trap cleanup EXIT + +cat > "$work/reader" <<'SH' +#!/bin/bash +IFS= read -r text +printf '%s' "$text" > "$1" +sleep 1 +SH +chmod +x "$work/reader" + +installed=() +for method in mozc hangul pinyin chewing; do + omarchy-setup-input "$method" > "$ARTIFACTS/setup-input-$method.log" 2>&1 || + fail "$method setup completes" "$(cat "$ARTIFACTS/setup-input-$method.log")" + group=$(fcitx5-remote -q) + info=$(busctl --user --json=short call org.fcitx.Fcitx5 /controller org.fcitx.Fcitx.Controller1 InputMethodGroupInfo s "$group") + installed+=("$method") + for retained in "${installed[@]}"; do + jq -e --arg method "$retained" '.data[1] | any(.[0] == $method)' <<< "$info" >/dev/null || fail "$retained is still registered" + done + owner_pid=$(busctl --user call org.freedesktop.DBus /org/freedesktop/DBus org.freedesktop.DBus GetConnectionUnixProcessID s org.fcitx.Fcitx5 | awk '{print $2}') + unit_pid=$(systemctl --user show omarchy-fcitx5.service --property MainPID --value) + [[ $owner_pid == "$unit_pid" ]] || fail "Fcitx bus owner is the supervised process" + + launch_app "foot --app-id=org.omarchy.ime-test $work/reader $work/result" + wait_until "$method test terminal opens" 15 window_present '^org\.omarchy\.ime-test$' + [[ $(fcitx5-remote) == "1" ]] || fail "$method setup starts in Latin mode" + fcitx5-remote -s "$method" + fcitx5-remote -c + wtype -M ctrl -M shift -k space -m shift -m ctrl + case "$method" in + mozc) wtype "nihongo"; wtype -k space ;; + hangul) wtype "gksrmf" ;; + pinyin) wtype "nihao"; wtype -k space ;; + chewing) wtype "su3cl3" ;; + esac + sleep 1 + screenshot "success-input-$method-composition" + wtype -k Return + sleep 0.3 + wtype -k Return + wait_until "$method commits text" 15 test -s "$work/result" + case "$method" in + mozc) grep -Fxq "日本語" "$work/result" || fail "Mozc commits Japanese" ;; + hangul) grep -Fxq "한글" "$work/result" || fail "Hangul commits Korean" ;; + *) python -c 'import pathlib,sys; assert any("\u4e00" <= c <= "\u9fff" for c in pathlib.Path(sys.argv[1]).read_text())' "$work/result" || fail "$method commits Han characters" ;; + esac + pass "$method commits composed text" + fcitx5-remote -c + close_windows '^org\.omarchy\.ime-test$' + rm -f "$work/result" +done diff --git a/test/shell.d/hyprland-jis-bindings-test.sh b/test/shell.d/hyprland-jis-bindings-test.sh new file mode 100755 index 00000000..5a842d8f --- /dev/null +++ b/test/shell.d/hyprland-jis-bindings-test.sh @@ -0,0 +1,128 @@ +#!/bin/bash + +set -euo pipefail + +source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh" + +require_command lua + +test_dir=$(mktemp -d) +trap 'rm -rf "$test_dir"' EXIT + +# Load the JIS bindings on a jp layout and fire one of them in a fake window, +# printing what it hands to Hyprland. TEST_FCITX5_CONFIG is the fcitx5 config, +# or unset for none. +fire() { + local keys="$1" class="$2" tags="${3-}" + local home="$test_dir/home" + + rm -rf "$home" + mkdir -p "$home/.config/fcitx5" + if [[ -v TEST_FCITX5_CONFIG ]]; then + printf '%s\n' "$TEST_FCITX5_CONFIG" >"$home/.config/fcitx5/config" + fi + + if [[ ${TEST_KEYBOARD_ONLY:-false} == "true" ]]; then + printf '[Groups/0/Items/0]\nName=keyboard-us\n' > "$home/.config/fcitx5/profile" + else + printf '[Groups/0/Items/0]\nName=keyboard-us\n[Groups/0/Items/1]\nName=mozc\n' > "$home/.config/fcitx5/profile" + fi + + HOME="$home" XDG_CONFIG_HOME="$home/.config" OMARCHY_PATH="$ROOT" \ + TEST_KEYS="$keys" TEST_CLASS="$class" TEST_TAGS="$tags" TEST_LAYOUT="${TEST_LAYOUT:-jp}" lua <<'LUA' +package.path = os.getenv("OMARCHY_PATH") .. "/?.lua;" .. package.path + +local real_open = io.open +io.open = function(path, mode) + if path == "/etc/vconsole.conf" then + local file = io.tmpfile() + file:write("XKBLAYOUT=" .. os.getenv("TEST_LAYOUT") .. "\n") + file:seek("set") + return file + end + + return real_open(path, mode) +end + +local binds = {} +o = { + bind = function(keys, description, dispatcher, options) + binds[keys] = { dispatcher = dispatcher, options = options or {} } + end, +} + +local window = { class = os.getenv("TEST_CLASS"), tags = {} } +for tag in os.getenv("TEST_TAGS"):gmatch("[^,]+") do + table.insert(window.tags, tag) +end + +hl = { + dsp = { + send_key_state = function(key) + return ("send %s %s %s"):format(key.mods, key.key, key.state) + end, + }, + dispatch = function(action) print(action) end, + timer = function(callback) callback() end, + get_active_window = function() return window end, + exec_cmd = function(command) print("exec " .. command) end, +} + +o.shell_succeeds = function(command) print("exec " .. command) end + +require("default.hypr.bindings.input-method") +require("default.hypr.bindings.jis") + +local bind = binds[os.getenv("TEST_KEYS")] +if bind.options.non_consuming then + print("non_consuming") +end +bind.dispatcher() +LUA +} + +assert_fires() { + local description="$1" expected="$2" + shift 2 + local actual + + actual=$(fire "$@") + [[ $actual == "$expected" ]] || + fail "$description" "expected: $expected"$'\n'"actual: $actual" + pass "$description" +} + +zoom="CTRL + SHIFT + semicolon" +assert_fires "the JIS + key zooms in with Ctrl + keypad plus" \ + $'send CTRL KP_Add down\nsend CTRL KP_Add up' "$zoom" "chromium" +assert_fires "the JIS + key zooms in windows without a class" \ + $'send CTRL KP_Add down\nsend CTRL KP_Add up' "$zoom" "" +assert_fires "the JIS + key zooms Obsidian in with Ctrl + ^" \ + $'send CTRL asciicircum down\nsend CTRL asciicircum up' "$zoom" "obsidian" +assert_fires "the JIS + key zooms the Obsidian Flatpak in with Ctrl + ^" \ + $'send CTRL asciicircum down\nsend CTRL asciicircum up' "$zoom" "md.obsidian.Obsidian" + +# The prefix bind has to pass Ctrl + Space on, or tmux and Herdr never see it. +prefix="CTRL + SPACE" +freed=$'[Hotkey/TriggerKeys]\n0=Zenkaku_Hankaku\n\n[Hotkey/ActivateKeys]\n0=Henkan' +TEST_FCITX5_CONFIG=$freed assert_fires "the terminal prefix switches to direct input once Ctrl + Space no longer toggles" \ + $'non_consuming\nexec timeout 0.3 fcitx5-remote --check -c' "$prefix" "com.mitchellh.ghostty" "terminal*" +TEST_FCITX5_CONFIG=$freed assert_fires "the prefix leaves input alone outside terminals" \ + "non_consuming" "$prefix" "chromium" "browser" + +# While fcitx5 still toggles on Ctrl + Space, switching to direct input would +# undo the toggle it just made. +assert_fires "the prefix leaves input alone without an fcitx5 config" \ + "non_consuming" "$prefix" "com.mitchellh.ghostty" "terminal*" +TEST_FCITX5_CONFIG=$'[Behavior]\nShareInputState=No' assert_fires "the prefix leaves input alone with the default trigger keys" \ + "non_consuming" "$prefix" "com.mitchellh.ghostty" "terminal*" +TEST_FCITX5_CONFIG=$'[Hotkey/TriggerKeys]\n0=Zenkaku_Hankaku\n1=Control+space' assert_fires "the prefix leaves input alone while Ctrl + Space is a trigger key" \ + "non_consuming" "$prefix" "com.mitchellh.ghostty" "terminal*" + +for layout in us kr; do + TEST_LAYOUT="$layout" TEST_FCITX5_CONFIG=$freed assert_fires "the terminal prefix works with $layout keyboards too" \ + $'non_consuming\nexec timeout 0.3 fcitx5-remote --check -c' "$prefix" "foot" "terminal" +done + +TEST_KEYBOARD_ONLY=true TEST_FCITX5_CONFIG=$freed assert_fires "keyboard-only profiles skip the terminal controller call" \ + "non_consuming" "$prefix" "foot" "terminal" diff --git a/test/shell.d/hyprland-keyboard-layout-test.sh b/test/shell.d/hyprland-keyboard-layout-test.sh index cd2bc5e2..6bc4e4ac 100644 --- a/test/shell.d/hyprland-keyboard-layout-test.sh +++ b/test/shell.d/hyprland-keyboard-layout-test.sh @@ -13,6 +13,15 @@ local vconsole = os.getenv("OMARCHY_VCONSOLE") local real_open = io.open io.open = function(path, mode) + if path == "/etc/omarchy/input-method" then + local preference = os.getenv("TEST_INPUT_PREFERENCE") + if not preference then return nil end + local file = io.tmpfile() + file:write(preference) + file:seek("set") + return file + end + if path ~= "/etc/vconsole.conf" then return real_open(path, mode) end @@ -128,3 +137,5 @@ sddm_layouts=$(sed -n '/^local non_latin_layouts =/,+1p' "$sddm_lua" | grep -o ' [[ $hooks_layouts == "$sddm_layouts" ]] || fail "greeter non-latin layout list stays in sync" "$(diff <(echo "$hooks_layouts") <(echo "$sddm_layouts"))" 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 desktop layout and clears the US variant" "[kr] [] [$base_options]" $'XKBLAYOUT=us\nXKBVARIANT=intl\n' diff --git a/test/shell.d/input-method-checks.py b/test/shell.d/input-method-checks.py new file mode 100644 index 00000000..0b595ea6 --- /dev/null +++ b/test/shell.d/input-method-checks.py @@ -0,0 +1,251 @@ +import argparse +import importlib.util +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + + +ROOT = Path(os.environ["OMARCHY_PATH"]) +spec = importlib.util.spec_from_file_location("input_setup", ROOT / "default/input-methods/configure.py") +setup = importlib.util.module_from_spec(spec) +spec.loader.exec_module(setup) + + +class InputMethodTest(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.home = Path(self.temporary.name) + self.config = self.home / "config" + self.vconsole = self.home / "vconsole" + self.preference = self.home / "preference" + self.vconsole.write_text("XKBLAYOUT=us\n") + self.preference.write_text("INPUT_METHOD=none\n") + env = {"XDG_CONFIG_HOME": str(self.config), "XDG_DATA_HOME": str(self.home / "data"), "OMARCHY_VCONSOLE": str(self.vconsole), "OMARCHY_INPUT_SELECTION": str(self.preference)} + self.environment = patch.dict(os.environ, env) + self.environment.start() + self.addCleanup(self.environment.stop) + self.process = patch.object(setup.subprocess, "run", return_value=subprocess.CompletedProcess([], 1, stdout="", stderr="")) + self.process.start() + self.addCleanup(self.process.stop) + + def seed(self, method=None): + setup.configure(argparse.Namespace(method=method, seed=True, defaults=False)) + + def test_every_installer_engine_is_seeded_and_inactive(self): + for method in ("mozc", "hangul", "pinyin", "chewing"): + with self.subTest(method=method): + profile = self.config / "fcitx5/profile" + profile.unlink(missing_ok=True) + self.preference.write_text(f"INPUT_METHOD={method}\nXKB_LAYOUT={'kr' if method == 'hangul' else ''}\n") + self.seed() + data = setup.sections(profile.read_text()) + self.assertEqual(data["Groups/0"]["DefaultIM"], method) + self.assertEqual(data["Groups/0/Items/1"]["Name"], method) + self.assertEqual(data["Groups/0/Items/0"]["Name"], "keyboard-kr" if method == "hangul" else "keyboard-us") + keys = setup.sections((self.config / "fcitx5/config").read_text()) + self.assertEqual(keys["Behavior"]["ActiveByDefault"], "False") + self.assertNotIn("Control+space", keys["Hotkey/TriggerKeys"].values()) + self.assertIn("Control+Shift+space", keys["Hotkey/TriggerKeys"].values()) + + def test_vconsole_variants_quotes_and_japanese_without_preference(self): + self.preference.unlink() + self.vconsole.write_text('XKBLAYOUT="jp" # JIS\n') + self.assertEqual(setup.selection(), ("mozc", "jp")) + self.vconsole.write_text("XKBLAYOUT='de'\nXKBVARIANT=nodeadkeys\n") + self.seed() + self.assertIn("Default Layout=de-nodeadkeys", (self.config / "fcitx5/profile").read_text()) + self.vconsole.write_text("XKBLAYOUT=ru\nXKBVARIANT=phonetic\n") + self.assertEqual(setup.selection(), ("none", "us")) + + def test_rerun_preserves_custom_multilingual_profile(self): + self.seed("mozc") + profile = self.config / "fcitx5/profile" + original = profile.read_bytes() + b"\n# custom configuration\n" + profile.write_bytes(original) + self.seed("hangul") + self.assertEqual(profile.read_bytes(), original) + + def test_stock_profile_is_repaired_but_custom_single_keyboard_is_not(self): + self.seed() + self.vconsole.write_text("XKBLAYOUT=fr\n") + self.seed() + profile = self.config / "fcitx5/profile" + self.assertIn("keyboard-fr", profile.read_text()) + self.seed("mozc") + self.assertNotIn("Name=mozc", profile.read_text()) + + def test_existing_shortcuts_and_addon_options_are_preserved(self): + path = self.config / "fcitx5/config" + setup.atomic_write(path, "# user's keys\n[Hotkey/TriggerKeys]\n0=Alt+space\n\n[Behavior]\nActiveByDefault=True\n") + quickphrase = self.config / "fcitx5/conf/quickphrase.conf" + setup.atomic_write(quickphrase, "[TriggerKey]\n\n") + setup.defaults(self.config, fresh=True) + config = setup.sections(path.read_text()) + self.assertEqual(config["Hotkey/TriggerKeys"], {"0": "Alt+space"}) + self.assertEqual(config["Behavior"], {"ActiveByDefault": "True"}) + self.assertEqual(quickphrase.read_text(), "[TriggerKey]\n\n") + + def test_generated_stock_shortcuts_are_repaired(self): + path = self.config / "fcitx5/config" + setup.atomic_write(path, "[Hotkey/TriggerKeys]\n0=Control+space\n1=Zenkaku_Hankaku\n2=Hangul\n\n[Hotkey/EnumerateGroupForwardKeys]\n0=Super+space\n") + setup.defaults(self.config, fresh=True) + config = setup.sections(path.read_text()) + self.assertEqual(config["Hotkey/EnumerateGroupForwardKeys"], {}) + self.assertNotIn("Control+space", config["Hotkey/TriggerKeys"].values()) + + def test_live_append_keeps_every_method_and_layout(self): + info = json.dumps({"data": ["de-nodeadkeys", [["keyboard-de-nodeadkeys", ""], ["mozc", "jp"]]]}) + updated = json.dumps({"data": ["de-nodeadkeys", [["keyboard-de-nodeadkeys", ""], ["mozc", "jp"], ["hangul", ""]]]}) + with patch.object(setup, "run", side_effect=["Work", info, "", "", "Work", updated]) as calls: + setup.live_append("hangul") + self.assertEqual(calls.call_args_list[2].args[0][-10:], ["ssa(ss)", "Work", "de-nodeadkeys", "3", "keyboard-de-nodeadkeys", "", "mozc", "jp", "hangul", ""]) + self.assertEqual(calls.call_args_list[3].args[0][-1], "Save") + info = json.dumps({"data": ["us", [["keyboard-us", ""], ["hangul", ""]]]}) + with patch.object(setup, "run", side_effect=["Default", info]) as calls: + setup.live_append("hangul") + self.assertEqual(calls.call_count, 2) + + def test_seed_in_a_live_desktop_is_a_harmless_retry(self): + self.seed() + profile = self.config / "fcitx5/profile" + original = profile.read_bytes() + with patch.object(setup.subprocess, "run", return_value=subprocess.CompletedProcess([], 0)): + self.seed("mozc") + self.assertEqual(profile.read_bytes(), original) + + def test_failed_seed_rolls_back_so_it_can_be_retried(self): + with patch.object(setup, "defaults", side_effect=OSError("disk full")): + with self.assertRaises(OSError): + self.seed("mozc") + self.assertFalse((self.config / "fcitx5/profile").exists()) + self.seed("mozc") + self.assertIn("Name=mozc", (self.config / "fcitx5/profile").read_text()) + + def test_failed_live_setup_restores_disk_and_restarts_the_daemon(self): + self.seed() + profile = self.config / "fcitx5/profile" + original = profile.read_bytes() + events = [] + def append(method): + profile.write_text("failed in-memory state") + raise RuntimeError("save failed") + def command(args): + events.append(args) + if args == ["omarchy-restart-xcompose"] and len(events) > 2: + self.assertEqual(profile.read_bytes(), original) + return "" + probes = 0 + def process(args, **kwargs): + nonlocal probes + if args[0] == "pgrep": + probes += 1 + return subprocess.CompletedProcess([], 0 if probes == 1 else 1) + return subprocess.CompletedProcess([], 0) + with patch.object(setup.subprocess, "run", side_effect=process), patch.object(setup, "run", side_effect=command), patch.object(setup, "wait_ready"), patch.object(setup, "live_append", side_effect=append), patch.object(setup.time, "sleep"): + with self.assertRaisesRegex(RuntimeError, "save failed"): + setup.configure(argparse.Namespace(method="mozc", seed=False, defaults=False)) + self.assertEqual(profile.read_bytes(), original) + self.assertIn(["systemctl", "--user", "stop", "omarchy-fcitx5.service"], events) + self.assertEqual(events.count(["omarchy-restart-xcompose"]), 2) + + def test_readiness_checks_the_dbus_array_payload_and_group(self): + available = json.dumps({"data": [[["mozc", "Mozc", "", "", "", "ja", False]]]}) + with patch.object(setup, "run", return_value=available), patch.object(setup, "live_group", return_value=("Default", "us", [["keyboard-us", ""]])): + setup.wait_ready("mozc") + + def test_a_silently_dropped_engine_is_rejected_and_group_restored(self): + old = ("Default", "us", [["keyboard-us", ""]]) + with patch.object(setup, "live_group", side_effect=[old, old]), patch.object(setup, "live_set") as setter: + with self.assertRaisesRegex(RuntimeError, "retain"): + setup.live_append("mozc") + self.assertEqual(setter.call_args_list[-1].args, old) + + def test_migration_without_a_bus_keeps_written_activation_and_defaults(self): + self.seed() + with patch.object(setup, "run", side_effect=subprocess.CalledProcessError(1, "busctl")): + setup.configure(argparse.Namespace(method=None, seed=False, defaults=True)) + self.assertTrue((self.home / "data/dbus-1/services/org.fcitx.Fcitx5.service").exists()) + + def test_live_migration_without_a_bus_does_not_restart_or_rewrite_profile(self): + self.seed() + profile = self.config / "fcitx5/profile" + original = profile.read_bytes() + with patch.object(setup.subprocess, "run", return_value=subprocess.CompletedProcess([], 0)), patch.object(setup, "run", side_effect=subprocess.CalledProcessError(1, "busctl")) as calls: + setup.configure(argparse.Namespace(method=None, seed=False, defaults=True)) + self.assertEqual(profile.read_bytes(), original) + self.assertFalse(any(call.args[0] == ["omarchy-restart-xcompose"] for call in calls.call_args_list)) + + def test_migration_does_not_add_an_engine_the_old_daemon_cannot_load(self): + self.seed() + self.vconsole.write_text("XKBLAYOUT=jp\n") + self.preference.write_text("INPUT_METHOD=mozc\n") + old = ("Default", "us", [["keyboard-us", ""]]) + new = ("Default", "jp", [["keyboard-jp", ""]]) + with patch.object(setup.subprocess, "run", return_value=subprocess.CompletedProcess([], 0)), patch.object(setup, "live_group", side_effect=[old, new]), patch.object(setup, "available_methods", return_value=[]), patch.object(setup, "live_set") as setter: + setup.configure(argparse.Namespace(method=None, seed=False, defaults=True)) + self.assertEqual(setter.call_args.args, new) + + def test_custom_keyboard_overrides_are_not_stock(self): + self.assertFalse(setup.stock_profile(setup.profile_text("de", "none").replace("keyboard-de", "keyboard-us"))) + self.assertFalse(setup.stock_profile(setup.profile_text("us", "none").replace("Layout=\n", "Layout=ru\n"))) + + def test_selected_live_engine_is_verified_after_the_context_reconnects(self): + self.seed("mozc") + profile = self.config / "fcitx5/profile" + saves = 0 + def command(args): + nonlocal saves + if args == setup.CONTROLLER + ["Save"]: + saves += 1 + if saves == 2: + profile.write_text(profile.read_text().replace("DefaultIM=mozc", "DefaultIM=hangul")) + return "Default" if args == ["fcitx5-remote", "-q"] else "" + with patch.object(setup, "run", side_effect=command) as commands, patch.object(setup.time, "sleep"): + setup.select_live_method("hangul", profile) + self.assertEqual(saves, 2) + self.assertEqual(sum(call.args[0] == ["fcitx5-remote", "-c"] for call in commands.call_args_list), 2) + + def test_live_setup_does_not_validate_an_unused_install_layout(self): + self.seed("mozc") + self.vconsole.write_text("XKBLAYOUT=invalid value\n") + with patch.object(setup.subprocess, "run", return_value=subprocess.CompletedProcess([], 0)), patch.object(setup, "run", return_value=""), patch.object(setup, "wait_ready"), patch.object(setup, "live_append"), patch.object(setup, "select_live_method"): + setup.configure(argparse.Namespace(method="hangul", seed=False, defaults=False)) + + def test_live_configuration_requires_a_desktop(self): + with self.assertRaisesRegex(RuntimeError, "Log in"): + setup.configure(argparse.Namespace(method="mozc", seed=False, defaults=False)) + self.assertFalse((self.config / "fcitx5/profile").exists()) + + def test_atomic_updates_keep_symlinks_and_modes(self): + target = self.home / "real-config" + target.write_text("before") + target.chmod(0o600) + link = self.home / "link" + link.symlink_to(target) + setup.atomic_write(link, "after") + self.assertTrue(link.is_symlink()) + self.assertEqual(target.read_text(), "after") + self.assertEqual(target.stat().st_mode & 0o777, 0o600) + + def test_input_preferences_are_data_and_invalid_values_fail(self): + self.preference.write_text("INPUT_METHOD=$(touch /tmp/should-not-exist)\n") + with self.assertRaises(ValueError): + self.seed() + + def test_font_fallback_does_not_replace_existing_preference(self): + setup.font_default(self.config, "mozc") + font = self.config / "fontconfig/conf.d/50-omarchy-input-method.conf" + self.assertIn("Noto Sans CJK JP", font.read_text()) + self.assertIn("Noto Sans Mono CJK JP", font.read_text()) + original = font.read_bytes() + setup.font_default(self.config, "hangul") + self.assertEqual(font.read_bytes(), original) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/shell.d/input-method-test.sh b/test/shell.d/input-method-test.sh new file mode 100644 index 00000000..054d1d7b --- /dev/null +++ b/test/shell.d/input-method-test.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +set -euo pipefail +source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" + +OMARCHY_PATH="$ROOT" python "$ROOT/test/shell.d/input-method-checks.py" diff --git a/test/shell.d/owe-integration-test.sh b/test/shell.d/owe-integration-test.sh index cde79db2..c229dcab 100644 --- a/test/shell.d/owe-integration-test.sh +++ b/test/shell.d/owe-integration-test.sh @@ -6,11 +6,12 @@ work=$(mktemp -d) trap 'rm -rf "$work"' EXIT mkdir -p "$work/bin" "$work/home" export TEST_CALLS="$work/calls" -for command in omarchy-pkg-add omarchy-hook-install systemctl; do +for command in omarchy-pkg-add omarchy-hook-install omarchy-input-method systemctl; do cat >"$work/bin/$command" <<'SH' #!/bin/bash printf '%s %s\n' "${0##*/}" "$*" >>"$TEST_CALLS" case "$*" in + 'configure --defaults') [[ ${TEST_INPUT_FAIL:-0} == 0 ]] ;; '--user enable owed.service') [[ ${TEST_TTY:-0} == 0 ]] ;; '--user is-active --quiet graphical-session.target') [[ ${TEST_TTY:-0} == 0 ]] ;; '--user start owed.service') [[ ${TEST_START_FAIL:-0} == 0 ]] ;; @@ -58,6 +59,12 @@ grep -E '^systemctl --user enable --now .*owed.service' "$TEST_CALLS" >/dev/null grep -Fx 'omarchy-hook-install theme-set /usr/share/owe/10-owe-sync' "$TEST_CALLS" >/dev/null pass "fresh installs enable OWE and install the theme refresh hook" +: >"$TEST_CALLS" +HOME="$work/home" PATH="$work/bin:$PATH" TEST_INPUT_FAIL=1 bash "$ROOT/install/user/first-run/enable-user-units.sh" +grep -Fx 'omarchy-input-method configure --defaults' "$TEST_CALLS" >/dev/null +grep -E '^systemctl --user enable --now .*owed.service' "$TEST_CALLS" >/dev/null +pass "failed input defaults do not block enabling first-run services" + cat >"$work/bin/ffmpegthumbnailer" <<'SH' #!/bin/bash printf 'thumbnail\n' >>"$TEST_CALLS" diff --git a/test/shell.d/setup-form-test.sh b/test/shell.d/setup-form-test.sh index 2b9878eb..1cefa560 100755 --- a/test/shell.d/setup-form-test.sh +++ b/test/shell.d/setup-form-test.sh @@ -63,6 +63,8 @@ trap 'if [[ ${FUNCNAME[0]:-} == "$PROMPT_FN" ]]; then printf "returned\n" >>"$MA printf 'keyboard=%s\n' "${keyboard:-}" printf 'keyboard_label=%s\n' "${keyboard_label:-}" +printf 'keyboard_input_method=%s\n' "${keyboard_input_method:-}" +printf 'keyboard_xkb_layout=%s\n' "${keyboard_xkb_layout:-}" printf 'username=%s\n' "${username:-}" printf 'password=%s\n' "${password:-}" printf 'password_confirmation=%s\n' "${password_confirmation:-}" @@ -133,6 +135,19 @@ assert_status 0 "keyboard prompt succeeds" grep -qF -- '--selected English (US)' "$GUM_ARGS" || fail "keyboard prompt preselects English (US)" pass "keyboard prompt maps the chosen label to its keymap" +for selection in 'Japanese|jp106|mozc|' 'Japanese (US keyboard)|us|mozc|' 'Korean|us|hangul|kr' 'Chinese (Simplified, Pinyin)|us|pinyin|' 'Chinese (Traditional, Zhuyin)|us|chewing|'; do + IFS='|' read -r label keymap method layout <<< "$selection" + run_prompt omarchy_prompt_keyboard "0:$label" + assert_status 0 "$label can be selected at install" + [[ $(field keyboard) == "$keymap" && $(field keyboard_input_method) == "$method" && $(field keyboard_xkb_layout) == "$layout" ]] || + fail "$label carries a real console keymap and a separate input preference" +done +pass "language keyboard choices carry their keymap, input method, and desktop override" + +keyboard_input_method=hangul keyboard_xkb_layout=kr omarchy_persist_input_selection "$tmp_dir/target" +[[ $(<"$tmp_dir/target/etc/omarchy/input-method") == $'INPUT_METHOD=hangul\nXKB_LAYOUT=kr' ]] || fail "first-boot setup persists the selection for user finalization" +pass "first-boot setup persists the selection for user finalization" + run_prompt omarchy_prompt_keyboard "1:" assert_status "$OMARCHY_FORM_BACK" "keyboard prompt reports Esc as back" assert_returned "keyboard prompt survives Esc under set -e" diff --git a/test/shell.d/systemd-test.sh b/test/shell.d/systemd-test.sh index ce7b79b7..66074c8b 100755 --- a/test/shell.d/systemd-test.sh +++ b/test/shell.d/systemd-test.sh @@ -71,7 +71,7 @@ grep -Fx 'WantedBy=graphical-session.target' "$fcitx_service" >/dev/null || grep -Fx 'ConditionEnvironment=WAYLAND_DISPLAY' "$fcitx_service" >/dev/null || fail "an update over SSH has a live user manager and no display; starting fcitx5 there wedges the unit active-but-blind, and Wants= will not replace it at graphical login" -grep -F 'pkill -x fcitx5' "$ROOT/bin/omarchy-restart-xcompose" >/dev/null || +grep -F 'pkill -u "$UID" -x fcitx5' "$ROOT/bin/omarchy-restart-xcompose" >/dev/null || fail "restart-xcompose cannot reload a fcitx5 running outside the unit, so it silently keeps serving the old table" grep -F 'omarchy-fcitx5.service' "$first_run_units" >/dev/null ||