Files

163 lines
5.9 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 review owns the review cursor and actions on a visible screen.
// All state is used by the reader's event loop; actions never perform device or speech I/O.
package review
import (
"git.stormux.org/storm/fenrir/internal/command"
"git.stormux.org/storm/fenrir/internal/i18n"
"git.stormux.org/storm/fenrir/internal/presentation"
"git.stormux.org/storm/fenrir/internal/screen"
)
// Review has a cursor independent of the application. Its zero value is ready for Update.
type Review struct {
result presentation.CommandResult
attributeFormat string
screen screen.Snapshot
suppressLineBreak bool
suppressScreenBoundary bool
keepOnScreenChange bool
ready bool
reviewing bool
x, y int
markCount, startMark, endMark int
tableMode bool
tableColumn int
tableHeaders []string
tableLayout tableLayout
tableManual bool
tableAutoDisabled bool
}
func (r *Review) Configure(lineBreak, endOfScreen, leaveOnScreenChange bool) {
r.suppressLineBreak = !lineBreak
r.suppressScreenBoundary = !endOfScreen
r.keepOnScreenChange = !leaveOnScreenChange
}
// handlers is the explicit list of review actions. Add an action file and an
// entry here; input bindings live separately in package input.
var handlers = map[command.ID]func(*Review) string{
command.AttributeCursor: (*Review).attribute_cursor,
command.CursorLine: (*Review).cursor_line,
command.CursorColumn: (*Review).cursor_column,
command.LineToCursor: (*Review).line_to_cursor,
command.LineFromCursor: (*Review).line_from_cursor,
command.ScreenFirstChar: (*Review).screen_first_character,
command.ScreenLastChar: (*Review).screen_last_character,
command.ScreenBeforeCursor: (*Review).screen_before_cursor,
command.ScreenAfterCursor: (*Review).screen_after_cursor,
command.ReviewUp: (*Review).review_up,
command.ReviewDown: (*Review).review_down,
command.SpellWord: (*Review).spell_word,
command.PhoneticWord: (*Review).phonetic_word,
command.SpellLine: (*Review).spell_line,
command.PhoneticLine: (*Review).phonetic_line,
command.PhoneticChar: (*Review).phonetic_character,
command.PreviousLine: (*Review).previousLine,
command.CurrentLine: (*Review).currentLine,
command.NextLine: (*Review).nextLine,
command.Top: (*Review).top,
command.Bottom: (*Review).bottom,
command.PreviousChar: (*Review).previousCharacter,
command.CurrentChar: (*Review).currentCharacter,
command.NextChar: (*Review).nextCharacter,
command.LineStart: (*Review).lineStart,
command.LineEnd: (*Review).lineEnd,
command.FirstText: (*Review).firstText,
command.LastText: (*Review).lastText,
command.PreviousWord: (*Review).previousWord,
command.CurrentWord: (*Review).currentWord,
command.NextWord: (*Review).nextWord,
command.Position: (*Review).position,
command.ReadScreen: (*Review).readScreen,
command.FollowCursor: (*Review).followCursor,
}
// Update refreshes the screen, applying the configured review-exit policy.
// It reports whether the screen session changed, for automatic reading.
func (r *Review) Update(next screen.Snapshot) bool {
previous := r.screen
switched := !r.ready || previous.Terminal != next.Terminal || previous.Generation != next.Generation || previous.Active != next.Active || previous.Rows != next.Rows || previous.Cols != next.Cols
r.screen = next
r.ready = next.Active
if switched {
r.Clear_marks()
r.tableHeaders = nil
r.tableLayout = tableLayout{}
r.tableManual, r.tableAutoDisabled = false, false
r.tableColumn = 0
}
if !next.Active {
r.reviewing = false
return switched
}
if switched && !r.keepOnScreenChange || !r.reviewing {
r.x, r.y = next.X, next.Y
if switched {
r.reviewing = false
}
}
r.x, r.y = max(0, min(r.x, next.Cols-1)), max(0, min(r.y, next.Rows-1))
if r.tableMode && (switched || !r.reviewing) {
r.sync_table_column()
}
return switched
}
// Active reports whether review is detached from the application cursor.
func (r *Review) Active() bool { return r.reviewing }
// Execute checks screen availability before invoking an action. Action methods
// share this guard; the caller owns presentation and interruption.
func (r *Review) Execute(id command.ID) presentation.CommandResult {
r.result = presentation.CommandResult{}
r.result.Text = r.execute(id)
return r.result
}
func (r *Review) execute(id command.ID) string {
if !r.ready {
r.result.Cue = presentation.Error
return i18n.T("no text console")
}
action, ok := handlers[id]
if !ok {
r.result.Cue = presentation.Error
return i18n.T("unbound")
}
switch id {
case command.AttributeCursor, command.CursorLine, command.CursorColumn, command.LineToCursor, command.LineFromCursor, command.FollowCursor:
// Reports use the active cursor without entering detached review.
default:
if !r.reviewing {
r.start_table_review()
}
r.reviewing = true
}
if r.tableMode {
if text, handled := r.table_command(id); handled {
return text
}
}
return action(r)
}