Files

322 lines
9.7 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 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
}
}
}