// 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 . // 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 }