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