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