Files

166 lines
6.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 (
"context"
"log/slog"
"sync"
"time"
"git.stormux.org/storm/fenrir/internal/config"
"git.stormux.org/storm/fenrir/internal/i18n"
"git.stormux.org/storm/fenrir/internal/notifications"
"git.stormux.org/storm/fenrir/internal/preferences"
"git.stormux.org/storm/fenrir/internal/presentation"
"git.stormux.org/storm/fenrir/internal/prompt"
"git.stormux.org/storm/fenrir/internal/reader"
"git.stormux.org/storm/fenrir/internal/screen"
"git.stormux.org/storm/fenrir/internal/speech"
"git.stormux.org/storm/fenrir/internal/terminal"
)
// application owns mutable reader state. Its methods run only on the event
// loop; asynchronous services return results rather than changing this state.
type application struct {
ctx context.Context
source Source
keyboards Keyboards
voice *echoSpeaker
cues SoundPlayer
options RunOptions
engine *reader.Reader
ui *internalUI
clipboards *clipboardHost
spellMenu *spellcheckMenu
prompts *prompt.Detector
settings applicationSettings
quick *quickSettings
notifications []notification
saver *settingsSaver
speechPending bool
speechResults chan speechChangeResult
overrideLoads chan func()
speechChoices chan preferences.Discovery
prepareSpeech func(config.Speech) (speech.Transport, error)
notificationSound notificationSoundGate
notificationHistory *notifications.Client
notificationClosers sync.WaitGroup
muted, temporarySilence bool
quitting, quitAfterSave bool
scriptGeneration, spellSession, spellFeedback uint64
lastTerminal int
lastActive bool
lastGeneration uint64
lastRows, lastCols int
lastNumLock bool
lastSnapshot screen.Snapshot
overrides map[int]*applicationOverride
overrideCurrent *applicationOverride
overrideFailed *applicationOverride
overrideTransition, overrideRestoreFailed, overrideDirty bool
}
func new_application(ctx context.Context, source Source, keyboards Keyboards, voice Speaker, options RunOptions) (*application, error) {
prompts, err := prompt.New(options.Settings.Prompt.ExactMatches, options.Settings.Prompt.CustomPatterns)
if err != nil {
return nil, err
}
a := &application{
ctx: ctx, source: source, keyboards: keyboards, options: options,
voice: &echoSpeaker{Speaker: voice}, cues: options.Sounds,
engine: reader.New(), prompts: prompts,
settings: applicationSettings{
live: options.Settings, saveDraft: options.Settings, resetBase: options.Settings,
remoteFields: make(map[string]uint64),
speechAvailable: options.Settings.Presentation.Speech.Enabled,
},
muted: !options.Settings.Presentation.Speech.Enabled,
quick: new_quick_settings(options.Settings.Presentation.Speech.Driver), saver: &settingsSaver{results: make(chan error, 1)},
}
if options.EffectiveSpeech != nil {
copy_speech_selection(&a.settings.live.Presentation.Speech, *options.EffectiveSpeech)
}
a.voice.punctuation, a.voice.punctuationLevel = options.Punctuation, options.Settings.Reading.PunctuationLevel
a.voice.settings, a.voice.play = &a.settings.live, a.play
a.ui = new_internal_ui(ctx, keyboards, a.voice)
a.ui.gridSettings = func() config.InternalUI { return a.settings.live.InternalUI }
a.ui.previewSpelling = a.preview_spelling
a.ui.discoverChoices, a.ui.saveSettings = options.SettingsChoices, a.save_menu_settings
a.speechResults = make(chan speechChangeResult, 1)
a.overrideLoads = make(chan func(), 1)
a.speechChoices = make(chan preferences.Discovery, 1)
a.prepareSpeech = prepare_speech
a.quick.selectSpeech = func(target config.Speech, done func(error, bool)) {
session, feedback, uiGeneration := a.spellSession, a.spellFeedback, a.ui.generation
a.change_speech(target, func(err error) {
done(err, a.spellSession == session && a.spellFeedback == feedback && a.ui.generation == uiGeneration && !a.quitting)
})
}
a.ui.catalog = func(discovery preferences.Discovery) {
if discovery.Driver == "" || discovery.Driver == a.settings.live.Presentation.Speech.Driver {
a.quick.discovery = discovery
}
}
if options.SettingsChoices != nil {
go func() {
discovery := options.SettingsChoices(options.Settings)
select {
case a.speechChoices <- discovery:
case <-ctx.Done():
}
}()
}
a.configure_reader()
a.clipboards = new_clipboard(options.Settings.Clipboard, a.engine, options.Clipboard, a.present, func() string { return a.voice.last })
a.clipboards.historyStore = options.ClipboardHistory
a.restore_clipboard()
a.spellMenu = &spellcheckMenu{ui: a.ui, service: options.Spellcheck, clipboards: a.clipboards}
if session, ok := source.(*terminal.Session); ok {
a.clipboards.queuePaste = session.Queue_paste
}
return a, nil
}
func (a *application) restore_clipboard() {
if a.options.ClipboardHistory == nil {
return
}
if a.settings.live.Clipboard.Persistent {
if err := a.options.ClipboardHistory.LoadErr; err != nil {
slog.Warn("clipboard history load failed", "error", err, "at", time.Now())
a.present(i18n.T("Clipboard import failed"), presentation.Error)
} else if err := a.clipboards.history.Restore(a.options.ClipboardHistory.Initial); err != nil {
slog.Warn("clipboard history load failed", "error", err, "at", time.Now())
a.present(i18n.T("Clipboard import failed"), presentation.Error)
} else if len(a.options.ClipboardHistory.Initial.Entries) > a.settings.live.Clipboard.NumberOfClipboards {
a.options.ClipboardHistory.Resize(a.settings.live.Clipboard.NumberOfClipboards)
}
if a.options.ClipboardHistory.LoadErr == nil {
a.clipboards.settings.Persistent = a.options.ClipboardHistory.InitialEnabled
a.settings.live.Clipboard.Persistent = a.options.ClipboardHistory.InitialEnabled
a.settings.saveDraft.Clipboard.Persistent = a.options.ClipboardHistory.InitialEnabled
}
}
}