* Unify input methods with the installer keyboard selection * Preserve inherited input keys and later keyboard changes * Exercise input engines across desktop toolkits * Show input modes in the keyboard indicator * Keep Pinyin setup prompts visible * Remember input selections before an app gains focus * Remove the Japanese input welcome notification * Show explicit choices on notification cards * Suppress the Cloudpinyin first-use prompt * Show the input switching shortcut in keyboard hints * Verify input setup and composition stay quiet * Use Super+I for input switching independently of Fcitx keys * Restore linked input settings and attribute test notifications to Fcitx * Correct the input setup shortcut hint * Keep input tooltips to the name and switching shortcut * Place the input language selector with the right bar widgets * Show input switching only for multiple layouts or methods * Unify input method and keyboard layout switching * Select keyboard layouts and input methods in the Typing menu * Apply typing selections immediately from the menu * Use installer keyboard choices and keep typing selections accurate * Queue typing changes and wait for saved selections * Keep queued typing selections when reopening the menu * Cycle keyboard layouts alongside composition engines * Avoid shadowing Python typing and retain pending mixed input selections * Keep input clicks working through controller and compositor failures * Move the keyboard layout widget to the right side of existing bars Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Map keyboard choices to the layouts the installer sets Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Keep the switching order when changing input methods Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Set input method fonts only after the selection saves Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Restore confirmed picker checks when an immediate change fails Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Route Super+I through the shell's input reader so unfocused choices stick Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Restore the input group when the font preference cannot be written Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Drop the azerty conversion now that the installer no longer offers it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Apply the owner's keyboard layout once before persisting the input choice A quattro merge kept a second apply_keyboard after the retry loop, which re-ran loadkeys and firstboot, ignored their failure, and overwrote the XKB layout setup supplies for keymaps kbd-model-map lacks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Give the Lao keyboard choice a Lao desktop layout The row used la-latin1, kbd's Latin American Spanish keymap, so Lao installs typed Spanish. There is no Lao console keymap; like Korean, the console stays US and the desktop gets la, which Omarchy keeps behind US as a non-Latin layout. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Load engines installed after Fcitx started when they are selected The Typing menu refused an engine whose package an update had installed, since Fcitx discovers engines only at startup. Restart input once and wait for the engine; name the package when it really is not installed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Cycle input methods back with Super+Shift+I Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Drop notification action buttons now that the Pinyin prompt is suppressed Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Cycle input only through the shell's reader Without the shell there is no bar or menu either, so the one-shot cycling path only duplicated the reader's logic. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * 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> * Keep Ctrl+Space out of Fcitx's toggles instead of probing for it The terminal prefix bind read the Fcitx config and profile on every press to avoid fighting a Ctrl+Space toggle. configure --defaults now removes that key from every trigger list, so the bind only checks for a terminal. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Run the input-method scripts with the system Python A bare python resolves through PATH, so a mise, pyenv or venv interpreter could run them without the system's PyGObject. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
220 lines
11 KiB
Python
220 lines
11 KiB
Python
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 test_pinyin_defaults_suppress_cloud_prompt(self):
|
|
setup.defaults(self.config, fresh=True)
|
|
pinyin = setup.values((self.config / "fcitx5/conf/pinyin.conf").read_text())
|
|
self.assertEqual(pinyin["FirstRun"], "False")
|
|
self.assertEqual(pinyin["CloudPinyinEnabled"], "False")
|
|
|
|
def test_pinyin_prompt_suppression_preserves_existing_preferences(self):
|
|
pinyin = self.config / "fcitx5/conf/pinyin.conf"
|
|
pinyin.parent.mkdir(parents=True)
|
|
pinyin.write_text("FirstRun=True\nCloudPinyinEnabled=True\nPageSize=7\n")
|
|
setup.defaults(self.config)
|
|
self.assertEqual(pinyin.read_text(), "FirstRun=False\nCloudPinyinEnabled=True\nPageSize=7\n")
|
|
|
|
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):
|
|
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"):
|
|
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.assertEqual(set(keys["Hotkey/TriggerKeys"].values()), {"Zenkaku_Hankaku", "Hangul"})
|
|
|
|
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_ctrl_space_is_removed_from_every_trigger_list(self):
|
|
path = self.config / "fcitx5/config"
|
|
setup.atomic_write(path, "[Hotkey/TriggerKeys]\n0=Alt+space\n1=Control+space\n2=Hangul\n\n[Behavior]\nActiveByDefault=True\n")
|
|
setup.defaults(self.config)
|
|
config = setup.sections(path.read_text())
|
|
self.assertEqual(config["Hotkey/TriggerKeys"], {"0": "Alt+space", "1": "Hangul"})
|
|
self.assertEqual(config["Behavior"], {"ActiveByDefault": "True"})
|
|
|
|
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_inherited_hotkeys_keep_fcitx_defaults_except_the_ctrl_space_toggle(self):
|
|
path = self.config / "fcitx5/config"
|
|
setup.atomic_write(path, "[Behavior]\nActiveByDefault=True\n")
|
|
setup.defaults(self.config, fresh=False)
|
|
self.assertEqual(path.read_text(), "[Behavior]\nActiveByDefault=True\n\n[Hotkey/TriggerKeys]\n0=Zenkaku_Hankaku\n1=Hangul\n")
|
|
self.assertFalse(setup.defaults(self.config, fresh=False))
|
|
|
|
def test_lao_preference_keeps_a_latin_fcitx_keyboard(self):
|
|
self.preference.write_text("INPUT_METHOD=none\nXKB_LAYOUT=la\n")
|
|
self.assertEqual(setup.selection(), ("none", "us"))
|
|
|
|
def test_korean_preference_yields_to_a_later_keyboard_change(self):
|
|
self.preference.write_text("INPUT_METHOD=hangul\nXKB_LAYOUT=kr\n")
|
|
self.assertEqual(setup.selection(), ("hangul", "kr"))
|
|
for layout, variant, expected in [("de", "nodeadkeys", "de-nodeadkeys"), ("us", "intl", "us-intl")]:
|
|
self.vconsole.write_text(f"XKBLAYOUT={layout}\nXKBVARIANT={variant}\n")
|
|
self.assertEqual(setup.selection(), ("hangul", expected))
|
|
|
|
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_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_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(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(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(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_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()
|