// 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 interaction models reader-owned forms without device, speech or // settings dependencies. Hosts own capture and perform the returned actions. package interaction import ( "fmt" "math" ) type Session struct { form Form tab, focus, cursor int // focus -1 is the tab strip. announceGridCoordinates, announceItemPosition bool selectAll bool } func New_session(form Form) (*Session, error) { if form.Type != TabForm { 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) } control.Items = append([]string(nil), control.Items...) control.Choices = append([]Choice(nil), control.Choices...) if control.Kind == ComboBox || control.Kind == Grid { if !(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 == Grid { if control.Columns < 1 { return nil, fmt.Errorf("invalid grid width %q", control.ID) } control.filter_grid() } if control.Kind == List && (len(control.Items) == 0 || control.Selected < 0 || control.Selected >= len(control.Items)) { return nil, fmt.Errorf("invalid list %q", control.ID) } } } return &Session{form: form, focus: -1, announceGridCoordinates: true, announceItemPosition: true}, 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 { 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.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...) control.filter_grid() matches := control.grid_matches() 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) + 1 for range count { s.focus = (s.focus+1+delta+count)%count - 1 if s.focus == -1 || !s.form.Tabs[s.tab].Controls[s.focus].Hidden { break } } s.selectAll = false if s.focus >= 0 { s.cursor = len([]rune(s.form.Tabs[s.tab].Controls[s.focus].Text)) } } // 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 key.Name == "escape" { 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 { delta := 0 switch key.Name { case "left", "up": delta = -1 case "right", "down": delta = 1 case "enter", "space": s.move_focus(1) } if delta != 0 { s.tab = (s.tab + delta + len(s.form.Tabs)) % len(s.form.Tabs) } if delta != 0 || key.Name == "enter" || key.Name == "space" { 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: switch key.Name { case "up", "left": control.Selected = max(0, control.Selected-1) case "down", "right": control.Selected = min(len(control.Choices)-1, control.Selected+1) case "home": control.Selected = 0 case "end": control.Selected = len(control.Choices) - 1 default: return Result{} } return Result{Speech: 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 } } }