Route quarantined Remote capture and balance bypass modifiers
Add RemoteCaptureRouter for normalized controller capture. Ordinary keys and Cthulhu/NVDA modifier chords are forwarded; only a required core escape chord and local chords derived from resolved bindings stay local. A local chord first releases forwarded modifiers remotely; a later forwarded key presses still-held ones again. Releases of local or withdrawn keys are consumed, and retirement reports physically held keys whose releases the native owner must swallow. No production caller. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6b3aa80138
commit
c1333c2fdb
3 files changed
+594
No files matched your search
@@ -136,6 +136,38 @@ existing internal-UI, learn-mode and injected-approval tests, passed with this
|
||||
test added. The `xset` change must follow the
|
||||
listener's connection because Xvfb resets defaults when its last client exits.
|
||||
|
||||
## Capture routing and bypass balancing (2026-11-06)
|
||||
|
||||
`internal/input/remote_capture.go` adds a quarantined `RemoteCaptureRouter`
|
||||
that consumes normalized transitions for one controller capture lifetime.
|
||||
Ordinary keys and Cthulhu/NVDA modifier chords are forwarded. Only a required
|
||||
core escape chord (a non-modifier key; proposed F11) and supplied local chords
|
||||
stay local. Callers derive local chords from `ResolveCommandBindings` filtered
|
||||
to Remote's commands, so user-assigned shortcuts are honored instead of a
|
||||
hard-coded bypass list; the escape always wins over a local binding. Chords use
|
||||
the same group-zero, level-zero keysym and modifier bits as the command owner.
|
||||
|
||||
Modifiers are forwarded when pressed because they cannot be classified yet.
|
||||
When a later key completes a local chord, every forwarded modifier is released
|
||||
remotely first (newest first) and marked withdrawn, so no prefix remains held
|
||||
on the remote. A forwarded key pressed while withdrawn modifiers are still held
|
||||
presses them again before itself, so the remote sees the physical chord.
|
||||
Repeats and releases of local and withdrawn keys are consumed. Releases for
|
||||
keys held before capture are ignored. Orphan repeats, double downs, invalid
|
||||
codes and more than 32 held keys stop routing. `Retire` returns remote releases
|
||||
for forwarded keys in reverse order plus a `Swallow` list of every physically
|
||||
held key: the local application never received those presses, so the native
|
||||
owner must consume their releases before ungrabbing.
|
||||
|
||||
Limitation: a withdrawn prefix reaches the remote as a tap of those modifiers.
|
||||
On Windows a lone Alt tap can activate an application menu bar, and NVDA treats
|
||||
modifier taps specially; this needs live testing and may need a neutral key
|
||||
between press and release. The router has no production caller, performs no
|
||||
I/O and grants no consent or provenance. Nine unit tests cover forwarding,
|
||||
withdrawal and re-press, consumed releases, escape priority, retirement,
|
||||
inconsistent input, invalid chords, resolved Tentacle bindings and composition
|
||||
with the repeat normalizer.
|
||||
|
||||
Still pending: Remote capture within the command owner; local escape and
|
||||
balanced bypass chords; remapping, learn/UI/lock/handoff arbitration; production
|
||||
helper parent-death and blocked-pipe tests; held-key collisions with actual
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package input
|
||||
|
||||
import "errors"
|
||||
|
||||
var ErrRemoteCaptureChord = errors.New("remote capture local shortcut is invalid")
|
||||
|
||||
// RemoteCaptureKey is one normalized captured transition. Symbol is the
|
||||
// group-zero, level-zero keysym the native owner resolved for a press, the
|
||||
// same form the command owner matches against bindings; releases and repeats are
|
||||
// matched by keycode, so a later layout change cannot alter ownership.
|
||||
type RemoteCaptureKey struct {
|
||||
Keycode uint8
|
||||
Symbol uint32
|
||||
Pressed bool
|
||||
Repeat bool
|
||||
}
|
||||
|
||||
// RemoteCaptureResult is the routing decision for one captured transition.
|
||||
// Remote transitions must be delivered in order before Local is dispatched.
|
||||
// Escape is set only for the core escape chord; Local names a matched
|
||||
// explicit local command. At most one of them is set.
|
||||
type RemoteCaptureResult struct {
|
||||
Remote []RemoteRepeatTransition
|
||||
Local Command
|
||||
Escape bool
|
||||
}
|
||||
|
||||
// RemoteCaptureRetirement transfers cleanup. Remote releases every key
|
||||
// forwarded and still owned, in reverse down order. Swallow lists every key
|
||||
// still physically held: the local application never received those presses,
|
||||
// so the native owner must consume their releases instead of ungrabbing first.
|
||||
type RemoteCaptureRetirement struct {
|
||||
Remote []RemoteRepeatTransition
|
||||
Swallow []uint8
|
||||
}
|
||||
|
||||
type remoteCaptureState uint8
|
||||
|
||||
const (
|
||||
remoteCaptureIdle remoteCaptureState = iota
|
||||
remoteCaptureForwarded // Down sent to the remote.
|
||||
remoteCaptureWithdrawn // Modifier released remotely after a local chord.
|
||||
remoteCaptureLocal // Press consumed by a local command.
|
||||
)
|
||||
|
||||
// RemoteCaptureRouter decides, for a controller capture lifetime, which keys
|
||||
// go to the remote and which stay local. Ordinary keys and Cthulhu/NVDA
|
||||
// modifier chords are forwarded. Only the core escape and the supplied local
|
||||
// chords stay local; callers derive those from resolved bindings so remaps
|
||||
// are honored rather than a hard-coded list.
|
||||
//
|
||||
// Modifiers cannot be classified when pressed, so they are forwarded at once.
|
||||
// When a later key completes a local chord, forwarded modifiers are released
|
||||
// remotely first, so no prefix stays held on the remote. While still held they
|
||||
// are withdrawn; a later forwarded key presses them again before itself, so the
|
||||
// remote sees the physical chord. Releases of local or withdrawn keys are
|
||||
// consumed and never reach the remote.
|
||||
//
|
||||
// Input must come from RemoteRepeatNormalizer. One serial owner drives an
|
||||
// instance; it performs no I/O and grants no consent or provenance.
|
||||
type RemoteCaptureRouter struct {
|
||||
escape CommandChord
|
||||
local CommandBindings
|
||||
cthulhu map[uint32]bool
|
||||
state [256]remoteCaptureState
|
||||
modifier [256]bool
|
||||
mask [256]uint16
|
||||
isCthulhu [256]bool
|
||||
order []uint8
|
||||
stopped bool
|
||||
retired bool
|
||||
}
|
||||
|
||||
// NewRemoteCaptureRouter requires a non-modifier escape chord. Local chords
|
||||
// equal to the escape are ignored: the escape always wins.
|
||||
func NewRemoteCaptureRouter(escape CommandChord, local CommandBindings, cthulhuModifiers []uint32) (*RemoteCaptureRouter, error) {
|
||||
router := &RemoteCaptureRouter{
|
||||
escape: escape,
|
||||
local: make(CommandBindings, len(local)),
|
||||
cthulhu: make(map[uint32]bool, len(cthulhuModifiers)),
|
||||
order: make([]uint8, 0, RemoteHeldKeyLimit),
|
||||
}
|
||||
for _, symbol := range cthulhuModifiers {
|
||||
router.cthulhu[symbol] = true
|
||||
}
|
||||
if escape.Symbol == 0 || router.isModifierSymbol(escape.Symbol) {
|
||||
return nil, ErrRemoteCaptureChord
|
||||
}
|
||||
for chord, command := range local {
|
||||
if chord == escape || command == "" {
|
||||
continue
|
||||
}
|
||||
if chord.Symbol == 0 || router.isModifierSymbol(chord.Symbol) {
|
||||
return nil, ErrRemoteCaptureChord
|
||||
}
|
||||
router.local[chord] = command
|
||||
}
|
||||
return router, nil
|
||||
}
|
||||
|
||||
func remoteModifierMask(symbol uint32) (uint16, bool) {
|
||||
switch symbol {
|
||||
case leftShiftKeySymbol, rightShiftKeySymbol:
|
||||
return 1, true
|
||||
case leftControlKeySymbol, rightControlKeySymbol:
|
||||
return controlModifier, true
|
||||
case 0xffe7, 0xffe8, 0xffe9, 0xffea: // Meta, Alt.
|
||||
return 8, true
|
||||
case 0xffeb, 0xffec: // Super.
|
||||
return 64, true
|
||||
case 0xffed, 0xffee: // Hyper: a modifier with no chord bit.
|
||||
return 0, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func (router *RemoteCaptureRouter) isModifierSymbol(symbol uint32) bool {
|
||||
_, modifier := remoteModifierMask(symbol)
|
||||
return modifier || router.cthulhu[symbol]
|
||||
}
|
||||
|
||||
func (router *RemoteCaptureRouter) fail(err error) (RemoteCaptureResult, error) {
|
||||
router.stopped = true
|
||||
return RemoteCaptureResult{}, err
|
||||
}
|
||||
|
||||
// Route handles one transition. An error stops routing permanently while
|
||||
// retaining ownership for Retire.
|
||||
func (router *RemoteCaptureRouter) Route(key RemoteCaptureKey) (RemoteCaptureResult, error) {
|
||||
if router == nil || router.stopped {
|
||||
return RemoteCaptureResult{}, ErrRemoteKeyState
|
||||
}
|
||||
code := key.Keycode
|
||||
if code < remoteRepeatMinKeycode {
|
||||
return router.fail(ErrRemoteKeyUnavailable)
|
||||
}
|
||||
state := router.state[code]
|
||||
if !key.Pressed {
|
||||
if state == remoteCaptureIdle {
|
||||
return RemoteCaptureResult{}, nil
|
||||
}
|
||||
router.forget(code)
|
||||
if state == remoteCaptureForwarded {
|
||||
return RemoteCaptureResult{Remote: []RemoteRepeatTransition{{Keycode: code}}}, nil
|
||||
}
|
||||
return RemoteCaptureResult{}, nil
|
||||
}
|
||||
if key.Repeat {
|
||||
switch state {
|
||||
case remoteCaptureIdle:
|
||||
return router.fail(ErrRemoteKeyCollision)
|
||||
case remoteCaptureForwarded:
|
||||
return RemoteCaptureResult{Remote: []RemoteRepeatTransition{{Keycode: code, Pressed: true, Repeat: true}}}, nil
|
||||
}
|
||||
return RemoteCaptureResult{}, nil
|
||||
}
|
||||
if state != remoteCaptureIdle {
|
||||
return router.fail(ErrRemoteKeyCollision)
|
||||
}
|
||||
if len(router.order) >= RemoteHeldKeyLimit {
|
||||
return router.fail(ErrRemoteKeyLimit)
|
||||
}
|
||||
if mask, modifier := remoteModifierMask(key.Symbol); modifier || router.cthulhu[key.Symbol] {
|
||||
router.hold(code, remoteCaptureForwarded)
|
||||
router.modifier[code] = true
|
||||
router.mask[code] = mask
|
||||
router.isCthulhu[code] = router.cthulhu[key.Symbol]
|
||||
return RemoteCaptureResult{Remote: []RemoteRepeatTransition{{Keycode: code, Pressed: true}}}, nil
|
||||
}
|
||||
chord := router.chord(key.Symbol)
|
||||
escape := chord == router.escape
|
||||
command := Command("")
|
||||
if !escape {
|
||||
command = router.local[chord]
|
||||
}
|
||||
if escape || command != "" {
|
||||
var result RemoteCaptureResult
|
||||
for index := len(router.order) - 1; index >= 0; index-- {
|
||||
held := router.order[index]
|
||||
if router.modifier[held] && router.state[held] == remoteCaptureForwarded {
|
||||
router.state[held] = remoteCaptureWithdrawn
|
||||
result.Remote = append(result.Remote, RemoteRepeatTransition{Keycode: held})
|
||||
}
|
||||
}
|
||||
router.hold(code, remoteCaptureLocal)
|
||||
result.Local, result.Escape = command, escape
|
||||
return result, nil
|
||||
}
|
||||
var result RemoteCaptureResult
|
||||
for _, held := range router.order {
|
||||
if router.state[held] == remoteCaptureWithdrawn {
|
||||
router.state[held] = remoteCaptureForwarded
|
||||
result.Remote = append(result.Remote, RemoteRepeatTransition{Keycode: held, Pressed: true})
|
||||
}
|
||||
}
|
||||
router.hold(code, remoteCaptureForwarded)
|
||||
result.Remote = append(result.Remote, RemoteRepeatTransition{Keycode: code, Pressed: true})
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Retire stops routing and transfers cleanup once.
|
||||
func (router *RemoteCaptureRouter) Retire() RemoteCaptureRetirement {
|
||||
if router == nil || router.retired {
|
||||
return RemoteCaptureRetirement{}
|
||||
}
|
||||
router.stopped = true
|
||||
router.retired = true
|
||||
var out RemoteCaptureRetirement
|
||||
for index := len(router.order) - 1; index >= 0; index-- {
|
||||
code := router.order[index]
|
||||
if router.state[code] == remoteCaptureForwarded {
|
||||
out.Remote = append(out.Remote, RemoteRepeatTransition{Keycode: code})
|
||||
}
|
||||
out.Swallow = append(out.Swallow, code)
|
||||
}
|
||||
router.state = [256]remoteCaptureState{}
|
||||
router.modifier = [256]bool{}
|
||||
router.order = router.order[:0]
|
||||
return out
|
||||
}
|
||||
|
||||
func (router *RemoteCaptureRouter) chord(symbol uint32) CommandChord {
|
||||
chord := CommandChord{Symbol: symbol}
|
||||
for _, code := range router.order {
|
||||
if !router.modifier[code] {
|
||||
continue
|
||||
}
|
||||
chord.Modifiers |= router.mask[code]
|
||||
chord.CthulhuModifier = chord.CthulhuModifier || router.isCthulhu[code]
|
||||
}
|
||||
return chord
|
||||
}
|
||||
|
||||
func (router *RemoteCaptureRouter) hold(code uint8, state remoteCaptureState) {
|
||||
router.state[code] = state
|
||||
router.order = append(router.order, code)
|
||||
}
|
||||
|
||||
func (router *RemoteCaptureRouter) forget(code uint8) {
|
||||
router.state[code] = remoteCaptureIdle
|
||||
router.modifier[code] = false
|
||||
router.mask[code] = 0
|
||||
router.isCthulhu[code] = false
|
||||
for index, held := range router.order {
|
||||
if held == code {
|
||||
copy(router.order[index:], router.order[index+1:])
|
||||
router.order = router.order[:len(router.order)-1]
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package input
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
testInsertSymbol uint32 = 0xff63
|
||||
testF11Symbol uint32 = 0xffc8
|
||||
codeShift uint8 = 50
|
||||
codeControl uint8 = 37
|
||||
codeAlt uint8 = 64
|
||||
codeInsert uint8 = 118
|
||||
codeA uint8 = 38
|
||||
codeR uint8 = 27
|
||||
codeF11 uint8 = 95
|
||||
)
|
||||
|
||||
var testEscape = CommandChord{Symbol: testF11Symbol}
|
||||
|
||||
func newTestCaptureRouter(t *testing.T, local CommandBindings) *RemoteCaptureRouter {
|
||||
t.Helper()
|
||||
router, err := NewRemoteCaptureRouter(testEscape, local, []uint32{testInsertSymbol})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return router
|
||||
}
|
||||
|
||||
type captureStep struct {
|
||||
key RemoteCaptureKey
|
||||
want RemoteCaptureResult
|
||||
}
|
||||
|
||||
func press(code uint8, symbol uint32) RemoteCaptureKey {
|
||||
return RemoteCaptureKey{Keycode: code, Symbol: symbol, Pressed: true}
|
||||
}
|
||||
func hold(code uint8, symbol uint32) RemoteCaptureKey {
|
||||
return RemoteCaptureKey{Keycode: code, Symbol: symbol, Pressed: true, Repeat: true}
|
||||
}
|
||||
func release(code uint8) RemoteCaptureKey { return RemoteCaptureKey{Keycode: code} }
|
||||
func remote(transitions ...RemoteRepeatTransition) RemoteCaptureResult {
|
||||
return RemoteCaptureResult{Remote: transitions}
|
||||
}
|
||||
|
||||
func runCaptureSteps(t *testing.T, router *RemoteCaptureRouter, steps []captureStep) {
|
||||
t.Helper()
|
||||
for index, step := range steps {
|
||||
got, err := router.Route(step.key)
|
||||
if err != nil {
|
||||
t.Fatalf("step %d: %v", index, err)
|
||||
}
|
||||
if len(got.Remote) == 0 {
|
||||
got.Remote = nil
|
||||
}
|
||||
if len(step.want.Remote) == 0 {
|
||||
step.want.Remote = nil
|
||||
}
|
||||
if !reflect.DeepEqual(got, step.want) {
|
||||
t.Fatalf("step %d: result = %+v, want %+v", index, got, step.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBehaviorRemoteCaptureForwardsOrdinaryAndReaderChords(t *testing.T) {
|
||||
router := newTestCaptureRouter(t, nil)
|
||||
runCaptureSteps(t, router, []captureStep{
|
||||
{press(codeShift, leftShiftKeySymbol), remote(down(codeShift))},
|
||||
{press(codeA, 'a'), remote(down(codeA))},
|
||||
{hold(codeA, 'a'), remote(repeat(codeA))},
|
||||
{release(codeA), remote(up(codeA))},
|
||||
{release(codeShift), remote(up(codeShift))},
|
||||
// A Cthulhu/NVDA modifier chord goes to the remote screen reader.
|
||||
{press(codeInsert, testInsertSymbol), remote(down(codeInsert))},
|
||||
{press(codeA, 'a'), remote(down(codeA))},
|
||||
{release(codeA), remote(up(codeA))},
|
||||
{release(codeInsert), remote(up(codeInsert))},
|
||||
// Releases for keys held before capture are not forwarded.
|
||||
{release(codeR), RemoteCaptureResult{}},
|
||||
})
|
||||
if got := router.Retire(); len(got.Remote) != 0 || len(got.Swallow) != 0 {
|
||||
t.Fatalf("retire = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBehaviorRemoteCaptureWithdrawsPrefixForLocalChord(t *testing.T) {
|
||||
local := CommandBindings{{Symbol: 'r', Modifiers: controlModifier, CthulhuModifier: true}: "tentacle/remote/toggle-remote-focus"}
|
||||
router := newTestCaptureRouter(t, local)
|
||||
runCaptureSteps(t, router, []captureStep{
|
||||
{press(codeInsert, testInsertSymbol), remote(down(codeInsert))},
|
||||
{press(codeControl, leftControlKeySymbol), remote(down(codeControl))},
|
||||
{hold(codeControl, leftControlKeySymbol), remote(repeat(codeControl))},
|
||||
// The local chord releases the forwarded prefix remotely, newest first.
|
||||
{press(codeR, 'r'), RemoteCaptureResult{Remote: []RemoteRepeatTransition{up(codeControl), up(codeInsert)}, Local: "tentacle/remote/toggle-remote-focus"}},
|
||||
{hold(codeR, 'r'), RemoteCaptureResult{}},
|
||||
{hold(codeControl, leftControlKeySymbol), RemoteCaptureResult{}},
|
||||
{release(codeR), RemoteCaptureResult{}},
|
||||
// A forwarded key while the prefix is still held presses it again.
|
||||
{press(codeA, 'a'), remote(down(codeInsert), down(codeControl), down(codeA))},
|
||||
{release(codeA), remote(up(codeA))},
|
||||
{release(codeControl), remote(up(codeControl))},
|
||||
{release(codeInsert), remote(up(codeInsert))},
|
||||
})
|
||||
}
|
||||
|
||||
func TestBehaviorRemoteCaptureWithdrawnReleaseIsConsumed(t *testing.T) {
|
||||
local := CommandBindings{{Symbol: 'r', Modifiers: controlModifier}: "tentacle/remote/disconnect"}
|
||||
router := newTestCaptureRouter(t, local)
|
||||
runCaptureSteps(t, router, []captureStep{
|
||||
{press(codeControl, rightControlKeySymbol), remote(down(codeControl))},
|
||||
{press(codeR, 'r'), RemoteCaptureResult{Remote: []RemoteRepeatTransition{up(codeControl)}, Local: "tentacle/remote/disconnect"}},
|
||||
{release(codeControl), RemoteCaptureResult{}}, // Already released remotely.
|
||||
{release(codeR), RemoteCaptureResult{}},
|
||||
{press(codeR, 'r'), remote(down(codeR))}, // Without Control it is ordinary.
|
||||
})
|
||||
}
|
||||
|
||||
func TestBehaviorRemoteCaptureEscapeWinsAndRetireSwallows(t *testing.T) {
|
||||
// A local binding on the escape chord cannot replace the escape.
|
||||
local := CommandBindings{testEscape: "tentacle/remote/other"}
|
||||
router := newTestCaptureRouter(t, local)
|
||||
runCaptureSteps(t, router, []captureStep{
|
||||
{press(codeShift, leftShiftKeySymbol), remote(down(codeShift))},
|
||||
{press(codeA, 'a'), remote(down(codeA))},
|
||||
{press(codeAlt, 0xffe9), remote(down(codeAlt))},
|
||||
// Shift+Alt+F11 is not the bare escape chord: it is forwarded.
|
||||
{press(codeF11, testF11Symbol), remote(down(codeF11))},
|
||||
{release(codeF11), remote(up(codeF11))},
|
||||
{release(codeAlt), remote(up(codeAlt))},
|
||||
{release(codeShift), remote(up(codeShift))},
|
||||
{press(codeF11, testF11Symbol), RemoteCaptureResult{Escape: true}},
|
||||
})
|
||||
got := router.Retire()
|
||||
want := RemoteCaptureRetirement{Remote: []RemoteRepeatTransition{up(codeA)}, Swallow: []uint8{codeF11, codeA}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("retire = %+v, want %+v", got, want)
|
||||
}
|
||||
if again := router.Retire(); again.Remote != nil || again.Swallow != nil {
|
||||
t.Fatalf("second retire = %+v", again)
|
||||
}
|
||||
if _, err := router.Route(release(codeA)); !errors.Is(err, ErrRemoteKeyState) {
|
||||
t.Fatalf("route after retire = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBehaviorRemoteCaptureRetireReleasesForwardedPrefixes(t *testing.T) {
|
||||
local := CommandBindings{{Symbol: 'r', Modifiers: controlModifier}: "tentacle/remote/menu"}
|
||||
router := newTestCaptureRouter(t, local)
|
||||
runCaptureSteps(t, router, []captureStep{
|
||||
{press(codeShift, leftShiftKeySymbol), remote(down(codeShift))},
|
||||
{press(codeControl, leftControlKeySymbol), remote(down(codeControl))},
|
||||
{press(codeR, 'r'), remote(down(codeR))}, // Shift+Control+r is not bound.
|
||||
})
|
||||
got := router.Retire()
|
||||
want := RemoteCaptureRetirement{
|
||||
Remote: []RemoteRepeatTransition{up(codeR), up(codeControl), up(codeShift)},
|
||||
Swallow: []uint8{codeR, codeControl, codeShift},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("retire = %+v, want %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBehaviorRemoteCaptureStopsOnInconsistentInput(t *testing.T) {
|
||||
for name, keys := range map[string][]RemoteCaptureKey{
|
||||
"orphan repeat": {hold(codeA, 'a')},
|
||||
"double down": {press(codeA, 'a'), press(codeA, 'a')},
|
||||
"invalid code": {press(codeA, 'a'), press(7, 'b')},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
router := newTestCaptureRouter(t, nil)
|
||||
var err error
|
||||
for _, key := range keys {
|
||||
if _, err = router.Route(key); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatal("inconsistent input accepted")
|
||||
}
|
||||
if _, err := router.Route(release(codeA)); !errors.Is(err, ErrRemoteKeyState) {
|
||||
t.Fatalf("stopped router = %v", err)
|
||||
}
|
||||
got := router.Retire()
|
||||
if len(keys) > 1 && !reflect.DeepEqual(got.Remote, []RemoteRepeatTransition{up(codeA)}) {
|
||||
t.Fatalf("retire = %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
router := newTestCaptureRouter(t, nil)
|
||||
for index := 0; index < RemoteHeldKeyLimit; index++ {
|
||||
code := uint8(100 + index)
|
||||
if _, err := router.Route(press(code, 'a')); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := router.Route(press(200, 'b')); !errors.Is(err, ErrRemoteKeyLimit) {
|
||||
t.Fatalf("overflow = %v", err)
|
||||
}
|
||||
if got := router.Retire(); len(got.Remote) != RemoteHeldKeyLimit || len(got.Swallow) != RemoteHeldKeyLimit {
|
||||
t.Fatalf("retire after overflow = %d/%d", len(got.Remote), len(got.Swallow))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBehaviorRemoteCaptureRejectsInvalidChords(t *testing.T) {
|
||||
for name, test := range map[string]struct {
|
||||
escape CommandChord
|
||||
local CommandBindings
|
||||
}{
|
||||
"empty escape": {CommandChord{}, nil},
|
||||
"modifier escape": {CommandChord{Symbol: leftShiftKeySymbol}, nil},
|
||||
"reader escape": {CommandChord{Symbol: testInsertSymbol}, nil},
|
||||
"modifier local": {testEscape, CommandBindings{{Symbol: leftControlKeySymbol}: "tentacle/remote/x"}},
|
||||
} {
|
||||
if _, err := NewRemoteCaptureRouter(test.escape, test.local, []uint32{testInsertSymbol}); !errors.Is(err, ErrRemoteCaptureChord) {
|
||||
t.Fatalf("%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Local chords come from resolved bindings filtered to Remote's commands, so a
|
||||
// user-assigned shortcut is local while ordinary reader commands are forwarded.
|
||||
func TestBehaviorRemoteCaptureHonorsResolvedRemaps(t *testing.T) {
|
||||
suggestions := []BindingOverride{{Layout: DesktopKeyboardLayout, Scope: "global", Command: "tentacle/remote/menu", Keys: []string{"Cthulhu+Ctrl+r"}}}
|
||||
remap := []BindingOverride{{Layout: DesktopKeyboardLayout, Scope: "global", Command: "tentacle/remote/menu", Keys: []string{"Cthulhu+Alt+r"}}}
|
||||
for name, overrides := range map[string][]BindingOverride{
|
||||
"suggested": MergeTentacleBindings(nil, suggestions),
|
||||
"remapped": MergeTentacleBindings(remap, suggestions),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
resolved, err := ResolveCommandBindings(DesktopKeyboardLayout, false, overrides)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
local := CommandBindings{}
|
||||
for chord, command := range resolved {
|
||||
if command == "tentacle/remote/menu" {
|
||||
local[chord] = command
|
||||
}
|
||||
}
|
||||
router := newTestCaptureRouter(t, local)
|
||||
modifierCode, modifierSymbol := codeControl, leftControlKeySymbol
|
||||
if name == "remapped" {
|
||||
modifierCode, modifierSymbol = codeAlt, uint32(0xffe9)
|
||||
}
|
||||
runCaptureSteps(t, router, []captureStep{
|
||||
{press(codeInsert, testInsertSymbol), remote(down(codeInsert))},
|
||||
{press(modifierCode, modifierSymbol), remote(down(modifierCode))},
|
||||
{press(codeR, 'r'), RemoteCaptureResult{Remote: []RemoteRepeatTransition{up(modifierCode), up(codeInsert)}, Local: "tentacle/remote/menu"}},
|
||||
})
|
||||
// Local reader commands are not Remote commands: Cthulhu+t
|
||||
// reaches the remote screen reader.
|
||||
other := newTestCaptureRouter(t, local)
|
||||
runCaptureSteps(t, other, []captureStep{
|
||||
{press(codeInsert, testInsertSymbol), remote(down(codeInsert))},
|
||||
{press(28, 't'), remote(down(28))},
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The normalizer and router compose: a classic repeat pair inside a local
|
||||
// chord produces no remote traffic and no second local command.
|
||||
func TestBehaviorRemoteCaptureComposesWithNormalizer(t *testing.T) {
|
||||
normalizer := newTestNormalizer(t)
|
||||
router := newTestCaptureRouter(t, CommandBindings{{Symbol: 'r', Modifiers: controlModifier}: "tentacle/remote/menu"})
|
||||
symbols := map[uint8]uint32{codeControl: leftControlKeySymbol, codeR: 'r'}
|
||||
var remoteOut []RemoteRepeatTransition
|
||||
var commands []Command
|
||||
now := time.Unix(1, 0)
|
||||
for _, event := range []Event{
|
||||
{Keycode: codeControl, Pressed: true, Timestamp: 10},
|
||||
{Keycode: codeR, Pressed: true, Timestamp: 20},
|
||||
{Keycode: codeR, Pressed: false, Timestamp: 700},
|
||||
{Keycode: codeR, Pressed: true, Timestamp: 700},
|
||||
{Keycode: codeR, Pressed: false, Timestamp: 800},
|
||||
{Keycode: codeControl, Pressed: false, Timestamp: 0},
|
||||
} {
|
||||
transitions, err := normalizer.Observe(event, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, transition := range transitions {
|
||||
result, err := router.Route(RemoteCaptureKey{Keycode: transition.Keycode, Symbol: symbols[transition.Keycode], Pressed: transition.Pressed, Repeat: transition.Repeat})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
remoteOut = append(remoteOut, result.Remote...)
|
||||
if result.Local != "" {
|
||||
commands = append(commands, result.Local)
|
||||
}
|
||||
}
|
||||
}
|
||||
if want := []RemoteRepeatTransition{down(codeControl), up(codeControl)}; !reflect.DeepEqual(remoteOut, want) {
|
||||
t.Fatalf("remote = %+v, want %+v", remoteOut, want)
|
||||
}
|
||||
if !reflect.DeepEqual(commands, []Command{"tentacle/remote/menu"}) {
|
||||
t.Fatalf("commands = %v", commands)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user