Files

160 lines
5.3 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 reader
import (
"slices"
"time"
"unicode"
"git.stormux.org/storm/fenrir/internal/presentation"
"git.stormux.org/storm/fenrir/internal/screen"
)
func (r *Reader) Configure_echo(characterEcho, wordEcho, echoOnCapsLock bool) {
r.characterEcho, r.wordEcho, r.echoOnCapsLock = characterEcho, wordEcho, echoOnCapsLock
}
const echoWindow = 500 * time.Millisecond
// Note_input associates direct application input with cursor movement and echo.
// It never speaks raw keys: password fields without screen echo stay silent.
func (r *Reader) Note_input(forwarded, typing, wordBoundary bool) {
r.progressDeletionAt = time.Time{}
r.applicationInputAt = time.Time{}
if forwarded {
r.applicationInputAt = time.Now()
}
if !forwarded || !typing {
r.inputCount = 0
r.inputBoundary = false
return
}
if time.Since(r.inputAt) > echoWindow {
r.inputCount = 0
r.inputBoundary = false
}
r.inputCount = min(r.inputCount+1, len(r.screen.Cells))
r.inputBoundary = r.inputBoundary || wordBoundary
r.inputAt = time.Now()
}
// typing_echo handles a cursor-anchored edit following text input. Unrelated
// output still goes through changes, even with all typing echo disabled.
func (r *Reader) typing_echo(previous, next screen.Snapshot) ([]presentation.Message, bool) {
if r.inputCount == 0 {
return nil, false
}
count := r.inputCount
if time.Since(r.inputAt) > echoWindow {
r.inputCount = 0
r.inputBoundary = false
return nil, false
}
if previous.X == next.X && previous.Y == next.Y && slices.Equal(previous.Cells, next.Cells) {
return nil, false // A readiness event may precede the application's echo.
}
r.inputCount = 0
boundary := r.inputBoundary
r.inputBoundary = false
before := previous.Cells
start := previous.Y*previous.Cols + previous.X
end := next.Y*next.Cols + next.X
// Align one screen scroll caused by typing across the bottom margin.
if end < start && previous.Y == previous.Rows-1 && next.Y == next.Rows-1 {
before = make([]rune, len(previous.Cells))
copy(before, previous.Cells[previous.Cols:])
for i := len(before) - previous.Cols; i < len(before); i++ {
before[i] = ' '
}
start -= previous.Cols
}
// Some terminals keep the cursor on the last column until the next key
// wraps it. Account for the already-written cell, or the newly-filled one.
if previous.X == previous.Cols-1 && end-start > count && start >= 0 && before[start] == next.Cells[start] {
start++
}
if next.X == next.Cols-1 && end < len(before) && before[end] != next.Cells[end] {
end++
}
if start < 0 || end <= start || end-start > count*2 || end > len(before) {
return nil, false
}
if !slices.Equal(before[:start], next.Cells[:start]) {
return nil, false
}
if !slices.Equal(before[end:], next.Cells[end:]) {
// Inserting in the middle of a line shifts the old suffix right.
rowEnd := (previous.Y + 1) * previous.Cols
advance := end - start
if next.Y != previous.Y || end > rowEnd || !slices.Equal(before[start:rowEnd-advance], next.Cells[end:rowEnd]) ||
!slices.Equal(before[rowEnd:], next.Cells[rowEnd:]) ||
!all_spaces(before[rowEnd-advance:rowEnd]) {
return nil, false
}
}
var output []presentation.Message
for index := start; index < end; index++ {
if !r.region_state().allows(index%next.Cols, index/next.Cols, next.Cols, false, true) {
continue
}
r.completed_word(index, next)
char := next.Cells[index]
if r.wordEcho && boundary && unicode.IsSpace(char) {
wordStart := index
for wordStart > 0 && !unicode.IsSpace(next.Cells[wordStart-1]) {
wordStart--
}
if wordStart < index {
output = append(output, presentation.Message{Text: string(next.Cells[wordStart:index])})
}
} else if !unicode.IsSpace(char) && (r.characterEcho || r.echoOnCapsLock && next.CapsLock) {
output = append(output, presentation.Character_message(char))
}
}
return output, true
}
func all_spaces(cells []rune) bool {
for _, char := range cells {
if !unicode.IsSpace(char) {
return false
}
}
return true
}
// Deletion echo uses only text verified against the previous screen. Like
// typing echo, it remains available with autoread/cursor/character echo off,
// but never exposes text excluded by a live region.
func (r *Reader) deletion_echo(deleted []rune, next screen.Snapshot) []presentation.Message {
var allowed []rune
for index, char := range deleted {
if r.region_state().allows(next.X+index, next.Y, next.Cols, false, true) {
allowed = append(allowed, char)
}
}
if len(allowed) == 1 {
return []presentation.Message{presentation.Character_message(allowed[0])}
}
if len(allowed) > 1 {
return []presentation.Message{{Text: string(allowed)}}
}
return nil
}