160 lines
5.3 KiB
Go
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
|
|
}
|