Files

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
}