// 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" "strings" "unicode" "git.stormux.org/storm/fenrir/internal/presentation" "git.stormux.org/storm/fenrir/internal/screen" ) func changes(previous, next screen.Snapshot, cursorEcho bool) (output []presentation.Message) { 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 } // Keep navigation over unchanged text audible even when other rows change // (mutt), but first read those changes. A dialog can finish drawing its // body while moving the cursor onto an already drawn button row. if cursorEcho && next.Y != previous.Y && strings.TrimSpace(next.Text(next.Y)) != "" && slices.Equal(previous.Line(next.Y), next.Line(next.Y)) { defer func() { output = append(output, presentation.Line_message(next, next.Y)) }() } // 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 }