// 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" "reflect" "sort" "strconv" "strings" "time" "git.stormux.org/storm/fenrir/internal/command" "git.stormux.org/storm/fenrir/internal/config" "git.stormux.org/storm/fenrir/internal/i18n" "git.stormux.org/storm/fenrir/internal/preferences" "git.stormux.org/storm/fenrir/internal/speech" ) type quickCategory struct { label string entries []config.Definition position int } type quickSettings struct { selectionChanged bool categories []quickCategory category int changed bool discovery preferences.Discovery selectSpeech func(config.Speech, func(error, bool)) revision uint64 fields map[string]uint64 } func new_quick_settings(driverID string) *quickSettings { driver, _ := speech.Find_driver(driverID) // Startup settings are validated. var sections []config.Section var visit func([]config.Section) visit = func(groups []config.Section) { for _, group := range groups { sections = append(sections, group) visit(group.Sections) } } visit(config.Sections()) menu := &quickSettings{} for _, section := range config.Ordered_sections(sections, []string{"presentation.speech", "reading", "keyboard"}) { category := quickCategory{label: section.Label} for _, definition := range section.Settings { if definition.ChoiceSource == "speech-module" && driver.Modules == nil { continue } if (definition.ChoiceSource == "speech-language" || definition.ChoiceSource == "speech-voice") && driver.Voices == nil { continue } if definition.QuickLabel != "" { category.entries = append(category.entries, definition) } } if section.ID == "presentation.speech" { ranks := map[string]int{"rate": 0, "pitch": 1, "volume": 2, "module": 3, "language": 4, "voice": 5} sort.SliceStable(category.entries, func(i, j int) bool { return ranks[strings.TrimPrefix(category.entries[i].Path, "presentation.speech.")] < ranks[strings.TrimPrefix(category.entries[j].Path, "presentation.speech.")] }) } if len(category.entries) > 0 { menu.categories = append(menu.categories, category) } } return menu } // Overlay only the curated quick settings, preserving file-only and session // choices such as automatic reading, mute and the attribute feedback override. func copy_quick_settings(target *config.Settings, source config.Settings) { for _, definition := range config.Definitions() { if definition.QuickLabel != "" && !speech_choice(definition.ChoiceSource) { if err := definition.Set(target, definition.Value(source)); err != nil { panic(err) } } } } func speech_parameters(settings config.Settings) speech.Parameters { voice := settings.Presentation.Speech return speech.Parameters{Rate: voice.Rate, Pitch: voice.Pitch, Volume: voice.Volume} } func (q *quickSettings) command(id command.ID, settings *config.Settings, voice Speaker) bool { switch id { case command.CurrentQuickEntry, command.CurrentQuickValue, command.NextQuickEntry, command.PreviousQuickEntry, command.NextQuickValue, command.PreviousQuickValue, command.NextQuickCategory, command.PreviousQuickCategory: default: return false } if len(q.categories) == 0 { voice.Say(i18n.T("Quick menu not available")) return true } categoryChanged := id == command.NextQuickCategory || id == command.PreviousQuickCategory if categoryChanged { step := 1 if id == command.PreviousQuickCategory { step = -1 } q.category = (q.category + step + len(q.categories)) % len(q.categories) } category := &q.categories[q.category] switch id { case command.NextQuickEntry: category.position = (category.position + 1) % len(category.entries) case command.PreviousQuickEntry: category.position = (category.position + len(category.entries) - 1) % len(category.entries) } entry := category.entries[category.position] previous := entry.Value(*settings) adjusting := id == command.NextQuickValue || id == command.PreviousQuickValue step := 1 if id == command.PreviousQuickValue { step = -1 } var text string switch { case entry.Kind == reflect.Bool: value := entry.Value(*settings).(bool) if adjusting { value = !value if err := entry.Set(settings, value); err != nil { panic(err) } q.changed = true } text = "unchecked" if value { text = "checked" } text = i18n.T(text) case entry.ChoiceSource != "": choices := entry.Choices(*settings) dynamic := entry.ChoiceSource == "speech-module" || entry.ChoiceSource == "speech-language" || entry.ChoiceSource == "speech-voice" if dynamic { choices = q.discovery.Choices(*settings)[entry.Path] } position := 0 for index, choice := range choices { if choice.ID == entry.Value(*settings).(string) { position = index break } } if adjusting { position = (position + step + len(choices)) % len(choices) if dynamic && q.selectSpeech != nil { candidate := *settings if err := entry.Set(&candidate, choices[position].ID); err != nil { panic(err) } if entry.ChoiceSource == "speech-module" { candidate.Presentation.Speech.Language = "" candidate.Presentation.Speech.Voice = "" } if entry.ChoiceSource == "speech-language" { candidate.Presentation.Speech.Voice = "" } message := i18n.T(choices[position].Label) if choices[position].ID == "" { message = i18n.T(entry.QuickLabel) + " " + i18n.T("default") } q.selectSpeech(candidate.Presentation.Speech, func(err error, present bool) { if err != nil { slog.Warn("speech selection failed", "error", err, "at", time.Now()) if present { voice.Say(i18n.T("Voice test failed")) } return } copy_speech_selection(&settings.Presentation.Speech, candidate.Presentation.Speech) q.changed = true q.selectionChanged = true for _, path := range []string{"presentation.speech.module", "presentation.speech.language", "presentation.speech.voice"} { q.mark_changed(path) } if present { voice.Say(message) } }) return true } if err := entry.Set(settings, choices[position].ID); err != nil { panic(err) } q.changed = true } text = i18n.T(choices[position].Label) default: value := entry.Value(*settings).(int) if adjusting { // Python's 0.05 normalized step on the 200-point SSIP scale. next := max(int(entry.Min), min(int(entry.Max), value+step*10)) if next != value { if err := entry.Set(settings, next); err != nil { panic(err) } voice.Configure(speech_parameters(*settings)) q.changed = true value = next } } text = strconv.Itoa(value) } if !reflect.DeepEqual(previous, entry.Value(*settings)) { q.mark_changed(entry.Path) } defaultSpeech := speech_choice(entry.ChoiceSource) && entry.Value(*settings).(string) == "" if defaultSpeech { text = i18n.T(entry.QuickLabel) + " " + i18n.T("default") } else if categoryChanged || id == command.CurrentQuickEntry || id == command.NextQuickEntry || id == command.PreviousQuickEntry { text = i18n.T(entry.QuickLabel) + " " + text } if categoryChanged { text = i18n.T(category.label) + ". " + text } voice.Say(text) return true } func (q *quickSettings) mark_changed(path string) { if q.fields == nil { q.fields = make(map[string]uint64) } q.revision++ q.fields[path] = q.revision } // Direct saves keep disk I/O off the input loop. Only one save is admitted at // a time; the captured settings remain an immutable snapshot for that write. type settingsSaver struct { busy bool results chan error snapshot config.Settings revision uint64 reply chan string menuDone func(error) } func (s *settingsSaver) save(ctx context.Context, path string, settings config.Settings) bool { return s.start(ctx, settings, func() error { doc, err := config.Open_document(path, settings) if err != nil { return err } return doc.Save(settings) }) } func (s *settingsSaver) start(ctx context.Context, settings config.Settings, write func() error) bool { if s.busy { return false } s.busy = true s.snapshot = settings s.reply = nil go func() { err := write() select { case s.results <- err: case <-ctx.Done(): } }() return true } func (s *settingsSaver) result(err error, voice Speaker) { s.busy = false if err != nil { slog.Warn("save settings failed", "error", err, "at", time.Now()) voice.Say(i18n.T("Failed to save settings.")) return } voice.Say(i18n.T("Settings saved.")) }