Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 85 additions & 4 deletions default/hypr/bindings/clipboard.lua
Original file line number Diff line number Diff line change
Expand Up @@ -32,18 +32,99 @@ local function active_window_is_terminal()
return false
end

local keycode_cache = {}

local function add_xkb_option(command, name, value)
if value ~= "" then
table.insert(command, "--" .. name)
table.insert(command, o.shell_quote(value))
end
end

-- Hyprland resolves a named key against the active XKB group. That fails when
-- the active group has no Latin C/V/X/A keysym. Resolve the logical shortcut
-- letter against the configured primary group instead, then inject that XKB
-- keycode. This keeps non-Latin secondary groups working without assuming the
-- primary layout is QWERTY (Dvorak/AZERTY included).
local function resolve_primary_keycode(key)
-- A keymap file overrides the kb_* rules, so they cannot say where the letter is.
if tostring(hl.get_config("input.kb_file") or "") ~= "" then
return key
end

local config = {
layout = tostring(hl.get_config("input.kb_layout") or "us"),
variant = tostring(hl.get_config("input.kb_variant") or ""),
model = tostring(hl.get_config("input.kb_model") or ""),
options = tostring(hl.get_config("input.kb_options") or ""),
rules = tostring(hl.get_config("input.kb_rules") or ""),
Comment on lines +55 to +60

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Keyboard overrides send wrong letters

resolve_primary_keycode reads only global input.kb_* settings. If a keyboard has its own Dvorak layout while the global layout is us, copy resolves to code:54 instead of Dvorak's code:31. The focused application receives the wrong shortcut.

Resolve against the keyboard used for the injected event, including its overrides, and include those settings in the cache key.

}
local cache_key = table.concat({
key,
config.layout,
config.variant,
config.model,
config.options,
config.rules,
}, "\0")

if keycode_cache[cache_key] then
return keycode_cache[cache_key]
end

local command = { "xkbcli how-to-type" }
add_xkb_option(command, "layout", config.layout)
add_xkb_option(command, "variant", config.variant)
add_xkb_option(command, "model", config.model)
add_xkb_option(command, "options", config.options)
add_xkb_option(command, "rules", config.rules)
table.insert(command, "--keysym")
table.insert(command, o.shell_quote(key:lower()))

local pipe = io.popen(table.concat(command, " ") .. " 2>/dev/null")
if not pipe then
keycode_cache[cache_key] = key
return key
end

local output = pipe:read("*a") or ""
pipe:close()
Comment on lines +84 to +91

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 First shortcuts block desktop events

Each uncached shortcut runs xkbcli and waits for all its output inside the Hyprland callback. The compositor waits for a keymap build before sending the shortcut, once for each letter and again after settings change. A slow resolver also delays other desktop events.

Move this work out of the keypress callback, or resolve asynchronously and cache the result.


-- xkbcli numbers layouts and levels from 1. Take the unshifted letter on the
-- primary layout even if another group is currently active.
for line in output:gmatch("[^\r\n]+") do
local keycode, layout_index, level = line:match("^%s*(%d+)%s+%S+%s+(%d+)%s.-%s(%d+)%s+%[")
if layout_index == "1" and level == "1" then
local resolved = "code:" .. keycode
keycode_cache[cache_key] = resolved
return resolved
end
end

-- Preserve the existing named-key behavior if xkbcli is unavailable or the
-- configured keymap cannot resolve this symbol. Do not guess a QWERTY code.
keycode_cache[cache_key] = key
return key
end

local function resolved_shortcut(mods, key)
return function()
send_shortcut_once(mods, resolve_primary_keycode(key))()
end
end

local function universal_clipboard_shortcut(default_mods, default_key, terminal_mods, terminal_key)
return function()
if active_window_is_terminal() then
send_shortcut_once(terminal_mods, terminal_key)()
send_shortcut_once(terminal_mods, resolve_primary_keycode(terminal_key))()
else
send_shortcut_once(default_mods, default_key)()
send_shortcut_once(default_mods, resolve_primary_keycode(default_key))()
end
end
end

o.bind("SUPER + A", "Select all", send_shortcut_once("CTRL", "A"))
o.bind("SUPER + A", "Select all", resolved_shortcut("CTRL", "A"))
o.bind("SUPER + C", "Universal copy", universal_clipboard_shortcut("CTRL", "C", "CTRL SHIFT", "C"))
o.bind("SUPER + V", "Universal paste", universal_clipboard_shortcut("CTRL", "V", "CTRL SHIFT", "V"))
o.bind("SUPER + X", "Universal cut", send_shortcut_once("CTRL", "X"))
o.bind("SUPER + X", "Universal cut", resolved_shortcut("CTRL", "X"))
o.bind("SUPER + CTRL + V", "Clipboard manager", { panel = "omarchy.clipboard" })
117 changes: 117 additions & 0 deletions test/shell.d/hyprland-default-config-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
source "$(dirname "${BASH_SOURCE[0]}")/base-test.sh"

require_command lua
require_command xkbcli

run_application_bindings() {
local home="$1"
Expand Down Expand Up @@ -95,6 +96,79 @@ require("default.hypr.omarchy")
LUA
}

run_clipboard_shortcuts() {
local layout="$1"
local variant="$2"
local file="${3:-}"

TEST_KB_LAYOUT="$layout" TEST_KB_VARIANT="$variant" TEST_KB_FILE="$file" OMARCHY_PATH="$ROOT" lua <<'LUA'
package.path = os.getenv("OMARCHY_PATH") .. "/?.lua;" .. package.path

local bindings = {}
local active_tags = {}
local current_binding = ""
local config = {
["input.kb_layout"] = os.getenv("TEST_KB_LAYOUT"),
["input.kb_variant"] = os.getenv("TEST_KB_VARIANT"),
["input.kb_model"] = "",
["input.kb_options"] = "",
["input.kb_rules"] = "",
["input.kb_file"] = os.getenv("TEST_KB_FILE"),
}

hl = {
dsp = {
exec_cmd = function(command)
return { command = command }
end,
send_key_state = function(spec)
return spec
end,
global = function(name)
return { global = name }
end,
},
bind = function(keys, dispatcher)
bindings[keys] = dispatcher
end,
dispatch = function(dispatcher)
print(table.concat({
current_binding,
dispatcher.mods,
dispatcher.key,
dispatcher.state,
}, "\t"))
end,
get_active_window = function()
return { tags = active_tags }
end,
get_config = function(key)
return config[key]
end,
timer = function(callback)
callback()
end,
}

require("default.hypr.helpers")
require("default.hypr.bindings.clipboard")

local function invoke(label, keys)
current_binding = label
bindings[keys]()
end

invoke("gui-select-all", "SUPER + A")
invoke("gui-copy", "SUPER + C")
invoke("gui-paste", "SUPER + V")
invoke("gui-cut", "SUPER + X")

active_tags = { "terminal*" }
invoke("terminal-copy", "SUPER + C")
invoke("terminal-paste", "SUPER + V")
LUA
}

tmpdir=$(mktemp -d)
trap 'rm -rf "$tmpdir"' EXIT

Expand All @@ -119,6 +193,49 @@ if grep -F 'wtype -M' "$ROOT/default/hypr/bindings/clipboard.lua" >/dev/null; th
fi
pass "universal clipboard shortcuts avoid virtual keyboard modifier merging"

qwerty_clipboard_output=$(run_clipboard_shortcuts "us,ru" "")
grep -Fqx $'gui-select-all\tCTRL\tcode:38\tdown' <<<"$qwerty_clipboard_output" ||
fail "select-all resolves A from the primary QWERTY layout" "$qwerty_clipboard_output"
grep -Fqx $'gui-copy\tCTRL\tcode:54\tdown' <<<"$qwerty_clipboard_output" ||
fail "universal copy resolves C from the primary QWERTY layout" "$qwerty_clipboard_output"
grep -Fqx $'gui-paste\tCTRL\tcode:55\tdown' <<<"$qwerty_clipboard_output" ||
fail "universal paste resolves V from the primary QWERTY layout" "$qwerty_clipboard_output"
grep -Fqx $'gui-cut\tCTRL\tcode:53\tdown' <<<"$qwerty_clipboard_output" ||
fail "universal cut resolves X from the primary QWERTY layout" "$qwerty_clipboard_output"
pass "universal clipboard shortcuts resolve primary QWERTY letters before injection"

dvorak_clipboard_output=$(run_clipboard_shortcuts "us,ru" "dvorak,")
grep -Fqx $'gui-copy\tCTRL\tcode:31\tdown' <<<"$dvorak_clipboard_output" ||
fail "universal copy resolves C from primary Dvorak" "$dvorak_clipboard_output"
grep -Fqx $'gui-paste\tCTRL\tcode:60\tdown' <<<"$dvorak_clipboard_output" ||
fail "universal paste resolves V from primary Dvorak" "$dvorak_clipboard_output"
grep -Fqx $'gui-cut\tCTRL\tcode:56\tdown' <<<"$dvorak_clipboard_output" ||
fail "universal cut resolves X from primary Dvorak" "$dvorak_clipboard_output"
grep -Fqx $'terminal-copy\tCTRL SHIFT\tcode:31\tdown' <<<"$dvorak_clipboard_output" ||
fail "terminal copy preserves Ctrl+Shift+C on Dvorak" "$dvorak_clipboard_output"
grep -Fqx $'terminal-paste\tCTRL SHIFT\tcode:60\tdown' <<<"$dvorak_clipboard_output" ||
fail "terminal paste preserves Ctrl+Shift+V on Dvorak" "$dvorak_clipboard_output"
pass "universal clipboard shortcuts preserve logical letters on primary Dvorak"

azerty_clipboard_output=$(run_clipboard_shortcuts "fr,ru" ",")
grep -Fqx $'gui-select-all\tCTRL\tcode:24\tdown' <<<"$azerty_clipboard_output" ||
fail "select-all resolves A from primary French AZERTY instead of the QWERTY A position" "$azerty_clipboard_output"
pass "select-all preserves logical A on primary AZERTY"

third_level_clipboard_output=$(run_clipboard_shortcuts "us" "3l")
grep -Fqx $'gui-select-all\tCTRL\tcode:41\tdown' <<<"$third_level_clipboard_output" ||
fail "select-all resolves the unshifted A, not a letter on a higher level" "$third_level_clipboard_output"
pass "select-all resolves the unshifted letter"

kb_file_clipboard_output=$(run_clipboard_shortcuts "us" "" "$tmpdir/custom.xkb")
grep -Fqx $'gui-paste\tCTRL\tV\tdown' <<<"$kb_file_clipboard_output" ||
fail "universal paste keeps the named key when a keymap file replaces the kb_* rules" "$kb_file_clipboard_output"
pass "universal clipboard shortcuts keep named keys under a keymap file"

grep -F 'timeout = 50, type = "oneshot"' "$ROOT/default/hypr/bindings/clipboard.lua" >/dev/null ||
fail "clipboard key resolution change removed the existing key-up timer"
pass "clipboard key resolution keeps the existing down/up timing"

removed_home="$tmpdir/removed-home"
mkdir -p "$removed_home/.local/state/omarchy"
touch "$removed_home/.local/state/omarchy/preinstalls-removed"
Expand Down