// 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 . //go:build linux && cgo package terminal /* #include "native.h" */ import "C" import ( "fmt" "unsafe" "git.stormux.org/storm/fenrir/internal/diagnostic" "git.stormux.org/storm/fenrir/internal/input" ) // Keyboard returns the input side of this session. Commands use exactly the // same router, modal capture and app dispatcher as evdev. func (s *Session) Keyboard() *Keyboard { return &Keyboard{session: s} } type Keyboard struct{ session *Session } func (k *Keyboard) Events() <-chan error { return k.session.inputEvents } func (k *Keyboard) Scan() error { return nil } func (k *Keyboard) Pass_next() { k.session.router.Pass_next() } func (k *Keyboard) Capture(enabled bool) error { s := k.session if enabled && !s.grabbed { status, err := s.capture_keyboard() if err != nil { return err } if status != 0 { return fmt.Errorf("X11 terminal capture unavailable (status %d)", status) } } s.router.Capture(enabled) if enabled { // Bytes already replayed by xterm must not reach the child during a form. s.pending = nil s.pasteStart, s.pasteEnd = 0, 0 } // The closing key is already in the router but may not yet be in s.held: // Capture(false) can run from handle inside Poll. Keep that grab until its // release rather than ungrabbing and regrabbing with an older timestamp. if !enabled && !s.router.Held(0) && len(s.held) == 0 && s.grabbed { C.terminal_x_release(s.x) s.grabbed = false } return nil } func (k *Keyboard) Close() error { C.terminal_x_release(k.session.x) return nil } // Update_modifiers applies only between complete chords and outside modal capture. // The native transaction retains the previous grabs if any new grab is refused. func (k *Keyboard) Update_modifiers(fenrirKeys, scriptKeys []string) (bool, error) { s := k.session if s.router.Captured() || s.grabbed || s.captureBlocked || len(s.held) != 0 || len(s.replayed) != 0 { return false, nil } candidate := s.router if err := candidate.Configure_modifiers(fenrirKeys, scriptKeys); err != nil { return false, err } var keys []C.int for _, key := range append(candidate.Reader_keys(), candidate.Script_keys()...) { keys = append(keys, C.int(key)) } var keyPointer *C.int if len(keys) > 0 { keyPointer = &keys[0] } status := C.terminal_x_update_keys(s.x, keyPointer, C.int(len(keys))) if err := native_error(status); err != nil { return false, fmt.Errorf("update X11 modifier keys: %w", err) } if status != 0 { return false, nil } s.router = candidate return true, nil } // capture_keyboard treats desktop/focus contention as a refused chord, not a // terminal-session failure. Keep modal intent while discarding stale key state. func (s *Session) capture_keyboard() (int, error) { status := C.terminal_x_consume(s.x) if err := native_error(status); err != nil { return 0, fmt.Errorf("acquire X11 terminal keyboard: %w", err) } if status == 0 { s.grabbed = true return 0, nil } diagnostic.Record("X11 keyboard capture refused", "status", int(status)) C.terminal_x_release(s.x) captured := s.router.Captured() s.router.Reset() s.router.Capture(captured) clear(s.held) clear(s.replayed) s.grabbed = false s.captureBlocked = true return int(status), nil } func (k *Keyboard) Poll(active, numLock bool, handle func(input.Decision)) error { s := k.session for code, key := range s.replayed { if C.terminal_x_down(s.x, C.int(code)) == 0 { s.router.Key(0, key, 0, numLock, true) delete(s.replayed, code) } } for { var event C.TerminalEvent code := C.terminal_x_next(s.x, &event) if code == 0 { break } if err := native_error(code); err != nil { return fmt.Errorf("X11 terminal input: %w", err) } if event._type != 1 { continue } if s.captureBlocked { // Do not reinterpret the tail of a refused Fenrir chord as a bare // command after its modifier state was cleared. Drain until key-up. C.terminal_x_release(s.x) continue } if !active { C.terminal_x_release(s.x) continue } // ReplayKeyboard redelivers a forwarded press to normal event // observers, including us. It must not interrupt speech twice. if event.value == 1 && s.lastReplay.code == int(event.keycode) && s.lastReplay.time == uint64(event.time) { continue } key := uint16(C.terminal_key_code(event.symbol, event.keycode)) if held, exists := s.held[int(event.keycode)]; exists { key = held } // Synchronize standard modifiers that xterm received before our grab. for _, modifier := range []struct { key uint16 mask uint32 }{ {input.KeyShift, 1}, {input.KeyCtrl, 4}, {input.KeyAlt, 8}, } { value := int32(0) if uint32(event.state)&modifier.mask != 0 { value = 1 } if modifier.key != key { s.router.Observe_modifier(1, modifier.key, value, numLock) } } value := int32(event.value) if value == 1 { if _, exists := s.held[int(event.keycode)]; exists { value = 2 } } decision := s.router.Key(0, key, value, numLock, false) if key == input.KeyCapsLock && s.router.Caps_modifier() { if value == 1 { s.capsConsumed = !decision.Forward s.capsRestore = uint32(event.state)&2 != 0 } if s.capsConsumed && value != 2 { // XKB locks on press and can unlock on release, even for grabbed keys. enabled := C.int(0) if s.capsRestore { enabled = 1 } if err := native_error(C.terminal_x_caps_lock(s.x, enabled)); err != nil { return err } } if value == 0 { s.capsConsumed = false } } if decision.ToggleCapsLock { if err := native_error(C.terminal_x_toggle_caps(s.x)); err != nil { return err } } // Acquire before dispatch: a failed capture must not execute a command // or open a form from a partial, stale chord. if event.value == 1 && !decision.Forward && !s.grabbed { status, err := s.capture_keyboard() if err != nil { return err } if status != 0 { continue } } if decision.UI != nil { decision.UI.Text = C.GoStringN(&event.text[0], event.length) // Control+A arrives as a control character from XLookupString. if decision.UI.Ctrl && event.symbol >= 'a' && event.symbol <= 'z' { decision.UI.Text = string(rune(event.symbol)) } } handle(decision) if event.value == 1 { if decision.Forward && !s.grabbed { C.terminal_x_replay(s.x, event.time) s.lastReplay.code, s.lastReplay.time = int(event.keycode), uint64(event.time) s.replayed[int(event.keycode)] = key } else { s.held[int(event.keycode)] = key if decision.Forward { var bytes [256]byte n := int(C.terminal_key(s.vt.native, &event, (*C.char)(unsafe.Pointer(&bytes[0])), C.int(len(bytes)))) s.pending = append(s.pending, bytes[:n]...) } } } else { delete(s.replayed, int(event.keycode)) delete(s.held, int(event.keycode)) if len(s.held) == 0 && s.grabbed && !s.router.Captured() { C.terminal_x_release(s.x) s.grabbed = false } } } if s.captureBlocked && C.terminal_x_keys_down(s.x) == 0 { s.captureBlocked = false } return s.relay_input() }