Unify input methods with the installer keyboard selection

This commit is contained in:
David Heinemeier Hansson committed 2026-10-07 12:59:36 +02:00
1 parent 402128b6a2
commit 9fe6a04715
34 files changed
+1207 -31

No files matched your search

+9
View File
@@ -0,0 +1,9 @@
#!/bin/bash
# omarchy:summary=Configure or inspect the input method selected at installation
# omarchy:args=<status|configure [--seed|--defaults] [none|mozc|hangul|pinyin|chewing]>
# omarchy:hidden=true
set -euo pipefail
python "$OMARCHY_PATH/default/input-methods/configure.py" "$@"
+1
View File
@@ -599,6 +599,7 @@ keyboard_form() {
done
apply_keyboard "$keyboard"
omarchy_persist_input_selection
}
confirm_reboot() {
+22 -1
View File
@@ -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
+30
View File
@@ -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."
+2
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
[TriggerKey]
0=Super+semicolon
+1
View File
@@ -0,0 +1 @@
Allow Overriding System XKB Settings=False
+20
View File
@@ -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
+66
View File
@@ -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 })
+43
View File
@@ -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)
+2 -21
View File
@@ -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 ""
+47
View File
@@ -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
+2
View File
@@ -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")
+378
View File
@@ -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'<alias><family>{family}</family><accept><family>Noto {face} CJK {variant}</family></accept></alias>')
atomic_write(path, '<?xml version="1.0"?>\n<!DOCTYPE fontconfig SYSTEM "urn:fontconfig:fonts.dtd">\n<fontconfig>\n' + "\n".join(rules) + "\n</fontconfig>\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}")
@@ -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
+7
View File
@@ -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"}
}
+1
View File
@@ -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"},
+3 -2
View File
@@ -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
+21
View File
@@ -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).
+4
View File
@@ -34,7 +34,11 @@ eza
fakeroot
fastfetch
fcitx5
fcitx5-chewing
fcitx5-chinese-addons
fcitx5-gtk
fcitx5-hangul
fcitx5-mozc
fcitx5-qt
fd
ffmpeg
+23 -4
View File
@@ -18,7 +18,8 @@
# so it never reaches the shell as SIGINT. Act on the status, never on a trap.
#
# Callers supply `notice <message> <seconds>` 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() {
+1
View File
@@ -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"
@@ -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 \
+1
View File
@@ -0,0 +1 @@
omarchy-input-method configure --seed
+7 -1
View File
@@ -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
+4
View File
@@ -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
+83
View File
@@ -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
+128
View File
@@ -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"
@@ -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'
+251
View File
@@ -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()
+6
View File
@@ -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"
+8 -1
View File
@@ -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"
+15
View File
@@ -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"
+1 -1
View File
@@ -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 ||