Files
fenrir/internal/app/screen_updates.go

171 lines
5.8 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 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
}