309 lines
10 KiB
Go
309 lines
10 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 config
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"os"
|
|
"reflect"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.stormux.org/storm/fenrir/internal/datetime"
|
|
"git.stormux.org/storm/fenrir/internal/punctuation"
|
|
"git.stormux.org/storm/fenrir/internal/sound"
|
|
"git.stormux.org/storm/fenrir/internal/speech"
|
|
)
|
|
|
|
// Section and Definition are derived from the typed TOML schema. A labelled
|
|
// struct is a section/group; labelled leaves supply controls and validation.
|
|
// Unlabelled leaves remain file-only settings and survive form submission.
|
|
type Section struct {
|
|
ID, Label string
|
|
Sections []Section
|
|
Settings []Definition
|
|
}
|
|
|
|
type Definition struct {
|
|
Path, Label string
|
|
Kind reflect.Kind
|
|
Min, Max, Step float64
|
|
ChoiceSource string
|
|
QuickLabel string
|
|
SpeechCapability string
|
|
}
|
|
|
|
type Choice struct {
|
|
ID, Label string
|
|
Unavailable string
|
|
}
|
|
|
|
func Sections() []Section { return sections(reflect.TypeFor[Settings](), "") }
|
|
|
|
func sections(structType reflect.Type, prefix string) []Section {
|
|
var result []Section
|
|
for index := range structType.NumField() {
|
|
field := structType.Field(index)
|
|
if field.Type.Kind() != reflect.Struct {
|
|
continue
|
|
}
|
|
path := prefix + field.Tag.Get("toml")
|
|
section := Section{ID: path, Label: field.Tag.Get("label")}
|
|
section.Sections = sections(field.Type, path+".")
|
|
for childIndex := range field.Type.NumField() {
|
|
child := field.Type.Field(childIndex)
|
|
if child.Type.Kind() == reflect.Struct || child.Tag.Get("label") == "" {
|
|
continue
|
|
}
|
|
definition := Definition{Path: path + "." + child.Tag.Get("toml"), Label: child.Tag.Get("label"), Kind: child.Type.Kind(), ChoiceSource: child.Tag.Get("choices"), QuickLabel: child.Tag.Get("quick"), SpeechCapability: child.Tag.Get("speech")}
|
|
if definition.ChoiceSource == "" && (definition.Kind == reflect.Int || definition.Kind == reflect.Float64) {
|
|
// Numeric bounds are mandatory metadata for UI-editable numbers.
|
|
for tag, target := range map[string]*float64{"min": &definition.Min, "max": &definition.Max, "step": &definition.Step} {
|
|
value, err := strconv.ParseFloat(child.Tag.Get(tag), 64)
|
|
if err != nil {
|
|
panic("invalid numeric setting definition: " + definition.Path + " " + tag)
|
|
}
|
|
*target = value
|
|
}
|
|
}
|
|
section.Settings = append(section.Settings, definition)
|
|
}
|
|
result = append(result, section)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func Definitions() []Definition {
|
|
var result []Definition
|
|
var visit func([]Section)
|
|
visit = func(sections []Section) {
|
|
for _, section := range sections {
|
|
result = append(result, section.Settings...)
|
|
visit(section.Sections)
|
|
}
|
|
}
|
|
visit(Sections())
|
|
return result
|
|
}
|
|
|
|
func setting_value(settings reflect.Value, path string) reflect.Value {
|
|
for _, name := range strings.Split(path, ".") {
|
|
structType := settings.Type()
|
|
found := false
|
|
for index := range structType.NumField() {
|
|
if structType.Field(index).Tag.Get("toml") == name {
|
|
settings = settings.Field(index)
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
panic("unknown setting definition: " + path)
|
|
}
|
|
}
|
|
return settings
|
|
}
|
|
|
|
func (d Definition) Value(settings Settings) any {
|
|
value := setting_value(reflect.ValueOf(settings), d.Path)
|
|
if value.Kind() == reflect.Slice {
|
|
return append([]string(nil), value.Interface().([]string)...)
|
|
}
|
|
return value.Interface()
|
|
}
|
|
|
|
// Set assigns a typed draft value. Validation still runs on the entire draft.
|
|
func (d Definition) Set(settings *Settings, value any) error {
|
|
target := setting_value(reflect.ValueOf(settings).Elem(), d.Path)
|
|
source := reflect.ValueOf(value)
|
|
if !source.IsValid() || source.Type() != target.Type() {
|
|
return fmt.Errorf("invalid value type for %s", d.Path)
|
|
}
|
|
target.Set(source)
|
|
return nil
|
|
}
|
|
|
|
// Available controls visibility and validation using the selected driver's
|
|
// implemented capabilities. Hidden form values can survive a driver round trip.
|
|
func (d Definition) Available(settings Settings) bool {
|
|
if d.SpeechCapability == "" {
|
|
return true
|
|
}
|
|
driver, err := speech.Find_driver(settings.Presentation.Speech.Driver)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
switch d.SpeechCapability {
|
|
case "serial":
|
|
return driver.Serial
|
|
case "modules":
|
|
return driver.Modules != nil
|
|
case "voices":
|
|
return driver.Voices != nil
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (d Definition) Choices(settings Settings) []Choice {
|
|
switch d.ChoiceSource {
|
|
case "keyboard-layout":
|
|
choices := []Choice{{ID: "desktop", Label: "Desktop"}, {ID: "laptop", Label: "Laptop"}}
|
|
if settings.Keyboard.Layout != "desktop" && settings.Keyboard.Layout != "laptop" {
|
|
choices = append(choices, Choice{ID: settings.Keyboard.Layout, Label: settings.Keyboard.Layout})
|
|
}
|
|
return choices
|
|
case "sound-driver":
|
|
var result []Choice
|
|
for _, driver := range sound.Drivers() {
|
|
result = append(result, Choice{ID: driver.ID, Label: driver.Label})
|
|
}
|
|
return result
|
|
case "sound-theme":
|
|
home, _ := os.UserHomeDir()
|
|
var result []Choice
|
|
found := false
|
|
for _, name := range sound.Theme_names(home) {
|
|
result = append(result, Choice{ID: name, Label: name})
|
|
found = found || name == settings.Presentation.Sound.Theme
|
|
}
|
|
if !found {
|
|
name := settings.Presentation.Sound.Theme
|
|
result = append(result, Choice{ID: name, Label: name})
|
|
}
|
|
return result
|
|
case "speech-baud":
|
|
var result []Choice
|
|
found := false
|
|
for _, baud := range speech.Baud_rates() {
|
|
id := strconv.Itoa(baud)
|
|
label := id
|
|
if baud == 0 {
|
|
label = "default"
|
|
}
|
|
result = append(result, Choice{ID: id, Label: label})
|
|
found = found || baud == settings.Presentation.Speech.HardwareBaudRate
|
|
}
|
|
// Preserve unused file values while another driver is selected. An
|
|
// active serial driver still validates them before save/startup.
|
|
if !found {
|
|
id := strconv.Itoa(settings.Presentation.Speech.HardwareBaudRate)
|
|
result = append(result, Choice{ID: id, Label: id})
|
|
}
|
|
return result
|
|
case "indentation", "attributes":
|
|
return []Choice{{ID: "none", Label: "none"}, {ID: "speech", Label: "speech"}, {ID: "beeps", Label: "beeps"}, {ID: "both", Label: "speech and beeps"}}
|
|
case "time-format", "date-format":
|
|
now := time.Now()
|
|
options := datetime.Time_options(now)
|
|
if d.ChoiceSource == "date-format" {
|
|
options = datetime.Date_options(now)
|
|
}
|
|
var result []Choice
|
|
for _, option := range options {
|
|
result = append(result, Choice{ID: option.Value, Label: option.Label})
|
|
}
|
|
return result
|
|
case "punctuation-level":
|
|
var result []Choice
|
|
for _, level := range punctuation.Levels() {
|
|
result = append(result, Choice{ID: level, Label: level})
|
|
}
|
|
return result
|
|
case "tracking-mode":
|
|
return []Choice{{ID: "cursor", Label: "cursor tracking"}, {ID: "table", Label: "table mode"}, {ID: "highlight", Label: "highlight tracking"}}
|
|
case "speech-driver":
|
|
var result []Choice
|
|
for _, driver := range speech.Drivers() {
|
|
choice := Choice{ID: driver.ID, Label: driver.Label}
|
|
if err := driver.Availability(); err != nil {
|
|
choice.Unavailable = err.Error()
|
|
}
|
|
result = append(result, choice)
|
|
}
|
|
return result
|
|
case "speech-module", "speech-language", "speech-voice":
|
|
result := []Choice{{ID: "", Label: "default"}}
|
|
if value := d.Value(settings).(string); value != "" {
|
|
result = append(result, Choice{ID: value, Label: value})
|
|
}
|
|
return result
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validate_definitions(settings Settings) error {
|
|
for _, definition := range Definitions() {
|
|
if !definition.Available(settings) {
|
|
continue
|
|
}
|
|
value := definition.Value(settings)
|
|
if definition.ChoiceSource == "" && (definition.Kind == reflect.Int || definition.Kind == reflect.Float64) {
|
|
var number float64
|
|
if definition.Kind == reflect.Int {
|
|
number = float64(value.(int))
|
|
} else {
|
|
number = value.(float64)
|
|
}
|
|
if math.IsNaN(number) || math.IsInf(number, 0) || number < definition.Min || number > definition.Max {
|
|
return fmt.Errorf("%s must be between %g and %g", definition.Path, definition.Min, definition.Max)
|
|
}
|
|
}
|
|
// Driver support is independent of transient local availability. Module
|
|
// choices are discovered by the selected backend, never a static allowlist.
|
|
if definition.ChoiceSource == "speech-driver" {
|
|
if _, err := speech.Find_driver(value.(string)); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if definition.ChoiceSource == "tracking-mode" || definition.ChoiceSource == "punctuation-level" || definition.ChoiceSource == "time-format" || definition.ChoiceSource == "date-format" || definition.ChoiceSource == "indentation" || definition.ChoiceSource == "attributes" {
|
|
valid := false
|
|
for _, choice := range definition.Choices(settings) {
|
|
valid = valid || choice.ID == value.(string)
|
|
}
|
|
if !valid {
|
|
return fmt.Errorf("unsupported %s %q", definition.Path, value)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Ordered_sections preserves explicit sibling order and appends omitted sections
|
|
// in schema order. Nested paths never create duplicate top-level tabs.
|
|
func Ordered_sections(sections []Section, order []string) []Section {
|
|
result := append([]Section(nil), sections...)
|
|
ranks := make(map[string]int)
|
|
for index, id := range order {
|
|
if _, ok := ranks[id]; !ok {
|
|
ranks[id] = index
|
|
}
|
|
}
|
|
sort.SliceStable(result, func(left, right int) bool {
|
|
leftRank, leftOK := ranks[result[left].ID]
|
|
rightRank, rightOK := ranks[result[right].ID]
|
|
if leftOK != rightOK {
|
|
return leftOK
|
|
}
|
|
return leftOK && leftRank < rightRank
|
|
})
|
|
return result
|
|
}
|