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