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