"""Fcitx defaults for offline finalization and updates."""
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 without_ctrl_space(text):
# Ctrl+Space belongs to tmux and Herdr, and the terminal binding returns to
# direct input on it; a Fcitx toggle on the same key would undo that. Without
# a TriggerKeys list Fcitx falls back to its default, which has Ctrl+Space.
section = re.search(r"(?ms)^\[Hotkey/TriggerKeys\][ \t]*\n(.*?)(?=^\[|\Z)", text)
if not section:
return text.rstrip() + ("\n\n" if text.strip() else "") + "[Hotkey/TriggerKeys]\n0=Zenkaku_Hankaku\n1=Hangul\n"
keys = values(section[1])
kept = [keys[key] for key in sorted(keys, key=lambda key: int(key) if key.isdigit() else 0) if keys[key] != "Control+space"]
if len(kept) == len(keys):
return text
body = "".join(f"{index}={key}\n" for index, key in enumerate(kept)) + ("\n" if section[1].endswith("\n\n") else "")
return text[:section.start(1)] + body + text[section.end(1):]
def defaults(config_home, fresh=False):
changed = False
files = ["config", "conf/quickphrase.conf", "conf/wayland.conf", "conf/xcb.conf", "conf/pinyin.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":
if name == "config":
original = without_ctrl_space(original)
existing = sections(original)
additions = []
for section, entries in sections(shipped).items():
# Missing hotkey sections also represent a choice: Fcitx's defaults.
if not fresh and section.startswith("Hotkey/"):
continue
# Existing lists (including explicitly empty lists) are user choices.
# A fresh profile has no working IME shortcut to preserve.
stock_lists = {
"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:
original = original.rstrip() + ("\n\n" if original.strip() else "") + "\n".join(additions)
changed |= atomic_write(target, original)
elif name == "conf/pinyin.conf":
# Suppress the first-use cloud prompt while preserving any explicit
# cloud-prediction preference and all other existing engine settings.
if not original:
updated = shipped
elif "FirstRun" in values(original):
updated = re.sub(r"(?m)^\s*FirstRun\s*=.*$", "FirstRun=False", original)
else:
updated = "FirstRun=False\n" + original
changed |= atomic_write(target, updated)
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):
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:
for path, original in before.items():
if original is None:
path.unlink(missing_ok=True)
else:
atomic_write(path, original)
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") in ("kr", "la") and vconsole.get("XKBLAYOUT", "us") == "us" and not variant:
layout, variant = preference["XKB_LAYOUT"], ""
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 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):
configure_inner(args, config_home, running)
def configure_inner(args, config_home, running):
method, layout = selection()
profile = config_home / "fcitx5/profile"
original = read(profile)
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)
return
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)
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(required=True)
mode.add_argument("--seed", action="store_true")
mode.add_argument("--defaults", action="store_true")
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}")