Files

226 lines
6.5 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"
"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()
}
}
}