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