// 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 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() }