Files

116 lines
4.5 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"
"strings"
"unicode"
"git.stormux.org/storm/fenrir/internal/presentation"
"git.stormux.org/storm/fenrir/internal/screen"
)
func changes(previous, next screen.Snapshot, cursorEcho bool) []presentation.Message {
// TUI navigation can move over unchanged text while a status line also
// changes. That status delta must not hide the newly focused row (mutt).
if cursorEcho && next.Y != previous.Y && strings.TrimSpace(next.Text(next.Y)) != "" && slices.Equal(previous.Line(next.Y), next.Line(next.Y)) {
return []presentation.Message{presentation.Line_message(next, next.Y)}
}
if slices.Equal(previous.Cells, next.Cells) {
if cursorEcho && next.Y != previous.Y {
return []presentation.Message{presentation.Line_message(next, next.Y)}
}
if cursorEcho && next.X != previous.X {
return []presentation.Message{presentation.Character_message(next.Line(next.Y)[next.X])}
}
return nil
}
// Match a scroll as one shift so old lines aren't read a second time.
// Find the first non-space cell once. Successively shorter overlaps
// cannot be meaningful scroll matches once they contain only whitespace.
// Avoid converting each candidate overlap into a whole-screen string.
firstText := slices.IndexFunc(next.Cells, func(cell rune) bool { return !unicode.IsSpace(cell) })
for shift := 1; shift < next.Rows && firstText >= 0 && firstText < (next.Rows-shift)*next.Cols; shift++ {
if !slices.Equal(previous.Cells[shift*next.Cols:], next.Cells[:(next.Rows-shift)*next.Cols]) {
continue
}
var result []presentation.Message
for row := next.Rows - shift; row < next.Rows; row++ {
if text := next.Text(row); strings.TrimSpace(text) != "" {
result = append(result, presentation.Message{Text: text})
}
}
return result
}
var changedRows []int
for row := 0; row < next.Rows; row++ {
if !slices.Equal(previous.Line(row), next.Line(row)) {
changedRows = append(changedRows, row)
}
}
scrollStart, scrollEnd := 0, 0
if len(changedRows) > 1 {
scrollStart, scrollEnd = screen.Scrolled_overlap(previous, next)
kept := changedRows[:0]
for _, row := range changedRows {
if row < scrollStart || row >= scrollEnd {
kept = append(kept, row)
}
}
changedRows = kept
}
if len(changedRows) == 1 && scrollStart == scrollEnd {
row := changedRows[0]
before, after := previous.Line(row), next.Line(row)
// A shorter replacement prompt can also move the cursor left. Only
// echo removed text when the surrounding text matches a deletion.
if next.Y == previous.Y && next.Y == row && next.X < previous.X && previous.X-next.X <= 4 && len([]rune(next.Text(row))) < len([]rune(previous.Text(row))) &&
slices.Equal(before[:next.X], after[:next.X]) && strings.TrimRight(string(before[previous.X:]), " ") == strings.TrimRight(string(after[next.X:]), " ") {
removed := before[next.X:previous.X]
if len(removed) == 1 {
return []presentation.Message{presentation.Character_message(removed[0])}
}
return []presentation.Message{{Text: string(removed)}}
}
start := 0
for start < min(len(before), len(after)) && before[start] == after[start] {
start++
}
endBefore, endAfter := len(before), len(after)
for endBefore > start && endAfter > start && before[endBefore-1] == after[endAfter-1] {
endBefore--
endAfter--
}
inserted := after[start:endAfter]
if len(inserted) != 0 && strings.TrimSpace(string(inserted)) != "" {
if len(inserted) == 1 {
return []presentation.Message{presentation.Character_message(inserted[0])}
}
return []presentation.Message{{Text: string(inserted)}}
}
}
var result []presentation.Message
for _, row := range changedRows {
if text := next.Text(row); strings.TrimSpace(text) != "" {
result = append(result, presentation.Message{Text: text})
}
}
return result
}