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