// 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 internalui models reader-owned forms without device, speech or // settings dependencies. Hosts own capture and perform the returned actions. package internalui import ( "fmt" "math" "strings" "unicode" "unicode/utf8" ) type Session struct { messages Messages form Form tab, focus, cursor int // focus -1 is the tab strip. announceGridCoordinates, announceItemPosition bool anchor int closed bool } func New_session(form Form) (*Session, error) { return NewSession(form, Messages{}) } func NewSession(form Form, messages Messages) (*Session, error) { s := &Session{messages: messages} if form.Type != TabForm && form.Type != FlatForm { return nil, fmt.Errorf("unsupported form type %q", form.Type) } if form.Title == "" || len(form.Tabs) == 0 { return nil, fmt.Errorf("form requires a title and tabs") } // Every session owns its mutable values, including nested slices. form.Tabs = append([]Tab(nil), form.Tabs...) ids := make(map[string]bool) for tabIndex := range form.Tabs { tab := &form.Tabs[tabIndex] if tab.ID == "" || tab.Label == "" || ids[tab.ID] { return nil, fmt.Errorf("invalid or duplicate tab %q", tab.ID) } ids[tab.ID] = true tab.Controls = append([]Control(nil), tab.Controls...) controlIDs := make(map[string]bool) for controlIndex := range tab.Controls { control := &tab.Controls[controlIndex] if control.ID == "" || control.Label == "" || controlIDs[control.ID] || control.Kind < CheckBox || control.Kind > Grid { return nil, fmt.Errorf("invalid control %q", control.ID) } controlIDs[control.ID] = true if control.Kind == Number { if control.UpDownStep == 0 { control.UpDownStep = 1 } if math.IsNaN(control.UpDownStep) || math.IsInf(control.UpDownStep, 0) || control.UpDownStep < 0 { return nil, fmt.Errorf("invalid vertical step %q", control.ID) } } if control.Kind == Number && (math.IsNaN(control.Number) || math.IsInf(control.Number, 0) || math.IsNaN(control.Min) || math.IsInf(control.Min, 0) || math.IsNaN(control.Max) || math.IsInf(control.Max, 0) || math.IsNaN(control.Step) || math.IsInf(control.Step, 0) || control.Min > control.Max || control.Step <= 0 || control.Number < control.Min || control.Number > control.Max) { return nil, fmt.Errorf("invalid range %q", control.ID) } if control.Secret && control.Kind != Entry { return nil, fmt.Errorf("only entries may be secret") } if control.MaxLength == 0 { control.MaxLength = 8192 } if control.MaxLength < 1 || control.MaxLength > 8192 || len([]rune(control.Text)) > control.MaxLength || !utf8.ValidString(control.Text) { return nil, fmt.Errorf("invalid entry length or text") } if control.Kind == Entry { for _, r := range control.Text { if unicode.IsControl(r) { return nil, fmt.Errorf("entries must contain single-line text") } } } control.Items = append([]string(nil), control.Items...) control.Choices = append([]Choice(nil), control.Choices...) if control.Kind == ComboBox || control.Kind == Grid || control.Kind == List && len(control.Choices) > 0 { if !control.Disabled && !(control.Kind == Grid && len(control.Choices) == 0) && (len(control.Choices) == 0 || control.Selected < 0 || control.Selected >= len(control.Choices)) { return nil, fmt.Errorf("invalid choices in %q", control.ID) } choiceIDs := make(map[string]bool) for _, choice := range control.Choices { if choice.Label == "" || choiceIDs[choice.ID] { return nil, fmt.Errorf("invalid choice in %q", control.ID) } choiceIDs[choice.ID] = true } } if (control.Kind == List || control.Kind == ComboBox) && len(control.Choices) > 0 { if control.Selected < 0 || control.Selected >= len(control.Choices) || control.Choices[control.Selected].Disabled { control.Selected = -1 for i, choice := range control.Choices { if !choice.Disabled { control.Selected = i break } } } if control.Selected < 0 { control.Disabled = true } } if control.Kind == Grid { if control.Columns < 1 { return nil, fmt.Errorf("invalid grid width %q", control.ID) } s.filter_grid(control) } if control.Kind == List && !control.Disabled && len(control.Choices) == 0 && (len(control.Items) == 0 || control.Selected < 0 || control.Selected >= len(control.Items)) { return nil, fmt.Errorf("invalid list %q", control.ID) } } } s.form, s.focus = form, -1 if form.Type == FlatForm { if len(form.Tabs) != 1 { return nil, fmt.Errorf("flat forms require one page") } s.move_focus(1) if s.closed || s.focus < 0 { return nil, fmt.Errorf("form has no enabled controls") } } s.announceGridCoordinates, s.announceItemPosition = true, true return s, nil } // Configure_grid controls spoken detail without changing grid navigation or filtering. func (s *Session) Configure_grid(coordinates, position bool) { s.announceGridCoordinates, s.announceItemPosition = coordinates, position } // Values returns a copy suitable for validation or submission by the host. func (s *Session) Values() map[string]Control { if s.closed { return nil } values := make(map[string]Control) for _, tab := range s.form.Tabs { for _, control := range tab.Controls { control.Items = append([]string(nil), control.Items...) control.Choices = append([]Choice(nil), control.Choices...) values[tab.ID+"."+control.ID] = control } } return values } // Focused_control returns an independent value for the host's current action. func (s *Session) Focused_control() (Control, bool) { if s.closed || s.focus < 0 { return Control{}, false } control := s.form.Tabs[s.tab].Controls[s.focus] control.Items = append([]string(nil), control.Items...) control.Choices = append([]Choice(nil), control.Choices...) return control, true } // Set_choices updates an asynchronous grid without moving keyboard focus or // discarding its search. Keep the selected word when it still matches. func (s *Session) Set_choices(tabID, controlID string, choices []Choice) { for tabIndex := range s.form.Tabs { tab := &s.form.Tabs[tabIndex] if tab.ID != tabID { continue } for index := range tab.Controls { control := &tab.Controls[index] if control.ID != controlID || control.Kind != Grid { continue } selected := "" position := control.Selected if position >= 0 && position < len(control.Choices) { selected = control.Choices[position].ID } control.Choices = append([]Choice(nil), choices...) s.filter_grid(control) matches := s.grid_matches(*control) for _, match := range matches { if control.Choices[match].ID == selected { control.Selected = match return } } if len(matches) > 0 { control.Selected = matches[min(max(position, 0), len(matches)-1)] } return } } } func (s *Session) move_focus(delta int) { if s.focus >= 0 && s.form.Tabs[s.tab].Controls[s.focus].Kind == List { s.form.Tabs[s.tab].Controls[s.focus].Text = "" } count := len(s.form.Tabs[s.tab].Controls) strip := s.form.Type == TabForm size := count if strip { size++ } if size == 0 { s.focus = -1 return } for range size { if strip { s.focus = (s.focus+1+delta+size)%size - 1 } else { s.focus = (s.focus + delta + size) % size } if s.focus == -1 || !s.form.Tabs[s.tab].Controls[s.focus].Hidden && !s.form.Tabs[s.tab].Controls[s.focus].Disabled { break } } if s.focus >= 0 && (s.form.Tabs[s.tab].Controls[s.focus].Hidden || s.form.Tabs[s.tab].Controls[s.focus].Disabled) { s.focus = -1 } if s.focus >= 0 { s.cursor = len([]rune(s.form.Tabs[s.tab].Controls[s.focus].Text)) s.anchor = s.cursor } } // Set_hidden changes navigation without rebuilding the form or losing entry // edits. If the focused control disappears, move forward to a visible target. func (s *Session) Set_hidden(tabID, controlID string, hidden bool) { for tabIndex := range s.form.Tabs { tab := &s.form.Tabs[tabIndex] if tab.ID != tabID { continue } for index := range tab.Controls { if tab.Controls[index].ID != controlID { continue } tab.Controls[index].Hidden = hidden if hidden && s.tab == tabIndex && s.focus == index { s.move_focus(1) } return } } } func (s *Session) handle(key Key) Result { if s.closed { return Result{} } if key.Name == "escape" { s.Close() return Result{Cancelled: true} } if key.Name == "tab" && !key.Ctrl && !key.Alt { previousGroup := s.focused_group() delta := 1 if key.Shift { delta = -1 } s.move_focus(delta) return Result{Speech: s.describe_focus(previousGroup)} } if key.Alt { return Result{} } if key.Ctrl && s.focus == -1 { return Result{} } if s.focus == -1 { if s.form.Type == FlatForm { return Result{} } delta := 0 switch key.Name { case "left", "up": delta = -1 case "right", "down": delta = 1 case "home": s.tab = 0 case "end": s.tab = len(s.form.Tabs) - 1 case "enter", "space": s.move_focus(1) } if delta != 0 { s.tab = min(len(s.form.Tabs)-1, max(0, s.tab+delta)) } if delta != 0 || key.Name == "enter" || key.Name == "space" || key.Name == "home" || key.Name == "end" { return Result{Speech: s.describe_focus("")} } return Result{} } control := &s.form.Tabs[s.tab].Controls[s.focus] if control.Kind == Entry { return s.edit(control, key) } if control.Kind == Grid { return s.grid(control, key) } if control.Kind == List { return s.list(control, key) } if key.Ctrl { return Result{} } switch control.Kind { case CheckBox: switch key.Name { case "enter", "space": control.Checked = !control.Checked default: return Result{} } case Number: delta := 0.0 switch key.Name { case "up": delta = control.UpDownStep case "down": delta = -control.UpDownStep case "right": delta = control.Step case "left": delta = -control.Step default: return Result{} } control.Number = math.Max(control.Min, math.Min(control.Max, math.Round((control.Number+delta)*1e9)/1e9)) case ComboBox: name := key.Name if name == "left" { name = "up" } if name == "right" { name = "down" } switch name { case "up", "down", "home", "end", "pageup", "pagedown": default: return Result{} } s.list(control, Key{Name: name}) return Result{Speech: s.describe_choice(*control)} case Button: if key.Name == "enter" || key.Name == "space" { return Result{Action: control.Action} } return Result{} } return Result{Speech: s.Describe()} } // Replace_choices updates a dependent combo without changing focus. func (s *Session) Replace_choices(tabID, controlID string, choices []Choice, selected string) { if len(choices) == 0 { return } for ti := range s.form.Tabs { tab := &s.form.Tabs[ti] if tab.ID != tabID { continue } for ci := range tab.Controls { control := &tab.Controls[ci] if control.ID != controlID || control.Kind != ComboBox { continue } control.Choices = append([]Choice(nil), choices...) control.Selected = 0 for index, choice := range choices { if choice.ID == selected { control.Selected = index break } } return } } } func (s *Session) Handle(key Key) Result { r := s.handle(key) if r.Action != "" && r.Action != "cancel" { c, _ := s.Focused_control() if !c.SkipValidation { if problem := s.Validate(); problem != "" { return Result{Speech: problem, Braille: problem, Cursor: -1} } } } if r.Speech != "" && r.Braille == "" { p := s.Presentation() r.Braille, r.Cursor = p.Braille, p.Cursor } return r } func (s *Session) Validate() string { for _, tab := range s.form.Tabs { for _, c := range tab.Controls { if c.Required && !c.Hidden && !c.Disabled && c.Kind == Entry && strings.TrimSpace(c.Text) == "" { return s.messages.Format("{0} is required", s.messages.T(c.Label)) } } } return "" } func (s *Session) Close() { s.closed = true for ti := range s.form.Tabs { for ci := range s.form.Tabs[ti].Controls { c := &s.form.Tabs[ti].Controls[ci] if c.Secret { c.Text = "" } } } s.cursor, s.anchor = 0, 0 } func (s *Session) SetChecked(id string, checked bool) { for ti := range s.form.Tabs { for ci := range s.form.Tabs[ti].Controls { c := &s.form.Tabs[ti].Controls[ci] if c.ID == id && c.Kind == CheckBox { c.Checked = checked } } } } // Select restores a host-owned reading caret without moving focus. func (s *Session) Select(tabID, id string, index int) { for ti := range s.form.Tabs { if s.form.Tabs[ti].ID != tabID { continue } for ci := range s.form.Tabs[ti].Controls { c := &s.form.Tabs[ti].Controls[ci] if c.ID == id && index >= 0 && index < len(c.Choices) && !c.Choices[index].Disabled { c.Selected = index } } } }