Add engines from the command line through the Typing menu's path

omarchy setup input <engine> had its own restart, append, select and
live-rollback code in configure.py, parallel to the menu's. It now
installs the packages and appends through typing_setup, leaving
configure.py with install-time seeding and update defaults.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
David Heinemeier HanssonandClaude Opus 5.5 committed 2026-10-11 07:54:21 +09:00
1 parent b0af02555e
commit cf9e8f3625
7 files changed
+70 -193

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
#!/bin/bash
# omarchy:summary=Configure, inspect, or switch input methods
# omarchy:args=<status|cycle [back]|configure [--seed|--defaults] [none|mozc|hangul|pinyin|chewing]>
# omarchy:args=<status|cycle [back]|configure --seed|--defaults>
# omarchy:hidden=true
set -euo pipefail
+1 -1
View File
@@ -25,5 +25,5 @@ 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"
python "$OMARCHY_PATH/default/input-methods/typing_setup.py" add "$method"
echo "Input method added. Use Super+I or the Japanese/Korean input key to switch; existing custom Fcitx keys are preserved."
+39 -112
View File
@@ -1,4 +1,4 @@
"""One Fcitx setup path for offline finalization and a running desktop."""
"""Fcitx defaults for offline finalization and updates."""
import argparse
from contextlib import contextmanager
@@ -131,41 +131,19 @@ def activation_default():
@contextmanager
def transaction(config_home, live=False):
def transaction(config_home):
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", "conf/pinyin.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
except BaseException:
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
@@ -260,109 +238,59 @@ def wait_ready(method):
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):
with transaction(config_home):
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
method, layout = selection()
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:
if not stock_profile(original):
return
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))
return
fresh = stock_profile(original)
changed = defaults(config_home, fresh=fresh)
if activation_default():
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)
# 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)
def main():
@@ -370,10 +298,9 @@ def main():
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 = configure_parser.add_mutually_exclusive_group(required=True)
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()
+13
View File
@@ -140,6 +140,13 @@ def save_inputs(selected, overrides=None):
raise
def add_input(method):
group, layout, items = setup.live_group()
current = [name for name, override in items if not name.startswith("keyboard-")]
if method not in current:
save_inputs(current + [method])
def keyboard_values(selected):
if not selected:
raise ValueError("Select at least one keyboard layout")
@@ -217,6 +224,12 @@ def apply_selection(kind, context, values):
def main():
if sys.argv[1:2] == ["add"] and len(sys.argv) == 3:
try:
add_input(sys.argv[2])
except (OSError, ValueError, RuntimeError, subprocess.CalledProcessError) as error:
raise SystemExit(f"Input method could not be added: {error}")
return
applying = len(sys.argv) == 5 and sys.argv[1] == "apply"
kind = sys.argv[2] if applying else sys.argv[1] if len(sys.argv) == 2 else ""
if kind not in ("input", "keyboard"):
+2 -2
View File
@@ -8,7 +8,7 @@ The ISO carries the extra fields in `omarchy_install.input_method` and `omarchy_
`default/input-methods/configure.py` owns the shared configuration behavior. Offline seeding replaces only a missing profile or Fcitx's untouched single `keyboard-us` guess. The group layout matches Hyprland's effective leading layout and variant; non-Latin layouts retain the US-leading behavior used for desktop bindings. The selected composition engine is the group's `DefaultIM`, with `ActiveByDefault=False` so initial typing remains Latin. Existing multilingual profiles and explicitly chosen keyboard layouts are preserved.
`omarchy setup input <engine>` installs the selected engine, restarts the supervised Fcitx unit so it discovers new addons, and appends the engine through `InputMethodGroupInfo`, `SetInputMethodGroupInfo`, and `Save`. It keeps the group name, layout, existing methods, and their layout overrides. There is no parallel unsupervised daemon. A per-user D-Bus activation file delegates `org.fcitx.Fcitx5` to `omarchy-fcitx5.service`; it is loaded before live restart.
`omarchy setup input <engine>` installs the selected engine's packages, then appends it through the same path as the Input Methods picker. Selecting an engine whose package is installed but which the running Fcitx has not discovered (one an update installed, say) restarts the supervised Fcitx unit once and waits for it; an engine whose package is missing is refused with the package name. Changes go through `InputMethodGroupInfo`, `SetInputMethodGroupInfo`, and `Save`, keeping the group name, layout, existing methods in their switching order, 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`.
`Setup > Typing > Keyboard Layouts` and `Input Methods` use the native menu's multi-select mode, with current entries checked and changes applied immediately. `default/input-methods/typing_setup.py` uses the installer’s keyboard choices, converted to desktop layouts through systemd’s keymap table, and reads the current input group. Input selection replaces only the non-keyboard group items and retains selected engines' layout overrides; deselection does not uninstall packages. The standard composition engines are already shipped in the base package set. Closing a picker keeps changes already applied. Failed updates leave its checked state unchanged.
@@ -16,7 +16,7 @@ Keyboard selections are persisted as data in `~/.config/omarchy/keyboard-layouts
Shortcut defaults reserve Ctrl+Space for Tmux/Herdr, Super+Space for the menu, Super+Shift+Space for the bar, and Super+grave for the console. Hyprland owns Super+I to cycle all selected compositor layouts followed by the configured Fcitx input methods, independently of Fcitx switching keys. With keyboard layouts alone, the same shortcut cycles the compositor layouts, keeping matching devices in sync even without the bar widget. The widget is enabled in the default right-hand bar layout and appears only when more than one input method or keyboard layout is configured. Fcitx handles Zenkaku/Hankaku and Hangul; Henkan activates and Muhenkan deactivates. An existing configured list, including an empty list, is preserved. Stock generated lists are repaired only while the user still has the single-keyboard default profile. A terminal Ctrl+Space returns to direct input before the following key, with a 300 ms timeout and no D-Bus activation; a keyboard-only profile skips this step. Wayland and XCB addons do not override the compositor's XKB settings.
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.
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 migration so a retry can complete. 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.
+6 -77
View File
@@ -47,7 +47,9 @@ class InputMethodTest(unittest.TestCase):
self.addCleanup(self.process.stop)
def seed(self, method=None):
setup.configure(argparse.Namespace(method=method, seed=True, defaults=False))
if method:
self.preference.write_text(f"INPUT_METHOD={method}\n")
setup.configure(argparse.Namespace(seed=True, defaults=False))
def test_every_installer_engine_is_seeded_and_inactive(self):
for method in ("mozc", "hangul", "pinyin", "chewing"):
@@ -128,18 +130,6 @@ class InputMethodTest(unittest.TestCase):
self.vconsole.write_text(f"XKBLAYOUT={layout}\nXKBVARIANT={variant}\n")
self.assertEqual(setup.selection(), ("hangul", expected))
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"
@@ -156,49 +146,15 @@ class InputMethodTest(unittest.TestCase):
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))
setup.configure(argparse.Namespace(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):
@@ -206,7 +162,7 @@ class InputMethodTest(unittest.TestCase):
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))
setup.configure(argparse.Namespace(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))
@@ -217,40 +173,13 @@ class InputMethodTest(unittest.TestCase):
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))
setup.configure(argparse.Namespace(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")
+8
View File
@@ -80,6 +80,14 @@ class TypingSetupTest(unittest.TestCase):
run.assert_not_called()
save.assert_not_called()
def test_command_line_addition_appends_once(self):
group = ("Default", "us", [["keyboard-us", ""], ["hangul", ""]])
with patch.object(typing.setup, "live_group", return_value=group), patch.object(typing, "save_inputs") as save:
typing.add_input("mozc")
save.assert_called_once_with(["hangul", "mozc"])
typing.add_input("hangul")
save.assert_called_once()
def test_failed_font_write_restores_the_input_group(self):
before = [["keyboard-us", ""]]
with patch.object(typing.setup, "live_group", return_value=("Default", "us", before)), patch.object(typing.setup, "available_methods", return_value=[["mozc", "Mozc"]]), patch.object(typing, "set_inputs"), patch.object(typing.setup, "font_default", side_effect=OSError("read-only")), patch.object(typing.setup, "live_set") as setter: