From 1c8ec3ce1049f5f6c00186ec0eb768d27edfd9d7 Mon Sep 17 00:00:00 2001 From: Chase Cichorz Date: Fri, 6 Nov 2026 14:49:01 -0600 Subject: [PATCH] Inject Remote lock keys instead of retiring keyboard input Refusing Caps, Num and Scroll Lock retired all remote input, so a controller using Caps Lock as its screen-reader modifier lost keyboard control on the first press. Lock keys are now owned like other keys and released once at retirement without another toggle. Co-Authored-By: Claude Opus 5.5 --- docs/testing/remote-key-target.md | 14 +++++++++- internal/input/remote_key_ledger.go | 9 +++---- internal/input/remote_key_ledger_test.go | 17 +++++++----- .../platform/xorg/remote_keys_native_test.go | 26 +++++++++++++++++-- 4 files changed, 52 insertions(+), 14 deletions(-) diff --git a/docs/testing/remote-key-target.md b/docs/testing/remote-key-target.md index e61692c..5328dbd 100644 --- a/docs/testing/remote-key-target.md +++ b/docs/testing/remote-key-target.md @@ -62,7 +62,7 @@ fixture changes maps and lock state explicitly to exercise these cases. See the [XKB library specification](https://xorg.freedesktop.org/archive/current/doc/libX11/XKB/xkblib.html) for `XkbGetMap`, `XkbGetState` and `XkbTranslateKeyCode`. -Lock-key downs are unavailable until lock synchronization and reader modifier +(Superseded: see Lock keys below.) Lock-key downs were unavailable until lock synchronization and reader modifier handling have a defined policy. This includes Caps Lock even when a reader configuration would otherwise use it as a modifier. AltGr, IME/dead keys, general group/level handling and exact remote autorepeat remain unfinished. @@ -114,3 +114,15 @@ external Remote race suite, affected-package vet, a separate reader build, the existing speech regression harness and Tentacles `make check` passed. No full reader suite, installed-binary behavior, audible speech, physical input/Braille or live NVDA/Orca acceptance was performed. + +## Lock keys (2026-11-06) + +Caps Lock, Num Lock and Scroll Lock are now owned like other keys instead of +being refused. Refusal retired all remote input, so a controller using Caps +Lock as its NVDA or Cthulhu modifier lost keyboard control on the first press. +A remote press and release toggles the lock here, as the same physical key +would. Retirement releases a held lock key once and never re-toggles a lock; +when Caps Lock is the controlled reader's Cthulhu modifier, the command owner +keeps its local lock state. A private Xvfb test checks that each lock key is +owned and released at retirement. Lock-state synchronization between computers +is not attempted. diff --git a/internal/input/remote_key_ledger.go b/internal/input/remote_key_ledger.go index dd8c1a1..24f1d9d 100644 --- a/internal/input/remote_key_ledger.go +++ b/internal/input/remote_key_ledger.go @@ -80,11 +80,10 @@ func (ledger *RemoteKeyLedger) Apply(name string, pressed bool) error { if !pressed { return nil // An unowned up must not release a physical/local key. } - if name == "Caps_Lock" || name == "Num_Lock" || name == "Scroll_Lock" { - // Lock synchronization and reader-modifier handling need their own - // native policy. The quarantined backend must not toggle local locks. - return ledger.fail(ErrRemoteKeyUnavailable) - } + // Lock keys are owned like any other key: a remote press and release + // toggles the lock here, as the same physical key would. The ledger still + // releases a lock key held at retirement; it never re-toggles a lock. When + // Caps Lock is the Cthulhu modifier, the command owner keeps its lock state. if len(ledger.held) == RemoteHeldKeyLimit { return ledger.fail(ErrRemoteKeyLimit) } diff --git a/internal/input/remote_key_ledger_test.go b/internal/input/remote_key_ledger_test.go index a678a8b..da3acab 100644 --- a/internal/input/remote_key_ledger_test.go +++ b/internal/input/remote_key_ledger_test.go @@ -170,11 +170,16 @@ func TestRemoteKeyLedgerBoundsAndUnavailableKeys(t *testing.T) { if !errors.Is(absent.Apply("a", true), ErrRemoteKeyState) || !errors.Is(absent.Retire(), ErrRemoteKeyState) { t.Fatal("nil ledger accepted work") } - for _, name := range []string{"unknown private key", "Caps_Lock", "Num_Lock", "Scroll_Lock"} { - ledger, backend := ledgerFixture(t) - if err := ledger.Apply(name, true); !errors.Is(err, ErrRemoteKeyUnavailable) || len(backend.effects) != 0 || len(backend.resolves) != 0 { - t.Fatal("unsupported key or lock reached the native backend") - } + ledger, backend := ledgerFixture(t) + if err := ledger.Apply("unknown private key", true); !errors.Is(err, ErrRemoteKeyUnavailable) || len(backend.effects) != 0 || len(backend.resolves) != 0 { + t.Fatal("unsupported key reached the native backend") + } + // Lock keys are owned like other keys and released once at retirement. + ledger, backend = ledgerFixture(t) + backend.codes["Caps_Lock"] = 66 + applyLedgerKey(t, ledger, "Caps_Lock", true) + if err := ledger.Retire(); err != nil || !reflect.DeepEqual(backend.effects, []ledgerTransition{{66, true}, {66, false}}) { + t.Fatal("lock key was not owned and released once", err, backend.effects) } for _, code := range []uint16{0, 7, 256, 65535} { ledger, backend := ledgerFixture(t) @@ -183,7 +188,7 @@ func TestRemoteKeyLedgerBoundsAndUnavailableKeys(t *testing.T) { t.Fatal("invalid native code produced an effect") } } - ledger, backend := ledgerFixture(t) + ledger, backend = ledgerFixture(t) for index := range RemoteHeldKeyLimit { name := string(rune('a' + index)) if index >= 26 { diff --git a/internal/platform/xorg/remote_keys_native_test.go b/internal/platform/xorg/remote_keys_native_test.go index 6b29703..ac82274 100644 --- a/internal/platform/xorg/remote_keys_native_test.go +++ b/internal/platform/xorg/remote_keys_native_test.go @@ -200,8 +200,30 @@ func TestNativeRemoteKeyTargetLayoutChangeKeepsRelease(t *testing.T) { remoteNativeHeld(t, probe, after, false) } -func TestNativeRemoteKeyTargetRejectsUnavailableAndLocks(t *testing.T) { - for _, name := range []string{"unknown private name", "Caps_Lock", "Num_Lock", "Scroll_Lock", "semicolon"} { +func TestNativeRemoteKeyTargetLockKeysAreOwnedAndReleased(t *testing.T) { + for _, name := range []string{"Caps_Lock", "Num_Lock", "Scroll_Lock"} { + t.Run(name, func(t *testing.T) { + remoteNativeThread(t) + target, probe := remoteNativeTarget(t), remoteNativeTarget(t) + code, err := target.backend.Resolve(name) + if err != nil { + t.Skip("lock key absent from this keymap") + } + if err := target.apply(name, true); err != nil { + t.Fatal("lock key refused", err) + } + remoteNativeHeld(t, probe, code, true) + // Retirement releases the held lock key without another toggle. + if err := target.retire(); err != nil { + t.Fatal(err) + } + remoteNativeHeld(t, probe, code, false) + }) + } +} + +func TestNativeRemoteKeyTargetRejectsUnavailable(t *testing.T) { + for _, name := range []string{"unknown private name", "semicolon"} { t.Run(name, func(t *testing.T) { remoteNativeThread(t) target, probe := remoteNativeTarget(t), remoteNativeTarget(t)