Files
cthulhu/internal/input/remote_capture.go
Chase CichorzandClaude Opus 5.5 c234cb82a8 Add Xorg Remote controller capture with a core F11 escape
The Xorg command owner gains an exclusive Remote capture mode. Native
transitions pass through the repeat normalizer and capture router;
forwarded keys leave as remote.input events, Remote's own shortcuts are
dispatched locally, and F11 ends capture on the X thread. Reader policy,
overflow or inconsistent input also end it. Ending releases forwarded
keys first and keeps the grab until held keys are released. Local key
echo is suppressed during capture. The app attaches the owner to
sessions installed with keyboard-capture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:06:38 -05:00

303 lines
9.9 KiB
Go

// 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
// Unsupported marks a fresh press the Remote key vocabulary cannot name.
// It is consumed locally with its repeats and release, never forwarded.
Unsupported bool
}
// RemoteCaptureEvent is one item from a native capture owner to its reader
// consumer. Exactly one of Key or Ended is set. Name is present only on a
// fresh press. Ended reports why capture stopped: "escape", "requested" or
// "local" (reader policy, overflow or an inconsistent native stream); no
// further events follow it.
type RemoteCaptureEvent struct {
Key *RemoteCaptureTransition
Ended string
}
type RemoteCaptureTransition struct {
Code uint8
Name string
Pressed bool
Repeat bool
}
// RemoteCaptureEscape is the core escape chord: F11 with no modifiers. It is
// handled by the native owner without waiting for the network or a Tentacle.
var RemoteCaptureEscape = CommandChord{Symbol: 0xffc8}
// RemoteCaptureKeyName returns the canonical Remote key name for a
// group-zero, level-zero keysym name, or "" when it has none.
func RemoteCaptureKeyName(keysym string) string {
switch keysym {
case "Prior":
keysym = "Page_Up"
case "Next":
keysym = "Page_Down"
case "KP_Prior":
keysym = "KP_Page_Up"
case "KP_Next":
keysym = "KP_Page_Down"
}
if !ValidRemoteKeyName(keysym) {
return ""
}
return keysym
}
// 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 key.Unsupported {
router.hold(code, remoteCaptureLocal)
return RemoteCaptureResult{}, nil
}
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
}
}
}