// 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 ( "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 } } }