Unify input methods with installer keyboard selection (#14433)
* 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>
This commit is contained in:
1 parent
f2a39c4dca
commit
0b9dace018
64 files changed
+2687
-53
No files matched your search
Executable
+126
@@ -0,0 +1,126 @@
|
||||
#!/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
|
||||
if type(bind.dispatcher) == "string" then
|
||||
print("exec " .. bind.dispatcher)
|
||||
else
|
||||
bind.dispatcher()
|
||||
end
|
||||
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.
|
||||
# Fcitx never toggles on it (configure --defaults removes it from its keys), so
|
||||
# a terminal can always return to direct input first.
|
||||
prefix="CTRL + SPACE"
|
||||
direct=$'non_consuming\nexec timeout 0.3 fcitx5-remote --check -c'
|
||||
assert_fires "the terminal prefix switches to direct input" "$direct" "$prefix" "com.mitchellh.ghostty" "terminal*"
|
||||
assert_fires "the prefix leaves input alone outside terminals" "non_consuming" "$prefix" "chromium" "browser"
|
||||
for layout in us kr; do
|
||||
TEST_LAYOUT="$layout" assert_fires "the terminal prefix works with $layout keyboards too" "$direct" "$prefix" "foot" "terminal"
|
||||
done
|
||||
TEST_KEYBOARD_ONLY=true assert_fires "keyboard-only profiles use the same prefix bind" "$direct" "$prefix" "foot" "terminal"
|
||||
|
||||
assert_fires "Super + I cycles configured input independently of custom Fcitx keys" \
|
||||
"exec omarchy-input-method cycle" "SUPER + I" "chromium"
|
||||
assert_fires "Super + Shift + I cycles configured input back" \
|
||||
"exec omarchy-input-method cycle back" "SUPER + SHIFT + I" "chromium"
|
||||
TEST_KEYBOARD_ONLY=true assert_fires "Super + I cycles keyboard layouts without an input engine" \
|
||||
"exec omarchy-input-method cycle" "SUPER + I" "foot" "terminal"
|
||||
@@ -13,6 +13,24 @@ local vconsole = os.getenv("OMARCHY_VCONSOLE")
|
||||
local real_open = io.open
|
||||
|
||||
io.open = function(path, mode)
|
||||
if path:match("/omarchy/keyboard%-layouts$") then
|
||||
local selected = os.getenv("TEST_KEYBOARD_SELECTION")
|
||||
if not selected then return nil end
|
||||
local file = io.tmpfile()
|
||||
file:write(selected)
|
||||
file:seek("set")
|
||||
return file
|
||||
end
|
||||
|
||||
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 +146,14 @@ 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 its installer US layout" "[kr] [] [$base_options]" $'XKBLAYOUT=us\n'
|
||||
TEST_INPUT_PREFERENCE=$'INPUT_METHOD=none\nXKB_LAYOUT=la\n' assert_input "Lao selection gets Lao behind US on the desktop" "[us,la] [,] [$toggle_options]" $'XKBLAYOUT=us\n'
|
||||
TEST_INPUT_PREFERENCE=$'INPUT_METHOD=none\nXKB_LAYOUT=xx\n' assert_input "an unknown desktop override is ignored" "[us] [] [$base_options]" $'XKBLAYOUT=us\n'
|
||||
TEST_INPUT_PREFERENCE=$'INPUT_METHOD=hangul\nXKB_LAYOUT=kr\n' assert_input "a later US variant overrides the Korean preference" "[us] [intl] [$base_options]" $'XKBLAYOUT=us\nXKBVARIANT=intl\n'
|
||||
TEST_INPUT_PREFERENCE=$'INPUT_METHOD=hangul\nXKB_LAYOUT=kr\n' assert_input "a later layout overrides the Korean preference" "[de] [nodeadkeys] [$base_options]" $'XKBLAYOUT=de\nXKBVARIANT=nodeadkeys\n'
|
||||
|
||||
TEST_KEYBOARD_SELECTION=$'XKBLAYOUT=us,fr\nXKBVARIANT=intl,\n' assert_input "menu selections replace installer desktop defaults" "[us,fr] [intl,] [$base_options]" $'XKBLAYOUT=jp\n'
|
||||
TEST_KEYBOARD_SELECTION=$'XKBLAYOUT=us,fr\nXKBVARIANT=intl,\n' assert_greeter_input "menu selections leave the greeter keyboard unchanged" "[jp] [] []" $'XKBLAYOUT=jp\n'
|
||||
|
||||
TEST_KEYBOARD_SELECTION=$'XKBLAYOUT=us,ru\nXKBVARIANT=,phonetic\n' assert_input "saved non-Latin selections retain the layout shortcut" "[us,ru] [,phonetic] [$toggle_options]" $'XKBLAYOUT=us\n'
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Regression fixtures for acceptance cleanup and notification attribution."""
|
||||
|
||||
from pathlib import Path
|
||||
import runpy
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
state = runpy.run_path(str(ROOT / "test/acceptance.d/input-method-state.py"))
|
||||
notifications = runpy.run_path(str(ROOT / "test/acceptance.d/input-method-notifications.py"))
|
||||
|
||||
|
||||
class AcceptanceHelpers(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
|
||||
def test_nested_linked_settings_restore_targets_links_and_modes(self):
|
||||
config = self.root / "fcitx5"
|
||||
config.mkdir()
|
||||
target = self.root / "dotfiles-config"
|
||||
target.write_text("custom switching keys")
|
||||
target.chmod(0o600)
|
||||
link = config / "config"
|
||||
link.symlink_to("../dotfiles-config")
|
||||
profile = config / "profile"
|
||||
profile.write_text("custom input methods")
|
||||
snapshot = self.root / "snapshot"
|
||||
state["backup"](snapshot, [config])
|
||||
target.write_text("test keys")
|
||||
target.chmod(0o644)
|
||||
profile.write_text("test methods")
|
||||
(config / "new-engine.conf").write_text("created by test")
|
||||
state["restore"](snapshot)
|
||||
self.assertTrue(link.is_symlink())
|
||||
self.assertEqual(link.readlink(), Path("../dotfiles-config"))
|
||||
self.assertEqual(target.read_text(), "custom switching keys")
|
||||
self.assertEqual(target.stat().st_mode & 0o777, 0o600)
|
||||
self.assertEqual(profile.read_text(), "custom input methods")
|
||||
self.assertFalse((config / "new-engine.conf").exists())
|
||||
|
||||
def test_directory_links_and_external_font_preferences_are_restored(self):
|
||||
target = self.root / "dotfiles"
|
||||
target.mkdir()
|
||||
(target / "profile").write_text("original")
|
||||
config = self.root / "fcitx5"
|
||||
config.symlink_to(target)
|
||||
font_target = self.root / "font-preference"
|
||||
font_target.write_text("original font")
|
||||
font = self.root / "font.conf"
|
||||
font.symlink_to(font_target)
|
||||
snapshot = self.root / "snapshot"
|
||||
state["backup"](snapshot, [config, font])
|
||||
(target / "profile").write_text("changed")
|
||||
font_target.write_text("test font")
|
||||
state["restore"](snapshot)
|
||||
self.assertTrue(config.is_symlink())
|
||||
self.assertTrue(font.is_symlink())
|
||||
self.assertEqual((target / "profile").read_text(), "original")
|
||||
self.assertEqual(font_target.read_text(), "original font")
|
||||
|
||||
def test_missing_targets_and_paths_remain_missing(self):
|
||||
absent = self.root / "absent"
|
||||
target = self.root / "missing-target"
|
||||
link = self.root / "dangling"
|
||||
link.symlink_to(target)
|
||||
snapshot = self.root / "snapshot"
|
||||
state["backup"](snapshot, [absent, link])
|
||||
absent.mkdir()
|
||||
(absent / "test").write_text("new")
|
||||
target.write_text("created through link")
|
||||
state["restore"](snapshot)
|
||||
self.assertFalse(absent.exists())
|
||||
self.assertTrue(link.is_symlink())
|
||||
self.assertFalse(target.exists())
|
||||
|
||||
def test_failed_restore_still_restarts_the_service(self):
|
||||
script = (ROOT / "test/acceptance.d/input-methods-test.sh").read_text()
|
||||
cleanup = "cleanup() {" + script.split("cleanup() {", 1)[1].split("\n}\ntrap cleanup", 1)[0] + "\n}"
|
||||
commands = """
|
||||
notification_monitor=""
|
||||
work=$1
|
||||
close_windows() { :; }
|
||||
systemctl() { printf '%s\n' "$*" >> "$work/service.log"; }
|
||||
python() { return 1; }
|
||||
busctl() { :; }
|
||||
"""
|
||||
result = subprocess.run(["bash", "-ec", commands + cleanup + "\ncleanup", "test", str(self.root)], capture_output=True, text=True)
|
||||
self.assertEqual(result.returncode, 1)
|
||||
self.assertIn("start omarchy-fcitx5.service", (self.root / "service.log").read_text())
|
||||
self.assertIn("snapshot retained", result.stderr)
|
||||
|
||||
def test_unchanged_link_targets_are_not_rewritten(self):
|
||||
target = self.root / "read-only-config"
|
||||
target.write_text("unchanged")
|
||||
target.chmod(0o444)
|
||||
link = self.root / "config"
|
||||
link.symlink_to(target)
|
||||
snapshot = self.root / "snapshot"
|
||||
state["backup"](snapshot, [link])
|
||||
with patch.object(Path, "unlink", side_effect=PermissionError("read-only")):
|
||||
state["restore"](snapshot)
|
||||
self.assertTrue(link.is_symlink())
|
||||
self.assertEqual(target.read_text(), "unchanged")
|
||||
|
||||
def test_new_parent_directories_are_removed(self):
|
||||
service = self.root / "dbus-1/services/org.fcitx.Fcitx5.service"
|
||||
snapshot = self.root / "snapshot"
|
||||
state["backup"](snapshot, [service])
|
||||
service.parent.mkdir(parents=True)
|
||||
service.write_text("created by setup")
|
||||
state["restore"](snapshot)
|
||||
self.assertFalse((self.root / "dbus-1").exists())
|
||||
|
||||
def test_notifications_follow_process_ownership_across_restarts(self):
|
||||
def call(sender, app, body):
|
||||
return f'''method call time=1 sender={sender} -> destination=org.freedesktop.Notifications serial=1 path=/org/freedesktop/Notifications; interface=org.freedesktop.Notifications; member=Notify
|
||||
string "{app}"
|
||||
uint32 0
|
||||
string "icon"
|
||||
string "summary"
|
||||
string "{body}"
|
||||
'''
|
||||
reminder = call(":1.99", "Fcitx", "unrelated reminder")
|
||||
first = call(":1.42", "Input Method", "input setup prompt")
|
||||
second = call(":1.43", "拼音", "cloud input prompt")
|
||||
changed = '''signal time=2 sender=org.freedesktop.DBus -> destination=(null destination) serial=2 path=/org/freedesktop/DBus; interface=org.freedesktop.DBus; member=NameOwnerChanged
|
||||
string "org.fcitx.Fcitx5"
|
||||
string ":1.42"
|
||||
string ":1.43"
|
||||
'''
|
||||
selected = list(notifications["input_notifications"](reminder + first + changed + second, ":1.42"))
|
||||
self.assertEqual(selected, [first, second])
|
||||
self.assertEqual(list(notifications["input_notifications"](reminder, ":1.42")), [])
|
||||
self.assertEqual(list(notifications["input_notifications"]("", ":1.42")), [])
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh"
|
||||
python "$ROOT/test/shell.d/input-acceptance-checks.py"
|
||||
Executable
+204
@@ -0,0 +1,204 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
OMARCHY_PATH="$ROOT" /usr/bin/python - <<'PY'
|
||||
import importlib.util
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
import json
|
||||
import subprocess
|
||||
|
||||
# Script-directory imports must not shadow Python's standard typing module.
|
||||
subprocess.run(["/usr/bin/python", "-c", "import sys; sys.path.insert(0, sys.argv[1]); import typing; from gi.repository import Gio; assert hasattr(typing, 'TYPE_CHECKING')", os.environ["OMARCHY_PATH"] + "/default/input-methods"], check=True)
|
||||
|
||||
spec = importlib.util.spec_from_file_location("indicator", os.environ["OMARCHY_PATH"] + "/default/input-methods/indicator.py")
|
||||
indicator = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(indicator)
|
||||
|
||||
with patch.object(indicator, "call", side_effect=[
|
||||
("Default",),
|
||||
("us", [("keyboard-us", ""), ("mozc", ""), ("mozc", "")]),
|
||||
("mozc", "Mozc", "", "fcitx_mozc", "あ", "ja", "mozc", True, "", {}),
|
||||
]):
|
||||
state = indicator.snapshot(None)
|
||||
assert state == {"methods": ["keyboard-us", "mozc"], "current": "mozc", "name": "Mozc", "label": "あ", "language": "ja"}
|
||||
print("ok - live state deduplicates modes and reads language independently of the engine label")
|
||||
|
||||
def check_cycle(current, methods, expected):
|
||||
assert indicator.cycle_choice({"current": current, "methods": methods}, [])[0] == expected
|
||||
|
||||
check_cycle("keyboard-us", ["keyboard-us", "mozc"], "mozc")
|
||||
check_cycle("mozc", ["keyboard-us", "mozc"], "keyboard-us")
|
||||
check_cycle("mozc", ["keyboard-us", "mozc", "hangul"], "hangul")
|
||||
check_cycle("hangul", ["keyboard-us", "mozc", "hangul"], "keyboard-us")
|
||||
check_cycle("keyboard-us", ["keyboard-us"], None)
|
||||
check_cycle("removed", ["keyboard-us", "mozc"], None)
|
||||
check_cycle("", [], None)
|
||||
print("ok - input cycling covers Latin, multiple engines, single modes, and stale contexts")
|
||||
|
||||
entries = [("keyboard-us", "English", "", "input-keyboard", "en", "en", True),
|
||||
("pinyin", "Pinyin", "", "fcitx-pinyin", "拼", "zh_CN", True)]
|
||||
live = {"methods": ["keyboard-us", "pinyin"], "current": "", "name": "", "label": "", "language": ""}
|
||||
selected = []
|
||||
def fake_call(bus, method, args=None):
|
||||
if method == "AvailableInputMethods":
|
||||
return (entries,)
|
||||
if method == "SetCurrentIM":
|
||||
selected.append(args.unpack()[0])
|
||||
live["current"] = selected[-1]
|
||||
return ()
|
||||
raise AssertionError(method)
|
||||
|
||||
with patch.object(indicator, "snapshot", side_effect=lambda bus: dict(live)), patch.object(indicator, "call", side_effect=fake_call), patch.object(indicator.subprocess, "run", return_value=subprocess.CompletedProcess([], 0, stdout='{"keyboards": []}')):
|
||||
reader = indicator.Indicator(None)
|
||||
assert reader.refresh()["current"] == "keyboard-us"
|
||||
reader.select_next()
|
||||
assert reader.refresh()["current"] == "pinyin"
|
||||
assert reader.pending == "pinyin" and selected == []
|
||||
reader.select_next()
|
||||
assert reader.pending == "keyboard-us"
|
||||
reader.select_next()
|
||||
live["current"] = "keyboard-us"
|
||||
assert reader.refresh()["current"] == "pinyin"
|
||||
assert reader.pending == "" and selected == ["pinyin"]
|
||||
live["current"] = ""
|
||||
reader.select_next()
|
||||
live["methods"] = ["pinyin"]
|
||||
reader.refresh()
|
||||
assert reader.pending == ""
|
||||
print("ok - desktop clicks update the label, cycle pending choices, and apply once a text context exists")
|
||||
|
||||
with patch.object(indicator, "snapshot", return_value={"methods": ["keyboard-us", "keyboard-fr"], "current": "keyboard-us"}), patch.object(indicator, "call") as call, patch.object(indicator.subprocess, "run", return_value=subprocess.CompletedProcess([], 0, stdout='{"keyboards": []}')) as process:
|
||||
indicator.Indicator(None).select_next()
|
||||
assert call.call_args_list[0].args[2].unpack() == ("keyboard-fr",)
|
||||
assert process.call_count == 1
|
||||
print("ok - the shortcut cycles Fcitx keyboard methods without requiring a bar widget")
|
||||
|
||||
keyboards = [
|
||||
{"name": "power-button", "layout": "us,fr", "active_layout_index": 1},
|
||||
{"name": "physical keyboard", "layout": "us,fr", "active_layout_index": 0},
|
||||
{"name": "consumer-control", "layout": "us,fr", "active_layout_index": 0},
|
||||
{"name": "custom", "layout": "de", "active_layout_index": 0},
|
||||
]
|
||||
expected = [["hyprctl", "switchxkblayout", item["name"], "1"] for item in keyboards[:3]]
|
||||
assert indicator.layout_switches(keyboards) == expected
|
||||
assert indicator.layout_switches([keyboards[-1]] + keyboards[:-1]) == expected
|
||||
assert indicator.layout_switches([{ "name": "custom-first", "layout": "us,de,fr", "active_layout_index": 2 }] + keyboards) == expected
|
||||
keyboards[1]["active_layout_index"] = 1
|
||||
assert all(command[-1] == "0" for command in indicator.layout_switches(keyboards))
|
||||
assert indicator.layout_switches([{ "name": "physical", "layout": "fr" }]) == []
|
||||
assert indicator.layout_switches([]) == []
|
||||
print("ok - layout switching synchronizes matching devices, wraps, and preserves device-specific layouts")
|
||||
|
||||
import json
|
||||
def reader_cycle(state, keyboards):
|
||||
with patch.object(indicator, "snapshot", return_value=dict(state)), patch.object(indicator, "call") as call, patch.object(indicator.subprocess, "run", return_value=subprocess.CompletedProcess([], 0, stdout=json.dumps({"keyboards": keyboards}))) as process:
|
||||
indicator.Indicator(None).select_next()
|
||||
return call, [command.args[0] for command in process.call_args_list[1:]]
|
||||
|
||||
call, commands = reader_cycle({"methods": ["keyboard-us"], "current": "keyboard-us"}, keyboards)
|
||||
assert commands == indicator.layout_switches(keyboards)
|
||||
print("ok - keyboard-only input falls back to compositor layouts")
|
||||
state = {"methods": ["keyboard-us", "mozc", "hangul"], "current": "keyboard-us"}
|
||||
keyboards[1]["active_layout_index"] = 0
|
||||
assert indicator.cycle_choice(state, keyboards) == ("keyboard-us", 1)
|
||||
call, commands = reader_cycle(state, keyboards)
|
||||
assert commands and all(command[-1] == "1" for command in commands)
|
||||
assert all(item.args[2].unpack() == ("keyboard-us",) for item in call.call_args_list if item.args[1] == "SetCurrentIM")
|
||||
keyboards[1]["active_layout_index"] = 1
|
||||
assert indicator.cycle_choice(state, keyboards) == ("mozc", None)
|
||||
state["current"] = "mozc"
|
||||
assert indicator.cycle_choice(state, keyboards) == ("hangul", None)
|
||||
state["current"] = "hangul"
|
||||
assert indicator.cycle_choice(state, keyboards) == ("keyboard-us", 0)
|
||||
call, commands = reader_cycle(state, keyboards)
|
||||
assert call.call_args_list[0].args[2].unpack() == ("keyboard-us",)
|
||||
assert all(command[-1] == "0" for command in commands)
|
||||
print("ok - layouts and composition engines share one cycle and returning to direct input resets the layout")
|
||||
|
||||
keyboards = [{"name": "physical", "layout": "us,dk", "active_layout_index": 0}]
|
||||
live = {"methods": ["keyboard-us", "pinyin"], "current": "", "name": "", "label": "", "language": ""}
|
||||
selected.clear()
|
||||
def fake_process(command, **kwargs):
|
||||
if command[:3] == ["hyprctl", "-j", "devices"]:
|
||||
return subprocess.CompletedProcess(command, 0, stdout=json.dumps({"keyboards": keyboards}))
|
||||
keyboards[0]["active_layout_index"] = int(command[-1])
|
||||
return subprocess.CompletedProcess(command, 0, stdout="")
|
||||
with patch.object(indicator, "snapshot", side_effect=lambda bus: dict(live)), patch.object(indicator, "call", side_effect=fake_call), patch.object(indicator.subprocess, "run", side_effect=fake_process):
|
||||
reader = indicator.Indicator(None)
|
||||
reader.last = "removed-engine"
|
||||
reader.select_next()
|
||||
assert keyboards[0]["active_layout_index"] == 1
|
||||
reader.select_next()
|
||||
assert reader.pending == "pinyin" and selected == []
|
||||
assert reader.refresh()["current"] == "pinyin"
|
||||
live["current"] = "keyboard-us"
|
||||
reader.refresh()
|
||||
assert selected == ["pinyin"] and reader.pending == ""
|
||||
reader.select_next()
|
||||
assert keyboards[0]["active_layout_index"] == 0
|
||||
assert selected[-1] == "keyboard-us"
|
||||
print("ok - mixed-layout bar clicks queue an engine without focus and apply it when a text field gains focus")
|
||||
|
||||
from unittest.mock import Mock
|
||||
callback = {}
|
||||
def register(source, flags, fn):
|
||||
callback["read"] = fn
|
||||
return 1
|
||||
def exercise():
|
||||
assert callback["read"](None, None) == indicator.GLib.SOURCE_CONTINUE
|
||||
assert callback["read"](None, None) == indicator.GLib.SOURCE_CONTINUE
|
||||
state = {"methods": ["keyboard-us", "pinyin"], "current": "keyboard-us", "name": "English", "label": "en", "language": "en"}
|
||||
loop = Mock()
|
||||
loop.run.side_effect = exercise
|
||||
with patch.object(indicator.GLib, "MainLoop", return_value=loop), patch.object(indicator.GLib, "io_add_watch", side_effect=register), patch.object(indicator.GLib, "timeout_add", return_value=1), patch.object(indicator.GLib, "source_remove"), patch.object(indicator.os, "read", return_value=b"cycle\n"), patch.object(indicator, "snapshot", return_value=state), patch.object(indicator.subprocess, "run", side_effect=[subprocess.CalledProcessError(1, "hyprctl"), subprocess.CompletedProcess([], 0, stdout='{"keyboards": []}')]), patch.object(indicator, "call") as call, patch("builtins.print"):
|
||||
indicator.watch(None)
|
||||
assert call.call_args.args[1] == "SetCurrentIM"
|
||||
print("ok - a failed compositor query leaves the bar click reader alive for the next request")
|
||||
|
||||
keyboards = [{"name": "physical", "layout": "us,dk", "active_layout_index": 0}]
|
||||
with patch.object(indicator, "snapshot", side_effect=indicator.GLib.Error("no fcitx")), patch.object(indicator.subprocess, "run", side_effect=fake_process):
|
||||
indicator.Indicator(None).select_next()
|
||||
assert keyboards[0]["active_layout_index"] == 1
|
||||
print("ok - the shortcut reader cycles compositor layouts when Fcitx is unavailable")
|
||||
|
||||
keyboards = [{"name": "physical", "layout": "us,dk", "active_layout_index": 0}]
|
||||
state = {"methods": ["keyboard-us", "mozc", "hangul"], "current": "keyboard-us"}
|
||||
assert indicator.cycle_choice(state, keyboards) == ("keyboard-us", 1)
|
||||
assert indicator.cycle_choice(state, keyboards, -1) == ("hangul", None)
|
||||
state["current"] = "mozc"
|
||||
assert indicator.cycle_choice(state, keyboards, -1) == ("keyboard-us", 1)
|
||||
assert indicator.cycle_choice({"methods": ["keyboard-us", "mozc", "hangul"], "current": "keyboard-us"}, [], -1) == ("hangul", None)
|
||||
assert indicator.layout_switches(keyboards, step=-1)[0][-1] == "1"
|
||||
print("ok - cycling back walks the same choices in reverse")
|
||||
|
||||
PY
|
||||
|
||||
stubs=$(mktemp -d)
|
||||
trap 'rm -rf "$stubs"' EXIT
|
||||
calls="$stubs/calls"
|
||||
cat >"$stubs/omarchy-shell" <<'SH'
|
||||
#!/bin/bash
|
||||
echo "shell $*" >>"$CALLS"
|
||||
echo "$SHELL_REPLY"
|
||||
SH
|
||||
chmod +x "$stubs/omarchy-shell"
|
||||
|
||||
CALLS=$calls SHELL_REPLY=ok PATH="$stubs:$PATH" OMARCHY_PATH="$ROOT" "$ROOT/bin/omarchy-input-method" cycle
|
||||
[[ $(<"$calls") == "shell shell cycleInput next" ]] || fail "Super+I goes through the shell's reader" "$(<"$calls")"
|
||||
pass "Super+I goes through the shell's reader, which retains a choice made without focus"
|
||||
|
||||
if CALLS=$calls SHELL_REPLY="not running" PATH="$stubs:$PATH" OMARCHY_PATH="$ROOT" "$ROOT/bin/omarchy-input-method" cycle 2>/dev/null; then
|
||||
fail "Super+I reports a missing input reader"
|
||||
fi
|
||||
pass "Super+I reports a missing input reader instead of silently doing nothing"
|
||||
|
||||
: >"$calls"
|
||||
CALLS=$calls SHELL_REPLY=ok PATH="$stubs:$PATH" OMARCHY_PATH="$ROOT" "$ROOT/bin/omarchy-input-method" cycle back
|
||||
[[ $(<"$calls") == "shell shell cycleInput back" ]] || fail "Super+Shift+I cycles back" "$(<"$calls")"
|
||||
if CALLS=$calls PATH="$stubs:$PATH" OMARCHY_PATH="$ROOT" "$ROOT/bin/omarchy-input-method" cycle sideways 2>/dev/null; then
|
||||
fail "an unknown cycle direction is rejected"
|
||||
fi
|
||||
pass "Super+Shift+I cycles back through the shell, and other directions are rejected"
|
||||
@@ -0,0 +1,219 @@
|
||||
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()
|
||||
@@ -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"
|
||||
@@ -57,6 +57,7 @@ stub_hyprctl <<BINDS
|
||||
$(lua_bind 64 "SUPER + W" "Close window")
|
||||
$(lua_bind 64 "SUPER + Q" "Close window")
|
||||
$(lua_bind 64 "SUPER + F" "Full screen")
|
||||
$(lua_bind 64 "SUPER + I" "Switch input language")
|
||||
$(lua_bind 64 "SUPER + S" "Toggle scratchpad")
|
||||
$(lua_bind 64 "SUPER + grave" "Toggle scratchpad")
|
||||
$(exec_bind 73 "SUPER SHIFT ALT + 0" "Move window silently to workspace 10" "true")
|
||||
@@ -68,6 +69,9 @@ rendered=$(keybindings)
|
||||
grep -q 'SUPER + F *→ Full screen' <<<"$rendered" ||
|
||||
fail "a chord with no alternative renders on its own" "$rendered"
|
||||
pass "the keybindings menu renders its entries"
|
||||
grep -q 'SUPER + I *→ Switch input language' <<<"$rendered" ||
|
||||
fail "the input switch shortcut appears in the cheatsheet" "$rendered"
|
||||
pass "the input switch shortcut appears in the cheatsheet"
|
||||
|
||||
# The menu's own scan of the config runs lua -E, so no LUA_INIT or LUA_PATH from
|
||||
# the environment reaches it and nothing there can move the platform root.
|
||||
|
||||
@@ -7,6 +7,26 @@ source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
run_node_test <<'JS'
|
||||
const model = requireFromRoot('shell/plugins/bar/widgets/KeyboardLayoutModel.js')
|
||||
|
||||
const japanese = { methods: ['keyboard-us', 'mozc'], current: 'mozc', name: 'Mozc', language: 'ja' }
|
||||
assertEqual(model.inputLabel(japanese, 'EN'), 'あ', 'Japanese input has a distinct active label')
|
||||
assertEqual(model.inputLabel({ ...japanese, label: 'ア' }, 'EN'), 'ア', 'the live engine mode takes precedence over its default glyph')
|
||||
assertEqual(model.inputLabel({ ...japanese, current: 'keyboard-us' }, 'EN'), 'EN', 'Latin input retains the keyboard label')
|
||||
assertEqual(model.inputLabel({}, 'EN'), 'EN', 'a stopped input service retains the keyboard label')
|
||||
assertEqual(model.inputLabel({ current: 'hangul' }, 'EN'), '한', 'Korean input has a distinct label')
|
||||
assertEqual(model.inputLabel({ current: 'custom', language: 'vi' }, 'EN'), 'VI', 'other engines use their reported language')
|
||||
assertEqual(model.inputTooltip(japanese, 'English (US)', false), 'Mozc · Super + I', 'input tooltip shows only its name and shortcut')
|
||||
assertEqual(model.inputTooltip(japanese, 'English (US)', true), 'Mozc · Super + I', 'multiple layouts keep the input tooltip compact')
|
||||
assertEqual(model.inputTooltip({ ...japanese, current: 'keyboard-us', name: 'Keyboard - English (US)' }, 'English (US)', false), 'English (US) · Super + I', 'Latin input omits the redundant keyboard prefix')
|
||||
assertEqual(model.inputTooltip({ ...japanese, current: 'keyboard-us', name: 'Keyboard - English (US)' }, 'Danish', true), 'Danish · Super + I', 'direct input tooltip follows the active compositor layout')
|
||||
assertEqual(model.inputTooltip({ methods: ['keyboard-fr'] }, 'French', false), 'French', 'a single layout tooltip stays compact')
|
||||
assertEqual(model.showIndicator('EN', false, false), false, 'English alone stays hidden')
|
||||
assertEqual(model.showIndicator('ENG', false, false), false, 'English before the brief table loads stays hidden')
|
||||
assertEqual(model.showIndicator('FR', false, false), false, 'French alone stays hidden')
|
||||
assertEqual(model.showIndicator('EN', false, true), true, 'Latin with another input mode stays available')
|
||||
assertEqual(model.showIndicator('EN', true, false), true, 'multiple keyboard layouts stay available')
|
||||
assertEqual(model.showIndicator('FR', true, false), true, 'French and English layouts stay available')
|
||||
assertEqual(model.inputTooltip({ methods: ['keyboard-fr'] }, 'French', true), 'French · Super + I', 'multiple layouts advertise their switching shortcut')
|
||||
|
||||
// Trimmed from xkbcli list, keeping the format of every section it prints,
|
||||
// including the options nested under an option group: those quote their
|
||||
// description and print an empty brief, one indent deeper than a layout's.
|
||||
|
||||
@@ -674,6 +674,11 @@ assert(
|
||||
/onPositionChanged: function\(mouse\) \{\s*root\.selectFromPointer\(row\.index, row, mouse\)\s*\}/.test(menuQml),
|
||||
'menu row hover routes through pointer movement gate'
|
||||
)
|
||||
assert(
|
||||
/dmenuSaved = dmenuSelected\s*\n\s*if \(dmenuOnChange\.length > 0\)/.test(menuQml)
|
||||
&& /root\.dmenuSaved = root\.dmenuSelected\s*\n\s*\} else \{\s*root\.dmenuSelected = root\.dmenuSaved/.test(menuQml),
|
||||
'a failed immediate change restores the confirmed checks, even after reopening onto queued ones'
|
||||
)
|
||||
assert(
|
||||
/onEntered: root\.selectFromPointer\(row\.index, row, \{\s*x: mouseArea\.mouseX,\s*y: mouseArea\.mouseY\s*\}\)/.test(menuQml),
|
||||
'menu samples pointer movement immediately when entering a row'
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -70,8 +70,10 @@ SH
|
||||
chmod +x "$stub_bin"/*
|
||||
export PATH="$stub_bin:$PATH"
|
||||
|
||||
sed -n '/^keyboard_form() {/,/^}/p; /^keyboard_xkb_settings() {/,/^}/p; /^apply_keyboard() {/,/^}/p' "$ROOT/bin/omarchy-provision-owner" |
|
||||
sed "s|/etc/|$root/etc/|g" >"$tmp/keyboard.sh"
|
||||
{
|
||||
sed -n '/^keyboard_form() {/,/^}/p; /^keyboard_xkb_settings() {/,/^}/p; /^apply_keyboard() {/,/^}/p' "$ROOT/bin/omarchy-provision-owner"
|
||||
sed -n '/^omarchy_persist_input_selection() {/,/^}/p' "$ROOT/install/provisioning/setup-form.sh"
|
||||
} | sed "s|/etc/|$root/etc/|g" >"$tmp/keyboard.sh"
|
||||
grep -q '^keyboard_form() {' "$tmp/keyboard.sh" && grep -q '^apply_keyboard() {' "$tmp/keyboard.sh" ||
|
||||
fail "omarchy-provision-owner defines the keyboard step"
|
||||
|
||||
@@ -143,6 +145,8 @@ form || fail "a known layout is set" "$(cat "$tmp/screen" "$tmp/log")"
|
||||
grep -qx 'loadkeys dk' "$tmp/calls" && grep -qx 'systemd-firstboot --keymap=dk --force' "$tmp/calls" ||
|
||||
fail "the layout is loaded on the console and persisted" "$(cat "$tmp/calls")"
|
||||
assert_layout dk "a known layout"
|
||||
[[ $(<"$root/etc/omarchy/input-method") == $'INPUT_METHOD=none\nXKB_LAYOUT=' ]] ||
|
||||
fail "a known layout records no input method" "$(cat "$root/etc/omarchy/input-method" 2>&1)"
|
||||
pass "a layout that loads, persists and reads back goes straight to the password form"
|
||||
|
||||
# systemd-firstboot writes only KEYMAP= for a keymap kbd-model-map lacks, as it
|
||||
@@ -191,7 +195,7 @@ model_map=/usr/share/systemd/kbd-model-map
|
||||
if [[ -r $model_map ]]; then
|
||||
source "$ROOT/install/provisioning/setup-form.sh"
|
||||
source "$tmp/keyboard.sh"
|
||||
while IFS='|' read -r label keymap; do
|
||||
while IFS='|' read -r label keymap _; do
|
||||
awk -v k="$keymap" '$1 == k { found = 1 } END { exit !found }' "$model_map" ||
|
||||
{ declare -F keyboard_xkb_settings >/dev/null && keyboard_xkb_settings "$keymap" >/dev/null; } ||
|
||||
fail "the $label layout ($keymap) gets an XKB layout"
|
||||
|
||||
@@ -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' 'Lao|us|none|la' '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"
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(os.environ["OMARCHY_PATH"])
|
||||
sys.path.insert(0, str(ROOT / "default/input-methods"))
|
||||
spec = importlib.util.spec_from_file_location("typing_setup", ROOT / "default/input-methods/typing_setup.py")
|
||||
typing = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(typing)
|
||||
|
||||
|
||||
class TypingSetupTest(unittest.TestCase):
|
||||
def test_variants_and_installer_binding_safety(self):
|
||||
self.assertEqual(typing.keyboard_values(["us:intl", "fr"]), ("us,fr", "intl,"))
|
||||
self.assertEqual(typing.keyboard_values(["ru:phonetic"]), ("us,ru", ",phonetic"))
|
||||
self.assertEqual(typing.keyboard_values(["ru:phonetic", "us"]), ("us,ru", ",phonetic"))
|
||||
with self.assertRaises(ValueError):
|
||||
typing.keyboard_values([])
|
||||
|
||||
def test_keyboard_catalog_uses_installer_choices(self):
|
||||
catalog = typing.keyboard_catalog()
|
||||
self.assertEqual(catalog["us"], "English (US)")
|
||||
self.assertEqual(catalog["gb"], "English (UK)")
|
||||
self.assertEqual(catalog["us:colemak"], "English (US, Colemak)")
|
||||
self.assertEqual(catalog["ch"], "German (Switzerland)")
|
||||
self.assertEqual(catalog["la"], "Lao")
|
||||
# Converted as the installer does.
|
||||
self.assertEqual(catalog["fr"], "French")
|
||||
self.assertEqual(catalog["bg:phonetic"], "Bulgarian")
|
||||
self.assertEqual(catalog["cz:qwerty"], "Czech")
|
||||
self.assertNotIn("az", catalog)
|
||||
self.assertEqual(catalog["latam"], "Spanish (Latin American)")
|
||||
self.assertNotIn("us:intl", catalog)
|
||||
self.assertLess(len(catalog), 60)
|
||||
|
||||
def test_readding_input_keeps_its_original_override(self):
|
||||
self.assertEqual(typing.input_items([["keyboard-us", ""]], ["mozc"], {"mozc": "jp"}),
|
||||
[["keyboard-us", ""], ["mozc", "jp"]])
|
||||
self.assertEqual(typing.input_items([["keyboard-us", ""], ["mozc", "us"]], ["mozc"], {"mozc": "jp"}),
|
||||
[["keyboard-us", ""], ["mozc", "us"]])
|
||||
|
||||
def test_input_deselection_keeps_keyboard_and_selected_overrides(self):
|
||||
items = [["keyboard-us", ""], ["mozc", "jp"], ["hangul", ""], ["custom", "de"]]
|
||||
self.assertEqual(typing.input_items(items, ["custom", "mozc"]), [["keyboard-us", ""], ["mozc", "jp"], ["custom", "de"]])
|
||||
self.assertEqual(typing.input_items(items, []), [["keyboard-us", ""]])
|
||||
|
||||
def test_input_changes_keep_the_switching_order(self):
|
||||
items = [["keyboard-us", ""], ["mozc", ""], ["keyboard-fr", ""], ["hangul", ""]]
|
||||
self.assertEqual(typing.input_items(items, ["mozc"]), [["keyboard-us", ""], ["mozc", ""], ["keyboard-fr", ""]])
|
||||
self.assertEqual(typing.input_items(items, ["pinyin", "hangul", "mozc"]), items + [["pinyin", ""]])
|
||||
|
||||
def test_failed_input_save_leaves_fonts_alone(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
before = ("Default", "us", [["keyboard-us", ""]])
|
||||
with patch.dict(os.environ, {"XDG_CONFIG_HOME": temporary}), patch.object(typing.setup, "live_group", return_value=before), patch.object(typing.setup, "available_methods", return_value=[["mozc", "Mozc"]]), patch.object(typing, "set_inputs", side_effect=RuntimeError("rejected")):
|
||||
with self.assertRaises(RuntimeError):
|
||||
typing.save_inputs(["mozc"])
|
||||
self.assertFalse((Path(temporary) / "fontconfig/conf.d/50-omarchy-input-method.conf").exists())
|
||||
|
||||
def test_an_engine_installed_after_fcitx_started_loads_on_selection(self):
|
||||
group = ("Default", "us", [["keyboard-us", ""]])
|
||||
present = subprocess.CompletedProcess([], 0)
|
||||
with patch.object(typing.setup, "live_group", return_value=group), patch.object(typing.setup, "available_methods", return_value=[]), patch.object(typing.subprocess, "run", return_value=present), patch.object(typing.setup, "run") as run, patch.object(typing.setup, "wait_ready") as ready, patch.object(typing, "set_inputs") as save, patch.object(typing.setup, "font_default"):
|
||||
typing.save_inputs(["mozc"])
|
||||
run.assert_called_once_with(["omarchy-restart-xcompose"])
|
||||
ready.assert_called_once_with("mozc")
|
||||
self.assertEqual(save.call_args.args[3], [["keyboard-us", ""], ["mozc", ""]])
|
||||
|
||||
def test_an_uninstalled_engine_names_its_package_without_restarting(self):
|
||||
absent = subprocess.CompletedProcess([], 1)
|
||||
with patch.object(typing.setup, "live_group", return_value=("Default", "us", [["keyboard-us", ""]])), patch.object(typing.setup, "available_methods", return_value=[]), patch.object(typing.subprocess, "run", return_value=absent), patch.object(typing.setup, "run") as run, patch.object(typing, "set_inputs") as save:
|
||||
with self.assertRaisesRegex(RuntimeError, "Install fcitx5-mozc to use Japanese"):
|
||||
typing.save_inputs(["mozc"])
|
||||
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:
|
||||
with self.assertRaises(OSError):
|
||||
typing.save_inputs(["mozc"])
|
||||
setter.assert_called_once_with("Default", "us", before)
|
||||
|
||||
def test_cancellation_does_not_change_input_settings(self):
|
||||
with patch.object(typing.setup, "live_group", return_value=("Default", "us", [["keyboard-us", ""]])), patch.object(typing, "input_catalog", return_value=({"mozc": "Japanese"}, {"mozc": "Japanese"})), patch.object(typing, "choose", return_value=None), patch.object(typing.setup, "live_set") as setter:
|
||||
self.assertFalse(typing.configure_inputs())
|
||||
setter.assert_not_called()
|
||||
|
||||
def test_failed_input_save_restores_the_original_group(self):
|
||||
before = [["keyboard-us", ""], ["mozc", "jp"]]
|
||||
after = [["keyboard-us", ""]]
|
||||
with patch.object(typing.setup, "run"), patch.object(typing.setup, "live_set") as setter, patch.object(typing.setup, "live_group", return_value=("Default", "us", before)):
|
||||
with self.assertRaises(RuntimeError):
|
||||
typing.set_inputs("Default", "us", before, after)
|
||||
self.assertEqual(setter.call_args_list[-1].args, ("Default", "us", before))
|
||||
|
||||
def test_picker_preselects_current_entries_and_returns_empty_selection(self):
|
||||
result = subprocess.CompletedProcess([], 0, stdout="[]")
|
||||
with patch.object(typing.subprocess, "run", return_value=result) as process:
|
||||
self.assertIsNone(typing.choose("Inputs", {"mozc": "Japanese", "hangul": "Korean"}, ["hangul"], "input"))
|
||||
args = process.call_args.args[0]
|
||||
self.assertEqual(args[2], "\tKorean")
|
||||
self.assertIn("--multiple", args)
|
||||
self.assertEqual(args[args.index("--change-key") + 1], "typing:input")
|
||||
self.assertEqual(args[-2:], ["--selected", "Korean"])
|
||||
|
||||
def test_apply_rejects_unknown_values(self):
|
||||
with self.assertRaises(ValueError):
|
||||
typing.apply_selection("input", {"catalog": {"mozc": "Japanese"}}, ["unknown"])
|
||||
|
||||
def test_immediate_selection_removes_an_input_and_accepts_keyboard_only(self):
|
||||
with patch.object(typing, "save_inputs") as save, patch("builtins.print"):
|
||||
typing.apply_selection("input", {"catalog": {"mozc": "Japanese", "hangul": "Korean"}}, ["Japanese"])
|
||||
save.assert_called_once_with(["mozc"], None)
|
||||
typing.apply_selection("input", {"catalog": {"mozc": "Japanese"}}, [])
|
||||
self.assertEqual(save.call_args.args, ([], None))
|
||||
|
||||
def test_keyboard_checks_reflect_normalized_selection(self):
|
||||
with patch.object(typing, "save_keyboard"), patch.object(typing, "keyboard_selection", return_value=["us", "ru"]), patch("builtins.print") as output:
|
||||
typing.apply_selection("keyboard", {"catalog": {"us": "English", "ru": "Russian"}}, ["Russian"])
|
||||
self.assertEqual(json.loads(output.call_args.args[0]), ["English", "Russian"])
|
||||
|
||||
def test_keyboard_override_failure_preserves_symlink_and_restores_settings(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
config = Path(temporary)
|
||||
target = config / "actual-layouts"
|
||||
target.write_text("XKBLAYOUT=de\n")
|
||||
target.chmod(0o600)
|
||||
path = config / "omarchy/keyboard-layouts"
|
||||
path.parent.mkdir()
|
||||
path.symlink_to(target)
|
||||
before = ("Default", "us", [["keyboard-us", ""], ["mozc", "jp"]])
|
||||
with patch.dict(os.environ, {"XDG_CONFIG_HOME": str(config)}), patch.object(typing.setup, "live_group", return_value=before), patch.object(typing.setup, "run", return_value=""), patch.object(typing, "keyboard_selection", return_value=["de"]), patch.object(typing.setup, "live_set") as setter:
|
||||
with self.assertRaisesRegex(RuntimeError, "override"):
|
||||
typing.save_keyboard(["us", "fr"])
|
||||
self.assertTrue(path.is_symlink())
|
||||
self.assertEqual(target.read_text(), "XKBLAYOUT=de\n")
|
||||
self.assertEqual(target.stat().st_mode & 0o777, 0o600)
|
||||
setter.assert_not_called()
|
||||
|
||||
def test_keyboard_save_aligns_fcitx_and_preserves_language_overrides(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
before = ("Default", "us", [["keyboard-us", ""], ["mozc", "jp"]])
|
||||
after = ("Default", "fr", [["keyboard-fr", ""], ["mozc", "jp"]])
|
||||
with patch.dict(os.environ, {"XDG_CONFIG_HOME": temporary}), patch.object(typing.setup, "live_group", side_effect=[before, after]), patch.object(typing.setup, "run", return_value=""), patch.object(typing, "keyboard_selection", return_value=["fr", "us:intl"]), patch.object(typing.setup, "live_set") as setter:
|
||||
typing.save_keyboard(["fr", "us:intl"])
|
||||
self.assertEqual(setter.call_args.args, after)
|
||||
self.assertEqual((Path(temporary) / "omarchy/keyboard-layouts").read_text(), "XKBLAYOUT=fr,us\nXKBVARIANT=,intl\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh"
|
||||
|
||||
OMARCHY_PATH="$ROOT" python "$ROOT/test/shell.d/typing-setup-checks.py"
|
||||
|
||||
run_node_test <<'JS'
|
||||
const menu = requireFromRoot('shell/plugins/menu/MenuModel.js')
|
||||
const options = ['French', '\tJapanese (Mozc)', 'x\tSame label\tfirst', 'x\tSame label\tsecond']
|
||||
assertEqual(menu.dmenuValue(options[2]), 'Same label\tfirst', 'multi-select keeps a stable subtext value without the icon')
|
||||
assertDeepEqual(menu.dmenuSelections(options, ['Same label\tsecond', 'French', 'stale']), ['French', 'Same label\tsecond'], 'multi-select preserves option order and excludes stale choices')
|
||||
assertDeepEqual(menu.dmenuSelections(options, ['Same label\tsecond', 'French', 'stale'], true), ['Same label\tsecond', 'French'], 'immediate selections preserve switching order')
|
||||
assertDeepEqual(menu.toggleDmenuSelection(['French'], 'French'), [], 'deselecting the final option produces an empty selection')
|
||||
assertDeepEqual(menu.toggleDmenuSelection(['French'], 'Japanese (Mozc)'), ['French', 'Japanese (Mozc)'], 'selecting an input retains earlier choices')
|
||||
JS
|
||||
Reference in new issue
Block a user