116 lines
4.5 KiB
Go
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
|
|
}
|