Merge #14433 input methods and remove Rime

The keyboard choice and omarchy-setup-input own Chinese input now. The
new passwordless sudo and input tooltips get Chinese text.
This commit is contained in:
ZacharyZhang-NY committed 2026-10-08 23:30:41 -04:00
commit fdd457c0fa
86 files changed
+2545 -207

No files matched your search

+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 ""
+49
View File
@@ -0,0 +1,49 @@
-- 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"
and (values.XKBLAYOUT or "us") == "us"
and (values.XKBVARIANT or "") == "" 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")
+389
View File
@@ -0,0 +1,389 @@
"""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", "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":
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/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 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, 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", "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
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" and vconsole.get("XKBLAYOUT", "us") == "us" and not variant:
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}")
+139
View File
@@ -0,0 +1,139 @@
"""Live Fcitx state for the existing keyboard layout widget."""
import json
import os
import sys
from gi.repository import Gio, GLib
BUS = "org.fcitx.Fcitx5"
INTERFACE = "org.fcitx.Fcitx.Controller1"
def call(bus, method, args=None):
# Reading the bar must neither activate a stopped service nor wait indefinitely.
return bus.call_sync(BUS, "/controller", INTERFACE, method, args, None,
Gio.DBusCallFlags.NO_AUTO_START, 1000, None).unpack()
def snapshot(bus):
group = call(bus, "CurrentInputMethodGroup")[0]
methods = list(dict.fromkeys(item[0] for item in
call(bus, "InputMethodGroupInfo", GLib.Variant("(s)", (group,)))[1]))
info = call(bus, "CurrentInputMethodInfo")
return {"methods": methods, "current": info[0], "name": info[1], "label": info[4], "language": info[5]}
def cycle(bus):
state = snapshot(bus)
methods = state["methods"]
if len(methods) > 1 and state["current"] in methods:
following = methods[(methods.index(state["current"]) + 1) % len(methods)]
call(bus, "SetCurrentIM", GLib.Variant("(s)", (following,)))
class Indicator:
def __init__(self, bus):
self.bus = bus
self.pending = ""
self.last = ""
self.entries = {}
def select_next(self):
state = snapshot(self.bus)
methods = state["methods"]
if len(methods) < 2:
return
current = self.pending or state["current"] or self.last or methods[0]
if current not in methods:
current = methods[0]
self.pending = methods[(methods.index(current) + 1) % len(methods)]
self.refresh()
def refresh(self):
state = snapshot(self.bus)
methods = state["methods"]
if self.pending not in methods:
self.pending = ""
if self.pending and state["current"]:
# Before the first application gains focus Fcitx has no input context.
# Retain the click until it has one, without changing startup defaults.
call(self.bus, "SetCurrentIM", GLib.Variant("(s)", (self.pending,)))
state = snapshot(self.bus)
if state["current"] == self.pending:
self.pending = ""
if state["current"]:
self.last = state["current"]
else:
chosen = self.pending or (self.last if self.last in methods else "") or next(iter(methods), "")
if chosen not in self.entries:
self.entries = {entry[0]: entry for entry in call(self.bus, "AvailableInputMethods")[0]}
entry = self.entries.get(chosen)
if entry:
state.update(current=entry[0], name=entry[1], label=entry[4], language=entry[5])
return state
def watch(bus):
previous = None
indicator = Indicator(bus)
loop = GLib.MainLoop()
buffer = ""
timer = 0
def refresh():
nonlocal previous
try:
state = indicator.refresh()
except GLib.Error:
indicator.pending = ""
indicator.last = ""
state = {"methods": [], "current": "", "name": "", "language": ""}
line = json.dumps(state, ensure_ascii=False)
if line != previous:
print(line, flush=True)
previous = line
def poll():
nonlocal timer
refresh()
timer = GLib.timeout_add(50 if indicator.pending else 500, poll)
return GLib.SOURCE_REMOVE
def command(source, condition):
nonlocal buffer, timer
data = os.read(sys.stdin.fileno(), 4096)
if not data:
loop.quit()
return GLib.SOURCE_REMOVE
buffer += data.decode()
while "\n" in buffer:
line, buffer = buffer.split("\n", 1)
if line == "cycle":
try:
indicator.select_next()
except GLib.Error:
indicator.pending = ""
refresh()
GLib.source_remove(timer)
timer = GLib.timeout_add(50 if indicator.pending else 500, poll)
return GLib.SOURCE_CONTINUE
# Fcitx's controller has no current-method-changed signal. Keep one bus
# connection open instead of spawning a command on every poll. Polling also
# follows per-application input contexts and recovers after service restarts.
GLib.io_add_watch(sys.stdin, GLib.IO_IN | GLib.IO_HUP, command)
poll()
loop.run()
if __name__ == "__main__":
try:
bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
if sys.argv[1:] == ["cycle"]:
cycle(bus)
else:
watch(bus)
except GLib.Error as error:
raise SystemExit(str(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"}
}
+3 -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"},
@@ -191,8 +192,8 @@
"setup.security.fido2": {"icon":"","label":"Fido2","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-fido2"},
"setup.security.sshd": {"icon":"󰣀","label":"SSHD","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-sshd"},
"setup.security.ssh-agent": {"icon":"󰣀","label":"SSH Agent","when":"! systemctl --user -q is-enabled gcr-ssh-agent.socket","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-ssh-agent"},
"setup.security.passwordless-sudo": {"icon":"󰟵","label":"Passwordless Sudo","action":"omarchy-launch-floating-terminal-with-presentation omarchy-sudo-passwordless"},
"setup.security.sudoless-docker": {"icon":"󰡨","label":"Sudoless Docker","when":"omarchy-sudo-docker --configured","action":"omarchy-launch-floating-terminal-with-presentation omarchy-setup-security-sudoless-docker"},
"setup.security.passwordless-sudo": {"icon":"󰟵","label":"Passwordless Sudo","action":"omarchy-launch-floating-terminal-with-presentation omarchy-sudo-passwordless"},
"setup.config.hyprland": {"icon":"","label":"Hyprland","action":"omarchy-launch-config-editor \"$HOME/.config/hypr/hyprland.lua\""},
"setup.config.hyprsunset": {"icon":"","label":"Hyprsunset","action":"omarchy-launch-config-editor ~/.config/hypr/hyprsunset.conf && omarchy-restart-hyprsunset"},
"setup.config.xcompose": {"icon":"󰞅","label":"XCompose","action":"omarchy-launch-config-editor ~/.XCompose && omarchy-restart-xcompose"},
@@ -243,6 +244,7 @@
"install.service.dropbox": {"icon":"","label":"Dropbox","disabled":"omarchy-pkg-present dropbox","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-dropbox"},
"install.service.spotify": {"icon":"󰓇","label":"Spotify","disabled":"omarchy-pkg-present spotify","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-spotify"},
"install.service.signal": {"icon":"󰭹","label":"Signal","disabled":"omarchy-pkg-present signal-desktop","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-signal"},
"install.service.slack": {"icon":"󰒱","label":"Slack","disabled":"omarchy-pkg-present slack-desktop","action":"omarchy-install-and-launch Slack slack-desktop slack"},
"install.service.tailscale": {"icon":"","label":"Tailscale","disabled":"omarchy-pkg-present tailscale","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-tailscale"},
"install.service.nordvpn": {"icon":"󱇱","label":"NordVPN","disabled":"omarchy-pkg-present nordvpn-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-nordvpn"},
"install.service.once": {"icon":"󰏖","label":"ONCE","disabled":"omarchy-pkg-present once-bin","action":"omarchy-launch-floating-terminal-with-presentation omarchy-install-service-once"},
+1
View File
@@ -88,6 +88,7 @@
"setup.monitors": {"label":"显示器"},
"setup.keybindings": {"label":"快捷键"},
"setup.input": {"label":"输入设备"},
"setup.input-method": {"label":"输入语言"},
"setup.network": {"label":"网络"},
"setup.network.dns.custom": {"label":"自定义"},
"setup.network.qr": {"label":"二维码"},
+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