Files

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() }