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
+22
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Configure, inspect, or switch input methods
|
||||
# omarchy:args=<status|cycle [back]|configure --seed|--defaults>
|
||||
# omarchy:hidden=true
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ ${1:-} == "cycle" ]]; then
|
||||
direction=${2:-next}
|
||||
if [[ $direction != "next" && $direction != "back" ]]; then
|
||||
echo "Usage: omarchy-input-method cycle [back]" >&2
|
||||
exit 1
|
||||
fi
|
||||
# The shell's reader retains a choice made while nothing has focus.
|
||||
if [[ $(omarchy-shell shell cycleInput "$direction") != "ok" ]]; then
|
||||
echo "The shell's input reader is not running" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
/usr/bin/python3 "$OMARCHY_PATH/default/input-methods/configure.py" "$@"
|
||||
fi
|
||||
@@ -555,7 +555,7 @@ output_binding_records_uncached() {
|
||||
|
||||
keybindings_cache_key() {
|
||||
{
|
||||
printf 'v14\n'
|
||||
printf 'v15\n'
|
||||
hyprctl devices 2>/dev/null | grep -F 'active keymap:'
|
||||
cat "$key_names_file" 2>/dev/null
|
||||
hyprctl binds 2>/dev/null
|
||||
|
||||
+46
-4
@@ -1,9 +1,9 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Pick one option from a menu
|
||||
# omarchy:summary=Pick one or multiple options from a menu
|
||||
# omarchy:group=menu
|
||||
# omarchy:name=select
|
||||
# omarchy:args=prompt [option...] [-- menu args...]
|
||||
# omarchy:args=prompt [option...] [-- --multiple --selected <value> --on-change <command-json> --change-key <key> --width <pixels> --maxheight <pixels>]
|
||||
# omarchy:examples=omarchy menu select Format jpg png|omarchy-menu-select Resolution 4k 1080p 720p -- --width 400
|
||||
|
||||
# An option may lead with an icon, as "<glyph><TAB><label>", and may trail a
|
||||
@@ -12,6 +12,12 @@
|
||||
# alone; an option with a subtext returns "<label><TAB><subtext>", so callers
|
||||
# with same-named rows get the subtext back as the stable key.
|
||||
|
||||
# With --multiple, clicking/Enter toggles rows and Apply returns a JSON array.
|
||||
# --on-change takes a JSON command array; each toggle runs it with the selection
|
||||
# JSON appended, applies immediately, and needs no Apply row. A successful
|
||||
# command may print a JSON array of actual selections to update the checkmarks.
|
||||
# --selected takes a returned value (label plus any subtext), and can be repeated.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if (( $# < 1 )); then
|
||||
@@ -25,12 +31,43 @@ shift
|
||||
options=()
|
||||
menu_width=""
|
||||
menu_maxheight=""
|
||||
multiple=0
|
||||
on_change="[]"
|
||||
change_key=""
|
||||
selected=()
|
||||
|
||||
while (( $# > 0 )); do
|
||||
if [[ $1 == "--" ]]; then
|
||||
shift
|
||||
while (( $# > 0 )); do
|
||||
case "$1" in
|
||||
--on-change)
|
||||
shift
|
||||
if (( $# == 0 )); then
|
||||
echo "omarchy-menu-select: --on-change requires a JSON command array" >&2
|
||||
exit 1
|
||||
fi
|
||||
on_change="$1"
|
||||
;;
|
||||
--change-key)
|
||||
shift
|
||||
if (( $# == 0 )); then
|
||||
echo "omarchy-menu-select: --change-key requires a value" >&2
|
||||
exit 1
|
||||
fi
|
||||
change_key="$1"
|
||||
;;
|
||||
--multiple)
|
||||
multiple=1
|
||||
;;
|
||||
--selected)
|
||||
shift
|
||||
if (( $# == 0 )); then
|
||||
echo "omarchy-menu-select: --selected requires a value" >&2
|
||||
exit 1
|
||||
fi
|
||||
selected+=("$1")
|
||||
;;
|
||||
--width)
|
||||
shift
|
||||
if (( $# == 0 )); then
|
||||
@@ -73,20 +110,25 @@ rm -f "$done_file"
|
||||
trap 'rm -f "$selection_file" "$done_file"' EXIT
|
||||
|
||||
options_json=$(perl -MEncode=decode -MJSON::PP=encode_json -e 'print encode_json([map { decode("UTF-8", $_) } @ARGV])' "${options[@]}")
|
||||
selected_json=$(perl -MEncode=decode -MJSON::PP=encode_json -e 'print encode_json([map { decode("UTF-8", $_) } @ARGV])' "${selected[@]}")
|
||||
payload=$(perl -MEncode=decode -MJSON::PP=encode_json,decode_json -e '
|
||||
my $payload = {
|
||||
mode => "select",
|
||||
prompt => decode("UTF-8", $ARGV[0]),
|
||||
options => decode_json($ARGV[1]),
|
||||
multiple => $ARGV[6] ? JSON::PP::true : JSON::PP::false,
|
||||
selected => decode_json($ARGV[7]),
|
||||
onChange => decode_json($ARGV[8]),
|
||||
changeKey => $ARGV[9],
|
||||
selectionFile => decode("UTF-8", $ARGV[2]),
|
||||
doneFile => decode("UTF-8", $ARGV[3])
|
||||
};
|
||||
$payload->{width} = int($ARGV[4]) if length($ARGV[4] // "");
|
||||
$payload->{maxHeight} = int($ARGV[5]) if length($ARGV[5] // "");
|
||||
print encode_json($payload)
|
||||
' "$prompt" "$options_json" "$selection_file" "$done_file" "$menu_width" "$menu_maxheight")
|
||||
' "$prompt" "$options_json" "$selection_file" "$done_file" "$menu_width" "$menu_maxheight" "$multiple" "$selected_json" "$on_change" "$change_key")
|
||||
|
||||
omarchy-shell shell summon omarchy.menu "$payload" >/dev/null
|
||||
omarchy-shell shell summon omarchy.menu "$payload" >/dev/null || exit 2
|
||||
|
||||
while [[ ! -e $done_file ]]; do
|
||||
sleep 0.05
|
||||
|
||||
@@ -615,6 +615,8 @@ keyboard_form() {
|
||||
# re-key below. Confirm it so a stray Ctrl+C doesn't bounce the machine.
|
||||
confirm_reboot && exec systemctl reboot
|
||||
done
|
||||
|
||||
omarchy_persist_input_selection
|
||||
}
|
||||
|
||||
confirm_reboot() {
|
||||
|
||||
@@ -2,9 +2,30 @@
|
||||
|
||||
# omarchy:summary=Restart the XCompose input method service (fcitx5) to apply new compose key settings.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# A fcitx5 running outside the unit owns org.fcitx.Fcitx5, so the unit's
|
||||
# instance would exit on arrival and the stale process would keep serving the
|
||||
# old ~/.XCompose -- a restart that reports success and changes nothing.
|
||||
systemctl --user stop omarchy-fcitx5.service
|
||||
pkill -x fcitx5 >/dev/null 2>&1 || true
|
||||
pkill -u "$UID" -x fcitx5 >/dev/null 2>&1 || true
|
||||
for attempt in {1..50}; do
|
||||
if ! pgrep -u "$UID" -x fcitx5 >/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
if pgrep -u "$UID" -x fcitx5 >/dev/null; then
|
||||
# An activation concurrent with stop may have restarted the unit.
|
||||
systemctl --user stop omarchy-fcitx5.service
|
||||
pkill -u "$UID" -x fcitx5 >/dev/null 2>&1 || true
|
||||
for attempt in {1..50}; do
|
||||
if ! pgrep -u "$UID" -x fcitx5 >/dev/null; then break; fi
|
||||
sleep 0.1
|
||||
done
|
||||
fi
|
||||
if pgrep -u "$UID" -x fcitx5 >/dev/null; then
|
||||
echo "Fcitx did not stop; input service was not restarted." >&2
|
||||
exit 1
|
||||
fi
|
||||
systemctl --user start omarchy-fcitx5.service
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Select input methods for Japanese, Korean, or Chinese typing
|
||||
# omarchy:args=[mozc|hangul|pinyin|chewing]
|
||||
# omarchy:examples=omarchy setup input | omarchy setup input mozc
|
||||
# omarchy:requires-sudo=true
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if (( $# > 1 )); then
|
||||
echo "Usage: omarchy setup input [mozc|hangul|pinyin|chewing]" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
method=${1:-}
|
||||
if [[ -z $method ]]; then
|
||||
/usr/bin/python3 "$OMARCHY_PATH/default/input-methods/typing_setup.py" input
|
||||
exit
|
||||
fi
|
||||
|
||||
case "$method" in
|
||||
mozc | hangul | pinyin | chewing) ;;
|
||||
*) echo "Unknown input method: $method" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
mapfile -t packages < <(jq -r --arg method "$method" '.[$method].packages[]' "$OMARCHY_PATH/default/input-methods/presets.json")
|
||||
omarchy-pkg-add "${packages[@]}"
|
||||
/usr/bin/python3 "$OMARCHY_PATH/default/input-methods/typing_setup.py" add "$method"
|
||||
echo "Input method added. Use Super+I or the Japanese/Korean input key to switch; existing custom Fcitx keys are preserved."
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Select the keyboard layouts available for switching
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
/usr/bin/python3 "$OMARCHY_PATH/default/input-methods/typing_setup.py" keyboard
|
||||
@@ -1807,6 +1807,8 @@ copy_missing_config_defaults() {
|
||||
while IFS= read -r -d '' source_path; do
|
||||
rel="${source_path#$source_dir/}"
|
||||
is_retired_config_file "$rel" && continue
|
||||
# The input migration preserves working shortcuts in existing IME profiles.
|
||||
[[ $rel == "fcitx5/config" ]] && continue
|
||||
|
||||
target_path="$target_dir/$rel"
|
||||
[[ -e $target_path || -L $target_path ]] && continue
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Keep Pinyin offline without prompting to enable cloud prediction.
|
||||
FirstRun=False
|
||||
CloudPinyinEnabled=False
|
||||
@@ -0,0 +1,2 @@
|
||||
[TriggerKey]
|
||||
0=Super+semicolon
|
||||
@@ -0,0 +1 @@
|
||||
Allow Overriding System XKB Settings=False
|
||||
@@ -0,0 +1,19 @@
|
||||
# Omarchy binds Super+I. Leave Ctrl+Space to tmux/Herdr and other Super keys to the desktop.
|
||||
[Hotkey/TriggerKeys]
|
||||
0=Zenkaku_Hankaku
|
||||
1=Hangul
|
||||
|
||||
[Hotkey/ActivateKeys]
|
||||
0=Henkan
|
||||
|
||||
[Hotkey/DeactivateKeys]
|
||||
0=Muhenkan
|
||||
|
||||
[Hotkey/AltTriggerKeys]
|
||||
|
||||
[Hotkey/EnumerateGroupForwardKeys]
|
||||
|
||||
[Hotkey/EnumerateGroupBackwardKeys]
|
||||
|
||||
[Behavior]
|
||||
ActiveByDefault=False
|
||||
@@ -30,9 +30,6 @@
|
||||
{
|
||||
"id": "omarchy.elsewhen"
|
||||
},
|
||||
{
|
||||
"id": "omarchy.keyboard-layout"
|
||||
},
|
||||
{
|
||||
"id": "omarchy.weather"
|
||||
},
|
||||
@@ -41,6 +38,9 @@
|
||||
}
|
||||
],
|
||||
"right": [
|
||||
{
|
||||
"id": "omarchy.keyboard-layout"
|
||||
},
|
||||
{
|
||||
"id": "omarchy.tray"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
-- 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
|
||||
|
||||
-- Keep the desktop shortcut independent of Fcitx's custom switching keys.
|
||||
-- Read the live Fcitx group so setup works without a compositor reload.
|
||||
o.bind("SUPER + I", "Switch input language", "omarchy-input-method cycle")
|
||||
o.bind("SUPER + SHIFT + I", "Switch input language back", "omarchy-input-method cycle back")
|
||||
|
||||
-- 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() 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 })
|
||||
@@ -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)
|
||||
+13
-21
@@ -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.selected()
|
||||
|
||||
local kb_layout = vconsole.XKBLAYOUT or "us"
|
||||
local kb_variant = vconsole.XKBVARIANT or ""
|
||||
@@ -47,6 +28,17 @@ if non_latin_layouts:find(" " .. kb_layout:match("^[^,]*") .. " ", 1, true) then
|
||||
kb_options = kb_options .. ",grp:alts_toggle"
|
||||
end
|
||||
|
||||
-- Saved selections already include their Latin-leading layout. Keep the
|
||||
-- installer's layout-switching shortcut when a non-Latin layout remains.
|
||||
if not kb_options:find("grp:alts_toggle", 1, true) then
|
||||
for layout in kb_layout:gmatch("[^,]+") do
|
||||
if non_latin_layouts:find(" " .. layout .. " ", 1, true) then
|
||||
kb_options = kb_options .. ",grp:alts_toggle"
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
hl.config({
|
||||
input = {
|
||||
kb_layout = kb_layout,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
-- 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.selected()
|
||||
local paths = require("default.hypr.paths")
|
||||
local values = read_values(paths.config_home .. "/omarchy/keyboard-layouts")
|
||||
if values.XKBLAYOUT then return values end
|
||||
return keyboard.installed()
|
||||
end
|
||||
|
||||
function keyboard.vconsole()
|
||||
return read_values("/etc/vconsole.conf")
|
||||
end
|
||||
|
||||
-- Choices whose console keymap is plain US but whose desktop layout is not:
|
||||
-- Korean keeps US letters, and Lao is non-Latin, so US still leads.
|
||||
local desktop_overrides = { kr = true, la = true }
|
||||
|
||||
function keyboard.installed()
|
||||
local values = keyboard.vconsole()
|
||||
local override = read_values("/etc/omarchy/input-method").XKB_LAYOUT
|
||||
if desktop_overrides[override]
|
||||
and (values.XKBLAYOUT or "us") == "us"
|
||||
and (values.XKBVARIANT or "") == "" then
|
||||
values.XKBLAYOUT, values.XKBVARIANT = override, ""
|
||||
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.selected().XKBLAYOUT or "us"
|
||||
return layout:match("^[^,]*") == "jp"
|
||||
end
|
||||
|
||||
return keyboard
|
||||
@@ -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.dictation")
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
"""Fcitx defaults for offline finalization and updates."""
|
||||
|
||||
import argparse
|
||||
from contextlib import contextmanager
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
import sys
|
||||
import time
|
||||
|
||||
|
||||
ROOT = Path(os.environ["OMARCHY_PATH"])
|
||||
PRESETS = json.loads((ROOT / "default/input-methods/presets.json").read_text())
|
||||
NON_LATIN = set("af am ara bd bg by et ge gr il in iq ir kg kh kz la lk mk mm mn mv np rs ru sy th tj ua".split())
|
||||
CONTROLLER = ["busctl", "--user", "call", "org.fcitx.Fcitx5", "/controller", "org.fcitx.Fcitx.Controller1"]
|
||||
|
||||
|
||||
def run(args, **kwargs):
|
||||
return subprocess.run(args, check=True, text=True, capture_output=True, **kwargs).stdout.strip()
|
||||
|
||||
|
||||
def read(path):
|
||||
return path.read_text() if path.exists() else ""
|
||||
|
||||
|
||||
def values(text):
|
||||
result = {}
|
||||
for line in text.splitlines():
|
||||
match = re.match(r"^\s*([\w ]+)\s*=\s*(.*?)\s*(?:\s+#.*)?$", line)
|
||||
if match:
|
||||
result[match[1].strip()] = match[2].strip("\"'")
|
||||
return result
|
||||
|
||||
|
||||
def sections(text):
|
||||
result = {}
|
||||
current = ""
|
||||
for line in text.splitlines():
|
||||
match = re.match(r"^\s*\[([^]]+)\]\s*$", line)
|
||||
if match:
|
||||
current = match[1]
|
||||
result.setdefault(current, {})
|
||||
elif current:
|
||||
result[current].update(values(line))
|
||||
return result
|
||||
|
||||
|
||||
def atomic_write(path, text):
|
||||
# Follow a user's config symlink rather than replacing the link itself.
|
||||
path = path.resolve()
|
||||
if read(path) == text:
|
||||
return False
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
mode = path.stat().st_mode & 0o777 if path.exists() else 0o644
|
||||
fd, temporary = tempfile.mkstemp(prefix=".omarchy-input-", dir=path.parent)
|
||||
try:
|
||||
with os.fdopen(fd, "w") as file:
|
||||
file.write(text)
|
||||
os.chmod(temporary, mode)
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
if os.path.exists(temporary):
|
||||
os.unlink(temporary)
|
||||
return True
|
||||
|
||||
|
||||
def key_list(entries):
|
||||
# Fcitx canonicalizes modifiers (e.g. Shift+Super instead of Super+Shift).
|
||||
return {frozenset(value.split("+")) for value in entries.values()}
|
||||
|
||||
|
||||
def without_ctrl_space(text):
|
||||
# Ctrl+Space belongs to tmux and Herdr, and the terminal binding returns to
|
||||
# direct input on it; a Fcitx toggle on the same key would undo that. Without
|
||||
# a TriggerKeys list Fcitx falls back to its default, which has Ctrl+Space.
|
||||
section = re.search(r"(?ms)^\[Hotkey/TriggerKeys\][ \t]*\n(.*?)(?=^\[|\Z)", text)
|
||||
if not section:
|
||||
return text.rstrip() + ("\n\n" if text.strip() else "") + "[Hotkey/TriggerKeys]\n0=Zenkaku_Hankaku\n1=Hangul\n"
|
||||
keys = values(section[1])
|
||||
kept = [keys[key] for key in sorted(keys, key=lambda key: int(key) if key.isdigit() else 0) if keys[key] != "Control+space"]
|
||||
if len(kept) == len(keys):
|
||||
return text
|
||||
body = "".join(f"{index}={key}\n" for index, key in enumerate(kept)) + ("\n" if section[1].endswith("\n\n") else "")
|
||||
return text[:section.start(1)] + body + text[section.end(1):]
|
||||
|
||||
|
||||
def defaults(config_home, fresh=False):
|
||||
changed = False
|
||||
files = ["config", "conf/quickphrase.conf", "conf/wayland.conf", "conf/xcb.conf", "conf/pinyin.conf"]
|
||||
for name in files:
|
||||
target = config_home / "fcitx5" / name
|
||||
original = read(target)
|
||||
shipped = read(ROOT / "config/fcitx5" / name)
|
||||
if name == "config" or name == "conf/quickphrase.conf":
|
||||
if name == "config":
|
||||
original = without_ctrl_space(original)
|
||||
existing = sections(original)
|
||||
additions = []
|
||||
for section, entries in sections(shipped).items():
|
||||
# Missing hotkey sections also represent a choice: Fcitx's defaults.
|
||||
if not fresh and section.startswith("Hotkey/"):
|
||||
continue
|
||||
# Existing lists (including explicitly empty lists) are user choices.
|
||||
# A fresh profile has no working IME shortcut to preserve.
|
||||
stock_lists = {
|
||||
"Hotkey/ActivateKeys": {"0": "Hangul_Hanja"},
|
||||
"Hotkey/DeactivateKeys": {"0": "Hangul_Romaja"},
|
||||
"Hotkey/AltTriggerKeys": {"0": "Shift_L"},
|
||||
"Hotkey/EnumerateGroupForwardKeys": {"0": "Super+space"},
|
||||
"Hotkey/EnumerateGroupBackwardKeys": {"0": "Super+Shift+space"},
|
||||
"TriggerKey": {"0": "Super+grave", "1": "Super+semicolon"},
|
||||
}
|
||||
if fresh and section in existing and section in stock_lists and (key_list(existing[section]) == key_list(stock_lists[section]) or (not existing[section] and section in {"Hotkey/ActivateKeys", "Hotkey/DeactivateKeys"})):
|
||||
original = re.sub(r"(?ms)^\[" + re.escape(section) + r"\]\s*\n.*?(?=^\[|\Z)", "", original)
|
||||
existing.pop(section)
|
||||
if section not in existing:
|
||||
additions.append(f"[{section}]\n" + "".join(f"{key}={value}\n" for key, value in entries.items()))
|
||||
if additions:
|
||||
original = original.rstrip() + ("\n\n" if original.strip() else "") + "\n".join(additions)
|
||||
changed |= atomic_write(target, original)
|
||||
elif name == "conf/pinyin.conf":
|
||||
# Suppress the first-use cloud prompt while preserving any explicit
|
||||
# cloud-prediction preference and all other existing engine settings.
|
||||
if not original:
|
||||
updated = shipped
|
||||
elif "FirstRun" in values(original):
|
||||
updated = re.sub(r"(?m)^\s*FirstRun\s*=.*$", "FirstRun=False", original)
|
||||
else:
|
||||
updated = "FirstRun=False\n" + original
|
||||
changed |= atomic_write(target, updated)
|
||||
elif not target.exists() and not target.is_symlink():
|
||||
changed |= atomic_write(target, shipped)
|
||||
return changed
|
||||
|
||||
|
||||
def activation_default():
|
||||
data_home = Path(os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local/share"))
|
||||
path = data_home / "dbus-1/services/org.fcitx.Fcitx5.service"
|
||||
original = read(path)
|
||||
# Repair the package's plain activation entry, but retain custom launchers.
|
||||
packaged = values(original).get("Exec") in ("/usr/bin/fcitx5", "/usr/bin/fcitx5 -d")
|
||||
if not original or (packaged and "SystemdService" not in values(original)):
|
||||
return atomic_write(path, read(ROOT / "default/input-methods/org.fcitx.Fcitx5.service"))
|
||||
return False
|
||||
|
||||
|
||||
@contextmanager
|
||||
def transaction(config_home):
|
||||
data_home = Path(os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local/share"))
|
||||
paths = [config_home / "fcitx5" / name for name in ["profile", "config", "conf/quickphrase.conf", "conf/wayland.conf", "conf/xcb.conf", "conf/pinyin.conf"]]
|
||||
paths += [config_home / "fontconfig/conf.d/50-omarchy-input-method.conf", data_home / "dbus-1/services/org.fcitx.Fcitx5.service"]
|
||||
before = {path.resolve(): path.read_text() if path.exists() else None for path in paths}
|
||||
try:
|
||||
yield
|
||||
except BaseException:
|
||||
for path, original in before.items():
|
||||
if original is None:
|
||||
path.unlink(missing_ok=True)
|
||||
else:
|
||||
atomic_write(path, original)
|
||||
raise
|
||||
|
||||
|
||||
def selection():
|
||||
vconsole = values(read(Path(os.environ.get("OMARCHY_VCONSOLE", "/etc/vconsole.conf"))))
|
||||
preference = values(read(Path(os.environ.get("OMARCHY_INPUT_SELECTION", "/etc/omarchy/input-method"))))
|
||||
layout = vconsole.get("XKBLAYOUT", "us").split(",")[0] or "us"
|
||||
variant = vconsole.get("XKBVARIANT", "").split(",")[0]
|
||||
if preference.get("XKB_LAYOUT") in ("kr", "la") and vconsole.get("XKBLAYOUT", "us") == "us" and not variant:
|
||||
layout, variant = preference["XKB_LAYOUT"], ""
|
||||
method = preference.get("INPUT_METHOD", "mozc" if layout == "jp" else "none")
|
||||
if method not in PRESETS:
|
||||
raise ValueError(f"Unknown installed input method: {method}")
|
||||
if layout in NON_LATIN:
|
||||
layout, variant = "us", ""
|
||||
group_layout = layout + (f"-{variant}" if variant else "")
|
||||
if not re.fullmatch(r"[A-Za-z0-9_-]+", group_layout):
|
||||
raise ValueError("Invalid installed keyboard layout")
|
||||
return method, group_layout
|
||||
|
||||
|
||||
def stock_profile(text):
|
||||
if not text.strip():
|
||||
return True
|
||||
groups = sections(text)
|
||||
items = [data.get("Name", "") for key, data in groups.items() if re.fullmatch(r"Groups/\d+/Items/\d+", key)]
|
||||
group = groups.get("Groups/0", {})
|
||||
return (items == ["keyboard-us"]
|
||||
and list(key for key in groups if re.fullmatch(r"Groups/\d+", key)) == ["Groups/0"]
|
||||
and group.get("Name", "Default") == "Default"
|
||||
and group.get("Default Layout", "us") == "us"
|
||||
and not groups.get("Groups/0/Items/0", {}).get("Layout"))
|
||||
|
||||
|
||||
def profile_text(layout, method):
|
||||
default_im = method if method != "none" else f"keyboard-{layout}"
|
||||
text = f"[Groups/0]\nName=Default\nDefault Layout={layout}\nDefaultIM={default_im}\n\n[Groups/0/Items/0]\nName=keyboard-{layout}\nLayout=\n"
|
||||
if method != "none":
|
||||
text += f"\n[Groups/0/Items/1]\nName={method}\nLayout=\n"
|
||||
return text + "\n[GroupOrder]\n0=Default\n"
|
||||
|
||||
|
||||
def font_default(config_home, method):
|
||||
variant = PRESETS[method]["font"]
|
||||
path = config_home / "fontconfig/conf.d/50-omarchy-input-method.conf"
|
||||
if not variant or path.exists() or path.is_symlink():
|
||||
return
|
||||
# Only CJK fallback changes; retain the interface and terminal's Latin font.
|
||||
rules = []
|
||||
for families, face in [("sans-serif|Liberation Sans", "Sans"), ("serif|Liberation Serif", "Serif"), ("monospace|JetBrainsMono Nerd Font", "Sans Mono")]:
|
||||
for family in families.split("|"):
|
||||
rules.append(f'<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 configure(args):
|
||||
config_home = Path(os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config"))
|
||||
running = subprocess.run(["pgrep", "-u", str(os.getuid()), "-x", "fcitx5"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0
|
||||
# Finalization can be retried after login. A live daemon owns its profile.
|
||||
if args.seed and running:
|
||||
return
|
||||
with transaction(config_home):
|
||||
configure_inner(args, config_home, running)
|
||||
|
||||
|
||||
def configure_inner(args, config_home, running):
|
||||
method, layout = selection()
|
||||
profile = config_home / "fcitx5/profile"
|
||||
original = read(profile)
|
||||
if args.seed:
|
||||
if not stock_profile(original):
|
||||
return
|
||||
atomic_write(profile, profile_text(layout, method))
|
||||
defaults(config_home, fresh=True)
|
||||
activation_default()
|
||||
font_default(config_home, method)
|
||||
return
|
||||
|
||||
fresh = stock_profile(original)
|
||||
changed = defaults(config_home, fresh=fresh)
|
||||
if activation_default():
|
||||
reload_bus()
|
||||
# Upgrades can run through sudo/SSH without access to the live user's bus.
|
||||
# Never overwrite its profile or restart input in that context.
|
||||
if fresh:
|
||||
if running:
|
||||
try:
|
||||
group, old_layout, items = live_group()
|
||||
if old_layout == "us" and items == [["keyboard-us", ""]]:
|
||||
new_items = [[f"keyboard-{layout}", ""]]
|
||||
if method != "none":
|
||||
if any(item[0] == method for item in available_methods()):
|
||||
new_items.append([method, ""])
|
||||
else:
|
||||
print(f"Finish input setup after login: omarchy setup input {method}", file=sys.stderr)
|
||||
live_set(group, layout, new_items)
|
||||
if live_group()[1:] != (layout, new_items):
|
||||
live_set(group, old_layout, items)
|
||||
raise RuntimeError("Fcitx did not retain the installed keyboard settings")
|
||||
except (OSError, ValueError, KeyError, IndexError, RuntimeError, subprocess.CalledProcessError) as error:
|
||||
print(f"Input profile alignment deferred until login: {error}", file=sys.stderr)
|
||||
else:
|
||||
atomic_write(profile, profile_text(layout, method))
|
||||
if running and changed:
|
||||
try:
|
||||
run(CONTROLLER + ["ReloadConfig"])
|
||||
except (OSError, subprocess.CalledProcessError) as error:
|
||||
print(f"Input defaults reload deferred until login: {error}", file=sys.stderr)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
commands.add_parser("status")
|
||||
configure_parser = commands.add_parser("configure")
|
||||
mode = configure_parser.add_mutually_exclusive_group(required=True)
|
||||
mode.add_argument("--seed", action="store_true")
|
||||
mode.add_argument("--defaults", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.command == "status":
|
||||
method, layout = selection()
|
||||
print(json.dumps({"installed_method": method, "layout": layout}))
|
||||
else:
|
||||
configure(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except (OSError, ValueError, RuntimeError, subprocess.CalledProcessError) as error:
|
||||
raise SystemExit(f"Input method setup failed: {error}")
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Live Fcitx state for the existing keyboard layout widget."""
|
||||
|
||||
from collections import Counter
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
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 layout_keyboard(keyboards):
|
||||
typed = [keyboard for keyboard in keyboards if not re.match(
|
||||
r"^(hl-virtual-keyboard|power-button|sleep-button|lid-switch|video-bus)", keyboard.get("name", ""))]
|
||||
typed = [item for item in typed if len(item.get("layout", "").split(",")) > 1]
|
||||
if not typed:
|
||||
return None
|
||||
# Consumer controls also carry the seat layout. Its frequency keeps an
|
||||
# individually configured keyboard from taking over the desktop shortcut.
|
||||
counts = Counter(item.get("layout", "") for item in keyboards)
|
||||
layout = max((item["layout"] for item in typed), key=lambda layout: counts[layout])
|
||||
keyboard = max((item for item in typed if item["layout"] == layout),
|
||||
key=lambda item: item.get("active_layout_index", 0))
|
||||
return keyboard
|
||||
|
||||
|
||||
def layout_switches(keyboards, index=None, step=1):
|
||||
keyboard = layout_keyboard(keyboards)
|
||||
if keyboard is None:
|
||||
return []
|
||||
layout = keyboard["layout"]
|
||||
following = index if index is not None else (keyboard.get("active_layout_index", 0) + step) % len(layout.split(","))
|
||||
return [["hyprctl", "switchxkblayout", item["name"], str(following)]
|
||||
for item in keyboards if item.get("layout") == layout and item.get("name")]
|
||||
|
||||
|
||||
def cycle_choice(state, keyboards, step=1):
|
||||
methods = state.get("methods", [])
|
||||
keyboard = layout_keyboard(keyboards)
|
||||
primary = next((method for method in methods if method.startswith("keyboard-")), None)
|
||||
if keyboard is None or primary is None:
|
||||
current = state.get("current")
|
||||
if len(methods) > 1 and current in methods:
|
||||
return methods[(methods.index(current) + step) % len(methods)], None
|
||||
return None, None
|
||||
# One direct-input method represents all compositor layouts. Composition
|
||||
# engines follow them in the user's configured order.
|
||||
choices = []
|
||||
for method in methods:
|
||||
if method == primary:
|
||||
choices.extend((method, index) for index in range(len(keyboard["layout"].split(","))))
|
||||
else:
|
||||
choices.append((method, None))
|
||||
current = state.get("current") or primary
|
||||
position = (current, keyboard.get("active_layout_index", 0) if current == primary else None)
|
||||
if position not in choices:
|
||||
return None, None
|
||||
return choices[(choices.index(position) + step) % len(choices)]
|
||||
|
||||
|
||||
class Indicator:
|
||||
def __init__(self, bus):
|
||||
self.bus = bus
|
||||
self.pending = ""
|
||||
self.last = ""
|
||||
self.entries = {}
|
||||
|
||||
def select_next(self, step=1):
|
||||
devices = subprocess.run(["hyprctl", "-j", "devices"], check=True, text=True, capture_output=True)
|
||||
keyboards = json.loads(devices.stdout).get("keyboards", [])
|
||||
try:
|
||||
state = snapshot(self.bus)
|
||||
except GLib.Error:
|
||||
# Without Fcitx, Super+I still cycles the compositor layouts.
|
||||
for command in layout_switches(keyboards, step=step):
|
||||
subprocess.run(command, check=True, capture_output=True)
|
||||
return
|
||||
methods = state["methods"]
|
||||
current = self.pending or state["current"] or self.last
|
||||
state["current"] = current if current in methods else next(iter(methods), "")
|
||||
following, index = cycle_choice(state, keyboards, step)
|
||||
if following is None:
|
||||
return
|
||||
self.pending = following
|
||||
for command in layout_switches(keyboards, index) if index is not None else []:
|
||||
subprocess.run(command, check=True, capture_output=True)
|
||||
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 in ("cycle", "cycle back"):
|
||||
try:
|
||||
indicator.select_next(-1 if line == "cycle back" else 1)
|
||||
except (GLib.Error, OSError, ValueError, subprocess.CalledProcessError):
|
||||
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:
|
||||
watch(Gio.bus_get_sync(Gio.BusType.SESSION, None))
|
||||
except (GLib.Error, OSError, ValueError, subprocess.CalledProcessError) 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
|
||||
@@ -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"}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
"""Native menu selectors for keyboard layouts and active input methods."""
|
||||
|
||||
from collections import Counter
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import configure as setup
|
||||
|
||||
|
||||
def keyboard_catalog():
|
||||
# Read the same choices as the ISO and first-boot form, then convert console
|
||||
# keymaps through systemd's mapping, just as localectl does during install.
|
||||
choices = setup.run(["bash", "-c", 'source "$1"; printf "%s\\n" "$OMARCHY_KEYBOARD_LAYOUTS"',
|
||||
"bash", str(setup.ROOT / "install/provisioning/setup-form.sh")])
|
||||
mappings = {}
|
||||
for line in Path("/usr/share/systemd/kbd-model-map").read_text().splitlines():
|
||||
fields = line.split()
|
||||
if not fields or fields[0].startswith("#"):
|
||||
continue
|
||||
layout = fields[1].split(",")[0]
|
||||
variant = fields[3].split(",")[0]
|
||||
mappings.setdefault(fields[0], layout + (":" + variant if variant != "-" else ""))
|
||||
# The console names systemd's table lacks convert as keyboard_xkb_settings in
|
||||
# omarchy-provision-owner does, so a choice types what it did at install.
|
||||
mappings.update({"bg-cp1251": "bg:phonetic", "colemak": "us:colemak", "cz": "cz:qwerty",
|
||||
"kyrgyz": "kg", "de_CH-latin1": "ch", "no-latin1": "no"})
|
||||
catalog = {}
|
||||
for choice in choices.splitlines():
|
||||
label, keymap, *input_settings = choice.split("|")
|
||||
layout = input_settings[1] if len(input_settings) > 1 else mappings.get(keymap, keymap)
|
||||
# Composition engines belong in Input Methods, not as duplicate US layouts.
|
||||
catalog.setdefault(layout, label)
|
||||
return catalog
|
||||
|
||||
|
||||
def keyboard_selection():
|
||||
layouts = json.loads(setup.run(["hyprctl", "-j", "getoption", "input:kb_layout"]))["str"].split(",")
|
||||
variants = json.loads(setup.run(["hyprctl", "-j", "getoption", "input:kb_variant"]))["str"].split(",")
|
||||
return [layout + (":" + variants[index] if index < len(variants) and variants[index] else "")
|
||||
for index, layout in enumerate(layouts)]
|
||||
|
||||
|
||||
def picker_rows(catalog, selected):
|
||||
# Current entries stay first, in switching order, followed by the catalog.
|
||||
keys = list(dict.fromkeys(selected + sorted(catalog, key=lambda key: catalog[key])))
|
||||
labels = {key: catalog.get(key, key) for key in keys}
|
||||
counts = Counter(labels.values())
|
||||
rows = {key: "\t" + labels[key] + (f" ({key})" if counts[labels[key]] > 1 else "") for key in keys}
|
||||
return rows
|
||||
|
||||
|
||||
def choose(title, catalog, selected, kind, overrides=None):
|
||||
catalog = {**catalog, **{key: catalog.get(key, key) for key in selected}}
|
||||
rows = picker_rows(catalog, selected)
|
||||
args = ["omarchy-menu-select", title] + list(rows.values())
|
||||
on_change = ["/usr/bin/python3", str(setup.ROOT / "default/input-methods/typing_setup.py"), "apply", kind, json.dumps({"catalog": catalog, "overrides": overrides or {}})]
|
||||
args += ["--", "--multiple", "--width", "620", "--maxheight", "650", "--on-change", json.dumps(on_change), "--change-key", "typing:" + kind]
|
||||
for key in selected:
|
||||
args += ["--selected", rows[key][1:]]
|
||||
result = subprocess.run(args, text=True, capture_output=True)
|
||||
if result.returncode == 1:
|
||||
return None
|
||||
if result.returncode:
|
||||
raise RuntimeError(result.stderr.strip() or "The selection menu could not open")
|
||||
|
||||
|
||||
def input_catalog(items):
|
||||
available = {item[0]: item[1] for item in setup.available_methods()}
|
||||
catalog = {key: preset["label"] for key, preset in setup.PRESETS.items() if key != "none"}
|
||||
for name, layout in items:
|
||||
if not name.startswith("keyboard-"):
|
||||
catalog.setdefault(name, available.get(name, name))
|
||||
return catalog, available
|
||||
|
||||
|
||||
def input_items(items, selected, overrides=None):
|
||||
if not any(item[0].startswith("keyboard-") for item in items):
|
||||
raise RuntimeError("The current input group has no keyboard input")
|
||||
# Retained entries keep their switching order; new engines follow them.
|
||||
kept = [item for item in items if item[0].startswith("keyboard-") or item[0] in selected]
|
||||
current = dict(items)
|
||||
return kept + [[name, (overrides or {}).get(name, "")] for name in selected if name not in current]
|
||||
|
||||
|
||||
def set_inputs(group, layout, before, after):
|
||||
if before == after:
|
||||
return
|
||||
try:
|
||||
# Removing the active engine must return typing to the keyboard.
|
||||
setup.run(["fcitx5-remote", "-c"])
|
||||
setup.live_set(group, layout, after)
|
||||
if setup.live_group() != (group, layout, after):
|
||||
raise RuntimeError("Fcitx did not retain the input selection")
|
||||
except BaseException:
|
||||
setup.live_set(group, layout, before)
|
||||
raise
|
||||
|
||||
|
||||
def configure_inputs():
|
||||
group, layout, items = setup.live_group()
|
||||
catalog, available = input_catalog(items)
|
||||
current = [name for name, override in items if not name.startswith("keyboard-")]
|
||||
choose("Input Methods", catalog, current, "input", dict(items))
|
||||
|
||||
|
||||
def load_engines(names, catalog):
|
||||
packages = sorted({package for name in names for package in setup.PRESETS.get(name, {}).get("packages", [])})
|
||||
absent = [package for package in packages
|
||||
if subprocess.run(["omarchy-pkg-present", package], capture_output=True).returncode]
|
||||
if absent or any(name not in setup.PRESETS for name in names):
|
||||
labels = ", ".join(catalog.get(name, name) for name in names)
|
||||
raise RuntimeError(f"Install {' '.join(absent) or 'its engine'} to use {labels}")
|
||||
# Fcitx discovers engines only at startup, so one installed while it was
|
||||
# running (by an update, say) needs a restart before it can be selected.
|
||||
setup.run(["omarchy-restart-xcompose"])
|
||||
for name in names:
|
||||
setup.wait_ready(name)
|
||||
|
||||
|
||||
def save_inputs(selected, overrides=None):
|
||||
group, layout, items = setup.live_group()
|
||||
catalog, available = input_catalog(items)
|
||||
missing = [name for name in selected if name not in available]
|
||||
if missing:
|
||||
load_engines(missing, catalog)
|
||||
group, layout, items = setup.live_group()
|
||||
set_inputs(group, layout, items, input_items(items, selected, overrides))
|
||||
# Only a saved engine claims the CJK font fallback, and a failed font write
|
||||
# returns the group so the menu's restored checks stay true.
|
||||
config_home = Path(os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config"))
|
||||
try:
|
||||
for name in selected:
|
||||
if name in setup.PRESETS:
|
||||
setup.font_default(config_home, name)
|
||||
except BaseException:
|
||||
setup.live_set(group, layout, items)
|
||||
raise
|
||||
|
||||
|
||||
def add_input(method):
|
||||
group, layout, items = setup.live_group()
|
||||
current = [name for name, override in items if not name.startswith("keyboard-")]
|
||||
if method not in current:
|
||||
save_inputs(current + [method])
|
||||
|
||||
|
||||
def keyboard_values(selected):
|
||||
if not selected:
|
||||
raise ValueError("Select at least one keyboard layout")
|
||||
parts = [value.split(":", 1) for value in selected]
|
||||
# Preserve the installer's Latin-leading rule for desktop keybindings.
|
||||
if parts[0][0] in setup.NON_LATIN:
|
||||
parts = [["us"]] + [part for part in parts if part != ["us"]]
|
||||
layouts = ",".join(part[0] for part in parts)
|
||||
variants = ",".join(part[1] if len(part) > 1 else "" for part in parts)
|
||||
return layouts, variants
|
||||
|
||||
|
||||
def save_keyboard(selected):
|
||||
layouts, variants = keyboard_values(selected)
|
||||
config_home = Path(os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config"))
|
||||
path = config_home / "omarchy/keyboard-layouts"
|
||||
original = setup.read(path) if path.exists() else None
|
||||
group, old_layout, before = setup.live_group()
|
||||
group_changed = False
|
||||
previous_errors = setup.run(["hyprctl", "configerrors"])
|
||||
try:
|
||||
setup.atomic_write(path, f"XKBLAYOUT={layouts}\nXKBVARIANT={variants}\n")
|
||||
setup.run(["hyprctl", "reload"])
|
||||
errors = setup.run(["hyprctl", "configerrors"])
|
||||
if errors and errors != previous_errors:
|
||||
raise RuntimeError(errors)
|
||||
expected = [layout + (":" + variant if variant else "")
|
||||
for layout, variant in zip(layouts.split(","), variants.split(","))]
|
||||
if keyboard_selection() != expected:
|
||||
raise RuntimeError("Your personal keyboard layout override takes precedence over this selection")
|
||||
first_layout = layouts.split(",")[0]
|
||||
first_variant = variants.split(",")[0]
|
||||
group_layout = first_layout + ("-" + first_variant if first_variant else "")
|
||||
primary = next((item[0] for item in before if item[0].startswith("keyboard-")), None)
|
||||
if primary is None:
|
||||
raise RuntimeError("The current input group has no keyboard input")
|
||||
keyboard = "keyboard-" + group_layout
|
||||
after = [[keyboard, ""]] + [item for item in before if item[0] not in (primary, keyboard)]
|
||||
group_changed = True
|
||||
setup.live_set(group, group_layout, after)
|
||||
if setup.live_group() != (group, group_layout, after):
|
||||
raise RuntimeError("Fcitx did not retain the keyboard selection")
|
||||
except BaseException:
|
||||
if original is None:
|
||||
path.resolve().unlink(missing_ok=True)
|
||||
else:
|
||||
setup.atomic_write(path, original)
|
||||
try:
|
||||
setup.run(["hyprctl", "reload"])
|
||||
finally:
|
||||
if group_changed:
|
||||
setup.live_set(group, old_layout, before)
|
||||
raise
|
||||
|
||||
|
||||
def configure_keyboard():
|
||||
current = keyboard_selection()
|
||||
catalog = keyboard_catalog()
|
||||
choose("Keyboard Layouts", catalog, current, "keyboard")
|
||||
|
||||
|
||||
def apply_selection(kind, context, values):
|
||||
catalog = context["catalog"]
|
||||
rows = picker_rows(catalog, [])
|
||||
valid = {row[1:]: key for key, row in rows.items()}
|
||||
if not isinstance(values, list) or any(not isinstance(value, str) or value not in valid for value in values):
|
||||
raise ValueError("The menu returned an invalid selection")
|
||||
selected = list(dict.fromkeys(valid[value] for value in values))
|
||||
if kind == "input":
|
||||
save_inputs(selected, context.get("overrides"))
|
||||
else:
|
||||
save_keyboard(selected)
|
||||
selected = keyboard_selection()
|
||||
print(json.dumps([rows[key][1:] for key in selected]))
|
||||
|
||||
|
||||
def main():
|
||||
if sys.argv[1:2] == ["add"] and len(sys.argv) == 3:
|
||||
try:
|
||||
add_input(sys.argv[2])
|
||||
except (OSError, ValueError, RuntimeError, subprocess.CalledProcessError) as error:
|
||||
raise SystemExit(f"Input method could not be added: {error}")
|
||||
return
|
||||
applying = len(sys.argv) == 5 and sys.argv[1] == "apply"
|
||||
kind = sys.argv[2] if applying else sys.argv[1] if len(sys.argv) == 2 else ""
|
||||
if kind not in ("input", "keyboard"):
|
||||
raise ValueError("Choose keyboard or input setup")
|
||||
title = "Input methods" if kind == "input" else "Keyboard layouts"
|
||||
try:
|
||||
if applying:
|
||||
apply_selection(kind, json.loads(sys.argv[3]), json.loads(sys.argv[4]))
|
||||
elif kind == "input":
|
||||
configure_inputs()
|
||||
else:
|
||||
configure_keyboard()
|
||||
except (OSError, ValueError, RuntimeError, subprocess.CalledProcessError) as error:
|
||||
subprocess.run(["omarchy-notification-send", title + " could not be updated", str(error)], check=True)
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -128,6 +128,9 @@
|
||||
"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.typing": {"icon":"","label":"Typing"},
|
||||
"setup.typing.keyboard": {"icon":"","label":"Keyboard Layouts","action":"omarchy-setup-keyboard"},
|
||||
"setup.typing.input": {"icon":"","label":"Input Methods","action":"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"},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Input methods
|
||||
|
||||
The install keyboard picker selects a console keymap plus an optional Fcitx engine. `install/provisioning/setup-form.sh` is shared by the ISO configurator and deferred first-boot owner setup. Its rows are `label|console keymap|input method|desktop XKB override`; the last two fields are optional, so ordinary keyboard choices keep their existing meaning.
|
||||
|
||||
The ISO carries the extra fields in `omarchy_install.input_method` and `omarchy_install.input_xkb_layout`. Both installation paths persist them in `/etc/omarchy/input-method` as `INPUT_METHOD` and `XKB_LAYOUT`. This file is parsed as data, never sourced. Korean and Lao have a real US console keymap and a `kr` or `la` desktop override (Lao is non-Latin, so US still leads on the desktop); Japanese uses `jp106`, or US when the US-keyboard choice is selected. Console and disk-unlock input remain ordinary keymaps.
|
||||
|
||||
`install/user/input-method.sh` calls `omarchy-input-method configure --seed` during user finalization, before graphical login. The standard engines are in the base package set so deferred provisioning and factory resets do not need network access to configure typing. `default/input-methods/presets.json` is the engine catalog used by the post-install selector.
|
||||
|
||||
`default/input-methods/configure.py` owns the shared configuration behavior. Offline seeding replaces only a missing profile or Fcitx's untouched single `keyboard-us` guess. The group layout matches Hyprland's effective leading layout and variant; non-Latin layouts retain the US-leading behavior used for desktop bindings. The selected composition engine is the group's `DefaultIM`, with `ActiveByDefault=False` so initial typing remains Latin. Existing multilingual profiles and explicitly chosen keyboard layouts are preserved.
|
||||
|
||||
`omarchy setup input <engine>` installs the selected engine's packages, then appends it through the same path as the Input Methods picker. Selecting an engine whose package is installed but which the running Fcitx has not discovered (one an update installed, say) restarts the supervised Fcitx unit once and waits for it; an engine whose package is missing is refused with the package name. Changes go through `InputMethodGroupInfo`, `SetInputMethodGroupInfo`, and `Save`, keeping the group name, layout, existing methods in their switching order, and their layout overrides. There is no parallel unsupervised daemon. A per-user D-Bus activation file delegates `org.fcitx.Fcitx5` to `omarchy-fcitx5.service`.
|
||||
|
||||
`Setup > Typing > Keyboard Layouts` and `Input Methods` use the native menu's multi-select mode, with current entries checked and changes applied immediately. `default/input-methods/typing_setup.py` uses the installer’s keyboard choices, converted to desktop layouts through systemd’s keymap table, and reads the current input group. Input selection replaces only the non-keyboard group items and retains selected engines' layout overrides; deselection does not uninstall packages. The standard composition engines are already shipped in the base package set. Closing a picker keeps changes already applied. Failed updates leave its checked state unchanged.
|
||||
|
||||
Keyboard selections are persisted as data in `~/.config/omarchy/keyboard-layouts`; `default.hypr.keyboard.selected()` reads them before falling back to installation defaults. Personal Hyprland overrides still take precedence. A save reloads and validates the compositor, aligns the Fcitx group's primary keyboard with the leading layout/variant, and restores the previous configuration and group on failure. Non-Latin selections keep the installer's Latin-leading rule for desktop shortcuts. System console, disk-unlock, and greeter layouts are unchanged.
|
||||
|
||||
Shortcut defaults reserve Ctrl+Space for Tmux/Herdr, Super+Space for the menu, Super+Shift+Space for the bar, and Super+grave for the console. Hyprland owns Super+I to cycle all selected compositor layouts followed by the configured Fcitx input methods, independently of Fcitx switching keys. With keyboard layouts alone, the same shortcut cycles the compositor layouts, keeping matching devices in sync even without the bar widget. The widget is enabled in the default right-hand bar layout and appears only when more than one input method or keyboard layout is configured. Fcitx handles Zenkaku/Hankaku and Hangul; Henkan activates and Muhenkan deactivates. Ctrl+Space is never a Fcitx toggle: `configure --defaults` removes it from any TriggerKeys list, keeping the user's other keys, and writes Omarchy's list where none exists, since Fcitx's built-in default includes it. Other existing lists, including empty ones, are preserved, and stock generated lists are repaired only while the user still has the single-keyboard default profile. A terminal Ctrl+Space returns to direct input before the following key, with a 300 ms timeout and no D-Bus activation. Wayland and XCB addons do not override the compositor's XKB settings.
|
||||
|
||||
The migration installs the standard engines and applies conservative defaults. It aligns an untouched Fcitx keyboard profile with the install layout, using the live controller when Fcitx is running. It leaves existing multilingual profiles and working trigger keys alone. Atomic file writes follow configuration symlinks, preserve file modes, and roll back on a failed seed or migration so a retry can complete. Migration bus errors defer profile alignment to the next login and do not restart input. If a pre-update daemon has not discovered a new engine, the migration aligns only its keyboard and prints the setup command.
|
||||
|
||||
Input selection does not change the system locale, repository region, desktop language, or Latin interface font. A new language setup seeds a CJK fallback preference once; additional languages do not overwrite it.
|
||||
|
||||
Focused checks live in `test/shell.d/input-method-test.sh`, `setup-form-test.sh`, `hyprland-keyboard-layout-test.sh`, and `hyprland-jis-bindings-test.sh`. `test/acceptance.d/input-methods-test.sh` exercises real composition in a disposable installed VM and restores the original user configuration. The ISO repository's keyboard tests cover transport and target persistence. Browser input must be verified against the shipped browser versions; the language selector is not a place to guess flags from older guides.
|
||||
|
||||
Default key values are checked against [Fcitx global configuration](https://github.com/fcitx/fcitx5/blob/master/src/lib/fcitx/globalconfig.cpp).
|
||||
@@ -165,3 +165,10 @@ renders under the label, filters with it, and comes back as
|
||||
`label\tsubtext` so callers with same-named rows get a stable key. This is
|
||||
how the pickers behind menu actions (`omarchy-menu-plugin`,
|
||||
`omarchy-menu-timezone`, ...) present lists without owning any UI.
|
||||
|
||||
|
||||
Pass `-- --multiple` to `omarchy-menu-select` for a multi-select request. Repeat `--selected <value>` to preselect returned values (labels plus any subtext, without icons). Clicking or pressing Enter toggles an option without closing the menu; Apply returns a JSON array in option order, including `[]` when all choices are deselected. Cancellation still exits 1. Selections survive filtering.
|
||||
|
||||
With `--on-change <JSON command array>`, each toggle runs that command with the proposed selection JSON appended as its final argument, and no Apply row is shown. The checkbox changes only after the command succeeds; a failed command retains the previous selection. Changes persist when the menu closes. The typing selectors use this mode.
|
||||
|
||||
For immediate multi-select requests, `--change-key <key>` identifies the setting being changed across picker openings. The menu keeps queued selections for that key when reopened, so a new toggle cannot discard a change still being saved. Without a key, the command array identifies the setting.
|
||||
@@ -122,6 +122,7 @@ or `omarchy.power`. There is no `bar` target.
|
||||
| `reloadConfig` | reload shell.json |
|
||||
| `applyTheme <colorsB64> <shellB64>` | push theme colors + shell.toml |
|
||||
| `toggleBarTransparency` | flip the bar background between solid and transparent |
|
||||
| `cycleInput <next\|back>` | switch to the next or previous input method or layout (Super+I, Super+Shift+I); `not running` without the input reader |
|
||||
| `setPluginEnabled <id> <"true"\|…>` | flip enabled bit (`ok` / `unknown`) |
|
||||
| `enablePlugin <id> <placementJson>` | enable and place in one mutation |
|
||||
| `putBarWidget <id> <placementJson>` | place a widget only where absent (`omarchy bar put`) |
|
||||
|
||||
@@ -34,7 +34,11 @@ eza
|
||||
fakeroot
|
||||
fastfetch
|
||||
fcitx5
|
||||
fcitx5-chewing
|
||||
fcitx5-chinese-addons
|
||||
fcitx5-gtk
|
||||
fcitx5-hangul
|
||||
fcitx5-mozc
|
||||
fcitx5-qt
|
||||
fd
|
||||
ffmpeg
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
# so it never reaches the shell as SIGINT. Act on the status, never on a trap.
|
||||
#
|
||||
# Callers supply `notice <message> <seconds>` for validation feedback, and set
|
||||
# the variables these prompts write: keyboard, keyboard_label, username,
|
||||
# the variables these prompts write: keyboard, keyboard_label,
|
||||
# keyboard_input_method, keyboard_xkb_layout, username,
|
||||
# password, password_confirmation, full_name, email_address, hostname, timezone.
|
||||
|
||||
OMARCHY_FORM_BACK=1
|
||||
@@ -36,6 +37,8 @@ English (US, Colemak)|colemak
|
||||
Belarusian|by
|
||||
Belgian|be-latin1
|
||||
Bulgarian|bg-cp1251
|
||||
Chinese (Simplified, Pinyin)|us|pinyin
|
||||
Chinese (Traditional, Zhuyin)|us|chewing
|
||||
Croatian|croat
|
||||
Czech|cz
|
||||
Danish|dk-latin1
|
||||
@@ -54,10 +57,12 @@ Hungarian|hu
|
||||
Icelandic|is-latin1
|
||||
Irish|ie
|
||||
Italian|it
|
||||
Japanese|jp106
|
||||
Japanese|jp106|mozc
|
||||
Japanese (US keyboard)|us|mozc
|
||||
Kazakh|kazakh
|
||||
Korean|us|hangul|kr
|
||||
Kyrgyz|kyrgyz
|
||||
Lao|la-latin1
|
||||
Lao|us||la
|
||||
Latvian|lv
|
||||
Lithuanian|lt
|
||||
Macedonian|mk-utf
|
||||
@@ -96,8 +101,22 @@ omarchy_prompt_keyboard() {
|
||||
gum choose --height 10 --selected "English (US)" --header "Select keyboard layout") && status=0 || status=$?
|
||||
((status == 0)) || return $status
|
||||
|
||||
keyboard_label="$choice"
|
||||
keyboard=$(printf '%s\n' "$OMARCHY_KEYBOARD_LAYOUTS" | awk -F'|' -v c="$choice" '$1==c{print $2; exit}')
|
||||
local selection
|
||||
selection=$(printf '%s\n' "$OMARCHY_KEYBOARD_LAYOUTS" | awk -F'|' -v c="$choice" '$1==c{print; exit}')
|
||||
[[ -n $selection ]] || return 1
|
||||
IFS='|' read -r keyboard_label keyboard keyboard_input_method keyboard_xkb_layout <<< "$selection"
|
||||
keyboard_input_method=${keyboard_input_method:-none}
|
||||
keyboard_xkb_layout=${keyboard_xkb_layout:-}
|
||||
}
|
||||
|
||||
# Root-side persistence. Console keymaps remain real keymaps; the input
|
||||
# preference survives separately for every user of the installed system.
|
||||
omarchy_persist_input_selection() {
|
||||
local target_root=${1:-} method=${keyboard_input_method:-none} layout=${keyboard_xkb_layout:-}
|
||||
case "$method" in none | mozc | hangul | pinyin | chewing) ;; *) return 1 ;; esac
|
||||
case "$layout" in "" | kr | la) ;; *) return 1 ;; esac
|
||||
mkdir -p "$target_root/etc/omarchy"
|
||||
printf 'INPUT_METHOD=%s\nXKB_LAYOUT=%s\n' "$method" "$layout" > "$target_root/etc/omarchy/input-method"
|
||||
}
|
||||
|
||||
omarchy_prompt_username() {
|
||||
|
||||
@@ -2,6 +2,7 @@ run_logged "$OMARCHY_INSTALL/user/theme.sh"
|
||||
run_logged "$OMARCHY_INSTALL/user/chromium.sh"
|
||||
run_logged "$OMARCHY_INSTALL/user/git.sh"
|
||||
run_logged "$OMARCHY_INSTALL/user/xcompose.sh"
|
||||
run_logged "$OMARCHY_INSTALL/user/input-method.sh"
|
||||
run_logged "$OMARCHY_INSTALL/user/mise-work.sh"
|
||||
|
||||
run_logged "$OMARCHY_INSTALL/user/hardware/asus/fix-audio-mixer.sh"
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
omarchy-input-method configure --defaults || echo "Input method defaults deferred; run omarchy setup input after login." >&2
|
||||
|
||||
systemctl --user daemon-reload
|
||||
|
||||
# One at a time: given a list, systemctl enables none of it when it cannot find
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
omarchy-input-method configure --seed
|
||||
@@ -6,7 +6,7 @@ It's also the one piece of the desktop that's always on screen, so it's worth kn
|
||||
|
||||
## What's on it by default
|
||||
|
||||
The bar has three sections. On the left sits the Omarchy logo (the menu launcher) and the workspace indicators. In the center you get the status indicators, the clock, the keyboard layout, the weather, and an Omarchy update badge. On the right: the system tray, agents, bluetooth, network, audio, display, and power.
|
||||
The bar has three sections. On the left sits the Omarchy logo (the menu launcher) and the workspace indicators. In the center you get the status indicators, the clock, Elsewhen (a world clock), the weather, and an Omarchy update badge. On the right: the keyboard layout (when more than one is configured), the system tray, agents, bluetooth, network, audio, display, and power.
|
||||
|
||||
A few of those only show up when they have something to say. The keyboard layout appears only if you've configured more than one layout. The update badge appears only when there's an Omarchy update waiting. And the agents icon appears the first time Omarchy finds AI coding usage on the machine (see [AI](17-ai.md)).
|
||||
|
||||
|
||||
@@ -59,7 +59,17 @@ On Dell XPS laptops with a haptic touchpad, you can also set the click strength
|
||||
|
||||
### Typing in Chinese, Japanese, and other languages
|
||||
|
||||
Omarchy runs the [fcitx5](https://fcitx-im.org/) input method framework as part of every session — it's what powers the CapsLock compose sequences. That means the plumbing for non-Latin input is already in place: install an input engine like `fcitx5-mozc` (Japanese) or `fcitx5-chinese-addons` (Chinese) with `omarchy pkg add`, plus `fcitx5-configtool` to add the engine to your input methods and set the key that switches between them.
|
||||
Select Japanese, Korean, Chinese (Simplified, Pinyin), or Chinese (Traditional, Zhuyin) when the installer asks for your keyboard. Omarchy configures the keyboard and matching input engine automatically, including during offline first-boot setup. Japanese uses Mozc, Korean uses standard two-set Hangul, Simplified Chinese uses Pinyin, and Traditional Chinese uses Chewing's standard Zhuyin arrangement. Japanese (US keyboard) provides Mozc with a US keyboard layout.
|
||||
|
||||
Typing starts in Latin mode. Press `Super + I` to cycle through configured input methods, and `Super + Shift + I` to cycle back. On Japanese keyboards, 全角/半角 toggles input, 変換 turns it on, and 無変換 turns it off. On a Korean keyboard layout, dedicated 한/영 and 한자 keys switch Hangul and convert to Hanja. `Ctrl + Space` stays available for Tmux and Herdr; CapsLock stays the compose key.
|
||||
|
||||
The keyboard indicator appears on the right of the bar when you have multiple keyboard layouts or input methods. Click it to switch input languages; Japanese displays あ for hiragana. For Japanese character choices, switch to Japanese, type a word such as `nihongo`, then press Space twice. Press Enter to accept your choice. If you also have multiple keyboard layouts, right-click the indicator to cycle those.
|
||||
|
||||
Use _Setup > Typing > Keyboard Layouts_ to select layouts such as English and French, from the same choices offered during installation. Use _Setup > Typing > Input Methods_ to select which composition engines are active. Both pickers open inside the Omarchy menu with your current selections checked. Click or press Enter to toggle a choice; each change applies immediately. Deselecting an input method removes it from the cycle without uninstalling it; the keyboard remains available.
|
||||
|
||||
Run `omarchy setup keyboard` or `omarchy setup input` to open the same pickers. To append one language from the command line, use `omarchy setup input mozc`, `hangul`, `pinyin`, or `chewing`. Your custom switching keys are preserved. For alternative engine layouts or keys, install `fcitx5-configtool` with `omarchy pkg add fcitx5-configtool` and launch it from a terminal. Selecting an input language does not change the desktop language.
|
||||
|
||||
Use `Super + I` to cycle through input methods or keyboard layouts, and `Super + Shift + I` to go back the other way, so with three configured you can move between two neighbours. The input indicator appears on the right of the bar only when more than one input method or layout is configured. This desktop binding works alongside your existing Fcitx switching keys; you can override it in `~/.config/hypr/bindings.lua`. Tapping Shift alone is not an input switch: Omarchy uses both Shift keys for CapsLock, which changes how input methods see Shift release. Input engines and the CapsLock compose sequences use the [Fcitx5](https://fcitx-im.org/) service already running in every session.
|
||||
|
||||
### Use ALT as SUPER
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
echo "Configure shared input-method defaults and free desktop shortcuts"
|
||||
|
||||
omarchy-pkg-add fcitx5-mozc fcitx5-hangul fcitx5-chinese-addons fcitx5-chewing
|
||||
omarchy-input-method configure --defaults
|
||||
@@ -0,0 +1,5 @@
|
||||
echo "Move the keyboard layout widget to the right side of the bar"
|
||||
|
||||
# An earlier migration put the widget just right of the clock. It now leads the
|
||||
# right-hand cluster. A widget the user moved out of the center stays put.
|
||||
omarchy-bar move omarchy.keyboard-layout --from-section center --section right --index 0 || true
|
||||
@@ -0,0 +1,38 @@
|
||||
pragma Singleton
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
property var state: ({})
|
||||
|
||||
function cycle(back) {
|
||||
if (!watcher.running) return false
|
||||
watcher.write(back ? "cycle back\n" : "cycle\n")
|
||||
return true
|
||||
}
|
||||
|
||||
// One reader and pending selection for every monitor's keyboard widget.
|
||||
Process {
|
||||
id: watcher
|
||||
command: ["/usr/bin/python3", Quickshell.env("OMARCHY_PATH") + "/default/input-methods/indicator.py"]
|
||||
running: true
|
||||
stdinEnabled: true
|
||||
stdout: SplitParser {
|
||||
onRead: data => {
|
||||
try { root.state = JSON.parse(data) } catch (e) {}
|
||||
}
|
||||
}
|
||||
onExited: {
|
||||
root.state = ({})
|
||||
restart.restart()
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: restart
|
||||
interval: 5000
|
||||
onTriggered: watcher.running = true
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ singleton AudioNodes 1.0 AudioNodes.qml
|
||||
singleton Border 1.0 Border.qml
|
||||
singleton Color 1.0 Color.qml
|
||||
singleton IpcRegistry 1.0 IpcRegistry.qml
|
||||
singleton InputMethodState 1.0 InputMethodState.qml
|
||||
singleton Style 1.0 Style.qml
|
||||
singleton UntypedInput 1.0 UntypedInput.qml
|
||||
singleton Util 1.0 Util.qml
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"name": "Keyboard layout",
|
||||
"version": "1.0.0",
|
||||
"author": "Omarchy",
|
||||
"description": "Current xkb layout, click cycles",
|
||||
"description": "Current keyboard layout and input language, click cycles",
|
||||
"kinds": [
|
||||
"bar-widget"
|
||||
],
|
||||
@@ -13,8 +13,9 @@
|
||||
},
|
||||
"barWidget": {
|
||||
"displayName": "Keyboard layout",
|
||||
"description": "Current xkb layout, click cycles",
|
||||
"description": "Current keyboard layout and input language, click cycles",
|
||||
"category": "Compositor",
|
||||
"defaultSection": "right",
|
||||
"allowMultiple": false
|
||||
},
|
||||
"omarchy": {
|
||||
|
||||
@@ -36,6 +36,9 @@ BarWidget {
|
||||
// xkb's own table rather than maintained by hand.
|
||||
property var layoutBriefs: ({})
|
||||
readonly property string layoutLabel: KeyboardLayoutModel.shortLabel(layoutFull, layoutBriefs)
|
||||
readonly property var inputState: InputMethodState.state
|
||||
readonly property bool multipleInputs: (inputState.methods || []).length > 1
|
||||
readonly property string inputLabel: KeyboardLayoutModel.inputLabel(inputState, layoutLabel)
|
||||
|
||||
// A query already in flight was started before this event, so it may read the
|
||||
// layout the switch replaced. Remember the request and re-run once it lands
|
||||
@@ -228,7 +231,7 @@ BarWidget {
|
||||
onTriggered: root.refresh()
|
||||
}
|
||||
|
||||
visible: layoutLabel !== "" && multipleLayouts
|
||||
visible: inputLabel !== "" && KeyboardLayoutModel.showIndicator(layoutLabel, multipleLayouts, multipleInputs)
|
||||
implicitWidth: button.implicitWidth
|
||||
implicitHeight: button.implicitHeight
|
||||
|
||||
@@ -236,10 +239,16 @@ BarWidget {
|
||||
id: button
|
||||
anchors.fill: parent
|
||||
bar: root.bar
|
||||
text: root.layoutLabel
|
||||
text: root.inputLabel
|
||||
fontSize: Style.font.caption
|
||||
horizontalMargin: 6
|
||||
tooltipText: root.layoutFull
|
||||
onPressed: function() { root.cycleLayout() }
|
||||
tooltipText: KeyboardLayoutModel.inputTooltip(root.inputState, root.layoutFull, root.multipleLayouts)
|
||||
onPressed: function(button) {
|
||||
if (root.multipleInputs && button === Qt.LeftButton) {
|
||||
InputMethodState.cycle()
|
||||
} else if (root.multipleLayouts) {
|
||||
root.cycleLayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,24 @@ function selectKeyboard(typed, namedByEvent) {
|
||||
}, keyboards[0])
|
||||
}
|
||||
|
||||
function inputLabel(state, fallback) {
|
||||
if (!state || !state.current || state.current.indexOf("keyboard-") === 0) return fallback
|
||||
if (state.label) return String(state.label).substring(0, 3)
|
||||
var labels = { mozc: "あ", hangul: "한", pinyin: "拼", chewing: "注" }
|
||||
return labels[state.current] || shortLabel(state.language || state.name, {})
|
||||
}
|
||||
|
||||
function inputTooltip(state, layout, multipleLayouts) {
|
||||
if (!state || !state.methods || state.methods.length < 2)
|
||||
return layout + (multipleLayouts ? " · Super + I" : "")
|
||||
var name = state.current && state.current.indexOf("keyboard-") === 0 ? layout : (state.name || layout)
|
||||
return name.replace(/^Keyboard - /, "") + " · Super + I"
|
||||
}
|
||||
|
||||
function showIndicator(layoutLabel, multipleLayouts, multipleInputs) {
|
||||
return multipleInputs || (layoutLabel !== "" && multipleLayouts)
|
||||
}
|
||||
|
||||
function layoutIndex(keyboard) {
|
||||
return (keyboard && keyboard.active_layout_index) || 0
|
||||
}
|
||||
@@ -120,6 +138,9 @@ if (typeof module !== "undefined") {
|
||||
isTypedKeyboard: isTypedKeyboard,
|
||||
layoutBriefs: layoutBriefs,
|
||||
selectKeyboard: selectKeyboard,
|
||||
shortLabel: shortLabel
|
||||
shortLabel: shortLabel,
|
||||
inputLabel: inputLabel,
|
||||
inputTooltip: inputTooltip,
|
||||
showIndicator: showIndicator
|
||||
}
|
||||
}
|
||||
+121
-2
@@ -57,6 +57,12 @@ Item {
|
||||
readonly property bool dmenuActive: mode === "select" || mode === "input"
|
||||
property string dmenuPrompt: ""
|
||||
property var dmenuOptions: []
|
||||
property bool dmenuMultiple: false
|
||||
property var dmenuSelected: []
|
||||
// What the last confirmed change applied; a failed change returns the checks here.
|
||||
property var dmenuSaved: []
|
||||
property string dmenuChangeKey: ""
|
||||
property var dmenuOnChange: []
|
||||
property string selectionFile: ""
|
||||
property string doneFile: ""
|
||||
property int dmenuWidth: 300
|
||||
@@ -563,6 +569,14 @@ Item {
|
||||
return
|
||||
}
|
||||
|
||||
if (root.dmenuMultiple && root.dmenuOnChange.length === 0) {
|
||||
displayModel.append({
|
||||
itemId: "dmenu.apply", disabled: false, kind: "dmenu", icon: "", iconFont: "",
|
||||
appIcon: "", appId: "", label: "Apply", target: "",
|
||||
detail: root.dmenuSelected.length + " selected", path: "", childCount: 0,
|
||||
action: "", provider: "", score: -1, section: ""
|
||||
})
|
||||
}
|
||||
var query = root.filterText.trim().toLowerCase()
|
||||
for (var i = 0; i < root.dmenuOptions.length; i++) {
|
||||
// An option is "<label>", "<glyph>\t<label>", or
|
||||
@@ -579,7 +593,7 @@ Item {
|
||||
itemId: "dmenu." + i,
|
||||
disabled: false,
|
||||
kind: "dmenu",
|
||||
icon: icon,
|
||||
icon: root.dmenuMultiple ? (root.dmenuSelected.indexOf(MenuModel.dmenuValue(root.dmenuOptions[i])) !== -1 ? "✓" : "○") : icon,
|
||||
iconFont: "",
|
||||
appIcon: "",
|
||||
appId: "",
|
||||
@@ -606,6 +620,18 @@ Item {
|
||||
})
|
||||
}
|
||||
|
||||
function updateDmenuChecks() {
|
||||
for (var i = 0; i < displayModel.count; i++) {
|
||||
var row = displayModel.get(i)
|
||||
if (row.itemId === "dmenu.apply") {
|
||||
displayModel.setProperty(i, "detail", root.dmenuSelected.length + " selected")
|
||||
} else if (row.kind === "dmenu") {
|
||||
var value = MenuModel.dmenuValue(root.dmenuOptions[Number(row.itemId.substring(6))])
|
||||
displayModel.setProperty(i, "icon", root.dmenuSelected.indexOf(value) !== -1 ? "✓" : "○")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rebuildDisplay() {
|
||||
if (root.dmenuActive) {
|
||||
root.rebuildDmenuDisplay()
|
||||
@@ -769,7 +795,22 @@ Item {
|
||||
}
|
||||
if (index < 0 || index >= displayModel.count) return
|
||||
var picked = displayModel.get(index)
|
||||
root.applyDmenuSelection(picked.detail ? picked.label + "\t" + picked.detail : picked.label)
|
||||
if (root.dmenuMultiple) {
|
||||
if (picked.itemId === "dmenu.apply") {
|
||||
root.applyDmenuSelection(JSON.stringify(MenuModel.dmenuSelections(root.dmenuOptions, root.dmenuSelected)))
|
||||
} else {
|
||||
var value = MenuModel.dmenuValue(root.dmenuOptions[Number(picked.itemId.substring(6))])
|
||||
var selected = MenuModel.toggleDmenuSelection(selectionProc.requestedSelection(), value)
|
||||
if (root.dmenuOnChange.length > 0) {
|
||||
selectionProc.enqueue(selected)
|
||||
} else {
|
||||
root.dmenuSelected = selected
|
||||
root.updateDmenuChecks()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
root.applyDmenuSelection(picked.detail ? picked.label + "\t" + picked.detail : picked.label)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -867,6 +908,14 @@ Item {
|
||||
mode = payload.mode === "input" ? "input" : "select"
|
||||
dmenuPrompt = String(payload.prompt || (mode === "input" ? "Input" : "Select"))
|
||||
dmenuOptions = Array.isArray(payload.options) ? payload.options : []
|
||||
dmenuMultiple = mode === "select" && payload.multiple === true
|
||||
dmenuOnChange = dmenuMultiple && Array.isArray(payload.onChange)
|
||||
&& payload.onChange.every(function(arg) { return typeof arg === "string" }) ? payload.onChange : []
|
||||
dmenuChangeKey = String(payload.changeKey || JSON.stringify(dmenuOnChange))
|
||||
dmenuSelected = MenuModel.dmenuSelections(dmenuOptions, Array.isArray(payload.selected) ? payload.selected : [], dmenuOnChange.length > 0)
|
||||
dmenuSaved = dmenuSelected
|
||||
if (dmenuOnChange.length > 0)
|
||||
dmenuSelected = MenuModel.dmenuSelections(dmenuOptions, selectionProc.requestedSelection(), true)
|
||||
selectionFile = String(payload.selectionFile || "")
|
||||
doneFile = String(payload.doneFile || "")
|
||||
requestActive = !!doneFile
|
||||
@@ -943,6 +992,66 @@ Item {
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: selectionProc
|
||||
property int serial: 0
|
||||
property string changeKey: ""
|
||||
property var selected: []
|
||||
property var queue: []
|
||||
property string collected: ""
|
||||
|
||||
function requestedSelection() {
|
||||
for (var i = queue.length - 1; i >= 0; i--) {
|
||||
if (queue[i].key === root.dmenuChangeKey) return queue[i].selected
|
||||
}
|
||||
if (running && changeKey === root.dmenuChangeKey) return selected
|
||||
return root.dmenuSelected
|
||||
}
|
||||
|
||||
function enqueue(values) {
|
||||
var selected = MenuModel.dmenuSelections(root.dmenuOptions, values, true)
|
||||
queue = queue.concat([{
|
||||
serial: root.requestSerial, key: root.dmenuChangeKey, selected: selected,
|
||||
command: root.dmenuOnChange.concat([JSON.stringify(selected)])
|
||||
}])
|
||||
if (!running) startNext()
|
||||
}
|
||||
|
||||
function startNext() {
|
||||
if (queue.length === 0) return
|
||||
var change = queue[0]
|
||||
queue = queue.slice(1)
|
||||
serial = change.serial
|
||||
changeKey = change.key
|
||||
selected = change.selected
|
||||
collected = ""
|
||||
command = change.command
|
||||
running = true
|
||||
}
|
||||
|
||||
stdout: StdioCollector {
|
||||
waitForEnd: true
|
||||
onStreamFinished: selectionProc.collected = text
|
||||
}
|
||||
onExited: function(exitCode) {
|
||||
if (changeKey === root.dmenuChangeKey && root.dmenuActive && root.dmenuOnChange.length > 0) {
|
||||
if (exitCode === 0) {
|
||||
var applied = selected
|
||||
if (collected.trim()) {
|
||||
try { applied = JSON.parse(collected) } catch (error) { applied = root.dmenuSelected }
|
||||
}
|
||||
if (Array.isArray(applied))
|
||||
root.dmenuSelected = MenuModel.dmenuSelections(root.dmenuOptions, applied, true)
|
||||
root.dmenuSaved = root.dmenuSelected
|
||||
} else {
|
||||
root.dmenuSelected = root.dmenuSaved
|
||||
}
|
||||
root.updateDmenuChecks()
|
||||
}
|
||||
startNext()
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: resultProc
|
||||
onExited: {
|
||||
@@ -1225,6 +1334,16 @@ Item {
|
||||
spacing: root.rowSpacing
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
WheelHandler {
|
||||
target: null
|
||||
onWheel: function(event) {
|
||||
var delta = event.pixelDelta.y || event.angleDelta.y / 120 * root.rowHeightForDetail("") * 3
|
||||
var bottom = resultList.originY + Math.max(0, resultList.contentHeight - resultList.height)
|
||||
resultList.contentY = Math.max(resultList.originY, Math.min(bottom, resultList.contentY - delta))
|
||||
event.accepted = true
|
||||
}
|
||||
}
|
||||
|
||||
section.property: "section"
|
||||
section.criteria: ViewSection.FullString
|
||||
section.delegate: Item {
|
||||
|
||||
@@ -501,6 +501,26 @@ function summonAction(action) {
|
||||
return { id: match[1], payload: match[2] || "{}" }
|
||||
}
|
||||
|
||||
|
||||
// Multi-select requests return stable values, without icons, in option order.
|
||||
function dmenuValue(option) {
|
||||
var parts = String(option || "").split("\t")
|
||||
if (parts.length > 1) parts.shift()
|
||||
return parts.join("\t")
|
||||
}
|
||||
|
||||
function dmenuSelections(options, selected, preserveOrder) {
|
||||
var available = options.map(dmenuValue)
|
||||
return (preserveOrder ? selected : available).filter(function(value, index, values) {
|
||||
return available.indexOf(value) !== -1 && selected.indexOf(value) !== -1 && values.indexOf(value) === index
|
||||
})
|
||||
}
|
||||
|
||||
function toggleDmenuSelection(selected, value) {
|
||||
return selected.indexOf(value) === -1 ? selected.concat([value])
|
||||
: selected.filter(function(item) { return item !== value })
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined") {
|
||||
module.exports = {
|
||||
summonAction: summonAction,
|
||||
@@ -531,6 +551,9 @@ if (typeof module !== "undefined") {
|
||||
descriptionTextMatches: descriptionTextMatches,
|
||||
matchesQuery: matchesQuery,
|
||||
searchScore: searchScore,
|
||||
dmenuValue: dmenuValue,
|
||||
dmenuSelections: dmenuSelections,
|
||||
toggleDmenuSelection: toggleDmenuSelection,
|
||||
displayRow: displayRow
|
||||
}
|
||||
}
|
||||
@@ -1779,6 +1779,12 @@ ShellRoot {
|
||||
return "ok"
|
||||
}
|
||||
|
||||
// Super+I goes through the bar's reader, which keeps a choice made before
|
||||
// any text field has focus until one does.
|
||||
function cycleInput(direction: string): string {
|
||||
return Commons.InputMethodState.cycle(direction === "back") ? "ok" : "not running"
|
||||
}
|
||||
|
||||
function rescanPlugins(): void {
|
||||
shell.reloadPlugins()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Select notifications sent by Fcitx, including addons with custom app names."""
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
def input_notifications(text, initial_owner):
|
||||
calls = re.split(r"(?m)^(?=(?:method call|signal|method return|error)\b)", text)
|
||||
owners = {initial_owner}
|
||||
for call in calls:
|
||||
if "interface=org.freedesktop.DBus; member=NameOwnerChanged" in call.split("\n", 1)[0]:
|
||||
names = re.findall(r'^\s*string "([^"]*)"', call, re.MULTILINE)
|
||||
if len(names) == 3 and names[0] == "org.fcitx.Fcitx5":
|
||||
owners.update(names[1:])
|
||||
owners.discard("")
|
||||
for call in calls:
|
||||
header = call.split("\n", 1)[0]
|
||||
if "interface=org.freedesktop.Notifications; member=Notify" not in header:
|
||||
continue
|
||||
sender = re.search(r"\bsender=(\S+)", header)
|
||||
if sender and sender[1] in owners:
|
||||
yield call
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
found = list(input_notifications(Path(sys.argv[1]).read_text(errors="replace"), sys.argv[2]))
|
||||
if found:
|
||||
print("\n".join(found))
|
||||
raise SystemExit(1 if found else 0)
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Snapshot test-owned config paths, including targets of nested symlinks."""
|
||||
|
||||
import filecmp
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
|
||||
def copy(source, destination):
|
||||
if source.is_symlink():
|
||||
destination.symlink_to(source.readlink())
|
||||
elif source.is_dir():
|
||||
shutil.copytree(source, destination, symlinks=True)
|
||||
elif source.exists():
|
||||
shutil.copy2(source, destination)
|
||||
|
||||
|
||||
def backup(directory, paths):
|
||||
directory.mkdir()
|
||||
recorded = {}
|
||||
|
||||
def capture(path):
|
||||
path = path.absolute()
|
||||
if str(path) in recorded:
|
||||
return
|
||||
name = str(len(recorded))
|
||||
recorded[str(path)] = name
|
||||
copy(path, directory / name)
|
||||
if not path.parent.exists():
|
||||
capture(path.parent)
|
||||
# Setup writes to the resolved target; intermediate links are unchanged.
|
||||
resolved = path.resolve()
|
||||
if resolved != path:
|
||||
capture(resolved)
|
||||
if path.is_dir() and not path.is_symlink():
|
||||
for child in path.rglob("*"):
|
||||
if child.is_symlink():
|
||||
capture(child)
|
||||
|
||||
for path in paths:
|
||||
capture(path)
|
||||
(directory / "paths.json").write_text(json.dumps(recorded))
|
||||
|
||||
|
||||
def unchanged(saved, original):
|
||||
if saved.is_symlink() or original.is_symlink():
|
||||
return saved.is_symlink() and original.is_symlink() and saved.readlink() == original.readlink()
|
||||
if not saved.exists() or not original.exists():
|
||||
return not saved.exists() and not original.exists()
|
||||
if saved.stat().st_mode != original.stat().st_mode:
|
||||
return False
|
||||
if saved.is_file() and original.is_file():
|
||||
return filecmp.cmp(saved, original, shallow=False)
|
||||
if saved.is_dir() and original.is_dir():
|
||||
names = {child.name for child in saved.iterdir()}
|
||||
return names == {child.name for child in original.iterdir()} and all(
|
||||
unchanged(saved / name, original / name) for name in names
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def restore(directory):
|
||||
for original, name in json.loads((directory / "paths.json").read_text()).items():
|
||||
path = Path(original)
|
||||
saved = directory / name
|
||||
if unchanged(saved, path):
|
||||
continue
|
||||
if path.is_symlink() or path.is_file():
|
||||
path.unlink()
|
||||
elif path.is_dir():
|
||||
shutil.rmtree(path)
|
||||
if saved.exists() or saved.is_symlink():
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
copy(saved, path)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
action, directory, *paths = sys.argv[1:]
|
||||
if action == "backup":
|
||||
backup(Path(directory), [Path(path) for path in paths])
|
||||
elif action == "restore":
|
||||
restore(Path(directory))
|
||||
else:
|
||||
raise SystemExit("Expected backup or restore")
|
||||
@@ -0,0 +1,186 @@
|
||||
#!/bin/bash
|
||||
|
||||
# VM-only: exercises the shared setup command and real composition for every
|
||||
# shipped engine. Restore the profile, shortcut settings, and font preference.
|
||||
set -euo pipefail
|
||||
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
|
||||
|
||||
selection=$(omarchy-input-method status)
|
||||
initial_method=$(jq -r .installed_method <<< "$selection")
|
||||
initial_layout=$(jq -r .layout <<< "$selection")
|
||||
group=$(fcitx5-remote -q)
|
||||
initial_info=$(busctl --user --json=short call org.fcitx.Fcitx5 /controller org.fcitx.Fcitx.Controller1 InputMethodGroupInfo s "$group")
|
||||
jq -e --arg layout "$initial_layout" '.data[0] == $layout' <<< "$initial_info" >/dev/null ||
|
||||
fail "installed keyboard layout reaches Fcitx"
|
||||
if [[ $initial_method != "none" ]]; then
|
||||
jq -e --arg method "$initial_method" '.data[1] | any(.[0] == $method)' <<< "$initial_info" >/dev/null ||
|
||||
fail "installer-selected engine is ready before live setup"
|
||||
fi
|
||||
pass "installer input selection reaches the first desktop session"
|
||||
|
||||
work=$(mktemp -d)
|
||||
config_home=${XDG_CONFIG_HOME:-$HOME/.config}
|
||||
data_home=${XDG_DATA_HOME:-$HOME/.local/share}
|
||||
paths=("$config_home/fcitx5" "$config_home/fontconfig/conf.d/50-omarchy-input-method.conf" "$data_home/dbus-1/services/org.fcitx.Fcitx5.service")
|
||||
python "$ROOT/test/acceptance.d/input-method-state.py" backup "$work/state" "${paths[@]}"
|
||||
|
||||
notification_monitor=""
|
||||
cleanup() {
|
||||
local status=$?
|
||||
if [[ -n $notification_monitor ]]; then
|
||||
kill "$notification_monitor" 2>/dev/null || true
|
||||
wait "$notification_monitor" 2>/dev/null || true
|
||||
fi
|
||||
close_windows '^org\.omarchy\.ime-test$'
|
||||
close_windows '^org\.omarchy\.ime-test-gtk$|^org\.qt-project\.qml$|^chrome-.*_omarchy-ime-entry\.html-Default$'
|
||||
systemctl --user stop omarchy-fcitx5.service || status=1
|
||||
python "$ROOT/test/acceptance.d/input-method-state.py" restore "$work/state" || status=1
|
||||
busctl --user call org.freedesktop.DBus /org/freedesktop/DBus org.freedesktop.DBus ReloadConfig >/dev/null || status=1
|
||||
systemctl --user start omarchy-fcitx5.service || status=1
|
||||
if (( status == 0 )); then
|
||||
rm -rf "$work"
|
||||
else
|
||||
printf 'Input test snapshot retained at %s\n' "$work/state" >&2
|
||||
fi
|
||||
exit "$status"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
dbus-monitor --session "type='method_call',interface='org.freedesktop.Notifications',member='Notify'" \
|
||||
"type='signal',interface='org.freedesktop.DBus',member='NameOwnerChanged',arg0='org.fcitx.Fcitx5'" > "$ARTIFACTS/input-method-notifications.log" 2>&1 &
|
||||
notification_monitor=$!
|
||||
monitor_ready() {
|
||||
kill -0 "$notification_monitor" && grep -q 'member=NameAcquired' "$ARTIFACTS/input-method-notifications.log"
|
||||
}
|
||||
wait_until "notification monitor attaches" 10 monitor_ready
|
||||
initial_owner=$(busctl --user call org.freedesktop.DBus /org/freedesktop/DBus org.freedesktop.DBus GetNameOwner s org.fcitx.Fcitx5 | cut -d '"' -f 2)
|
||||
|
||||
cat > "$work/reader" <<'SH'
|
||||
#!/bin/bash
|
||||
IFS= read -r text
|
||||
printf '%s' "$text" > "$1"
|
||||
sleep 1
|
||||
SH
|
||||
chmod +x "$work/reader"
|
||||
|
||||
cat > "$work/entry.py" <<'PY'
|
||||
import gi
|
||||
gi.require_version("Gtk", "4.0")
|
||||
from gi.repository import Gtk
|
||||
|
||||
app = Gtk.Application(application_id="org.omarchy.ime-test-gtk")
|
||||
def activate(app):
|
||||
window = Gtk.ApplicationWindow(application=app, title="GTK input test")
|
||||
window.set_default_size(600, 160)
|
||||
entry = Gtk.Entry()
|
||||
window.set_child(entry)
|
||||
window.present()
|
||||
entry.grab_focus()
|
||||
app.connect("activate", activate)
|
||||
app.run()
|
||||
PY
|
||||
|
||||
cat > "$work/entry.qml" <<'QML'
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
ApplicationWindow {
|
||||
visible: true
|
||||
width: 600
|
||||
height: 160
|
||||
title: "Qt input test"
|
||||
TextField {
|
||||
anchors.fill: parent
|
||||
focus: true
|
||||
}
|
||||
}
|
||||
QML
|
||||
|
||||
cat > "$work/omarchy-ime-entry.html" <<'HTML'
|
||||
<!doctype html><meta charset="utf-8"><title>Browser input test</title>
|
||||
<input autofocus style="font-size:32px;width:90%" aria-label="Input test">
|
||||
HTML
|
||||
|
||||
compose() {
|
||||
fcitx5-remote -s "$method"
|
||||
fcitx5-remote -c
|
||||
# Use the controller for composition; wtype cannot prove global shortcuts.
|
||||
fcitx5-remote -o
|
||||
case "$method" in
|
||||
mozc) wtype "nihongo"; wtype -k space ;;
|
||||
hangul) wtype "gksrmf" ;;
|
||||
pinyin) wtype "nihao"; wtype -k space ;;
|
||||
chewing) wtype "su3cl3" ;;
|
||||
esac
|
||||
sleep 1
|
||||
}
|
||||
|
||||
verify_text() {
|
||||
case "$method" in
|
||||
mozc) grep -Fxq "日本語" "$work/result" || fail "$1 commits Japanese" ;;
|
||||
hangul) grep -Fxq "한글" "$work/result" || fail "$1 commits Korean" ;;
|
||||
*) python -c 'import pathlib,sys; assert any("\u4e00" <= c <= "\u9fff" for c in pathlib.Path(sys.argv[1]).read_text())' "$work/result" || fail "$1 commits Han characters" ;;
|
||||
esac
|
||||
pass "$1 commits composed text"
|
||||
}
|
||||
|
||||
installed=()
|
||||
for method in mozc hangul pinyin chewing; do
|
||||
omarchy-setup-input "$method" > "$ARTIFACTS/setup-input-$method.log" 2>&1 ||
|
||||
fail "$method setup completes" "$(cat "$ARTIFACTS/setup-input-$method.log")"
|
||||
group=$(fcitx5-remote -q)
|
||||
info=$(busctl --user --json=short call org.fcitx.Fcitx5 /controller org.fcitx.Fcitx.Controller1 InputMethodGroupInfo s "$group")
|
||||
installed+=("$method")
|
||||
for retained in "${installed[@]}"; do
|
||||
jq -e --arg method "$retained" '.data[1] | any(.[0] == $method)' <<< "$info" >/dev/null || fail "$retained is still registered"
|
||||
done
|
||||
owner_pid=$(busctl --user call org.freedesktop.DBus /org/freedesktop/DBus org.freedesktop.DBus GetConnectionUnixProcessID s org.fcitx.Fcitx5 | awk '{print $2}')
|
||||
unit_pid=$(systemctl --user show omarchy-fcitx5.service --property MainPID --value)
|
||||
[[ $owner_pid == "$unit_pid" ]] || fail "Fcitx bus owner is the supervised process"
|
||||
|
||||
launch_app "foot --app-id=org.omarchy.ime-test $work/reader $work/result"
|
||||
wait_until "$method test terminal opens" 15 window_present '^org\.omarchy\.ime-test$'
|
||||
[[ $(fcitx5-remote) == "1" ]] || fail "$method setup starts in Latin mode"
|
||||
compose
|
||||
screenshot "success-input-$method-composition"
|
||||
wtype -k Return
|
||||
sleep 0.3
|
||||
wtype -k Return
|
||||
wait_until "$method commits text" 15 test -s "$work/result"
|
||||
verify_text "$method terminal"
|
||||
fcitx5-remote -c
|
||||
close_windows '^org\.omarchy\.ime-test$'
|
||||
rm -f "$work/result"
|
||||
|
||||
for toolkit in gtk qt browser; do
|
||||
case "$toolkit" in
|
||||
gtk) command="python $work/entry.py"; class='^org\.omarchy\.ime-test-gtk$' ;;
|
||||
qt) command="qml6 $work/entry.qml"; class='^org\.qt-project\.qml$' ;;
|
||||
browser) command="chromium --user-data-dir=$work/chromium --no-first-run --app=file://$work/omarchy-ime-entry.html"; class='^chrome-.*_omarchy-ime-entry\.html-Default$' ;;
|
||||
esac
|
||||
launch_app "uwsm app -- $command"
|
||||
wait_until "$method $toolkit opens" 30 window_present "$class"
|
||||
sleep 1
|
||||
compose
|
||||
screenshot "success-input-$method-$toolkit-composition"
|
||||
wtype -k Return
|
||||
fcitx5-remote -c
|
||||
wl-copy --clear
|
||||
wtype -M ctrl -k a -k c -m ctrl
|
||||
sleep 0.3
|
||||
wl-paste --no-newline > "$work/result" || fail "$method $toolkit copies committed text"
|
||||
verify_text "$method $toolkit"
|
||||
screenshot "success-input-$method-$toolkit-committed"
|
||||
close_windows "$class"
|
||||
wait_until "$toolkit closes" 15 window_absent "$class"
|
||||
done
|
||||
done
|
||||
|
||||
# Stop and drain the monitor before reading a complete final call.
|
||||
kill -0 "$notification_monitor" 2>/dev/null || fail "notification monitor stays attached"
|
||||
kill "$notification_monitor" 2>/dev/null || true
|
||||
wait "$notification_monitor" 2>/dev/null || true
|
||||
notification_monitor=""
|
||||
if ! python "$ROOT/test/acceptance.d/input-method-notifications.py" "$ARTIFACTS/input-method-notifications.log" "$initial_owner"; then
|
||||
fail "input setup and composition produce no additional notifications" "$(cat "$ARTIFACTS/input-method-notifications.log")"
|
||||
fi
|
||||
pass "all four input engines remain quiet during setup and composition"
|
||||
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