Files

271 lines
10 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 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
}