192 lines
5.2 KiB
Go
192 lines
5.2 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/interaction"
|
|
"git.stormux.org/storm/fenrir/internal/preferences"
|
|
)
|
|
|
|
// internalUI is the host boundary for every form type and feature. All state
|
|
// changes run on the app loop; workers return generation-tagged presentation.
|
|
// Admitted settings writes complete through the application save coordinator.
|
|
type internalUI struct {
|
|
catalog func(preferences.Discovery)
|
|
ctx context.Context
|
|
keys Keyboards
|
|
voice Speaker
|
|
active bool
|
|
tutorial *tutorialMode
|
|
name string
|
|
generation uint64
|
|
session *interaction.Session
|
|
action func(string, *interaction.Session)
|
|
updates chan uiUpdate
|
|
saveSettings func(*config.Document, config.Settings, func(error)) bool
|
|
discoverChoices func(config.Settings) preferences.Discovery
|
|
changed func(*interaction.Session)
|
|
onClosed func()
|
|
gridSettings func() config.InternalUI
|
|
previewSpelling func(config.Reading)
|
|
speechOverride *config.Speech
|
|
testSpeech func(context.Context, config.Speech, config.Speech) error
|
|
cancelTest context.CancelFunc
|
|
testDone <-chan struct{}
|
|
testWorkers sync.WaitGroup
|
|
}
|
|
|
|
type uiUpdate struct {
|
|
apply func() // Event-loop-only completion; guarded by menu generation.
|
|
discovery *preferences.Discovery
|
|
generation uint64
|
|
session *interaction.Session
|
|
action func(string, *interaction.Session)
|
|
speech string
|
|
changed func(*interaction.Session)
|
|
close bool
|
|
}
|
|
|
|
func new_internal_ui(ctx context.Context, keys Keyboards, voice Speaker) *internalUI {
|
|
ui := &internalUI{ctx: ctx, keys: keys, voice: voice, updates: make(chan uiUpdate, 8)}
|
|
ui.testSpeech = ui.test_synthesizer
|
|
return ui
|
|
}
|
|
|
|
func (ui *internalUI) stop_test() {
|
|
if ui.cancelTest != nil {
|
|
ui.cancelTest()
|
|
ui.cancelTest = nil
|
|
}
|
|
}
|
|
|
|
func (ui *internalUI) begin(name string) (uint64, error) {
|
|
if err := ui.keys.Capture(true); err != nil {
|
|
return 0, err
|
|
}
|
|
ui.stop_test()
|
|
ui.generation++
|
|
ui.active, ui.session, ui.action = true, nil, nil
|
|
ui.changed, ui.onClosed = nil, nil
|
|
ui.name = name
|
|
return ui.generation, nil
|
|
}
|
|
|
|
func (ui *internalUI) close() {
|
|
if !ui.active {
|
|
return
|
|
}
|
|
ui.stop_test()
|
|
if ui.tutorial != nil {
|
|
if err := ui.keys.Capture(false); err != nil {
|
|
slog.Error("release tutorial capture", "error", err, "at", time.Now())
|
|
ui.voice.Say(i18n.T(tutorialReleaseFailed))
|
|
return
|
|
}
|
|
ui.generation++
|
|
ui.active, ui.tutorial = false, nil
|
|
ui.voice.Say(i18n.T(tutorialExit))
|
|
return
|
|
}
|
|
ui.generation++
|
|
name := ui.name
|
|
ui.name = ""
|
|
ui.active, ui.session, ui.action = false, nil, nil
|
|
if ui.onClosed != nil {
|
|
ui.onClosed()
|
|
}
|
|
ui.changed, ui.onClosed = nil, nil
|
|
if err := ui.keys.Capture(false); err != nil {
|
|
slog.Error("release internal UI capture", "error", err, "at", time.Now())
|
|
}
|
|
ui.voice.Say(i18n.Format("{0} closed", i18n.T(name)))
|
|
}
|
|
|
|
func (ui *internalUI) post(update uiUpdate) {
|
|
select {
|
|
case ui.updates <- update:
|
|
case <-ui.ctx.Done():
|
|
}
|
|
}
|
|
|
|
func (ui *internalUI) update(update uiUpdate) {
|
|
if !ui.active || ui.generation != update.generation {
|
|
return
|
|
}
|
|
if update.apply != nil {
|
|
update.apply()
|
|
return
|
|
}
|
|
if update.discovery != nil && ui.catalog != nil {
|
|
ui.catalog(*update.discovery)
|
|
}
|
|
if update.close {
|
|
ui.close()
|
|
} else {
|
|
ui.session, ui.action = update.session, update.action
|
|
if update.changed != nil {
|
|
ui.changed = update.changed
|
|
}
|
|
}
|
|
if update.speech != "" {
|
|
ui.voice.Say(update.speech)
|
|
}
|
|
}
|
|
|
|
func (ui *internalUI) configure_session(session *interaction.Session) {
|
|
if ui.gridSettings != nil {
|
|
settings := ui.gridSettings()
|
|
session.Configure_grid(settings.AnnounceGridCoordinates, settings.AnnounceItemPosition)
|
|
}
|
|
}
|
|
|
|
func (ui *internalUI) handle(key interaction.Key) {
|
|
if key.Name == "escape" {
|
|
ui.close()
|
|
return
|
|
}
|
|
if ui.session == nil {
|
|
return
|
|
} // Loading or saving still owns every key.
|
|
ui.configure_session(ui.session)
|
|
result := ui.session.Handle(key)
|
|
if ui.changed != nil {
|
|
ui.changed(ui.session)
|
|
}
|
|
if result.Cancelled || result.Action == "cancel" {
|
|
ui.close()
|
|
return
|
|
}
|
|
if result.Speech != "" {
|
|
ui.voice.Say(result.Speech)
|
|
}
|
|
if result.Action != "" && ui.action != nil {
|
|
ui.action(result.Action, ui.session)
|
|
}
|
|
}
|
|
|
|
func (ui *internalUI) failed(message string, err error) {
|
|
ui.voice.Say(i18n.Format(message, err.Error()))
|
|
}
|