405 lines
12 KiB
Go
405 lines
12 KiB
Go
// Fenrir console screen reader.
|
|
// Copyright (c) 2026 Storm Dragon
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify it
|
|
// under the terms of the GNU General Public License as published by the Free
|
|
// Software Foundation, either version 3 of the License, or (at your option)
|
|
// any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful, but WITHOUT
|
|
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
|
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
|
// more details.
|
|
//
|
|
// You should have received a copy of the GNU General Public License along with
|
|
// this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
|
|
|
|
// Package input routes Linux key codes without depending on a device backend.
|
|
package input
|
|
|
|
import (
|
|
"git.stormux.org/storm/fenrir/internal/command"
|
|
"git.stormux.org/storm/fenrir/internal/interaction"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
KeyEsc = 1
|
|
KeyBackspace = 14
|
|
KeyTab = 15
|
|
KeyQ = 16
|
|
KeyEnter = 28
|
|
KeyCtrl = 29
|
|
KeyF = 33
|
|
KeyH = 35
|
|
KeyShift = 42
|
|
KeyRightShift = 54
|
|
KeyAlt = 56
|
|
KeySpace = 57
|
|
KeyCapsLock = 58
|
|
KeyF4 = 62
|
|
KeyKP7 = 71
|
|
KeyKP8 = 72
|
|
KeyKP9 = 73
|
|
KeyKP4 = 75
|
|
KeyKP5 = 76
|
|
KeyKP6 = 77
|
|
KeyKPPlus = 78
|
|
KeyKP1 = 79
|
|
KeyKP2 = 80
|
|
KeyKP3 = 81
|
|
KeyKP0 = 82
|
|
KeyKPDot = 83
|
|
KeyKPEnter = 96
|
|
KeyRightCtrl = 97
|
|
KeyRightAlt = 100
|
|
KeyHome = 102
|
|
KeyUp = 103
|
|
KeyLeft = 105
|
|
KeyRight = 106
|
|
KeyEnd = 107
|
|
KeyDown = 108
|
|
KeyInsert = 110
|
|
KeyDelete = 111
|
|
KeyMeta = 125
|
|
KeyRightMeta = 126
|
|
)
|
|
|
|
type Decision struct {
|
|
Code uint16 // physical key code, for forwarded application press/repeat hooks
|
|
ToggleCapsLock bool // completed Shift+Caps Lock gesture
|
|
Forward bool
|
|
Interrupt bool
|
|
Keypress bool // a fresh physical press, excluding repeats and state repair
|
|
Deletion bool // forwarded Backspace/Delete, including repeats
|
|
Typing bool // forwarded text key or repeat; the screen supplies its actual text
|
|
WordBoundary bool // a forwarded Space, rather than cursor navigation
|
|
Command command.ID
|
|
UI *interaction.Key
|
|
Learn *LearnKey // captured physical press and its inactive command
|
|
}
|
|
|
|
type Router struct {
|
|
readerKeys map[uint16]bool
|
|
capsShift, capsUsed bool
|
|
keys map[int]map[uint16]heldKey
|
|
passArmed bool
|
|
passing bool
|
|
captured bool
|
|
bindings map[chord]command.ID
|
|
doubleBindings map[chord]command.ID
|
|
tripleBindings map[chord]command.ID
|
|
multiPressTimeout time.Duration
|
|
pressCount int
|
|
lastChord chord
|
|
lastDevice int
|
|
lastPress time.Time
|
|
now func() time.Time
|
|
scriptKeys map[uint16]bool
|
|
scriptBindings map[string]command.ID
|
|
}
|
|
|
|
type heldKey struct {
|
|
forwarded bool
|
|
commandID command.ID
|
|
ui bool
|
|
}
|
|
|
|
func (r *Router) Reset() {
|
|
r.lastPress = time.Time{}
|
|
r.capsShift, r.capsUsed = false, false
|
|
r.keys = nil
|
|
r.passArmed = false
|
|
r.passing = false
|
|
r.captured = false
|
|
}
|
|
|
|
func (r *Router) Drop(device int) {
|
|
r.capsUsed = true
|
|
r.lastPress = time.Time{}
|
|
delete(r.keys, device)
|
|
if r.empty() {
|
|
r.passing = false
|
|
}
|
|
}
|
|
|
|
func (r *Router) Forwarded(device int) []uint16 {
|
|
var keys []uint16
|
|
for key, state := range r.keys[device] {
|
|
if state.forwarded {
|
|
keys = append(keys, key)
|
|
}
|
|
}
|
|
return keys
|
|
}
|
|
|
|
// Idle reports whether all devices have released their routed keys.
|
|
func (r *Router) Idle() bool { return r.empty() }
|
|
|
|
// Held reports physical state for one device, including consumed keys whose
|
|
// release has not arrived yet.
|
|
func (r *Router) Held(device int) bool { return len(r.keys[device]) != 0 }
|
|
|
|
func (r *Router) Pass_next() {
|
|
r.lastPress = time.Time{}
|
|
r.passArmed = true
|
|
}
|
|
|
|
// Key keeps the routing of a press through repeat/release, even if a modifier
|
|
// or NumLock changes in between. Sync deltas repair state without commands.
|
|
func (r *Router) Key(device int, key uint16, value int32, numLock, syncEvent bool) Decision {
|
|
return r.key(device, key, value, numLock, syncEvent, syncEvent)
|
|
}
|
|
|
|
// Observe_modifier records an aggregate X11 modifier sample on its separate
|
|
// shadow device. These samples accompany ordinary presses/releases; they are
|
|
// not evidence of lost input and must not cancel a pending double press. The
|
|
// next physical action still checks the complete chord and timeout. Actual
|
|
// state repair, handoff and capture continue to invalidate through Key/Reset.
|
|
func (r *Router) Observe_modifier(device int, key uint16, value int32, numLock bool) {
|
|
if key == KeyCtrl || key == KeyShift || key == KeyAlt {
|
|
r.key(device, key, value, numLock, true, false)
|
|
}
|
|
}
|
|
|
|
func (r *Router) key(device int, key uint16, value int32, numLock, syncEvent, repair bool) Decision {
|
|
if repair {
|
|
// X11 checks modifier state before every event, including unchanged
|
|
// keys. Only an actual repair invalidates a pending multi-press.
|
|
_, held := r.keys[device][key]
|
|
if value == 0 && held || value == 1 && !held {
|
|
r.lastPress = time.Time{}
|
|
r.capsUsed = true
|
|
}
|
|
}
|
|
if r.keys == nil {
|
|
r.keys = make(map[int]map[uint16]heldKey)
|
|
}
|
|
if r.keys[device] == nil {
|
|
r.keys[device] = make(map[uint16]heldKey)
|
|
}
|
|
held := r.keys[device]
|
|
state, exists := held[key]
|
|
if value == 0 {
|
|
delete(held, key)
|
|
if r.empty() {
|
|
r.passing = false
|
|
}
|
|
toggle := key == KeyCapsLock && exists && !state.forwarded && r.Caps_modifier() && r.capsShift && !r.capsUsed && !syncEvent && !r.captured && !r.passing
|
|
if key == KeyCapsLock {
|
|
r.capsShift, r.capsUsed = false, false
|
|
}
|
|
return Decision{Forward: exists && state.forwarded, ToggleCapsLock: toggle}
|
|
}
|
|
if value != 1 && value != 2 {
|
|
return Decision{}
|
|
}
|
|
if exists {
|
|
if state.ui {
|
|
if r.captured && value == 2 && !syncEvent && !pure_modifier(key) && key != KeyEsc && key != KeyEnter && key != KeyKPEnter && key != KeySpace {
|
|
decision := r.ui_key(key)
|
|
decision.UI.Repeat = true
|
|
return decision
|
|
}
|
|
return Decision{}
|
|
}
|
|
if state.forwarded {
|
|
typing := !syncEvent && r.typing_key(key, numLock)
|
|
return Decision{Code: key, Forward: true, Deletion: !syncEvent && deletion_key(key, numLock), Typing: typing, WordBoundary: typing && key == KeySpace, Interrupt: !syncEvent && !pure_modifier(key)}
|
|
}
|
|
commandID := state.commandID
|
|
if value == 2 && repeatable(commandID) && !syncEvent {
|
|
return Decision{Command: commandID, Interrupt: true}
|
|
}
|
|
return Decision{}
|
|
}
|
|
// Orphan repeats do not become new presses after a handoff.
|
|
if value == 2 {
|
|
return Decision{}
|
|
}
|
|
if !syncEvent && r.Caps_modifier() {
|
|
switch key {
|
|
case KeyCapsLock:
|
|
r.capsShift, r.capsUsed = r.down(KeyShift) || r.down(KeyRightShift), false
|
|
for _, held := range r.keys {
|
|
for code := range held {
|
|
if code != KeyShift && code != KeyRightShift {
|
|
r.capsUsed = true
|
|
}
|
|
}
|
|
}
|
|
case KeyShift, KeyRightShift:
|
|
if r.down(KeyCapsLock) {
|
|
r.capsShift = true
|
|
}
|
|
default:
|
|
if r.down(KeyCapsLock) {
|
|
r.capsUsed = true
|
|
}
|
|
}
|
|
}
|
|
if r.captured {
|
|
var learn *LearnKey
|
|
if !syncEvent {
|
|
learn = r.learn_key(device, key, numLock)
|
|
}
|
|
held[key] = heldKey{ui: true}
|
|
if syncEvent || pure_modifier(key) || r.reader_modifier(key, numLock) {
|
|
return Decision{Learn: learn, Keypress: !syncEvent, Interrupt: !syncEvent && (key == KeyCtrl || key == KeyRightCtrl)}
|
|
}
|
|
decision := r.ui_key(key)
|
|
decision.Learn = learn
|
|
decision.Keypress = true
|
|
return decision
|
|
}
|
|
if r.passArmed && !syncEvent {
|
|
r.passArmed, r.passing = false, true
|
|
}
|
|
commandID := command.ID("")
|
|
scriptChord := false
|
|
if !r.passing {
|
|
commandID, scriptChord = r.script_binding(key, numLock)
|
|
if !scriptChord {
|
|
commandID = r.binding(key, numLock)
|
|
}
|
|
}
|
|
if !syncEvent && !pure_modifier(key) && !r.reader_modifier(key, numLock) {
|
|
commandID = r.multi_press(device, r.binding_chord(key, numLock), commandID, scriptChord)
|
|
}
|
|
forwarded := commandID == "" && !scriptChord && !(r.reader_modifier(key, numLock) && !r.passing)
|
|
held[key] = heldKey{forwarded: forwarded, commandID: commandID}
|
|
if syncEvent {
|
|
return Decision{Forward: forwarded}
|
|
}
|
|
typing := forwarded && r.typing_key(key, numLock)
|
|
return Decision{Code: key, Keypress: true, Deletion: forwarded && deletion_key(key, numLock), Typing: typing, WordBoundary: typing && key == KeySpace, Forward: forwarded, Command: commandID, Interrupt: commandID != "" || forwarded && (!pure_modifier(key) || key == KeyCtrl || key == KeyRightCtrl)}
|
|
}
|
|
|
|
func (r *Router) typing_key(key uint16, numLock bool) bool {
|
|
for scriptKey := range r.scriptKeys {
|
|
if r.script_modifier(scriptKey, numLock) && r.down(scriptKey) {
|
|
return false
|
|
}
|
|
}
|
|
if r.down(KeyCtrl) || r.down(KeyRightCtrl) || r.down(KeyAlt) || r.reader_down(numLock) || r.down(KeyMeta) || r.down(KeyRightMeta) {
|
|
return false
|
|
}
|
|
// Physical text-key positions, independent of keyboard layout. AltGr is
|
|
// allowed; text and capitalization come from the echoed screen cells.
|
|
if key >= 2 && key <= 13 || key >= 16 && key <= 27 || key >= 30 && key <= 41 || key >= 43 && key <= 53 {
|
|
return true
|
|
}
|
|
switch key {
|
|
case KeySpace, 86, 89, 124: // space, ISO extra key, RO and Yen
|
|
return true
|
|
case KeyKP0, KeyKP1, KeyKP2, KeyKP3, KeyKP4, KeyKP5, KeyKP6, KeyKP7, KeyKP8, KeyKP9, KeyKPDot:
|
|
return numLock
|
|
case 55, 74, 78, 98: // keypad multiply, minus, plus and divide
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (r *Router) down(key uint16) bool {
|
|
for _, held := range r.keys {
|
|
if _, ok := held[key]; ok {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (r *Router) empty() bool {
|
|
for _, held := range r.keys {
|
|
if len(held) != 0 {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (r *Router) reader_modifier(key uint16, numLock bool) bool {
|
|
if r.readerKeys == nil {
|
|
return key == KeyInsert || key == KeyMeta || key == KeyRightMeta || key == KeyKP0 && !numLock
|
|
}
|
|
return r.readerKeys[key] && (key != KeyKP0 || !numLock)
|
|
}
|
|
|
|
// Caps_modifier reports whether Caps Lock is reserved for reader or script chords.
|
|
func (r *Router) Caps_modifier() bool { return r.readerKeys[KeyCapsLock] || r.scriptKeys[KeyCapsLock] }
|
|
|
|
// Reader_keys supplies the X11 adapter with the physical keys to intercept.
|
|
func (r *Router) Reader_keys() []uint16 {
|
|
var keys []uint16
|
|
for _, key := range []uint16{KeyInsert, KeyMeta, KeyRightMeta, KeyCapsLock, KeyKP0} {
|
|
if r.reader_modifier(key, false) {
|
|
keys = append(keys, key)
|
|
}
|
|
}
|
|
return keys
|
|
}
|
|
|
|
func (r *Router) reader_down(numLock bool) bool {
|
|
for _, key := range r.Reader_keys() {
|
|
if r.reader_modifier(key, numLock) && r.down(key) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func pure_modifier(key uint16) bool {
|
|
return key == KeyCtrl || key == KeyRightCtrl || key == KeyShift || key == KeyRightShift || key == KeyAlt || key == KeyRightAlt || key == KeyInsert || key == KeyMeta || key == KeyRightMeta
|
|
}
|
|
|
|
func (r *Router) binding(key uint16, numLock bool) command.ID {
|
|
if r.reader_modifier(key, numLock) || pure_modifier(key) {
|
|
return ""
|
|
}
|
|
combo := r.binding_chord(key, numLock)
|
|
if combo.modifiers&fenrirModifier != 0 {
|
|
if id := r.bindings[combo]; id != "" {
|
|
return id
|
|
}
|
|
return command.Unbound
|
|
}
|
|
if numLock || combo.modifiers != 0 {
|
|
return ""
|
|
}
|
|
return r.bindings[combo]
|
|
}
|
|
|
|
func (r *Router) binding_chord(key uint16, numLock bool) chord {
|
|
var mods modifiers
|
|
if r.reader_down(numLock) {
|
|
mods |= fenrirModifier
|
|
}
|
|
if r.down(KeyCtrl) || r.down(KeyRightCtrl) {
|
|
mods |= ctrlModifier
|
|
}
|
|
if r.down(KeyShift) || r.down(KeyRightShift) {
|
|
mods |= shiftModifier
|
|
}
|
|
if r.down(KeyAlt) || r.down(KeyRightAlt) {
|
|
mods |= altModifier
|
|
}
|
|
return chord{key: key, modifiers: mods}
|
|
}
|
|
|
|
func repeatable(commandID command.ID) bool {
|
|
switch commandID {
|
|
case command.NextQuickCategory, command.PreviousQuickCategory, command.NextQuickEntry, command.PreviousQuickEntry, command.NextQuickValue, command.PreviousQuickValue:
|
|
return true
|
|
case command.PreviousLine, command.CurrentLine, command.NextLine, command.PreviousWord, command.CurrentWord, command.NextWord, command.PreviousChar, command.CurrentChar, command.NextChar,
|
|
command.SpellLine, command.PhoneticLine, command.SpellWord, command.PhoneticWord, command.PhoneticChar,
|
|
command.ReviewUp, command.ReviewDown:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func deletion_key(key uint16, numLock bool) bool {
|
|
return key == KeyBackspace || key == KeyDelete || key == KeyKPDot && !numLock
|
|
}
|