// 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" "errors" "fmt" "log/slog" "time" "git.stormux.org/storm/fenrir/internal/clipboard" "git.stormux.org/storm/fenrir/internal/config" "git.stormux.org/storm/fenrir/internal/control" "git.stormux.org/storm/fenrir/internal/presentation" "git.stormux.org/storm/fenrir/internal/scripts" "git.stormux.org/storm/fenrir/internal/spellcheck" "git.stormux.org/storm/fenrir/internal/terminal" ) // Run owns all screen/review/input state on one event loop. Speech network // work happens in Speaker's workers. A fatal I/O error always releases input. func Run(ctx context.Context, source Source, keyboards Keyboards, voices Speaker, options ...RunOptions) (result error) { tick := time.NewTicker(10 * time.Millisecond) defer tick.Stop() return run(ctx, source, keyboards, voices, tick.C, options...) } func run(ctx context.Context, source Source, keyboards Keyboards, voices Speaker, ticks <-chan time.Time, options ...RunOptions) (result error) { keysClosed := false var keyboardCloseError error releaseKeyboards := func() { if !keysClosed { keysClosed = true keyboardCloseError = keyboards.Close() } } defer func() { if errors.Is(result, terminal.ErrClosed) { result = nil } releaseKeyboards() result = errors.Join(result, keyboardCloseError) }() opts := RunOptions{Settings: config.Defaults()} if len(options) > 0 { opts = options[0] } a, err := new_application(ctx, source, keyboards, voices, opts) if err != nil { return err } defer func() { a.ui.close(); a.ui.testWorkers.Wait() }() defer func() { if a.notificationHistory != nil { a.notificationHistory.Close() } a.notificationClosers.Wait() }() defer func() { for _, selected := range a.overrides { selected.adapter.Close() } }() // Release grabs before waiting for UI tasks, adapters or audio shutdown. defer releaseKeyboards() return a.loop(ticks) } func (a *application) loop(ticks <-chan time.Time) error { var scriptResults <-chan scripts.Result if a.options.ScriptRunner != nil { scriptResults = a.options.ScriptRunner.Results() } var remoteRequests <-chan control.Request if a.options.Remote != nil { remoteRequests = a.options.Remote.Requests() } var spellResults <-chan spellcheck.Result if a.options.Spellcheck != nil { spellResults = a.options.Spellcheck.Results() } var clipboardResults <-chan clipboard.Result if a.options.Clipboard != nil { clipboardResults = a.options.Clipboard.Results() } var historyErrors <-chan error var historyUpdates <-chan clipboard.HistoryUpdate if a.options.ClipboardHistory != nil { historyErrors = a.options.ClipboardHistory.Errors() historyUpdates = a.options.ClipboardHistory.Updates() } var soundErrors <-chan error if a.cues != nil { soundErrors = a.cues.Errors() } clipboardTick := time.NewTicker(500 * time.Millisecond) defer clipboardTick.Stop() if err := a.read_screen(); err != nil { return err } scan := time.NewTicker(time.Second) defer scan.Stop() if err := a.poll_input(true); err != nil { return err } if a.options.Ready != nil { if err := a.options.Ready(); err != nil { return err } } for { if a.quitting { return nil } var notificationUpdates <-chan struct{} if a.notificationHistory != nil { notificationUpdates = a.notificationHistory.Updates() } select { case <-notificationUpdates: a.refresh_notification_history() case discovery := <-a.speechChoices: a.quick.discovery = discovery case loaded := <-a.overrideLoads: loaded() case result := <-a.speechResults: a.speech_result(result) if len(a.overrides) != 0 || a.overrideCurrent != nil { a.overrideDirty = true } case result := <-spellResults: if err := a.poll_input(false); err != nil { return err } a.spellcheck_result(result) case err := <-a.saver.results: a.settings_save_result(err) a.overrideDirty = true a.quitting = a.quitAfterSave case result := <-scriptResults: if err := a.poll_input(false); err != nil { return err } a.script_result(result) case request := <-remoteRequests: if err := a.poll_input(false); err != nil { return err } a.remote_request(request) case err := <-historyErrors: slog.Warn("clipboard history update failed", "error", err, "at", time.Now()) a.present(err.Error(), presentation.Error) case update := <-historyUpdates: a.clipboards.history_update(update) a.settings.live.Clipboard.Persistent = a.clipboards.settings.Persistent a.settings.saveDraft.Clipboard.Persistent = a.clipboards.settings.Persistent case result := <-clipboardResults: if err := a.poll_input(false); err != nil { return err } a.clipboards.result(result) case <-clipboardTick.C: a.clipboards.poll() case update := <-a.ui.updates: if err := a.poll_input(false); err != nil { return err } a.ui.update(update) case <-a.ctx.Done(): return nil case err := <-a.voice.Errors(): return fmt.Errorf("speech connection failed: %w", err) case err := <-soundErrors: slog.Warn("sound disabled", "error", err, "at", time.Now()) soundErrors, a.cues = nil, nil case err := <-a.source.Events(): if err := a.screen_event(err); err != nil { return err } 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 } case <-scan.C: if err := a.keyboards.Scan(); err != nil { return err } // Acquire newly discovered idle keyboards before their first key. if err := a.poll_input(true); err != nil { return err } case <-ticks: if a.overrideDirty { a.overrideDirty = false if err := a.read_screen(); err != nil { return err } } if err := a.poll_input(false); err != nil { return err } a.play_progress() a.spellMenu.poll() } } }