Files

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()))
}