// 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 produces automatic output and dispatches review commands. package reader import ( "strconv" "time" "git.stormux.org/storm/fenrir/internal/applicationoverrides" "git.stormux.org/storm/fenrir/internal/command" "git.stormux.org/storm/fenrir/internal/diagnostic" "git.stormux.org/storm/fenrir/internal/i18n" "git.stormux.org/storm/fenrir/internal/presentation" "git.stormux.org/storm/fenrir/internal/progress" "git.stormux.org/storm/fenrir/internal/review" "git.stormux.org/storm/fenrir/internal/screen" ) type Reader struct { progress progress.Monitor progressEnabled bool progressDeletionAt time.Time screen screen.Snapshot regions map[int]*terminalRegions regionRevision uint64 review review.Review automatic bool cursorEcho bool trackingMode string keepReviewOnCursorChange bool characterEcho, wordEcho, echoOnCapsLock bool inputCount int inputBoundary bool completedWords []string lastCompletedWord string inputAt time.Time applicationInputAt time.Time applicationActivity bool applicationActivityAt time.Time attributeConfigured, attributeResumeMode string attributeMode, attributeFormat string lastAttribute screen.Attribute attributeKnown bool indentationMode string lastIndent int indentKnown bool } func New() *Reader { r := &Reader{automatic: true, cursorEcho: true, characterEcho: true, trackingMode: "cursor"} r.Configure_attributes("none", presentation.Default_attribute_format) return r } func (r *Reader) Configure(automatic, cursorEcho bool) { r.automatic, r.cursorEcho = automatic, cursorEcho } func (r *Reader) Configure_tracking(mode string) { r.trackingMode = mode r.review.Set_table_mode(mode == "table") } func (r *Reader) Tracking_mode() string { return r.trackingMode } func (r *Reader) Automatic_reading() bool { return r.automatic } func (r *Reader) Configure_review(lineBreak, endOfScreen, leaveOnCursorChange, leaveOnScreenChange bool) { r.review.Configure(lineBreak, endOfScreen, leaveOnScreenChange) r.keepReviewOnCursorChange = !leaveOnCursorChange } func (r *Reader) Set_mark() presentation.Message { return r.review.Set_mark() } func (r *Reader) Clear_marks() { r.review.Clear_marks() } func (r *Reader) Marked_text(copying bool) (string, bool) { return r.review.Marked_text(copying) } func (r *Reader) Update(next screen.Snapshot) []presentation.Message { return r.Update_application(next, applicationoverrides.Frame{}) } func (r *Reader) Update_application(next screen.Snapshot, frame applicationoverrides.Frame) (output []presentation.Message) { r.completedWords = nil previous := r.screen r.applicationActivity = next.Active && frame.Recognized && frame.OwnsPresentation && frame.Activity && r.progressEnabled // Python runs cursor-change commands only after recent direct application // input (300 ms). Reader commands and passive output must preserve review. if !r.keepReviewOnCursorChange && r.review.Active() && !r.applicationInputAt.IsZero() && time.Since(r.applicationInputAt) < 300*time.Millisecond && previous.Active && next.Active && previous.Terminal == next.Terminal && previous.Generation == next.Generation && previous.Rows == next.Rows && previous.Cols == next.Cols && (previous.X != next.X || previous.Y != next.Y || frame.Recognized && frame.FocusChanged) { r.review.Resume_tracking() } switched := r.review.Update(next) r.screen = next path := "screen-changes" if diagnostic.Enabled() { pendingInput := r.inputCount defer func() { r.trace_update(previous, next, switched, pendingInput, path, output) }() } if frame.Recognized && frame.OwnsPresentation { path = "application" defer func() { if !next.Active || !r.automatic { return } if !r.review.Active() { output = append(output, frame.Focus...) } output = append(output, frame.Updates...) }() } defer func() { if frame.OwnsPresentation { return } if message := r.automatic_attributes(previous, next, switched); message != nil { output = append([]presentation.Message{*message}, output...) } if message := r.automatic_indentation(previous, next, switched); message != nil { output = append([]presentation.Message{*message}, output...) } }() if switched || !next.Active { r.lastCompletedWord = "" r.inputCount = 0 r.inputBoundary = false r.applicationInputAt = time.Time{} r.progressDeletionAt = time.Time{} } if !next.Active { r.progress.Reset() path = "inactive" return nil } if switched { r.progress.Reset() path = "session-change" if !r.automatic { path = "automatic-disabled" return nil } if frame.OwnsPresentation { return nil } output := []presentation.Message{{Text: i18n.Format("screen {0}", strconv.Itoa(next.Terminal)), Cue: presentation.TTYChanged}} if r.region_state().allows(next.X, next.Y, next.Cols, true, true) { output = append(output, presentation.Line_message(r.Automatic_snapshot(), next.Y)) } return output } regions := r.region_state() typingPrevious := regions.filtered_snapshot(previous, false, true) typingNext := regions.filtered_snapshot(next, false, true) if frame.OwnsPresentation { if frame.Input == nil { r.inputCount = 0 } else { typingPrevious = input_snapshot(typingPrevious, *frame.Input) typingNext = input_snapshot(typingNext, *frame.Input) } } if echo, matched := r.typing_echo(typingPrevious, typingNext); matched { r.progress.Reset() if !frame.OwnsPresentation && r.progressEnabled && regions.has_live() { // Typing can update a ruler/progress region in the same snapshot. // Inspect only live regions here, so typed percentages elsewhere // cannot become progress cues, even on the same physical row. r.progress.Update(regions.live_snapshot(previous), regions.live_snapshot(next), -1) } path = "typing-echo" return echo } deleted := r.match_deletion(previous, next) if frame.OwnsPresentation && frame.Input == nil { deleted = nil } if frame.OwnsPresentation { r.progress.Reset() } if !frame.OwnsPresentation && r.progressEnabled { deletionRow := -1 if len(deleted) > 0 { deletionRow = next.Y } r.progress.Update(regions.progress_snapshot(previous), regions.progress_snapshot(next), deletionRow) } if !r.automatic { path = "automatic-disabled" if len(deleted) > 0 { path = "deletion-echo" return r.deletion_echo(deleted, next) } return nil } if frame.OwnsPresentation { if len(deleted) > 0 { return r.deletion_echo(deleted, next) } if frame.InputCursor && frame.Input != nil && r.cursorEcho && !r.review.Active() && !r.applicationInputAt.IsZero() && time.Since(r.applicationInputAt) < 300*time.Millisecond && frame.Input.Contains(previous.X, previous.Y, next.Cols) && frame.Input.Contains(next.X, next.Y, next.Cols) && regions.allows(next.X, next.Y, next.Cols, true, true) { return input_cursor_feedback(typingPrevious, typingNext) } return nil } readingPrevious := regions.filtered_snapshot(previous, true, true) readingNext := r.Automatic_snapshot() if r.trackingMode == "highlight" && !r.review.Active() { if output := highlights(readingPrevious, readingNext); len(output) > 0 { path = "highlight" return output } } return changes(readingPrevious, readingNext, r.trackingMode == "cursor" && r.cursorEcho && !r.review.Active() && regions.allows(next.X, next.Y, next.Cols, true, true)) } // Command returns the complete feedback for one action, without retaining // presentation state for callers to retrieve after dispatch. func (r *Reader) Command(id command.ID) presentation.CommandResult { r.applicationInputAt = time.Time{} r.progressDeletionAt = time.Time{} if result, handled := r.region_command(id); handled { return result } switch id { case command.ToggleAttributes: return presentation.CommandResult{Text: r.toggle_attributes()} case command.ToggleTracking: switch r.trackingMode { case "cursor": r.Configure_tracking("table") return presentation.CommandResult{Text: i18n.T("table mode")} case "table": r.Configure_tracking("highlight") return presentation.CommandResult{Text: i18n.T("highlight tracking")} default: r.Configure_tracking("cursor") return presentation.CommandResult{Text: i18n.T("cursor tracking")} } case command.AutoRead: r.automatic = !r.automatic if r.automatic { return presentation.CommandResult{Text: i18n.T("autoread enabled")} } return presentation.CommandResult{Text: i18n.T("autoread disabled")} case command.Unbound: return presentation.CommandResult{Text: i18n.T("unbound"), Cue: presentation.Error} } beforeAttribute, beforeKnown := r.review.Attribute() previousRow := r.review.Row() result := r.review.Execute(id) afterAttribute, afterKnown := r.review.Attribute() result.Attributes = r.attribute_feedback(id != command.AttributeCursor && beforeKnown && afterKnown && beforeAttribute != afterAttribute) result.Indentation = r.indentation_feedback(r.review.Row() != previousRow) return result }