// 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 app import ( "fmt" "time" "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/spellcheck" ) func (a *application) read_screen() error { started := time.Now() snapshot, err := a.source.Read() diagnostic.Timing("screen capture", started) if err != nil { return err } a.share_notification_history(snapshot.Terminal, snapshot.Active) if a.lastTerminal == 0 { if snapshot.Active { a.present(i18n.T("Start Fenrir"), presentation.Ready) } else { a.play(presentation.Ready) } } sessionChanged := a.lastTerminal != 0 && (snapshot.Terminal != a.lastTerminal || snapshot.Active != a.lastActive) if a.lastTerminal != 0 && (sessionChanged || snapshot.Generation != a.lastGeneration || snapshot.Rows != a.lastRows || snapshot.Cols != a.lastCols) { a.spellSession++ } if a.cues != nil && (sessionChanged || a.lastTerminal != 0 && (snapshot.Generation != a.lastGeneration || snapshot.Rows != a.lastRows || snapshot.Cols != a.lastCols)) { a.cues.Clear_progress() } if sessionChanged { a.voice.Interrupt() a.ui.close() a.clipboards.session_changed(snapshot.Terminal) if a.options.ScriptRunner != nil { a.scriptGeneration = a.options.ScriptRunner.Cancel() } } a.clipboards.terminal = snapshot.Terminal a.lastSnapshot = snapshot frame, ready := a.observe_override(snapshot) if !ready { a.lastTerminal, a.lastActive = snapshot.Terminal, snapshot.Active a.lastGeneration, a.lastRows, a.lastCols = snapshot.Generation, snapshot.Rows, snapshot.Cols a.lastNumLock = snapshot.NumLock return nil } updated := time.Now() output := a.engine.Update_application(snapshot, frame) if a.options.Spellcheck != nil && a.settings.live.Reading.Spellcheck && !a.ui.active && !a.muted && !a.temporarySilence { for _, word := range a.engine.Completed_words() { a.options.Spellcheck.Submit(spellcheck.Request{Operation: spellcheck.Automatic, Word: word, Generation: a.spellSession, Revision: a.spellFeedback}) } } restoreLine := a.prompts.Observe(a.engine.Automatic_snapshot()) restored := a.temporarySilence && !a.muted && !a.ui.active && restoreLine != "" if restored { a.temporarySilence = false a.present(i18n.T("Speech restored"), presentation.SpeechOn) if a.engine.Automatic_reading() { a.present(restoreLine, "") } } diagnostic.Timing("screen processing", updated) soundAvailable := a.cues != nil if player, ok := a.cues.(interface{ Available() bool }); ok { soundAvailable = player.Available() } diagnostic.Record("reader presentation", "terminal", snapshot.Terminal, "generation", snapshot.Generation, "messages", len(output), "ui_active", a.ui.active, "temporary_silence", a.temporarySilence, "muted", a.muted, "speech_available", a.settings.live.Presentation.Speech.Enabled, "progress_enabled", a.settings.live.Presentation.Progress.Enabled, "sound_enabled", a.settings.live.Presentation.Sound.Enabled, "sound_available", soundAvailable) // VT sounds remain available while automatic reading is disabled. if sessionChanged && snapshot.Active && len(output) == 0 { a.play(presentation.TTYChanged) } if !a.ui.active && !restored { for _, message := range output { if message.Attributes != nil { a.present_attributes(message) } else if message.Indentation != nil { a.present_indentation(message) } else if message.Character != nil && !a.temporarySilence { a.voice.say_character(*message.Character) } else { a.present(message.Text, message.Cue) } } } a.lastTerminal, a.lastActive = snapshot.Terminal, snapshot.Active a.lastGeneration, a.lastRows, a.lastCols = snapshot.Generation, snapshot.Rows, snapshot.Cols a.lastNumLock = snapshot.NumLock a.play_progress() return nil } func (a *application) poll_input(inputReady bool) error { tty, active, numLock, err := a.source.State() if err != nil { return err } changed := tty != a.lastTerminal || active != a.lastActive if changed { if err := a.read_screen(); err != nil { return err } active, numLock = a.lastActive, a.lastNumLock } if inputReady || changed { if err := a.keyboards.Poll(active, numLock, a.handle_input); err != nil { return err } } a.apply_pending_modifiers() return nil } func (a *application) screen_event(err error) error { if err != nil { return fmt.Errorf("screen watcher failed: %w", err) } // Interrupt for any pending key before presenting the resulting // screen echo. This prevents that same key from canceling its echo // just because the kernel reported screen readiness first. select { case err := <-a.keyboards.Events(): if err != nil { return fmt.Errorf("keyboard watcher failed: %w", err) } if err := a.poll_input(true); err != nil { return err } default: } if a.quitting { return nil } previousTTY, previousText := a.lastTerminal, a.lastActive if err := a.read_screen(); err != nil { return err } if previousTTY != a.lastTerminal || previousText != a.lastActive { if err := a.keyboards.Poll(a.lastActive, a.lastNumLock, a.handle_input); err != nil { return err } } return nil }