// 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 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) }