Files
fenrir/internal/app/quick_settings.go

300 lines
9.1 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"
"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."))
}