166 lines
6.8 KiB
Go
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
|
|
}
|
|
}
|
|
}
|