Files

259 lines
7.7 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>.
//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()
}