301 lines
9.2 KiB
Go
301 lines
9.2 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
// Package interaction implements focus-free controls. It owns no keyboard,
|
|
// accessibility objects, output services or callbacks; the reader owns those.
|
|
package interaction
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
ui "git.stormux.org/storm/screenreader-common/internalui"
|
|
"strings"
|
|
"unicode"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
type Kind uint8
|
|
|
|
const (
|
|
List Kind = iota + 1
|
|
Entry
|
|
CheckBox
|
|
Button
|
|
)
|
|
|
|
type Item struct {
|
|
ID string `json:"id"`
|
|
Label string `json:"label"`
|
|
Disabled bool `json:"disabled,omitempty"`
|
|
}
|
|
type Element struct {
|
|
ID string `json:"id"`
|
|
Label string `json:"label"`
|
|
Kind Kind `json:"kind"`
|
|
Items []Item `json:"items,omitempty"`
|
|
Text string `json:"text,omitempty"`
|
|
Checked bool `json:"checked,omitempty"`
|
|
Disabled bool `json:"disabled,omitempty"`
|
|
Required bool `json:"required,omitempty"`
|
|
MaxLength int `json:"max_length,omitempty"`
|
|
// Secret is supported only on transient entries, never preference pages.
|
|
Secret bool `json:"secret,omitempty"`
|
|
}
|
|
type Form struct {
|
|
Title string `json:"title"`
|
|
Elements []Element `json:"elements"`
|
|
}
|
|
type Key uint8
|
|
|
|
const (
|
|
Text Key = iota
|
|
Up
|
|
Down
|
|
Left
|
|
Right
|
|
Home
|
|
End
|
|
PageUp
|
|
PageDown
|
|
Tab
|
|
Enter
|
|
Escape
|
|
Backspace
|
|
Delete
|
|
)
|
|
|
|
type Event struct {
|
|
Key Key
|
|
Text string
|
|
Shift, Control, Alt bool
|
|
}
|
|
type Presentation struct {
|
|
Speech, Braille string
|
|
Cursor int
|
|
}
|
|
|
|
// Effect describes a host operation. Activation does not close the form: the
|
|
// caller can validate, replace it with another form, or finish the interaction.
|
|
type Effect struct {
|
|
Presentation
|
|
Activated string
|
|
Cancelled, Paste bool
|
|
Copy *string
|
|
}
|
|
type Value struct {
|
|
Text string `json:"text,omitempty"`
|
|
Selected string `json:"selected,omitempty"`
|
|
Checked bool `json:"checked,omitempty"`
|
|
}
|
|
type elementState struct {
|
|
element Element
|
|
selected int
|
|
}
|
|
type Session struct {
|
|
core *ui.Session
|
|
title string
|
|
grid bool
|
|
checkbox string
|
|
}
|
|
|
|
const maxTextLength = 8192
|
|
|
|
func NewSession(form Form) (*Session, error) {
|
|
return new_session(form, 256, 64)
|
|
}
|
|
|
|
// ValidatePreferences shares control validation with internal forms, while
|
|
// allowing a native, scrolling page to contain several full voice editors.
|
|
func ValidatePreferences(form Form) error {
|
|
for _, element := range form.Elements {
|
|
if element.Secret {
|
|
return errors.New("secret entries are not supported in preferences")
|
|
}
|
|
}
|
|
_, err := new_session(form, 256, 256)
|
|
return err
|
|
}
|
|
|
|
// NewNavigationSession is for core document lists. External forms retain
|
|
// their smaller protocol limit. The selected row follows the reading caret.
|
|
func NewNavigationSession(title string, items []Item, selected int) (*Session, error) {
|
|
session, err := new_session(Form{Title: title, Elements: []Element{{ID: "items", Label: title, Kind: List, Items: items}}}, 65536, 64)
|
|
if err == nil && selected >= 0 && selected < len(items) && !items[selected].Disabled {
|
|
session.core.Select("form", "items", selected)
|
|
}
|
|
return session, err
|
|
}
|
|
|
|
func new_session(form Form, maxItems, maxElements int) (*Session, error) {
|
|
if form.Title == "" || len(form.Title) > 1024 || len(form.Elements) == 0 || len(form.Elements) > maxElements {
|
|
return nil, fmt.Errorf("a titled form with 1 to %d controls is required", maxElements)
|
|
}
|
|
session := struct {
|
|
elements []elementState
|
|
focus int
|
|
}{focus: -1}
|
|
ids := map[string]bool{}
|
|
for _, element := range form.Elements {
|
|
if element.ID == "" || len(element.ID) > 1024 || element.Label == "" || len(element.Label) > 1024 || ids[element.ID] || element.Kind < List || element.Kind > Button {
|
|
return nil, errors.New("controls need unique IDs, labels and supported kinds")
|
|
}
|
|
ids[element.ID] = true
|
|
if element.Secret && element.Kind != Entry {
|
|
return nil, errors.New("only entries may be secret")
|
|
}
|
|
if element.MaxLength == 0 {
|
|
element.MaxLength = maxTextLength
|
|
}
|
|
if element.MaxLength < 1 || element.MaxLength > maxTextLength || len([]rune(element.Text)) > element.MaxLength {
|
|
return nil, errors.New("invalid entry length")
|
|
}
|
|
if !utf8.ValidString(element.Text) || strings.ContainsAny(element.Text, "\r\n\x00") {
|
|
return nil, errors.New("entries must contain single-line text")
|
|
}
|
|
for _, character := range element.Text {
|
|
if unicode.IsControl(character) {
|
|
return nil, errors.New("entries must contain single-line text")
|
|
}
|
|
}
|
|
element.Items = append([]Item(nil), element.Items...)
|
|
state := elementState{element: element, selected: -1}
|
|
itemIDs := map[string]bool{}
|
|
if len(element.Items) > maxItems {
|
|
return nil, errors.New("too many list items")
|
|
}
|
|
for index, item := range element.Items {
|
|
if item.ID == "" || len(item.ID) > 1024 || item.Label == "" || len(item.Label) > 1024 || itemIDs[item.ID] {
|
|
return nil, errors.New("list items need unique IDs and labels")
|
|
}
|
|
itemIDs[item.ID] = true
|
|
if state.selected < 0 && !item.Disabled {
|
|
state.selected = index
|
|
}
|
|
}
|
|
if element.Kind == List && state.selected < 0 {
|
|
state.element.Disabled = true
|
|
}
|
|
session.elements = append(session.elements, state)
|
|
if session.focus < 0 && !state.element.Disabled {
|
|
session.focus = len(session.elements) - 1
|
|
}
|
|
}
|
|
if session.focus < 0 {
|
|
return nil, errors.New("form has no enabled controls")
|
|
}
|
|
controls := make([]ui.Control, 0, len(session.elements))
|
|
for _, state := range session.elements {
|
|
e := state.element
|
|
c := ui.Control{ID: e.ID, Label: e.Label, Text: e.Text, Checked: e.Checked, Disabled: state.element.Disabled, Required: e.Required, Secret: e.Secret, MaxLength: e.MaxLength, Selected: state.selected, Action: e.ID}
|
|
switch e.Kind {
|
|
case List:
|
|
c.Kind = ui.List
|
|
c.SelectAction = true
|
|
for _, item := range e.Items {
|
|
c.Choices = append(c.Choices, ui.Choice{ID: item.ID, Label: item.Label, Literal: true, Disabled: item.Disabled})
|
|
}
|
|
case Entry:
|
|
c.Kind = ui.Entry
|
|
case CheckBox:
|
|
c.Kind = ui.CheckBox
|
|
case Button:
|
|
c.Kind = ui.Button
|
|
}
|
|
// Empty disabled lists need no selectable placeholder in the shared model.
|
|
controls = append(controls, c)
|
|
}
|
|
core, err := ui.NewSession(ui.Form{Type: ui.FlatForm, Title: form.Title, Detailed: true, Tabs: []ui.Tab{{ID: "form", Label: form.Title, Controls: controls}}}, ui.Messages{})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &Session{core: core, title: form.Title}, nil
|
|
}
|
|
|
|
func (s *Session) Presentation() Presentation {
|
|
r := s.core.Presentation()
|
|
return Presentation{Speech: r.Speech, Braille: r.Braille, Cursor: r.Cursor}
|
|
}
|
|
func (s *Session) Opening() Presentation {
|
|
r := s.Presentation()
|
|
if !s.grid {
|
|
c, ok := s.core.Focused_control()
|
|
if ok && c.Kind == ui.List && len(s.core.Values()) > 1 {
|
|
r.Speech = c.Label + ". " + r.Speech
|
|
}
|
|
}
|
|
r.Speech = s.title + ". " + r.Speech
|
|
return r
|
|
}
|
|
func (s *Session) Values() map[string]Value {
|
|
values := s.core.Values()
|
|
if values == nil {
|
|
return nil
|
|
}
|
|
result := map[string]Value{}
|
|
if s.grid {
|
|
for _, c := range values {
|
|
if c.ID == s.checkbox {
|
|
result[c.ID] = Value{Checked: c.Checked}
|
|
break
|
|
}
|
|
}
|
|
c, ok := s.core.Focused_control()
|
|
if ok && c.Kind == ui.Grid && c.Selected >= 0 {
|
|
v := c.Choices[c.Selected]
|
|
result["grid"] = Value{Selected: v.ID, Text: v.Label}
|
|
}
|
|
return result
|
|
}
|
|
for _, c := range values {
|
|
v := Value{Text: c.Text, Checked: c.Checked}
|
|
if c.Kind == ui.List {
|
|
v.Text = ""
|
|
if c.Selected >= 0 && c.Selected < len(c.Choices) {
|
|
v.Selected = c.Choices[c.Selected].ID
|
|
}
|
|
}
|
|
result[c.ID] = v
|
|
}
|
|
return result
|
|
}
|
|
func (s *Session) Close() { s.core.Close() }
|
|
func (s *Session) Handle(event Event) Effect {
|
|
name := map[Key]string{Up: "up", Down: "down", Left: "left", Right: "right", Home: "home", End: "end", PageUp: "pageup", PageDown: "pagedown", Tab: "tab", Enter: "enter", Escape: "escape", Backspace: "backspace", Delete: "delete"}[event.Key]
|
|
if event.Key == Text && event.Text == " " {
|
|
name = "space"
|
|
}
|
|
r := s.core.Handle(ui.Key{Name: name, Text: event.Text, Shift: event.Shift, Ctrl: event.Control, Alt: event.Alt})
|
|
if s.grid {
|
|
c, ok := s.core.Focused_control()
|
|
if ok && c.Kind == ui.CheckBox {
|
|
s.core.SetChecked(c.ID, c.Checked)
|
|
}
|
|
}
|
|
if !s.grid && event.Key == Tab && r.Speech != "" {
|
|
c, ok := s.core.Focused_control()
|
|
if ok && c.Kind == ui.List && len(s.core.Values()) > 1 {
|
|
r.Speech = c.Label + ". " + r.Speech
|
|
}
|
|
}
|
|
return effect(r)
|
|
}
|
|
func effect(r ui.Result) Effect {
|
|
return Effect{Presentation: Presentation{Speech: r.Speech, Braille: r.Braille, Cursor: r.Cursor}, Activated: r.Action, Cancelled: r.Cancelled, Paste: r.Paste, Copy: r.Copy}
|
|
}
|
|
func (s *Session) Insert(text string) Effect { return effect(s.core.Insert(text)) }
|
|
|
|
// NewUnifiedSession exposes the full toolkit to core reader features. External
|
|
// protocol forms continue through NewSession and its bounded validation.
|
|
func NewUnifiedSession(form ui.Form, messages ui.Messages) (*Session, error) {
|
|
core, err := ui.NewSession(form, messages)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &Session{core: core, title: form.Title}, nil
|
|
}
|
|
|
|
// ControlValues includes rich typed values keyed by page.control, including
|
|
// plaintext secrets. Use only for submission; never log or persist secrets.
|
|
func (s *Session) ControlValues() map[string]ui.Control { return s.core.Values() }
|