259 lines
7.7 KiB
Go
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()
|
|
}
|