Files

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
}