// 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 . 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 }