Files
screenreader-common/internalui/session.go
T

466 lines
13 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 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
}
}
}
}