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