Preserve Num Lock during compatible Cua foreground input

This commit is contained in:
Spencer Bull committed 2026-09-15 23:55:59 -05:00
1 parent f868f3c767
commit b2176d5cab
5 files changed
+488 -77

No files matched your search

@@ -1,6 +1,6 @@
{
"files": {
"CMakeLists.txt": "4a2027383ae39b052c4cf30732810f93ee1170b7987777aa9e8ea282abb17731",
"CMakeLists.txt": "7e874a595e1abd708cb0626bd9f0f58d79b4b4bbc6d99b45a0cbe1c5c53b43bc",
"LICENSE.md": "c0779290c1d4783169aa3dbfb55feb505e563ef8a004bbf55298ceffcfbda8d9",
"SOURCE-PROVENANCE.json": "54f514664c84e1358a435f29cd6befd5661b0b133d76997191c000b10f021a75",
"cmake/DetectHyprlandAPI.cmake": "216133ec0eb141c3696bf3770a23e63e46521c9e91a0245a7cb75c20a75ba2c5",
@@ -9,17 +9,17 @@
"include/cua_hyprland/session.hpp": "e6a968e4f2ac28122cb7413a0e318f6222d2a0a1b7b0c45f4ab419a78639ebea",
"include/cua_hyprland/status.hpp": "56a9656647c0f4eeb0c588cd4b98a77df198d1f90421f973e9a80e6802f61495",
"src/drag_geometry.hpp": "c5b783d15ff197f22938f08f8d176bab5d45544fe989d150f15cb99295acedb8",
"src/foreground_route.hpp": "bf07e9ada1a3e25dd85611d5f3f4f725df50b1b8fc1d4c4903fb5c6dc9c3d5f8",
"src/foreground_route.hpp": "4aa016c237b33c15e352a9f5f64bbbca7e1a9c1671ffd593a95a0d87994fb519",
"src/inject_server.cpp": "0935283580c50fcf0e4ad956f858885700536002b1d86d2f078da9c4404ee9e8",
"src/inject_server.hpp": "67de008b4d6983371207bb22a57bab154b1dedda9b38d1207d3ac8cb379382eb",
"src/input_client_deadline.hpp": "00a91a789ff698820607449ff7152e2fb50d0f315e0fa9c5c3855752bfffe2ef",
"src/input_experiment.cpp": "358fa86abd21dbfda8460b21b3568d0199e47ed79ef6c5dd0c7e0c6d4c026290",
"src/input_experiment.cpp": "7017748c782b64b0bc1d257f4ade1a8d46fd19ce940ecc21f22d628614fcef37",
"src/input_experiment.hpp": "9da2ddab7f0de6e9cf02aea53e9119acef17ef8513af849bb5aa3d137ebd12c3",
"src/input_grant.hpp": "90b544b2f559bacd920b85ab5915f09ff201de3c05a052ce1b71ff71f767e6a6",
"src/keyboard_layout.hpp": "66d7d1ace6357c5be26f4a9b57730709fe21e30267437051c62b6697eb073300",
"src/keyboard_layout.hpp": "bc2ec039ac1974caebbb66fe4acb7a2a81832c9054b3aa644a9adcc8954a2467",
"src/owned_socket_path.hpp": "8e784656d944c700f3ded383c93a8cadf846dabdaa4cc12673bcb637c26d9fd1",
"src/passive_pointer_target.hpp": "ede36fd9fd6e95ae5923c751f12591084392be9d2270eb06ad64eb4f245169fa",
"src/plugin.cpp": "e36817f12b47f317dde300cfa6820ce2d408c8fe5bfea3a3e5c727b86d461d3e",
"src/plugin.cpp": "712fd73ef8e9046e0fd91531b7bf5da50ce37ccca9df174160137e1924a74b09",
"src/primary_trace.cpp": "e9468d1a3f3be2a90d47bf8c4a638ad8a60fe10b6297582ab7825751cb707aa9",
"src/primary_trace.hpp": "8d62535fb0b24a02bb80d9a8dcd540b39204afb2f3b4bcb5cabd5275c3b5eaa7",
"src/protocol.cpp": "bd083d65efb05e80946566dc535b1a6fadaa581c66ec327eff41796feba795b1",
@@ -32,24 +32,25 @@
"tests/desktop_fault_policy_fixture.cpp": "ac24d675ebc64cc98148e852eb5aba5858bffc06332678d14276d04b317aaf15",
"tests/desktop_fault_policy_test.py": "c3f624c0239036babd23eaaf1bb6b722f3a0c3321a5d3d6668ae7ab4910ca95c",
"tests/drag_geometry_test.cpp": "d32ea649d008fc051fe18555d6fbc54ba5d057b61880b648c5df8aa076a53fbc",
"tests/foreground_modifiers_test.py": "abf0ddde2d51c6639c8bdca8fdda51cdc8f922b57575a00fc8925c683ccc3bb2",
"tests/foreground_route_test.cpp": "1024168828b13ee6abd8242941e73c042e9381b39108e3ada74823039c7e7932",
"tests/input_client_deadline_test.cpp": "d62373a7815d531f1269c9a838773595f43e8bcef6482fa140edb162e59a6cac",
"tests/input_grant_test.cpp": "1f327b7ee678189ebad6a50bb1b9bd06767521cebc9cfb478d92de4a8bf7e7fe",
"tests/keyboard_layout_test.cpp": "80f76d16896bbb77269a52e12c18c079d642618d29d0608f23a3d7ae2844f21f",
"tests/keyboard_layout_test.cpp": "3bb0fade4675d7ad92a81eb4a1c5201dd1d01bcc418b7ea59a4284dc235e5fb2",
"tests/mock-hyprland/mock.hpp": "3aeb1a4b9d6b83506b66c129d3fa812330fad4509fe218a0dbb99dd2bb5b6319",
"tests/mock-hyprland/src/config/values/types/BoolValue.hpp": "47cf2cca89f71a273573968cb9b8ba46a1496841d6c892756ebf123500a7ecb3",
"tests/mock-hyprland/src/plugins/PluginAPI.hpp": "5654d90ec9090a88bea3d31f8a79617d4c79742b09068648e64448319d395110",
"tests/owned_socket_path_test.cpp": "eaff6b5c6f148eca6c8650ee3dc212a5e892f002ee4f68b2a1290c7205ee7e42",
"tests/passive_pointer_target_test.cpp": "2aeef1de1dc8932b26ab8c41b83fb16a4289ff96c177a5816088f07b9a168948",
"tests/plugin_api_test.cpp": "1e7e200c309996ee945c88e172273ae942be2837e24564d422dee79d8b77d8a2",
"tests/plugin_input_lifetime_test.cpp": "9d291fc6d7de5e9a80a07c2de16de85739e9ba6fe2ceace371f64b94af4724c0",
"tests/plugin_input_lifetime_test.cpp": "82e57b335ea1216ea24cca07fe4feebafebdebb779785fde20b8dab6ee222e1b",
"tests/protocol_test.cpp": "119cfe0df81c0c00036a2d181764eda7601d6ee72459c2275a96226d4f670447",
"tests/seat_lifetime_test.cpp": "b07570edbe0a142f97c54560eeb93e8327c435ab3b8cbc7496d55175e387b78a",
"tests/status_test.cpp": "b8990efc53ec3820cfe498c920b9220c4b70615ad585468558e032e7619e32f3",
"tests/transport_test.cpp": "deef114a950a27eaff0a530165ddf7db0bfc0fe7e8fb55bdbb66135c8e0c04c9",
"verify.py": "fb35d62313ff4661f892f88666919b33b160f8b6d4fb2d5d52610708bf7f4a54"
},
"patch_sha256": "5847cd2c300a9b3c7dc1e836722a0e2b222892e308290195d973300c7f74e0e4",
"patch_sha256": "e74d9021111a5c217b12f3e13c1bbdd260e829c60db5fc2f7293ba4846e1a7d7",
"schema": 1,
"upstream_manifest_sha256": "54f514664c84e1358a435f29cd6befd5661b0b133d76997191c000b10f021a75",
"upstream_revision": "cc54254464c0c9aebfd6547fe7e4a0ceaf0456d7"
+14 -14
View File
@@ -4,7 +4,7 @@
# shellcheck shell=bash disable=SC2034,SC2154
pkgname=cua-hyprland-plugin
pkgver=0.26.1
pkgrel=4
pkgrel=5
pkgdesc='Cua input candidate for reviewed profile omarchy-hyprland-0562r3-remaps'
arch=('x86_64')
url='https://github.com/trycua/cua'
@@ -14,8 +14,8 @@ makedepends=('cmake>=3.30' 'ninja' 'pkgconf' 'gcc' 'patch')
options=('!strip' '!debug' '!lto')
_stem='cua-hyprland-plugin-0.26.1-cc54254464c0c9aebfd6547fe7e4a0ceaf0456d7'
_archive_sha256='47bca9e018f32f4fcfe683f91c7475c60368f3b65d318cc35c1f2de88a4ee9ab'
_kit_sha256='aa88498fc9635e493a64aaf4e2a08cdb5249d576d7e00c3cf100799d5c9f23f2'
_profile_sha256='ee9b7cf5fbcc78ab169a87b5edb061a85c4425f7ddf41c90328cf020550c5f9a'
_kit_sha256='089f447e11cacd8c2d3d6cd56528776417c51d353b9677c47c42bba1ef79c9f9'
_profile_sha256='fc3034649af98f7f81178ef575660143c249664b9ff5d0e1ccaf79d7580df17b'
_verifier_sha256='480f649cbd35a0ddc7f49dc0a3a44785402c9e0ec9653b0f8ea4965d52d7f900'
_cxx="${CUA_RELEASE_CXX:-/usr/bin/g++}"
_download_name='cua-hyprland-plugin-0.26.1-cc54254464c0c9aebfd6547fe7e4a0ceaf0456d7-profile-omarchy-stable-20260910-kit-1.1.0-5dacea79a6a5927e59fca7b51e6c04e94fa45133ac1b8fb7f4e1f442d113eb9a-67f89ceb47edd973aa748820eadd02ddbfb3d3e4.tar.gz'
@@ -24,17 +24,17 @@ source=('https://github.com/trycua/cua/releases/download/cua-hyprland-kit-v1.1.0
'PROFILE.json')
noextract=("$_download_name")
sha256sums=('a89bfa7f6490f598719dfabdd2a5badc137e27b0764fd25e6a05e1f969433520'
'ee9b7cf5fbcc78ab169a87b5edb061a85c4425f7ddf41c90328cf020550c5f9a')
'fc3034649af98f7f81178ef575660143c249664b9ff5d0e1ccaf79d7580df17b')
# Downstream inputs are also checked explicitly when makepkg integrity is skipped.
declare -gA _downstream_sha256=(
['independent-keymaps.patch']='5847cd2c300a9b3c7dc1e836722a0e2b222892e308290195d973300c7f74e0e4'
['DOWNSTREAM-PROVENANCE.json']='e87949fa29d8c006f1c829cb81e640980b349a704bedb5f1d4fe7dc47746994e'
['independent-keymaps.patch']='e74d9021111a5c217b12f3e13c1bbdd260e829c60db5fc2f7293ba4846e1a7d7'
['DOWNSTREAM-PROVENANCE.json']='e0c95350ec3ff2dd54a05e9377d79ddffffdb1cf494d05553a90207a3a919f7e'
['downstream.py']='7c9725805ad038737bd4d346c85f27991ce9ddfff436a32ebf515cad1bf98ca1'
['downstream_test.py']='7b9a992979b22a5e061fdf011ac6be59a3f1ae6e0d7c0a8ff631f74e71984c5a'
)
source+=('independent-keymaps.patch' 'DOWNSTREAM-PROVENANCE.json' 'downstream.py' 'downstream_test.py')
sha256sums+=('5847cd2c300a9b3c7dc1e836722a0e2b222892e308290195d973300c7f74e0e4' 'e87949fa29d8c006f1c829cb81e640980b349a704bedb5f1d4fe7dc47746994e' '7c9725805ad038737bd4d346c85f27991ce9ddfff436a32ebf515cad1bf98ca1' '7b9a992979b22a5e061fdf011ac6be59a3f1ae6e0d7c0a8ff631f74e71984c5a')
sha256sums+=('e74d9021111a5c217b12f3e13c1bbdd260e829c60db5fc2f7293ba4846e1a7d7' 'e0c95350ec3ff2dd54a05e9377d79ddffffdb1cf494d05553a90207a3a919f7e' '7c9725805ad038737bd4d346c85f27991ce9ddfff436a32ebf515cad1bf98ca1' '7b9a992979b22a5e061fdf011ac6be59a3f1ae6e0d7c0a8ff631f74e71984c5a')
_verify_download() {
python3 -I - "$SRCDEST/$_download_name" "$_download_sha256" "$srcdir" "$1" "$SRCDEST/PROFILE.json" <<'CUA_DOWNLOAD_PY'
@@ -75,10 +75,10 @@ require(payload.keys() == expected.keys(), 'outer kit inventory mismatch')
# files as -2. Derive a version-only profile with the original source/tooling
# and byte checks intact; record its own profile and kit provenance digests.
profile_data = Path(profile_path).read_bytes()
require(digest(profile_data) == 'ee9b7cf5fbcc78ab169a87b5edb061a85c4425f7ddf41c90328cf020550c5f9a',
require(digest(profile_data) == 'fc3034649af98f7f81178ef575660143c249664b9ff5d0e1ccaf79d7580df17b',
'local profile checksum mismatch')
profile = json.loads(payload['PROFILE.json'])
profile.update(profile_id='omarchy-hyprland-0562r3-remaps', package_release=4)
profile.update(profile_id='omarchy-hyprland-0562r3-remaps', package_release=5)
profile['hyprland']['package_version'] = '0.56.2-3'
require(json.loads(profile_data) == profile, 'local profile changes more than the reviewed package revision')
payload['PROFILE.json'] = profile_data
@@ -86,7 +86,7 @@ provenance = json.loads(payload['KIT-PROVENANCE.json'])
provenance['profile_sha256'] = digest(profile_data)
payload['KIT-PROVENANCE.json'] = (json.dumps(provenance, sort_keys=True, indent=2) + '\n').encode()
recipe = payload['PKGBUILD'].decode()
for old, new in [('pkgrel=2\n', 'pkgrel=4\n'), ('omarchy-stable-20260910', profile['profile_id']),
for old, new in [('pkgrel=2\n', 'pkgrel=5\n'), ('omarchy-stable-20260910', profile['profile_id']),
('hyprland=0.56.2-2', 'hyprland=0.56.2-3'),
('5dacea79a6a5927e59fca7b51e6c04e94fa45133ac1b8fb7f4e1f442d113eb9a', digest(profile_data)),
('7beb736adfd334eed52e84070177634269e3a09f8bb25971b38606933ff4c997', digest(payload['KIT-PROVENANCE.json']))]:
@@ -94,10 +94,10 @@ for old, new in [('pkgrel=2\n', 'pkgrel=4\n'), ('omarchy-stable-20260910', profi
payload['PKGBUILD'] = recipe.encode()
payload['SHA256SUMS'] = ''.join(f'{digest(body)} {name}\n' for name, body in sorted(payload.items())
if name != 'SHA256SUMS').encode()
expected.update({'PROFILE.json': 'ee9b7cf5fbcc78ab169a87b5edb061a85c4425f7ddf41c90328cf020550c5f9a',
'KIT-PROVENANCE.json': 'aa88498fc9635e493a64aaf4e2a08cdb5249d576d7e00c3cf100799d5c9f23f2',
'PKGBUILD': '0e71ddab36e5d9214c35811f2cbf89cc98e5b4fdf6656f85e6b3885d07a1c021',
'SHA256SUMS': '404cf40875ab14b8af84847abd4674945b1c7ab2e0bfb1605fa11e2d85d789db'})
expected.update({'PROFILE.json': 'fc3034649af98f7f81178ef575660143c249664b9ff5d0e1ccaf79d7580df17b',
'KIT-PROVENANCE.json': '089f447e11cacd8c2d3d6cd56528776417c51d353b9677c47c42bba1ef79c9f9',
'PKGBUILD': '0cbf2cd34c3c5038a5ed51e6bf84acd81844e4c2bb08ad7203959201bba61da9',
'SHA256SUMS': 'd01b9e0be4c5bcf84cc2ecef9f11f44cedfc1ca5b31afbfcf52aa2efbd636a09'})
for name, content in payload.items():
require(digest(content) == expected[name], 'derived kit checksum mismatch: ' + name)
require(srcdir.is_dir() and not srcdir.is_symlink(), 'srcdir must be a real directory')
+1 -1
View File
@@ -12,7 +12,7 @@
"sha256": "da8fcacf347bcbed83edc40108c6e2298da095e22246bd764e9bb382786cebb2"
},
"kit_version": "1.1.0",
"package_release": 4,
"package_release": 5,
"profile_id": "omarchy-hyprland-0562r3-remaps",
"runtime": {
"basename": "libstdc++.so.6.0.36",
+8 -6
View File
@@ -1,6 +1,6 @@
# Optional Cua Hyprland plugin
This package targets **Omarchy stable x86_64**, with Inkscape `1.4.4-6` and two independent background-input lanes. Package release `4` adds an Omarchy patch for independent agent keymaps and operation-specific foreground checks; the upstream native qualification below covers the unpatched source, not this change. Cua's native qualification is recorded in [the kit's qualification record](https://github.com/trycua/cua/releases/download/cua-hyprland-kit-v1.1.0-omarchy-stable-20260910/QUALIFICATION.md) and [Cua #3698](https://github.com/trycua/cua/pull/3698). Omabot replay and Omarchy's merge decision are recorded in [omarchy-pkgs #346](https://github.com/omacom/omarchy-pkgs/pull/346). Scheduling the recipe does not expand the qualified stable target.
This package targets **Omarchy stable x86_64**, with Inkscape `1.4.4-6` and two independent background-input lanes. Package release `5` includes the Omarchy patch for independent agent keymaps, operation-specific foreground checks, and compatible Num Lock state; the upstream native qualification below covers the unpatched source, not this change. Cua's native qualification is recorded in [the kit's qualification record](https://github.com/trycua/cua/releases/download/cua-hyprland-kit-v1.1.0-omarchy-stable-20260910/QUALIFICATION.md) and [Cua #3698](https://github.com/trycua/cua/pull/3698). Omabot replay and Omarchy's merge decision are recorded in [omarchy-pkgs #346](https://github.com/omacom/omarchy-pkgs/pull/346). Scheduling the recipe does not expand the qualified stable target.
The plugin is optional. Cua Driver works independently, and installation does not load the plugin or enable input. The package follows the normal edge-to-RC-to-stable promotion path instead of the fast release ring. Its PKGBUILD limits builds to x86_64; only stable x86_64 is a qualified target.
@@ -12,7 +12,7 @@ It is not a repackaging of the unmodified 0.24.0 plugin.
The qualified upstream Driver pairing is `cua-driver-bin 0.27.0-1`, with input protocol v3. Driver 0.27.0 contains the bounded stale-geometry retry validated with the upstream module; its production plugin source is the base for the downstream patch used here. Discovery protocol v2 is separate. A newer Driver release is a changed pairing and requires affected replay before promotion.
Profile `omarchy-hyprland-0562r3-remaps`, kit tooling `1.1.0`, and package release `4` pin:
Profile `omarchy-hyprland-0562r3-remaps`, kit tooling `1.1.0`, and package release `5` pin:
- Hyprland `0.56.2-3`, headers `0.56.2`, and measured executable/header hashes.
- GCC `16.2.1 20260810`, including compiler bytes and emitted ELF identity.
@@ -54,7 +54,9 @@ Each background lane owns a canonical US keymap and independent modifier state.
Plain click, scroll, drag, and foreground activation do not require a canonical keyboard layout. Foreground keys still use the primary seat: the plugin checks the requested key and modifier sequence against its actual XKB map before activation or input. Unrelated remaps are accepted; a sequence whose symbols or modifier/lock transitions differ from the canonical meaning is refused with `unsupported_layout`. Arbitrary foreground layout translation remains outside protocol v3.
Both routes retain target/conflict checks and cancellation on desktop/keymap changes. `hyprctl -j cua:status` exposes `keyboard_layout_independent: true` for installers to distinguish this implementation from an older mapped module. The marker does not identify every future package revision; plugin updates still require a fresh desktop session.
Foreground typing preserves Num Lock and admits a requested key sequence only when its symbols and shortcut semantics still match the canonical meaning. Num Lock does not block unaffected letters, top-row digits, Enter, or compatible shortcuts; a keypad sequence whose meaning changes is refused. Caps Lock, other unsupported lock states, held or latched modifiers, and nonzero layout groups remain guarded.
Both routes retain target/conflict checks and cancellation on desktop/keymap changes. `hyprctl -j cua:status` exposes `keyboard_layout_independent: true` and `foreground_numlock_compatible: true` for installers to distinguish this implementation from an older mapped module. The marker does not identify every future package revision; plugin updates still require a fresh desktop session.
## Historical upstream qualification
@@ -102,7 +104,7 @@ background refusal never authorizes a hidden foreground fallback or unlock.
Build the unsigned candidate in edge:
```sh
bin/repo build --package cua-hyprland-plugin --arch x86_64 --mirror edge
./bin/build --package cua-hyprland-plugin --arch x86_64 --mirror edge
```
In a fresh worker matching the reviewed profile:
@@ -140,7 +142,7 @@ kit-provenance digest:
```sh
python3 /usr/share/cua-hyprland-plugin/profile_verify.py \
--kit /usr/share/cua-hyprland-plugin \
--kit-sha256 aa88498fc9635e493a64aaf4e2a08cdb5249d576d7e00c3cf100799d5c9f23f2 \
--kit-sha256 089f447e11cacd8c2d3d6cd56528776417c51d353b9677c47c42bba1ef79c9f9 \
--consumer /usr/lib/cua/hyprland/cua-hyprland-plugin.so
```
@@ -172,7 +174,7 @@ hyprctl reload
hyprctl -j cua:status
```
Continue only when status reports `keyboard_layout_independent: true`, input protocol v3, input capability, socket paths, and the expected compositor identity. Do not change `kb_layout`, `kb_options`, or NumLock for background input. If you previously followed the stock-US override instructions, remove only that Cua-specific override and reload to restore your underlying personal settings.
Continue only when status reports `keyboard_layout_independent: true`, `foreground_numlock_compatible: true`, input protocol v3, input capability, socket paths, and the expected compositor identity. Do not change `kb_layout`, `kb_options`, or NumLock for background input. If you previously followed the stock-US override instructions, remove only that Cua-specific override and reload to restore your underlying personal settings.
Start Driver with `CUA_DRIVER_RS_ENABLE_WAYLAND=1`. In a new disposable Inkscape document, test an admitted background key operation and pointer operation, then verify the result in both a fresh snapshot and a saved/reopened SVG. Driver text-route restrictions still apply. Never test against an existing document or automatically replay an action with a partial or unknown outcome.
@@ -1,8 +1,8 @@
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 8a80de2..5dedb8f 100644
index 8a80de2..2d48237 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -39,8 +39,24 @@ target_compile_options(cua_hyprland_protocol PRIVATE -Wall -Wextra -Wpedantic -W
@@ -39,8 +39,28 @@ target_compile_options(cua_hyprland_protocol PRIVATE -Wall -Wextra -Wpedantic -W
cua_hyprland_harden(cua_hyprland_protocol)
set_target_properties(cua_hyprland_protocol PROPERTIES POSITION_INDEPENDENT_CODE ON)
@@ -23,11 +23,15 @@ index 8a80de2..5dedb8f 100644
+ add_test(NAME cua_hyprland_agent_keymap_test
+ COMMAND ${Python3_EXECUTABLE} -B ${CMAKE_CURRENT_SOURCE_DIR}/tests/agent_keymap_test.py)
+ set_tests_properties(cua_hyprland_agent_keymap_test PROPERTIES
+ ENVIRONMENT "CXX=${CMAKE_CXX_COMPILER}")
+ add_test(NAME cua_hyprland_foreground_modifiers_test
+ COMMAND ${Python3_EXECUTABLE} -B ${CMAKE_CURRENT_SOURCE_DIR}/tests/foreground_modifiers_test.py)
+ set_tests_properties(cua_hyprland_foreground_modifiers_test PROPERTIES
+ ENVIRONMENT "CXX=${CMAKE_CXX_COMPILER}")
add_test(NAME cua_hyprland_desktop_fault_policy_test
COMMAND ${Python3_EXECUTABLE} -B
${CMAKE_CURRENT_SOURCE_DIR}/tests/desktop_fault_policy_test.py)
@@ -218,6 +234,7 @@ if(CUA_HYPRLAND_BUILD_PLUGIN)
@@ -218,6 +238,7 @@ if(CUA_HYPRLAND_BUILD_PLUGIN)
message(FATAL_ERROR "Input is pinned to Hyprland 0.56.2")
endif()
target_sources(cua_hyprland_plugin PRIVATE src/input_experiment.cpp)
@@ -35,8 +39,29 @@ index 8a80de2..5dedb8f 100644
endif()
if(CUA_HYPRLAND_INPUT)
target_compile_definitions(cua_hyprland_plugin PRIVATE CUA_HYPRLAND_INPUT=1)
diff --git a/src/foreground_route.hpp b/src/foreground_route.hpp
index 0b675d3..c3af461 100644
--- a/src/foreground_route.hpp
+++ b/src/foreground_route.hpp
@@ -68,11 +68,13 @@ struct ForegroundSeatBindings {
bool unique() const { return primary_candidates == 1; }
};
-inline ForegroundFailureReason foreground_key_modifier_failure(const std::array<std::uint32_t, 4>& modifiers) {
- // The KEY mapping assumes a neutral US state, including layout group zero.
+inline ForegroundFailureReason foreground_key_modifier_failure(const std::array<std::uint32_t, 4>& modifiers,
+ std::uint32_t allowed_locked = 0) {
+ // The caller may admit a keymap-resolved ambient Num Lock mask. All other
+ // human modifiers and nonzero layout groups remain unsupported.
if (modifiers[0]) return ForegroundFailureReason::keyboard_depressed;
if (modifiers[1]) return ForegroundFailureReason::keyboard_latched;
- if (modifiers[2]) return ForegroundFailureReason::keyboard_locked;
+ if (modifiers[2] & ~allowed_locked) return ForegroundFailureReason::keyboard_locked;
if (modifiers[3]) return ForegroundFailureReason::keyboard_group;
return ForegroundFailureReason::none;
}
diff --git a/src/input_experiment.cpp b/src/input_experiment.cpp
index fc7e740..f256fc4 100644
index fc7e740..5d80d3e 100644
--- a/src/input_experiment.cpp
+++ b/src/input_experiment.cpp
@@ -9,6 +9,8 @@
@@ -174,25 +199,75 @@ index fc7e740..f256fc4 100644
if (xkb_context_) xkb_context_unref(xkb_context_);
if (keymap_fd >= 0) close(keymap_fd);
}
@@ -786,7 +790,7 @@ struct InputExperiment::Impl {
@@ -782,12 +786,40 @@ struct InputExperiment::Impl {
.exact_pointer_focus = root && g_pSeatManager->m_state.pointerFocus == root,
};
}
+ std::array<std::uint32_t, 4> capture_foreground_modifiers(bool needs_keyboard) const {
+ const auto physical = g_pSeatManager->m_keyboard.lock();
+ if (!physical) throw ForegroundFailure{ForegroundFailureReason::physical_keyboard};
+ std::array<std::uint32_t, 4> result{};
+ const auto observe = [&](const auto& kb, bool primary) {
+ const std::array<std::uint32_t, 4> state{kb->m_modifiersState.depressed, kb->m_modifiersState.latched,
+ kb->m_modifiersState.locked, kb->m_modifiersState.group};
+ if (needs_keyboard) {
+ const auto failure = foreground_key_modifier_failure(state, foreground_numlock_mask(kb->m_xkbKeymap));
+ if (failure != ForegroundFailureReason::none) throw ForegroundFailure{failure};
+ // Hyprland combines shared raw masks. A lock from a different
+ // encoding must not be reinterpreted as the primary Num Lock.
+ if (state[2] && state[2] != foreground_numlock_mask(physical_keymap))
+ throw ForegroundFailure{ForegroundFailureReason::keyboard_locked};
+ }
+ for (unsigned i = 0; i < 3; ++i) result[i] |= state[i];
+ if (primary) result[3] = state[3];
+ };
+ observe(physical, true);
+ for (const auto& kb : g_pInputManager->m_keyboards) {
+ if (kb == physical || !kb->m_enabled || !kb->shareStates() ||
+ (kb->isVirtual() && g_pInputManager->shouldIgnoreVirtualKeyboard(kb))) continue;
+ observe(kb, false);
+ }
+ return result;
+ }
void require_foreground(Client& c) {
if (lease != &c) throw ForegroundFailure{ForegroundFailureReason::lease};
if (c.dead) throw ForegroundFailure{ForegroundFailureReason::client_dead};
if (!available()) throw ForegroundFailure{ForegroundFailureReason::session_unavailable};
- if (!layout_qualified()) throw ForegroundFailure{ForegroundFailureReason::unsupported_layout};
+ if (foreground_keyboard_used && !physical_layout_ready()) throw ForegroundFailure{ForegroundFailureReason::unsupported_layout};
if (Clock::now() >= expires) throw ForegroundFailure{ForegroundFailureReason::lease_expired};
+ if (foreground_keyboard_used && capture_foreground_modifiers(true) != foreground_modifiers)
+ throw ForegroundFailure{ForegroundFailureReason::keyboard_state};
const auto failure = foreground_guard(c).dispatch_failure(foreground_needs_pointer);
if (failure != ForegroundFailureReason::none) throw ForegroundFailure{failure};
@@ -813,6 +817,8 @@ struct InputExperiment::Impl {
}
@@ -803,24 +835,31 @@ struct InputExperiment::Impl {
p->sendButton(event_ms(), held_button, WL_POINTER_BUTTON_STATE_RELEASED);
p->sendFrame();
}
+ // Synthetic events only change our private state. If human input
+ // cancelled the action, restore the current real state, not stale locks.
+ const auto restore_modifiers = g_pSeatManager->m_keyboard.lock() ?
+ capture_foreground_modifiers(false) : foreground_modifiers;
if (foreground_keyboard_used && root && root->good() && g_pSeatManager->m_state.keyboardFocus == root)
for (const auto& weak : foreground_keyboards)
if (const auto k = weak.lock(); k && k->good()) {
for (auto code : held_keys) k->sendKey(event_ms(), code, WL_KEYBOARD_KEY_STATE_RELEASED);
- k->sendMods(foreground_modifiers[0], foreground_modifiers[1], foreground_modifiers[2], foreground_modifiers[3]);
+ k->sendMods(restore_modifiers[0], restore_modifiers[1], restore_modifiers[2], restore_modifiers[3]);
}
held_button = 0; held_keys.clear();
foreground_pointers.clear(); foreground_keyboards.clear(); foreground_surface.reset(); foreground_seat.reset();
foreground_started = false;
foreground_keyboard_used = false;
+ if (physical_state) xkb_state_unref(physical_state);
+ physical_state = physical_keymap ? xkb_state_new(physical_keymap) : nullptr;
}
void start_foreground(Client& c, double x, double y, bool needs_pointer, bool needs_keyboard) {
- void start_foreground(Client& c, double x, double y, bool needs_pointer, bool needs_keyboard) {
+ void start_foreground(Client& c, double x, double y, bool needs_pointer, bool needs_keyboard,
+ const std::array<std::uint32_t, 4>& modifiers) {
const auto root = c.surface.lock();
@@ -820,7 +826,7 @@ struct InputExperiment::Impl {
const auto physical = g_pSeatManager->m_keyboard.lock();
const auto failure = foreground_guard(c).activation_failure();
if (failure != ForegroundFailureReason::none) throw ForegroundFailure{failure};
if (!physical) throw ForegroundFailure{ForegroundFailureReason::physical_keyboard};
@@ -201,16 +276,55 @@ index fc7e740..f256fc4 100644
if (needs_pointer && !g_pSeatManager->m_mouse) throw ForegroundFailure{ForegroundFailureReason::physical_pointer};
const Vector2D local{x + c.geometry[0] - c.geometry[4], y + c.geometry[1] - c.geometry[5]};
if (needs_pointer && (!point(c, x, y) || root->at(local, true).first != root)) throw ForegroundFailure{ForegroundFailureReason::pointer_target};
@@ -843,7 +849,7 @@ struct InputExperiment::Impl {
const auto modifier_failure = foreground_key_modifier_failure(foreground_modifiers);
if (modifier_failure != ForegroundFailureReason::none) throw ForegroundFailure{modifier_failure};
}
@@ -831,24 +870,16 @@ struct InputExperiment::Impl {
for (const auto& k : seat->m_keyboards) if (k && k->good()) foreground_keyboards.push_back(k);
if (needs_pointer && foreground_pointers.empty()) throw ForegroundFailure{ForegroundFailureReason::pointer_resources};
if (foreground_keyboards.empty()) throw ForegroundFailure{ForegroundFailureReason::keyboard_resources};
- foreground_modifiers = {physical->m_modifiersState.depressed, physical->m_modifiersState.latched,
- physical->m_modifiersState.locked, physical->m_modifiersState.group};
- for (const auto& kb : g_pInputManager->m_keyboards) {
- if (!kb->m_enabled || !kb->shareStates() || (kb->isVirtual() && g_pInputManager->shouldIgnoreVirtualKeyboard(kb))) continue;
- foreground_modifiers[0] |= kb->m_modifiersState.depressed;
- foreground_modifiers[1] |= kb->m_modifiersState.latched;
- foreground_modifiers[2] |= kb->m_modifiersState.locked;
- }
- if (needs_keyboard) {
- const auto modifier_failure = foreground_key_modifier_failure(foreground_modifiers);
- if (modifier_failure != ForegroundFailureReason::none) throw ForegroundFailure{modifier_failure};
- }
- xkb_state_update_mask(keyboard_state, foreground_modifiers[0], foreground_modifiers[1], foreground_modifiers[2], 0, 0, foreground_modifiers[3]);
+ foreground_modifiers = modifiers;
+ if (needs_keyboard && capture_foreground_modifiers(true) != foreground_modifiers)
+ throw ForegroundFailure{ForegroundFailureReason::keyboard_state};
+ if (needs_keyboard) xkb_state_update_mask(physical_state, foreground_modifiers[0], foreground_modifiers[1], foreground_modifiers[2], 0, 0, foreground_modifiers[3]);
foreground_surface = root;
foreground_seat = seat;
foreground_needs_pointer = needs_pointer;
@@ -893,16 +899,17 @@ struct InputExperiment::Impl {
c.foreground_attempted = true;
foreground_started = true;
+ foreground_keyboard_used = needs_keyboard;
foreground_activating = true;
// Activation intentionally persists. Never save, borrow, or restore focus.
if (g_pSeatManager->m_state.keyboardFocus != root || Desktop::focusState()->window() != c.window.lock() ||
@@ -858,7 +889,16 @@ struct InputExperiment::Impl {
if (lease != &c) throw ForegroundFailure{ForegroundFailureReason::lease};
const auto focus_failure = foreground_guard(c).dispatch_failure(false);
if (focus_failure != ForegroundFailureReason::none) throw ForegroundFailure{focus_failure};
- if (!needs_pointer) { require_foreground(c); return; }
+ if (!needs_pointer) {
+ require_foreground(c);
+ if (needs_keyboard)
+ for (const auto& weak : foreground_keyboards) {
+ const auto k = weak.lock();
+ if (!k || !k->good()) throw ForegroundFailure{ForegroundFailureReason::keyboard_resources};
+ k->sendMods(foreground_modifiers[0], foreground_modifiers[1], foreground_modifiers[2], foreground_modifiers[3]);
+ }
+ return;
+ }
foreground_activating = true;
::Pointer::mgr()->warpTo({x + c.geometry[0], y + c.geometry[1]});
if (lease != &c) throw ForegroundFailure{ForegroundFailureReason::lease};
@@ -893,16 +933,17 @@ struct InputExperiment::Impl {
}
void foreground_key(Client& c, std::uint32_t code, bool pressed) {
require_foreground(c);
@@ -233,7 +347,7 @@ index fc7e740..f256fc4 100644
}
}
bool pointer_enter(Client& c, double x, double y) {
@@ -942,8 +949,8 @@ struct InputExperiment::Impl {
@@ -942,8 +983,8 @@ struct InputExperiment::Impl {
held_button = pressed ? value : 0;
}
bool keyboard_enter(Client& c) {
@@ -244,7 +358,7 @@ index fc7e740..f256fc4 100644
unsigned count = 0;
for (auto& k : keyboards) {
if (k->dead || !k->wl->resource() || k->wl->client() != root->client()) continue;
@@ -1030,7 +1037,7 @@ struct InputExperiment::Impl {
@@ -1030,7 +1071,7 @@ struct InputExperiment::Impl {
if (kProduction && (!InputGrant::single_operation(requested_cap) ||
(requested_cap == 16 && route != InputRoute::primary_foreground))) { invalidate(c); send(c, refusal("unsupported")); return; }
if (kProduction && !available()) { invalidate(c, false); send(c, refusal("session_unavailable")); return; }
@@ -253,7 +367,7 @@ index fc7e740..f256fc4 100644
const auto pid = number(f[1]); const auto address = number(f[2], 16);
PHLWINDOW window;
for (const auto& w : Desktop::windowState()->windows())
@@ -1090,7 +1097,7 @@ struct InputExperiment::Impl {
@@ -1090,7 +1131,7 @@ struct InputExperiment::Impl {
if (c.token.empty() || f[2] != c.token || !refresh(c)) { send(c, refusal("stale_target")); return; }
if (number(f[3]) != c.revision) { if (kProduction) revoke("stale_geometry"); send(c, refusal("stale_geometry")); return; }
if (!available()) { revoke("session_unavailable", true); send(c, refusal("session_unavailable")); return; }
@@ -262,18 +376,22 @@ index fc7e740..f256fc4 100644
if (lease && Clock::now() >= expires) revoke("lease_expired");
if (drag) { send(c, refusal("lease_busy")); return; }
if (lease != &c || !(capabilities & cap) || (kProduction && !grant.permits(cap, Clock::now()))) {
@@ -1184,6 +1191,10 @@ struct InputExperiment::Impl {
@@ -1184,8 +1225,13 @@ struct InputExperiment::Impl {
if (Clock::now() + std::chrono::milliseconds(duration + 50) >= expires) { send(c, refusal("lease_expired")); return; }
}
}
+ if (command == "KEY" && !foreground_chord_compatible(physical_keymap, keymap, code, mods)) {
+ const auto modifiers = capture_foreground_modifiers(command == "KEY");
+ if (command == "KEY" && !foreground_chord_compatible(physical_keymap, keymap, code, mods, modifiers)) {
+ revoke("unsupported_layout", true);
+ send(c, refusal("unsupported_layout")); return;
+ }
if (!consume_grant(c, cap)) return;
start_foreground(c, x, y, command != "KEY" && command != "ACTIVATE", command == "KEY");
- start_foreground(c, x, y, command != "KEY" && command != "ACTIVATE", command == "KEY");
+ start_foreground(c, x, y, command != "KEY" && command != "ACTIVATE", command == "KEY", modifiers);
if (command == "KEY") {
@@ -1227,7 +1238,7 @@ struct InputExperiment::Impl {
const std::array<std::uint32_t, 4> keys{42, 29, 56, 125};
for (unsigned i = 0; i < 4; ++i) if ((mods & (1u << i)) && keys[i] != code) foreground_key(c, keys[i], true);
@@ -1227,7 +1273,7 @@ struct InputExperiment::Impl {
if (lease) {
if (lease->dead) revoke("disconnected", true);
else if (Clock::now() >= expires) revoke("lease_expired");
@@ -284,14 +402,15 @@ index fc7e740..f256fc4 100644
}
diff --git a/src/keyboard_layout.hpp b/src/keyboard_layout.hpp
new file mode 100644
index 0000000..ab7defc
index 0000000..f5f51c8
--- /dev/null
+++ b/src/keyboard_layout.hpp
@@ -0,0 +1,102 @@
@@ -0,0 +1,127 @@
+#pragma once
+
+#include "foreground_route.hpp"
+#include <xkbcommon/xkbcommon.h>
+#include <xkbcommon/xkbcommon-names.h>
+#include <array>
+#include <cstdint>
+#include <memory>
@@ -359,16 +478,37 @@ index 0000000..ab7defc
+ return true;
+}
+
+// Resolve the virtual modifier through this map; Num Lock is not necessarily
+// encoded as Mod2. Ambiguous encodings are deliberately not admitted.
+inline xkb_mod_mask_t foreground_numlock_mask(xkb_keymap* map) {
+ const auto mask = map ? xkb_keymap_mod_get_mask(map, XKB_VMOD_NAME_NUM) : 0;
+ return mask && !(mask & (mask - 1)) &&
+ !(mask & xkb_keymap_mod_get_mask(map, XKB_MOD_NAME_CAPS)) ? mask : 0;
+}
+
+// Simulate the complete chord before delivering any events or changing focus.
+// This is compatibility checking, not layout translation: physical key positions
+// remain unchanged. Unrelated remaps are harmless, requested remaps fail closed.
+inline bool foreground_chord_compatible(xkb_keymap* physical, xkb_keymap* canonical,
+ std::uint32_t code, std::uint32_t mods) {
+ std::uint32_t code, std::uint32_t mods,
+ const std::array<std::uint32_t, 4>& modifiers) {
+ if (!physical || !canonical) return false;
+ if (foreground_key_modifier_failure(modifiers, foreground_numlock_mask(physical)) !=
+ ForegroundFailureReason::none) return false;
+ using State = std::unique_ptr<xkb_state, decltype(&xkb_state_unref)>;
+ State actual{xkb_state_new(physical), xkb_state_unref};
+ State expected{xkb_state_new(canonical), xkb_state_unref};
+ if (!actual || !expected) return false;
+ // The client retains Num Lock, but wire keys still mean the neutral US
+ // chord. A third state rejects keypad/navigation changes caused by the lock.
+ State intended{xkb_state_new(canonical), xkb_state_unref};
+ if (!actual || !expected || !intended) return false;
+ if (modifiers[2]) {
+ const auto canonical_lock = foreground_numlock_mask(canonical);
+ if (!canonical_lock) return false;
+ xkb_state_update_mask(actual.get(), 0, 0, modifiers[2], 0, 0, 0);
+ xkb_state_update_mask(expected.get(), 0, 0, canonical_lock, 0, 0, 0);
+ if (!same_modifier_state(actual.get(), expected.get())) return false;
+ }
+ const auto event = [&](std::uint32_t key, xkb_key_direction direction) {
+ // A client interprets the press using the preceding modifiers event.
+ // Stock both_capslock_cancel gives Shift a Caps_Lock symbol at its
@@ -376,9 +516,12 @@ index 0000000..ab7defc
+ // keycode, but their lock/latch/group effects still must agree.
+ if (direction == XKB_KEY_DOWN &&
+ (!same_key_symbols(actual.get(), expected.get(), key + 8) ||
+ !same_consumed_modifiers(actual.get(), expected.get(), key + 8))) return false;
+ !same_consumed_modifiers(actual.get(), expected.get(), key + 8) ||
+ !same_key_symbols(expected.get(), intended.get(), key + 8) ||
+ !same_consumed_modifiers(expected.get(), intended.get(), key + 8))) return false;
+ xkb_state_update_key(actual.get(), key + 8, direction);
+ xkb_state_update_key(expected.get(), key + 8, direction);
+ xkb_state_update_key(intended.get(), key + 8, direction);
+ return same_modifier_state(actual.get(), expected.get());
+ };
+ constexpr std::array<std::uint32_t, 4> keys{42, 29, 56, 125};
@@ -391,7 +534,7 @@ index 0000000..ab7defc
+}
+} // namespace cua::hyprland
diff --git a/src/plugin.cpp b/src/plugin.cpp
index 88b9900..2d24471 100644
index 88b9900..ae7fc7c 100644
--- a/src/plugin.cpp
+++ b/src/plugin.cpp
@@ -218,6 +218,11 @@ std::string status_output(bool json) {
@@ -401,7 +544,7 @@ index 88b9900..2d24471 100644
+#ifdef CUA_HYPRLAND_INPUT
+ // Installation checks compiled support before enabling input seats.
+ result.pop_back();
+ result += ",\"keyboard_layout_independent\":true}";
+ result += ",\"keyboard_layout_independent\":true,\"foreground_numlock_compatible\":true}";
+#endif
#if defined(CUA_HYPRLAND_TEST_INPUT) || defined(CUA_HYPRLAND_INPUT)
if (g_experiment) {
@@ -583,12 +726,243 @@ index 68ca6c8..7a4d1b4 100644
return (ROOT / 'tests/desktop_fault_policy_fixture.cpp').read_text().replace(
'// PRODUCTION_METHODS', methods)
diff --git a/tests/foreground_modifiers_test.py b/tests/foreground_modifiers_test.py
new file mode 100644
index 0000000..013c9bf
--- /dev/null
+++ b/tests/foreground_modifiers_test.py
@@ -0,0 +1,225 @@
+"""Exercise production foreground state admission, dispatch and unwind with XKB."""
+import os
+from pathlib import Path
+import re
+import shlex
+import subprocess
+import tempfile
+import unittest
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+class ForegroundModifiersTest(unittest.TestCase):
+ def test_production_modifiers(self):
+ source = (ROOT / 'src/input_experiment.cpp').read_text()
+ methods = []
+ for name in ('capture_foreground_modifiers', 'require_foreground', 'finish_foreground',
+ 'start_foreground', 'foreground_key'):
+ body = re.search(r'^ \S[^\n]*\b' + name + r'\(.*?^ }', source, re.M | re.S)
+ self.assertIsNotNone(body, name)
+ methods.append(body.group())
+ preflight = re.search(r' const auto modifiers = capture_foreground_modifiers\(command == "KEY"\);.*?'
+ r' start_foreground\([^\n]*;', source, re.S)
+ self.assertIsNotNone(preflight)
+ fixture = r'''
+#include "keyboard_layout.hpp"
+#include <algorithm>
+#include <chrono>
+#include <cstdlib>
+#include <iostream>
+#include <memory>
+#include <string>
+#include <vector>
+using namespace cua::hyprland;
+using Clock = std::chrono::steady_clock;
+void check(bool ok, const char* why) { if (!ok) { std::cerr << why; std::exit(1); } }
+struct Vector2D { double x, y; };
+constexpr int WL_KEYBOARD_KEY_STATE_PRESSED=1, WL_KEYBOARD_KEY_STATE_RELEASED=0;
+constexpr int WL_POINTER_BUTTON_STATE_RELEASED=0;
+struct Root : std::enable_shared_from_this<Root> {
+ bool good() const { return true; }
+ int client() const { return 0; }
+ auto at(Vector2D, bool) { return std::pair{shared_from_this(), 0}; }
+};
+struct Resource {
+ xkb_state* client = nullptr;
+ std::vector<xkb_keysym_t> symbols;
+ std::array<unsigned, 4> last{};
+ std::vector<std::array<unsigned, 4>> history;
+ bool good() const { return true; }
+ void sendKey(unsigned, unsigned code, unsigned down) {
+ if (down) symbols.push_back(xkb_state_key_get_one_sym(client, code + 8));
+ }
+ void sendMods(unsigned d, unsigned l, unsigned k, unsigned g) {
+ last={d,l,k,g}; history.push_back(last); xkb_state_update_mask(client,d,l,k,0,0,g);
+ }
+ void sendButton(unsigned, unsigned, unsigned) {}
+ void sendFrame() {}
+};
+struct Keyboard {
+ struct { unsigned depressed=0,latched=0,locked=0,group=0; } m_modifiersState;
+ xkb_keymap* m_xkbKeymap = nullptr;
+ bool m_enabled=true, shared=true, virt=false;
+ bool shareStates() const { return shared; }
+ bool isVirtual() const { return virt; }
+};
+struct Seat {
+ std::vector<std::shared_ptr<Resource>> m_keyboards, m_pointers;
+ bool good() const { return true; }
+};
+struct SeatManager {
+ std::weak_ptr<Keyboard> m_keyboard;
+ bool m_mouse=true;
+ struct { std::shared_ptr<Root> keyboardFocus, pointerFocus; } m_state;
+ std::shared_ptr<Seat> seat;
+ auto seatResourceForClient(int) { return seat; }
+ void setPointerFocus(std::shared_ptr<Root> root, Vector2D) { m_state.pointerFocus=root; }
+} manager, *g_pSeatManager=&manager;
+struct InputManager {
+ std::vector<std::shared_ptr<Keyboard>> m_keyboards;
+ bool shouldIgnoreVirtualKeyboard(const std::shared_ptr<Keyboard>&) { return false; }
+} input, *g_pInputManager=&input;
+namespace Desktop {
+constexpr int FOCUS_REASON_OTHER=0;
+struct Focus {
+ std::shared_ptr<Root> root;
+ std::shared_ptr<Keyboard> change_on_focus;
+ auto window() { return root; }
+ auto surface() { return root; }
+ void fullWindowFocus(std::shared_ptr<Root>, int, std::shared_ptr<Root> r) {
+ root=r; manager.m_state.keyboardFocus=r;
+ if (change_on_focus) change_on_focus->m_modifiersState.locked=0;
+ }
+} focus;
+auto focusState() { return &focus; }
+}
+namespace Pointer { struct Manager { void warpTo(Vector2D) {} } pointer; auto mgr(){ return &pointer; } }
+struct Client {
+ bool dead=false, foreground_attempted=false;
+ std::weak_ptr<Root> surface, window;
+ std::array<double, 6> geometry{};
+};
+struct Lane {
+ xkb_keymap* physical_keymap=nullptr;
+ xkb_keymap* keymap=nullptr;
+ xkb_state* physical_state=nullptr;
+ std::array<unsigned,4> foreground_modifiers{};
+ std::vector<std::weak_ptr<Resource>> foreground_keyboards, foreground_pointers;
+ std::weak_ptr<Root> foreground_surface;
+ std::weak_ptr<Seat> foreground_seat;
+ std::vector<unsigned> held_keys;
+ unsigned held_button=0;
+ bool foreground_started=false,foreground_activating=false,foreground_keyboard_used=false;
+ bool foreground_needs_pointer=false, layout_ready=true, physical_held=false;
+ Client* lease=nullptr;
+ Clock::time_point expires=Clock::now()+std::chrono::hours(1);
+ unsigned consumed=0, refused=0;
+ bool available() { return true; }
+ bool physical_layout_ready() { return layout_ready; }
+ bool point(Client&, double,double) { return true; }
+ unsigned event_ms() { return 0; }
+ void foreground_motion(Client&,double,double) {}
+ ForegroundGuard foreground_guard(Client&) {
+ return {.exact_root=true,.physical_keys=physical_held,.exact_keyboard_focus=true,.exact_pointer_focus=true};
+ }
+ void revoke(const char*, bool) {}
+ auto refusal(const char*) { return std::string{}; }
+ void send(Client&, const std::string&) { ++refused; }
+ bool consume_grant(Client&, unsigned) { ++consumed; return true; }
+ // METHODS
+ void preflight(Client& c, unsigned code, unsigned mods) {
+ const std::string command="KEY";
+ const unsigned cap=2;
+ const double x=0,y=0;
+ // PREFLIGHT
+ }
+ ~Lane() { if (physical_state) xkb_state_unref(physical_state); }
+};
+int main() {
+ auto context=xkb_context_new(XKB_CONTEXT_NO_FLAGS);
+ const xkb_rule_names names{"evdev","pc105","us","","ctrl:nocaps"};
+ auto physical=xkb_keymap_new_from_names(context,&names,XKB_KEYMAP_COMPILE_NO_FLAGS);
+ auto canonical=agent_keymap(context);
+ auto keyboard=std::make_shared<Keyboard>(); keyboard->m_xkbKeymap=physical;
+ manager.m_keyboard=keyboard; input.m_keyboards={keyboard};
+ auto root=std::make_shared<Root>();
+ manager.m_state.keyboardFocus=root; manager.m_state.pointerFocus=root; Desktop::focus.root=root;
+ manager.seat=std::make_shared<Seat>(); auto resource=std::make_shared<Resource>();
+ resource->client=xkb_state_new(physical); manager.seat->m_keyboards={resource};
+ Client client; client.surface=root; client.window=root;
+ Lane lane; lane.physical_keymap=physical; lane.keymap=canonical;
+ lane.physical_state=xkb_state_new(physical); lane.lease=&client;
+ const auto num=foreground_numlock_mask(physical);
+ keyboard->m_modifiersState.locked=num;
+ lane.preflight(client,30,1);
+ check(lane.foreground_started && lane.foreground_modifiers[2]==num && resource->last[2]==num,
+ "actual Num Lock state was not captured and published before input");
+ lane.foreground_key(client,42,true); lane.foreground_key(client,30,true);
+ lane.foreground_key(client,30,false); lane.foreground_key(client,42,false);
+ check(resource->symbols.back()==XKB_KEY_A,"client did not receive intended shifted text");
+ for (const auto& state : resource->history)
+ check(state[2]==num,"dispatch transiently cleared Num Lock");
+ lane.finish_foreground();
+ check(resource->last==std::array<unsigned,4>{0,0,num,0} && keyboard->m_modifiersState.locked==num,
+ "completion changed real or client Num Lock state");
+ const auto consumed=lane.consumed;
+ lane.preflight(client,79,0);
+ check(lane.refused==1 && lane.consumed==consumed && !lane.foreground_started,
+ "caller checked neutral state and admitted Num Lock keypad semantics");
+ keyboard->m_modifiersState.locked=0;
+ lane.preflight(client,79,0); lane.foreground_key(client,79,true); lane.foreground_key(client,79,false);
+ check(resource->symbols.back()==XKB_KEY_KP_End,"neutral keypad contract changed"); lane.finish_foreground();
+ keyboard->m_modifiersState.locked=num; lane.preflight(client,30,1); lane.foreground_key(client,42,true);
+ keyboard->m_modifiersState.locked=0;
+ try { lane.foreground_key(client,30,true); check(false,"ambient state change accepted"); }
+ catch (ForegroundFailure f) { check(f.reason==ForegroundFailureReason::keyboard_state,"wrong state change refusal"); }
+ lane.finish_foreground(); check(resource->last==std::array<unsigned,4>{},"cancellation restored stale Num Lock");
+ keyboard->m_modifiersState.locked=num; lane.preflight(client,30,1); lane.foreground_key(client,42,true);
+ keyboard->m_modifiersState.locked=xkb_keymap_mod_get_mask(physical,XKB_MOD_NAME_CAPS);
+ keyboard->m_modifiersState.depressed=xkb_keymap_mod_get_mask(physical,XKB_MOD_NAME_CTRL);
+ lane.finish_foreground();
+ check(resource->last[0]==keyboard->m_modifiersState.depressed && resource->last[2]==keyboard->m_modifiersState.locked,
+ "cancellation failed to restore current human Caps and held Control");
+ keyboard->m_modifiersState.depressed=0; keyboard->m_modifiersState.locked=num;
+ lane.physical_held=true;
+ try { lane.preflight(client,30,0); check(false,"held physical key admitted"); }
+ catch (ForegroundFailure f) { check(f.reason==ForegroundFailureReason::physical_keys,"wrong held-key refusal"); }
+ lane.physical_held=false;
+ auto shared=std::make_shared<Keyboard>(); shared->m_xkbKeymap=physical;
+ shared->m_modifiersState.locked=num;
+ keyboard->m_modifiersState.locked=0;
+ input.m_keyboards.push_back(shared);
+ lane.preflight(client,30,0);
+ check(lane.foreground_modifiers[2]==num && resource->last[2]==num,"shared Num Lock was ignored");
+ lane.foreground_key(client,30,true); lane.foreground_key(client,30,false); lane.finish_foreground();
+ check(resource->last[2]==num,"shared Num Lock not restored");
+ shared->m_modifiersState.locked=xkb_keymap_mod_get_mask(physical,XKB_MOD_NAME_CAPS);
+ try { lane.preflight(client,30,0); check(false,"shared Caps state admitted"); }
+ catch (ForegroundFailure f) { check(f.reason==ForegroundFailureReason::keyboard_locked,"wrong shared lock refusal"); }
+ shared->shared=false; lane.preflight(client,30,0); lane.finish_foreground();
+ // Focus callbacks can change real state between preflight and first event.
+ keyboard->m_modifiersState.locked=num;
+ Desktop::focus.root.reset(); Desktop::focus.change_on_focus=keyboard;
+ try { lane.preflight(client,30,0); check(false,"focus-time state change accepted"); }
+ catch (ForegroundFailure f) { check(f.reason==ForegroundFailureReason::keyboard_state,"wrong focus change refusal"); }
+ lane.finish_foreground();
+ check(resource->last[2]==0,"focus-time cancellation restored stale lock");
+ xkb_state_unref(resource->client); xkb_keymap_unref(physical); xkb_keymap_unref(canonical); xkb_context_unref(context);
+}
+'''.replace('// METHODS', '\n'.join(methods)).replace('// PREFLIGHT', preflight.group())
+ compiler = shlex.split(os.environ.get('CXX', 'c++'))
+ flags = shlex.split(subprocess.check_output(['pkg-config', '--cflags', '--libs', 'xkbcommon'], text=True))
+ with tempfile.TemporaryDirectory(prefix='cua-foreground-modifiers-') as directory:
+ cpp, binary = Path(directory) / 'fixture.cpp', Path(directory) / 'fixture'
+ cpp.write_text(fixture)
+ build = subprocess.run([*compiler, '-std=c++20', '-Wall', '-Wextra', '-Wpedantic', '-Werror',
+ '-I', str(ROOT / 'src'), str(cpp), '-o', str(binary), *flags],
+ capture_output=True, text=True, timeout=60)
+ self.assertEqual(build.returncode, 0, build.stdout + build.stderr)
+ result = subprocess.run([str(binary)], capture_output=True, text=True, timeout=10)
+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/tests/keyboard_layout_test.cpp b/tests/keyboard_layout_test.cpp
new file mode 100644
index 0000000..dd7c962
index 0000000..6d57616
--- /dev/null
+++ b/tests/keyboard_layout_test.cpp
@@ -0,0 +1,97 @@
@@ -0,0 +1,130 @@
+#include "keyboard_layout.hpp"
+#include <cstdlib>
+#include <iostream>
@@ -617,24 +991,53 @@ index 0000000..dd7c962
+ auto german = map("de", "");
+ check(stock && nocaps && swapctrl && swapalt && german, "test keymaps unavailable");
+ for (auto* physical : {stock.get(), nocaps.get(), swapctrl.get(), swapalt.get(), german.get()}) {
+ check(foreground_chord_compatible(physical, agent.get(), 30, 0), "unrelated remap blocked A");
+ check(foreground_chord_compatible(physical, agent.get(), 30, 1), "unrelated remap blocked Shift+A");
+ check(foreground_chord_compatible(physical, agent.get(), 28, 0), "unrelated remap blocked Return");
+ check(foreground_chord_compatible(physical, agent.get(), 30, 0, {}), "unrelated remap blocked A");
+ check(foreground_chord_compatible(physical, agent.get(), 30, 1, {}), "unrelated remap blocked Shift+A");
+ check(foreground_chord_compatible(physical, agent.get(), 28, 0, {}), "unrelated remap blocked Return");
+ }
+ check(foreground_chord_compatible(stock.get(), agent.get(), 30, 2), "stock Omarchy blocked Ctrl+A");
+ check(foreground_chord_compatible(nocaps.get(), agent.get(), 30, 2), "Caps to Ctrl blocked Ctrl+A");
+ check(!foreground_chord_compatible(swapctrl.get(), agent.get(), 30, 2), "Ctrl/Caps swap sent wrong Ctrl+A");
+ check(!foreground_chord_compatible(swapctrl.get(), agent.get(), 29, 0), "remapped modifier key accepted");
+ check(!foreground_chord_compatible(swapalt.get(), agent.get(), 30, 4), "Alt/Super swap sent wrong Alt+A");
+ check(!foreground_chord_compatible(swapalt.get(), agent.get(), 30, 8), "Alt/Super swap sent wrong Super+A");
+ check(!foreground_chord_compatible(german.get(), agent.get(), 21, 0), "German Z accepted as US Y");
+ check(!foreground_chord_compatible(german.get(), agent.get(), 3, 1), "German shifted punctuation accepted");
+ check(!foreground_chord_compatible(nullptr, agent.get(), 30, 0), "missing physical map accepted");
+ check(foreground_chord_compatible(stock.get(), agent.get(), 30, 2, {}), "stock Omarchy blocked Ctrl+A");
+ check(foreground_chord_compatible(nocaps.get(), agent.get(), 30, 2, {}), "Caps to Ctrl blocked Ctrl+A");
+ check(!foreground_chord_compatible(swapctrl.get(), agent.get(), 30, 2, {}), "Ctrl/Caps swap sent wrong Ctrl+A");
+ check(!foreground_chord_compatible(swapctrl.get(), agent.get(), 29, 0, {}), "remapped modifier key accepted");
+ check(!foreground_chord_compatible(swapalt.get(), agent.get(), 30, 4, {}), "Alt/Super swap sent wrong Alt+A");
+ check(!foreground_chord_compatible(swapalt.get(), agent.get(), 30, 8, {}), "Alt/Super swap sent wrong Super+A");
+ check(!foreground_chord_compatible(german.get(), agent.get(), 21, 0, {}), "German Z accepted as US Y");
+ check(!foreground_chord_compatible(german.get(), agent.get(), 3, 1, {}), "German shifted punctuation accepted");
+ check(!foreground_chord_compatible(nullptr, agent.get(), 30, 0, {}), "missing physical map accepted");
+ for (auto* physical : {agent.get(), stock.get(), nocaps.get()}) {
+ const auto numlock = foreground_numlock_mask(physical);
+ check(numlock != 0, "Num Lock encoding missing");
+ for (const auto locked : {0u, numlock}) {
+ const std::array<std::uint32_t, 4> ambient{0, 0, locked, 0};
+ for (const auto key : {30u, 48u, 44u, 2u, 11u, 28u, 57u})
+ for (const auto mods : {0u, 1u, 2u, 3u})
+ check(foreground_chord_compatible(physical, agent.get(), key, mods, ambient),
+ "Num Lock blocked ordinary text or shortcut");
+ for (const auto key : {71u, 72u, 75u, 79u, 82u, 83u})
+ check(foreground_chord_compatible(physical, agent.get(), key, 0, ambient) == !locked,
+ "Num Lock keypad semantic change was ignored");
+ }
+ const auto caps = xkb_keymap_mod_get_mask(physical, XKB_MOD_NAME_CAPS);
+ for (const auto locked : {caps, caps | numlock, 0x80000000u})
+ check(!foreground_chord_compatible(physical, agent.get(), 30, 0, {0, 0, locked, 0}),
+ "unsupported lock accepted");
+ check(!foreground_chord_compatible(physical, agent.get(), 30, 0, {numlock, 0, numlock, 0}),
+ "held modifier accepted with Num Lock");
+ check(!foreground_chord_compatible(physical, agent.get(), 30, 0, {0, numlock, numlock, 0}),
+ "latched modifier accepted with Num Lock");
+ check(!foreground_chord_compatible(physical, agent.get(), 30, 0, {0, 0, numlock, 1}),
+ "nonzero group accepted with Num Lock");
+ }
+ const auto numlock = foreground_numlock_mask(agent.get());
+ check(!foreground_chord_compatible(swapctrl.get(), agent.get(), 30, 2, {0, 0, numlock, 0}),
+ "Num Lock hid Ctrl remap");
+ check(!foreground_chord_compatible(german.get(), agent.get(), 21, 0, {0, 0, numlock, 0}),
+ "Num Lock hid layout mismatch");
+ // All supported wire chords remain compatible on the independent map.
+ for (unsigned code = 1; code <= 247; ++code)
+ if (code != 58 && code != 69 && code != 70)
+ for (unsigned mods = 0; mods < 16; ++mods)
+ check(foreground_chord_compatible(agent.get(), agent.get(), code, mods), "canonical chord rejected");
+ check(foreground_chord_compatible(agent.get(), agent.get(), code, mods, {}), "canonical chord rejected");
+ // Two lanes never share modifier state and never mutate the human state.
+ State first{xkb_state_new(agent.get()), xkb_state_unref};
+ State other{xkb_state_new(second.get()), xkb_state_unref};
@@ -654,7 +1057,9 @@ index 0000000..dd7c962
+ "key <AC01> { type=\"ALPHABETIC\", symbols[Group1]=[a,A], actions[Group1]=[LockMods(modifiers=Mod3),LockMods(modifiers=Mod3)] };");
+ Map lock{xkb_keymap_new_from_string(context.get(), changed.c_str(), XKB_KEYMAP_FORMAT_TEXT_V1, XKB_KEYMAP_COMPILE_NO_FLAGS), xkb_keymap_unref};
+ check(bool(lock), "action fixture failed to compile");
+ check(!foreground_chord_compatible(lock.get(), agent.get(), 30, 0), "hidden lock action accepted");
+ check(!foreground_chord_compatible(lock.get(), agent.get(), 30, 0, {}), "hidden lock action accepted");
+ check(!foreground_chord_compatible(lock.get(), agent.get(), 30, 0, {0, 0, numlock, 0}),
+ "Num Lock hid an unexpected lock action");
+ // Equal symbols and modifier state can still alter toolkit shortcut matching.
+ serialized = xkb_keymap_get_as_string(agent.get(), XKB_KEYMAP_FORMAT_TEXT_V1);
+ changed = serialized;
@@ -665,7 +1070,9 @@ index 0000000..dd7c962
+ changed.insert(type_end, "preserve[Shift] = Shift;\n");
+ Map preserved{xkb_keymap_new_from_string(context.get(), changed.c_str(), XKB_KEYMAP_FORMAT_TEXT_V1, XKB_KEYMAP_COMPILE_NO_FLAGS), xkb_keymap_unref};
+ check(bool(preserved), "preserved modifier fixture failed to compile");
+ check(!foreground_chord_compatible(preserved.get(), agent.get(), 30, 1), "consumed modifier mismatch accepted");
+ check(!foreground_chord_compatible(preserved.get(), agent.get(), 30, 1, {}), "consumed modifier mismatch accepted");
+ check(!foreground_chord_compatible(preserved.get(), agent.get(), 30, 1, {0, 0, numlock, 0}),
+ "Num Lock hid changed shortcut consumption");
+ // A modifier that preserves Shift state but emits another symbol must fail.
+ serialized = xkb_keymap_get_as_string(agent.get(), XKB_KEYMAP_FORMAT_TEXT_V1);
+ changed = serialized;
@@ -677,7 +1084,7 @@ index 0000000..dd7c962
+ "key <LFSH> { symbols[Group1]=[Delete], actions[Group1]=[SetMods(modifiers=Shift)] };");
+ Map badshift{xkb_keymap_new_from_string(context.get(), changed.c_str(), XKB_KEYMAP_FORMAT_TEXT_V1, XKB_KEYMAP_COMPILE_NO_FLAGS), xkb_keymap_unref};
+ check(bool(badshift), "shift fixture failed to compile");
+ check(!foreground_chord_compatible(badshift.get(), agent.get(), 30, 1), "modifier press symbols not checked");
+ check(!foreground_chord_compatible(badshift.get(), agent.get(), 30, 1, {}), "modifier press symbols not checked");
+ for (auto route : {InputRoute::independent, InputRoute::primary_foreground}) {
+ for (uint64_t cap : {1, 4, 8, 16}) check(keyboard_layout_ready(route, cap, false, false), "pointer gated on layout");
+ check(!keyboard_layout_ready(route, 2, false, false), "key accepted without map");
@@ -687,7 +1094,7 @@ index 0000000..dd7c962
+ std::cout << "keyboard layout tests passed\n";
+}
diff --git a/tests/plugin_input_lifetime_test.cpp b/tests/plugin_input_lifetime_test.cpp
index ff09451..b999925 100644
index ff09451..456da44 100644
--- a/tests/plugin_input_lifetime_test.cpp
+++ b/tests/plugin_input_lifetime_test.cpp
@@ -51,12 +51,17 @@ int main() {
@@ -703,17 +1110,18 @@ index ff09451..b999925 100644
};
+#ifdef CUA_HYPRLAND_INPUT
+ const auto initial_status = HyprlandAPI::registered_legacy_command->fn(FORMAT_JSON, {});
+ check(created == 0 && initial_status.find("\"configured\":false") != std::string::npos && initial_status.find("\"keyboard_layout_independent\":true") != std::string::npos,
+ check(created == 0 && initial_status.find("\"configured\":false") != std::string::npos && initial_status.find("\"keyboard_layout_independent\":true") != std::string::npos && initial_status.find("\"foreground_numlock_compatible\":true") != std::string::npos,
+ "disabled production module advertises compiled keyboard support before enable");
+#endif
for (unsigned i = 0; i < 20; ++i) {
toggle(true);
#ifdef CUA_HYPRLAND_INPUT
@@ -64,6 +69,7 @@ int main() {
@@ -64,6 +69,8 @@ int main() {
check(status.find("\"state\":\"input_v3_candidate\"") != std::string::npos &&
status.find("trusted_local_per_action") != std::string::npos &&
status.find("\"input\":{}") != std::string::npos &&
+ status.find("\"keyboard_layout_independent\":true") != std::string::npos &&
+ status.find("\"foreground_numlock_compatible\":true") != std::string::npos &&
status.find("operator") == std::string::npos,
"v3 status advertises its actual admission mode");
#endif