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