After commit c018f5fd29 Fenrir is being switched over to go. If you do not want to be part of the testing process, please switch to master.

This commit is contained in:
Storm Dragon committed 2026-10-27 20:30:55 -04:00
1 parent c018f5fd29
commit 0e50de4c76
1047 files changed
+58364 -63747

No files matched your search

+299
View File
@@ -0,0 +1,299 @@
// 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"
"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."))
}