241 lines
8.8 KiB
Go
241 lines
8.8 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 preferences adapts the shared typed setting definitions to reusable
|
|
// interaction controls. Hosts own input capture and disk I/O.
|
|
package preferences
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"git.stormux.org/storm/fenrir/internal/config"
|
|
"git.stormux.org/storm/fenrir/internal/interaction"
|
|
"git.stormux.org/storm/fenrir/internal/speech"
|
|
"github.com/pelletier/go-toml/v2"
|
|
)
|
|
|
|
type Choices map[string][]config.Choice
|
|
|
|
const MenuTitle = "Fenrir Menu"
|
|
|
|
func New_session(settings config.Settings, order []string, discovered ...Choices) (*interaction.Session, error) {
|
|
return new_session(settings, order, MenuTitle, false, discovered...)
|
|
}
|
|
|
|
func New_application_session(settings config.Settings, order []string, title string, discovered ...Choices) (*interaction.Session, error) {
|
|
return new_session(settings, order, title, true, discovered...)
|
|
}
|
|
|
|
func new_session(settings config.Settings, order []string, title string, application bool, discovered ...Choices) (*interaction.Session, error) {
|
|
if err := settings.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
form := interaction.Form{Type: interaction.TabForm, Title: title}
|
|
for _, section := range config.Ordered_sections(config.Sections(), order) {
|
|
if section.Label == "" {
|
|
continue
|
|
}
|
|
tab := interaction.Tab{ID: section.ID, Label: section.Label}
|
|
var append_section func(config.Section, string) error
|
|
append_section = func(section config.Section, group string) error {
|
|
for _, definition := range section.Settings {
|
|
if application && !config.Application_setting(definition.Path) {
|
|
continue
|
|
}
|
|
control, err := make_control(definition, settings, discovered)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
control.ID = strings.TrimPrefix(definition.Path, tab.ID+".")
|
|
control.Group = group
|
|
tab.Controls = append(tab.Controls, control)
|
|
}
|
|
for _, child := range config.Ordered_sections(section.Sections, order) {
|
|
label := child.Label
|
|
if group != "" {
|
|
label = group + ". " + label
|
|
}
|
|
if err := append_section(child, label); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if section.ID == "keyboard" && !application {
|
|
tab.Controls = append(tab.Controls, modifier_controls(settings)...)
|
|
}
|
|
if section.ID == "presentation.speech" {
|
|
driver, _ := speech.Find_driver(settings.Presentation.Speech.Driver)
|
|
tab.Controls = append(tab.Controls, interaction.Control{ID: "speech.test", Label: "Test synthesizer", Group: "Speech", Kind: interaction.Button, Action: "test-synthesizer", Hidden: !driver.Hardware})
|
|
}
|
|
return nil
|
|
}
|
|
if err := append_section(section, ""); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(tab.Controls) == 0 {
|
|
continue
|
|
}
|
|
tab.Controls = append(tab.Controls, interaction.Control{ID: "ok", Label: "OK", Kind: interaction.Button, Action: "save"}, interaction.Control{ID: "cancel", Label: "Cancel", Kind: interaction.Button, Action: "cancel"})
|
|
form.Tabs = append(form.Tabs, tab)
|
|
}
|
|
return interaction.New_session(form)
|
|
}
|
|
|
|
func make_control(definition config.Definition, settings config.Settings, discovered []Choices) (interaction.Control, error) {
|
|
control := interaction.Control{Label: definition.Label, Hidden: !definition.Available(settings)}
|
|
value := definition.Value(settings)
|
|
if definition.ChoiceSource != "" {
|
|
choices := definition.Choices(settings)
|
|
// Only backend discovery can supply module choices. Driver support always
|
|
// comes from the implemented registry, never a user-editable choice list.
|
|
if (definition.ChoiceSource == "speech-module" || definition.ChoiceSource == "speech-language" || definition.ChoiceSource == "speech-voice") && len(discovered) > 0 {
|
|
if supplied, ok := discovered[0][definition.Path]; ok {
|
|
choices = supplied
|
|
}
|
|
}
|
|
control.Kind = interaction.ComboBox
|
|
control.Selected = -1
|
|
for index, choice := range choices {
|
|
control.Choices = append(control.Choices, interaction.Choice{ID: choice.ID, Label: choice.Label, Unavailable: choice.Unavailable})
|
|
if choice.ID == fmt.Sprint(value) {
|
|
control.Selected = index
|
|
}
|
|
}
|
|
if control.Selected < 0 {
|
|
return control, fmt.Errorf("missing saved choice for %s", definition.Path)
|
|
}
|
|
return control, nil
|
|
}
|
|
switch definition.Kind {
|
|
case reflect.Bool:
|
|
control.Kind, control.Checked = interaction.CheckBox, value.(bool)
|
|
case reflect.Int, reflect.Float64:
|
|
control.Kind = interaction.Number
|
|
if definition.Kind == reflect.Int {
|
|
control.Number = float64(value.(int))
|
|
} else {
|
|
control.Number = value.(float64)
|
|
}
|
|
control.Min, control.Max, control.Step = definition.Min, definition.Max, definition.Step
|
|
if definition.Path == "presentation.speech.rate" {
|
|
control.UpDownStep = 10
|
|
}
|
|
case reflect.String:
|
|
control.Kind, control.Text = interaction.Entry, value.(string)
|
|
case reflect.Slice:
|
|
devices := value.([]string)
|
|
if devices == nil {
|
|
devices = []string{}
|
|
}
|
|
encoded, err := json.Marshal(devices)
|
|
if err != nil {
|
|
return control, err
|
|
}
|
|
control.Kind, control.Text = interaction.Entry, string(encoded)
|
|
default:
|
|
return control, fmt.Errorf("unsupported control for %s", definition.Path)
|
|
}
|
|
return control, nil
|
|
}
|
|
|
|
func Settings(session *interaction.Session, base config.Settings) (config.Settings, error) {
|
|
return settings_values(session, base, false)
|
|
}
|
|
|
|
func Application_settings(session *interaction.Session, base config.Settings) (config.Settings, error) {
|
|
return settings_values(session, base, true)
|
|
}
|
|
|
|
func settings_values(session *interaction.Session, base config.Settings, application bool) (config.Settings, error) {
|
|
values := session.Values()
|
|
// Visibility follows the form's selected driver, including unsaved switches.
|
|
driver, hasDriver := values["presentation.speech.driver"]
|
|
if (!application && !hasDriver) || (hasDriver && (driver.Selected < 0 || driver.Selected >= len(driver.Choices))) {
|
|
return config.Settings{}, fmt.Errorf("invalid speech driver selection")
|
|
}
|
|
if hasDriver {
|
|
base.Presentation.Speech.Driver = driver.Choices[driver.Selected].ID
|
|
}
|
|
for _, definition := range config.Definitions() {
|
|
if !definition.Available(base) || (application && !config.Application_setting(definition.Path)) {
|
|
continue
|
|
}
|
|
control, ok := values[definition.Path]
|
|
if !ok {
|
|
return config.Settings{}, fmt.Errorf("missing setting %s", definition.Path)
|
|
}
|
|
var value any
|
|
if definition.ChoiceSource != "" {
|
|
if control.Selected < 0 || control.Selected >= len(control.Choices) {
|
|
return config.Settings{}, fmt.Errorf("invalid selection for %s", definition.Path)
|
|
}
|
|
value = control.Choices[control.Selected].ID
|
|
if definition.Kind == reflect.Int {
|
|
number, err := strconv.Atoi(value.(string))
|
|
if err != nil {
|
|
return config.Settings{}, fmt.Errorf("invalid selection for %s", definition.Path)
|
|
}
|
|
value = number
|
|
}
|
|
} else {
|
|
switch definition.Kind {
|
|
case reflect.Bool:
|
|
value = control.Checked
|
|
case reflect.Int:
|
|
if math.Trunc(control.Number) != control.Number {
|
|
return config.Settings{}, fmt.Errorf("%s requires a whole number", definition.Path)
|
|
}
|
|
value = int(control.Number)
|
|
case reflect.Float64:
|
|
value = control.Number
|
|
case reflect.String:
|
|
value = control.Text
|
|
case reflect.Slice:
|
|
var array struct {
|
|
Value []string `toml:"value"`
|
|
}
|
|
if err := toml.NewDecoder(strings.NewReader("value = " + control.Text)).DisallowUnknownFields().Decode(&array); err != nil {
|
|
return config.Settings{}, fmt.Errorf("%s: %w", definition.Path, err)
|
|
}
|
|
value = array.Value
|
|
}
|
|
}
|
|
if err := definition.Set(&base, value); err != nil {
|
|
return config.Settings{}, err
|
|
}
|
|
}
|
|
if !application {
|
|
if err := modifier_settings(values, &base); err != nil {
|
|
return config.Settings{}, err
|
|
}
|
|
}
|
|
// Inactive Speech Dispatcher choices stay in the form's draft for switching
|
|
// back, but cannot be sent to or saved as selections for a hardware driver.
|
|
for _, definition := range config.Definitions() {
|
|
if (definition.SpeechCapability == "modules" || definition.SpeechCapability == "voices") && !definition.Available(base) {
|
|
if err := definition.Set(&base, ""); err != nil {
|
|
return config.Settings{}, err
|
|
}
|
|
}
|
|
}
|
|
return base, base.Validate()
|
|
}
|