Add shared internal UI toolkit for both readers

This commit is contained in:
ak47 committed 2026-11-12 01:27:42 -05:00
1 parent 672caca854
commit e49ba198d7
34 files changed
+3471

No files matched your search

+39
View File
@@ -0,0 +1,39 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package interaction
import (
"bytes"
"encoding/json"
"errors"
"strings"
)
// UnmarshalJSON keeps a malformed secret flag from silently becoming an
// ordinary entry (encoding/json otherwise accepts null for a boolean). The
// wire decoder separately rejects duplicate keys and bounds nesting/framing.
func (element *Element) UnmarshalJSON(data []byte) error {
type plain Element
var value plain
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&value); err != nil {
return err
}
var fields map[string]json.RawMessage
if err := json.Unmarshal(data, &fields); err != nil {
return err
}
for name, raw := range fields {
if strings.EqualFold(name, "secret") {
flag := string(bytes.TrimSpace(raw))
if name != "secret" || flag != "true" && flag != "false" {
return errors.New("secret must be a boolean")
}
}
}
*element = Element(value)
return nil
}
+39
View File
@@ -0,0 +1,39 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package interaction
import (
"errors"
ui "git.stormux.org/storm/screenreader-common/internalui"
)
type GridTab struct {
ID, Label string
Items []Item
}
func NewGridSession(title string, tabs []GridTab, columns int, checkbox Element) (*Session, error) {
if title == "" || len(tabs) == 0 || columns < 1 || columns > 64 || checkbox.Kind != CheckBox || checkbox.ID == "" || checkbox.Label == "" {
return nil, errors.New("invalid tabbed grid")
}
form := ui.Form{Type: ui.TabForm, Title: title, Detailed: true}
for _, tab := range tabs {
choices := make([]ui.Choice, 0, len(tab.Items))
for _, item := range tab.Items {
if item.ID == "" {
return nil, errors.New("invalid grid item")
}
choices = append(choices, ui.Choice{ID: item.ID, Label: item.Label, Disabled: item.Disabled, Literal: true})
}
form.Tabs = append(form.Tabs, ui.Tab{ID: tab.ID, Label: tab.Label, Controls: []ui.Control{
{ID: "grid", Label: "Grid", Kind: ui.Grid, Choices: choices, Columns: columns, Action: "select-grid-item"},
{ID: checkbox.ID, Label: checkbox.Label, Kind: ui.CheckBox, Checked: checkbox.Checked},
}})
}
core, err := ui.NewSession(form, ui.Messages{})
if err != nil {
return nil, err
}
return &Session{core: core, title: title, grid: true, checkbox: checkbox.ID}, nil
}
+29
View File
@@ -0,0 +1,29 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package interaction
import "testing"
func TestGridRowsBoundsAndDisabledItems(t *testing.T) {
items := []Item{{ID: "1", Label: "One"}, {ID: "2", Label: "Two"}, {ID: "3", Label: "Three"}, {ID: "4", Label: "Four"}, {ID: "5", Label: "Five", Disabled: true}, {ID: "6", Label: "Six"}}
session, err := NewGridSession("Picker", []GridTab{{ID: "all", Label: "All", Items: items}}, 3, Element{ID: "copy", Label: "Copy", Kind: CheckBox})
if err != nil {
t.Fatal(err)
}
session.Handle(Event{Key: Tab})
for _, step := range []struct {
key Key
want string
}{{Right, "2"}, {Right, "3"}, {Down, "6"}, {Down, "6"}, {Up, "2"}, {Home, "1"}, {Left, "1"}, {End, "6"}, {Right, "6"}, {PageUp, "1"}, {PageDown, "6"}} {
session.Handle(Event{Key: step.key})
if got := session.Values()["grid"].Selected; got != step.want {
t.Fatalf("%v selected %q want %q", step.key, got, step.want)
}
}
if result := session.Handle(Event{Key: Escape}); !result.Cancelled {
t.Fatal("escape not cancelled")
}
if session.Handle(Event{Key: Enter}).Activated != "" || session.Presentation().Speech != "" {
t.Fatal("closed grid responded")
}
}
+93
View File
@@ -0,0 +1,93 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package interaction
import "testing"
func secretForm(text string) Form {
return Form{Title: "Connection", Elements: []Element{
{ID: "key", Label: "Channel key", Kind: Entry, Secret: true, Text: text, Required: true, MaxLength: 12},
{ID: "connect", Label: "Connect", Kind: Button},
}}
}
func TestSecretEntryEditingAndPresentation(t *testing.T) {
session, err := NewSession(secretForm("猫é123"))
if err != nil {
t.Fatal(err)
}
if got := session.Opening(); got.Speech != "Connection. Channel key, password edit, 5 characters" || got.Braille != "Channel key, password edit: *****" || got.Cursor != 33 {
t.Fatalf("unexpected masked opening: %+v", got)
}
steps := []struct {
event Event
speech, text, braille string
}{
{Event{Key: Home}, "Hidden character", "猫é123", "Channel key, password edit: *****"},
{Event{Key: Right, Shift: true}, "Hidden character, selected", "猫é123", "Channel key, password edit: ***** (selected 1 characters)"},
{Event{Text: "密"}, "Inserted 1 character", "密é123", "Channel key, password edit: *****"},
{Event{Key: Delete}, "Deleted 1 character", "密123", "Channel key, password edit: ****"},
{Event{Key: Backspace}, "Deleted 1 character", "123", "Channel key, password edit: ***"},
{Event{Key: End}, "End of text", "123", "Channel key, password edit: ***"},
{Event{Text: "a", Control: true}, "Selected all", "123", "Channel key, password edit: *** (selected 3 characters)"},
{Event{Text: "c", Control: true}, "Copy and cut are unavailable for password fields", "123", "Channel key, password edit: *** (selected 3 characters)"},
{Event{Text: "x", Control: true}, "Copy and cut are unavailable for password fields", "123", "Channel key, password edit: *** (selected 3 characters)"},
{Event{Key: Backspace}, "Deleted 3 characters", "", "Channel key, password edit: "},
{Event{Key: Enter}, "Channel key is required", "", "Channel key is required"},
}
for index, step := range steps {
effect := session.Handle(step.event)
if effect.Speech != step.speech || effect.Braille != step.braille || effect.Copy != nil || effect.Activated != "" || session.Values()["key"].Text != step.text {
t.Fatalf("secret editing step %d failed", index)
}
}
if !session.Handle(Event{Text: "v", Control: true}).Paste {
t.Fatal("explicit paste not requested")
}
if effect := session.Insert("pasted猫"); effect.Speech != "Inserted 7 characters" || effect.Braille != "Channel key, password edit: *******" {
t.Fatal("paste was not masked")
}
for _, text := range []string{"oversized content", "bad\ntext", string([]byte{0xff})} {
session.Insert(text)
if session.Values()["key"].Text != "pasted猫" {
t.Fatal("invalid paste modified secret")
}
}
session.Handle(Event{Key: Tab})
if got := session.Handle(Event{Key: Tab, Shift: true}); got.Speech != "Channel key, password edit, 7 characters" {
t.Fatal("returning to password did not mask it")
}
session.Handle(Event{Key: Tab})
if effect := session.Handle(Event{Key: Enter}); effect.Activated != "connect" || session.Values()["key"].Text != "pasted猫" {
t.Fatal("submission lost the actual secret")
}
}
func TestSecretEntryCloseAndValidation(t *testing.T) {
for _, escape := range []bool{false, true} {
session, _ := NewSession(secretForm("fixture"))
if escape {
if !session.Handle(Event{Key: Escape}).Cancelled {
t.Fatal("escape did not cancel")
}
} else {
session.Close()
}
session.Close()
if session.Values() != nil || session.Presentation() != (Presentation{}) || session.Insert("more") != (Effect{}) {
t.Fatal("closed editor retained or accepted a secret")
}
}
for _, kind := range []Kind{List, CheckBox, Button} {
form := secretForm("")
form.Elements[0].Kind = kind
if _, err := NewSession(form); err == nil {
t.Fatal("secret accepted on non-entry")
}
}
if err := ValidatePreferences(secretForm("fixture")); err == nil {
t.Fatal("secret accepted in persistent preferences")
}
}
+300
View File
@@ -0,0 +1,300 @@
// 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() }
+191
View File
@@ -0,0 +1,191 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package interaction
import (
"strconv"
"strings"
"testing"
)
func TestNavigationListsRetainExternalFormLimit(t *testing.T) {
items := make([]Item, 300)
for index := range items {
items[index] = Item{ID: strconv.Itoa(index), Label: "Example item"}
}
if _, err := NewSession(Form{Title: "External form", Elements: []Element{{ID: "items", Label: "Items", Kind: List, Items: items}}}); err == nil {
t.Fatal("external form exceeded its protocol limit")
}
session, err := NewNavigationSession("Links", items, 299)
if err != nil {
t.Fatal(err)
}
if effect := session.Handle(Event{Key: Enter}); effect.Activated != "299" {
t.Fatalf("lost initial selection: %+v", effect)
}
}
func TestListNavigationAndIndependentSessions(t *testing.T) {
form := Form{Title: "Actions", Elements: []Element{{ID: "actions", Label: "Actions", Kind: List, Items: []Item{{ID: "click", Label: "Click"}, {ID: "skip", Label: "Unavailable", Disabled: true}, {ID: "press", Label: "Press"}, {ID: "ancestor", Label: "Click ancestor"}}}}}
first, err := NewSession(form)
if err != nil {
t.Fatal(err)
}
second, _ := NewSession(form)
if opening := first.Opening().Speech; opening != "Actions. Click" {
t.Fatalf("opening speech = %q", opening)
}
if moved := first.Handle(Event{Key: Down}).Speech; moved != "Press" {
t.Fatalf("option speech = %q", moved)
}
if effect := first.Handle(Event{Key: Enter}); effect.Activated != "press" {
t.Fatalf("selection = %+v", effect)
}
if second.Handle(Event{Key: Enter}).Activated != "click" {
t.Fatal("sessions share state")
}
first.Handle(Event{Key: End})
first.Handle(Event{Key: Down})
if first.Handle(Event{Key: Enter}).Activated != "ancestor" {
t.Fatal("boundary moved selection")
}
first.Handle(Event{Key: Home})
first.Handle(Event{Text: "p"})
if first.Handle(Event{Key: Enter}).Activated != "press" {
t.Fatal("typeahead failed")
}
if !first.Handle(Event{Key: Escape}).Cancelled || first.Handle(Event{Key: Enter}).Activated != "" {
t.Fatal("cancelled session remained usable")
}
form.Elements[0].Items[0].Label = "Changed"
if !strings.Contains(second.Presentation().Speech, "Click") {
t.Fatal("caller mutation changed session")
}
}
func TestCallerSuppliedMenuTitleAndFormListLabel(t *testing.T) {
menu, err := NewSession(Form{Title: "Wormhole Menu", Elements: []Element{{ID: "choices", Label: "Options", Kind: List, Items: []Item{{ID: "send", Label: "Send focused item"}, {ID: "receive", Label: "Receive file"}}}}})
if err != nil {
t.Fatal(err)
}
if opening := menu.Opening().Speech; opening != "Wormhole Menu. Send focused item" {
t.Fatalf("custom title = %q", opening)
}
if moved := menu.Handle(Event{Key: Down}).Speech; moved != "Receive file" {
t.Fatalf("custom menu option = %q", moved)
}
if focus := menu.Handle(Event{Key: Tab}).Speech; focus != "Receive file" {
t.Fatalf("menu repeated title on Tab: %q", focus)
}
form, _ := NewSession(Form{Title: "Example", Elements: []Element{{ID: "code", Label: "Code", Kind: Entry}, {ID: "method", Label: "Method", Kind: List, Items: []Item{{ID: "send", Label: "Send"}}}}})
if focus := form.Handle(Event{Key: Tab}).Speech; focus != "Method. Send" {
t.Fatalf("form list lost its field label: %q", focus)
}
}
func TestFormTraversalEditingAndValidation(t *testing.T) {
session, err := NewSession(Form{Title: "Receive", Elements: []Element{{ID: "code", Label: "Code", Kind: Entry, Required: true, MaxLength: 8}, {ID: "disabled", Label: "Disabled", Kind: Button, Disabled: true}, {ID: "keep", Label: "Keep", Kind: CheckBox}, {ID: "submit", Label: "Receive", Kind: Button}}})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(session.Handle(Event{Key: Enter}).Speech, "required") {
t.Fatal("missing required validation")
}
session.Handle(Event{Text: "ab猫"})
session.Handle(Event{Key: Left, Shift: true})
session.Insert("é")
if session.Values()["code"].Text != "abé" {
t.Fatal("Unicode selection replacement")
}
session.Handle(Event{Key: Home})
session.Handle(Event{Key: Delete})
if session.Values()["code"].Text != "bé" {
t.Fatal("Unicode deletion")
}
session.Handle(Event{Text: "a", Control: true})
if effect := session.Handle(Event{Text: "c", Control: true}); effect.Copy == nil || *effect.Copy != "bé" {
t.Fatal("copy selection")
}
if !session.Handle(Event{Text: "v", Control: true}).Paste {
t.Fatal("paste host request missing")
}
before := session.Values()["code"].Text
if !strings.Contains(session.Insert("too long to fit").Speech, "limit") || session.Values()["code"].Text != before {
t.Fatal("oversized insertion changed field")
}
if !strings.Contains(session.Insert("bad\ntext").Speech, "single-line") {
t.Fatal("multiline paste accepted")
}
if session.Insert(string([]byte{0xff})).Speech != "Invalid text" || session.Values()["code"].Text != before {
t.Fatal("invalid UTF-8 paste changed entry")
}
session.Handle(Event{Key: Tab})
session.Handle(Event{Text: " "})
if !session.Values()["keep"].Checked {
t.Fatal("checkbox not toggled / disabled control took focus")
}
session.Handle(Event{Key: Tab})
if session.Handle(Event{Key: Enter}).Activated != "submit" {
t.Fatal("button did not submit")
}
session.Handle(Event{Key: Tab})
if !strings.Contains(session.Presentation().Speech, "Code") {
t.Fatal("Tab did not wrap")
}
session.Handle(Event{Key: Tab, Shift: true})
if session.Handle(Event{Key: Enter}).Activated != "submit" {
t.Fatal("Shift Tab did not wrap")
}
}
func TestWordMovementAndSelection(t *testing.T) {
session, _ := NewSession(Form{Title: "Edit", Elements: []Element{{ID: "text", Label: "Text", Kind: Entry, Text: "one two 猫"}}})
session.Handle(Event{Key: Left, Control: true, Shift: true})
if effect := session.Handle(Event{Text: "c", Control: true}); effect.Copy == nil || *effect.Copy != "猫" {
t.Fatal("word selection")
}
session.Handle(Event{Key: Backspace})
if session.Values()["text"].Text != "one two " {
t.Fatal("word selection deletion")
}
session.Handle(Event{Key: Home})
session.Handle(Event{Key: Right, Control: true})
session.Insert("é")
if session.Values()["text"].Text != "one étwo " {
t.Fatalf("word movement = %+v", session.Values())
}
}
func TestInvalidForms(t *testing.T) {
for _, form := range []Form{{}, {Title: "Empty", Elements: []Element{{ID: "list", Label: "List", Kind: List}}}, {Title: "Duplicate", Elements: []Element{{ID: "same", Label: "First", Kind: Button}, {ID: "same", Label: "Second", Kind: Entry}}}} {
if _, err := NewSession(form); err == nil {
t.Fatalf("accepted invalid form %+v", form)
}
}
}
func TestButtonActivationValidatesRequiredEntries(t *testing.T) {
for _, event := range []Event{{Key: Enter}, {Key: Text, Text: " "}} {
t.Run(map[Key]string{Enter: "Enter", Text: "Space"}[event.Key], func(t *testing.T) {
session, err := NewSession(Form{Title: "Receive", Elements: []Element{
{ID: "code", Label: "Code", Kind: Entry, Required: true},
{ID: "submit", Label: "Receive", Kind: Button},
}})
if err != nil {
t.Fatal(err)
}
session.Handle(Event{Key: Tab})
if effect := session.Handle(event); effect.Activated != "" || effect.Speech != "Code is required" {
t.Fatalf("empty required field accepted: %+v", effect)
}
session.Handle(Event{Key: Tab, Shift: true})
session.Insert("1234")
session.Handle(Event{Key: Tab})
if effect := session.Handle(event); effect.Activated != "submit" {
t.Fatalf("completed form rejected: %+v", effect)
}
})
}
}
+44
View File
@@ -0,0 +1,44 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package interaction_test
import (
flat "git.stormux.org/storm/screenreader-common/interaction"
"git.stormux.org/storm/screenreader-common/interaction/tabbed"
"testing"
)
// Exercise both public form APIs, so an update to grid movement or speech must
// reach both readers even though their surrounding focus/presentation differs.
func TestGridBehaviorAcrossFormLayouts(t *testing.T) {
items := []flat.Item{{ID: "a", Label: "Alpha"}, {ID: "b", Label: "Beta"}, {ID: "c", Label: "Charlie"}, {ID: "d", Label: "Delta"}, {ID: "e", Label: "Echo"}}
choices := make([]tabbed.Choice, len(items))
for i, item := range items {
choices[i] = tabbed.Choice{ID: item.ID, Label: item.Label}
}
first, err := flat.NewGridSession("Picker", []flat.GridTab{{ID: "items", Label: "Items", Items: items}}, 3, flat.Element{ID: "copy", Label: "Copy", Kind: flat.CheckBox})
if err != nil {
t.Fatal(err)
}
second, err := tabbed.New_session(tabbed.Form{Type: tabbed.TabForm, Title: "Picker", Tabs: []tabbed.Tab{{ID: "items", Label: "Items", Controls: []tabbed.Control{{ID: "grid", Label: "Grid", Kind: tabbed.Grid, Choices: choices, Columns: 3}}}}})
if err != nil {
t.Fatal(err)
}
first.Handle(flat.Event{Key: flat.Tab})
second.Handle(tabbed.Key{Name: "tab"})
for _, step := range []struct {
key flat.Key
name string
}{{flat.Right, "right"}, {flat.Down, "down"}, {flat.Down, "down"}, {flat.Up, "up"}, {flat.End, "end"}, {flat.Left, "left"}, {flat.Home, "home"}} {
a := first.Handle(flat.Event{Key: step.key})
b := second.Handle(tabbed.Key{Name: step.name})
if a.Speech != b.Speech {
t.Fatalf("%s speech: %q != %q", step.name, a.Speech, b.Speech)
}
selected := second.Values()["items.grid"]
if first.Values()["grid"].Selected != selected.Choices[selected.Selected].ID {
t.Fatalf("%s selection mismatch", step.name)
}
}
}
+120
View File
@@ -0,0 +1,120 @@
// 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 tabbed
import (
"strings"
"testing"
)
func TestGroupedChoicesTraversalIsolationAndSelection(t *testing.T) {
form := Form{Type: TabForm, Title: "Settings", Tabs: []Tab{{ID: "presentation", Label: "Presentation", Controls: []Control{
{ID: "speech.driver", Label: "Driver", Group: "Speech", Kind: ComboBox, Choices: []Choice{{ID: "first", Label: "First"}, {ID: "second", Label: "Second", Unavailable: "fixture dependency missing"}}},
{ID: "speech.enabled", Label: "Speech enabled", Group: "Speech", Kind: CheckBox},
{ID: "sound.enabled", Label: "Sound enabled", Group: "Sound", Kind: CheckBox},
{ID: "ok", Label: "OK", Kind: Button, Action: "save"},
}}}}
session, err := New_session(form)
if err != nil {
t.Fatal(err)
}
if got := session.Handle(Key{Name: "tab"}).Speech; got != "Speech. Driver combo box First" {
t.Fatal(got)
}
for _, key := range []Key{{Text: "invented"}, {Name: "enter"}, {Name: "space"}, {Ctrl: true, Text: "a"}} {
if result := session.Handle(key); result.Action != "" || result.Cancelled {
t.Fatal(result)
}
}
if got := session.Values()["presentation.speech.driver"].Choices[0].ID; got != "first" {
t.Fatal(got)
}
if got := session.Handle(Key{Name: "down"}).Speech; got != "Second. unavailable: fixture dependency missing" {
t.Fatal(got)
}
session.Handle(Key{Name: "down"})
if got := session.Values()["presentation.speech.driver"].Selected; got != 1 {
t.Fatal("combo wrapped", got)
}
values := session.Values()
values["presentation.speech.driver"].Choices[1].Label = "altered"
if strings.Contains(session.Describe(), "altered") || form.Tabs[0].Controls[0].Selected != 0 {
t.Fatal("aliased choice state")
}
session.Handle(Key{Name: "home"})
if got := session.Values()["presentation.speech.driver"].Selected; got != 0 {
t.Fatal(got)
}
if got := session.Handle(Key{Name: "tab"}).Speech; got != "Speech enabled unchecked" {
t.Fatal("repeated group", got)
}
if got := session.Handle(Key{Name: "tab"}).Speech; got != "Sound. Sound enabled unchecked" {
t.Fatal(got)
}
if got := session.Handle(Key{Name: "tab", Shift: true}).Speech; got != "Speech. Speech enabled unchecked" {
t.Fatal(got)
}
if !session.Handle(Key{Name: "escape"}).Cancelled {
t.Fatal("escape trapped")
}
}
func TestComboChangesSpeakOnlyValueAndFocusRepeatsLabel(t *testing.T) {
form := Form{Type: TabForm, Title: "Settings", Tabs: []Tab{{ID: "settings", Label: "Settings", Controls: []Control{
{ID: "format", Label: "Format", Kind: ComboBox, Choices: []Choice{{ID: "one", Label: "First"}, {ID: "two", Label: "Second"}, {ID: "three", Label: "Third"}}},
{ID: "ok", Label: "OK", Kind: Button, Action: "save"},
}}}}
session, err := New_session(form)
if err != nil {
t.Fatal(err)
}
if got := session.Handle(Key{Name: "tab"}).Speech; got != "Format combo box First" {
t.Fatal(got)
}
for _, testCase := range []struct{ key, want string }{
{"down", "Second"}, {"right", "Third"}, {"up", "Second"},
{"left", "First"}, {"end", "Third"}, {"home", "First"},
} {
if got := session.Handle(Key{Name: testCase.key}).Speech; got != testCase.want {
t.Fatal(testCase.key, got, testCase.want)
}
}
session.Handle(Key{Name: "tab"})
if got := session.Handle(Key{Name: "tab", Shift: true}).Speech; got != "Format combo box First" {
t.Fatal("reverse focus omitted label", got)
}
session.Handle(Key{Name: "tab", Shift: true})
if got := session.Handle(Key{Name: "tab"}).Speech; got != "Format combo box First" {
t.Fatal("forward focus omitted label", got)
}
}
func TestChoiceDefinitionsRejectMissingAndDuplicateSelections(t *testing.T) {
for _, control := range []Control{
{Choices: nil},
{Choices: []Choice{{ID: "one", Label: "One"}}, Selected: 1},
{Choices: []Choice{{ID: "one", Label: "One"}, {ID: "one", Label: "Duplicate"}}},
{Choices: []Choice{{ID: "one"}}},
} {
control.ID, control.Label, control.Kind = "driver", "Driver", ComboBox
form := Form{Type: TabForm, Title: "Settings", Tabs: []Tab{{ID: "presentation", Label: "Presentation", Controls: []Control{control}}}}
if _, err := New_session(form); err == nil {
t.Fatal("invalid combo accepted", control)
}
}
}
+48
View File
@@ -0,0 +1,48 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package tabbed
import (
"strings"
"testing"
)
func TestEmptyAndRefreshedGridKeepsFocusSearchAndLiteralWords(t *testing.T) {
form := grid_form()
form.Tabs[0].Controls[0].Choices = nil
form.Tabs[0].Controls[0].DeleteAction = "remove"
session, err := New_session(form)
if err != nil {
t.Fatal(err)
}
session.Handle(Key{Name: "tab"})
if got := session.Handle(Key{Name: "enter"}); got.Action != "" || got.Speech != "No matches" {
t.Fatal(got)
}
session.Handle(Key{Text: "c"})
session.Set_choices("one", "grid", []Choice{{ID: "cat", Label: "cat", Literal: true}, {ID: "car", Label: "car", Literal: true}})
session.Handle(Key{Name: "right"})
session.Set_choices("one", "grid", []Choice{{ID: "car", Label: "car", Literal: true}, {ID: "cat", Label: "cat", Literal: true}})
control, _ := session.Focused_control()
if control.Text != "c" || control.Choices[control.Selected].ID != "car" {
t.Fatal(control)
}
if got := session.Handle(Key{Name: "delete", Repeat: true}); got.Action != "" {
t.Fatal(got)
}
if got := session.Handle(Key{Name: "delete"}); got.Action != "remove" {
t.Fatal(got)
}
session.Set_choices("one", "grid", nil)
if !strings.Contains(session.Describe(), "No matches") {
t.Fatal(session.Describe())
}
// Updating a grid must own its choices, independently of worker buffers.
choices := []Choice{{ID: "cat", Label: "cat", Literal: true}}
session.Set_choices("one", "grid", choices)
choices[0].Label = "wrong"
if !strings.Contains(session.Describe(), "cat") {
t.Fatal(session.Describe())
}
}
+128
View File
@@ -0,0 +1,128 @@
// Fenrir console screen reader.
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package tabbed
import (
"strings"
"testing"
)
func grid_form() Form {
return Form{Type: TabForm, Title: "Picker", Tabs: []Tab{{ID: "one", Label: "One", Controls: []Control{{
ID: "grid", Label: "Items", Kind: Grid, Columns: 3, Action: "choose",
Choices: []Choice{{ID: "a", Label: "Apple"}, {ID: "b", Label: "Bat"}, {ID: "c", Label: "Cat"},
{ID: "d", Label: "Dog"}, {ID: "j", Label: "Jack o'lantern"}, {ID: "jp", Label: "Japan"}, {ID: "é", Label: "Éclair"}},
}}}}}
}
func TestGridNavigationRaggedRowsAndActivation(t *testing.T) {
session, err := New_session(grid_form())
if err != nil {
t.Fatal(err)
}
session.Handle(Key{Name: "tab"})
for _, trial := range []struct{ key, want string }{
{"right", "b"}, {"up", "b"}, {"down", "j"}, {"down", "é"}, {"down", "é"},
{"up", "d"}, {"left", "c"}, {"home", "a"}, {"left", "a"}, {"end", "é"}, {"right", "é"},
} {
session.Handle(Key{Name: trial.key})
control, _ := session.Focused_control()
if got := control.Choices[control.Selected].ID; got != trial.want {
t.Fatalf("%s: got %s, want %s", trial.key, got, trial.want)
}
}
// XLookupString supplies carriage return alongside the named Enter key.
if got := session.Handle(Key{Name: "enter", Text: "\r"}); got.Action != "choose" {
t.Fatal(got)
}
}
func TestGridPrefixEmptyResultsUnicodeAndIsolation(t *testing.T) {
form := grid_form()
session, err := New_session(form)
if err != nil {
t.Fatal(err)
}
session.Handle(Key{Name: "tab"})
if got := session.Handle(Key{Text: "J"}); !strings.Contains(got.Speech, "Jack o'lantern") || !strings.Contains(got.Speech, "1 of 2") {
t.Fatal(got)
}
session.Handle(Key{Name: "right"})
control, _ := session.Focused_control()
if control.Selected != 5 {
t.Fatal("filtered selection lost catalog index", control)
}
control.Choices[5].Label = "changed"
if got := session.Handle(Key{Text: "z"}); !strings.Contains(got.Speech, "No matches") {
t.Fatal(got)
}
if got := session.Handle(Key{Name: "enter"}); got.Action != "" {
t.Fatal("empty results activated", got)
}
session.Handle(Key{Name: "backspace"})
if got := session.Handle(Key{Name: "right"}); !strings.Contains(got.Speech, "Japan") {
t.Fatal("focused value aliased session", got)
}
session.Handle(Key{Name: "backspace", Ctrl: true})
if got := session.Handle(Key{Text: "é"}); !strings.Contains(got.Speech, "Éclair") {
t.Fatal(got)
}
session.Handle(Key{Name: "backspace"})
control, _ = session.Focused_control()
if control.Text != "" || form.Tabs[0].Controls[0].Selected != 0 || form.Tabs[0].Controls[0].Text != "" {
t.Fatal("Unicode deletion or form isolation failed", control)
}
if got := session.Handle(Key{Name: "escape"}); !got.Cancelled {
t.Fatal(got)
}
}
func TestGridValidation(t *testing.T) {
form := grid_form()
form.Tabs[0].Controls[0].Columns = 0
if _, err := New_session(form); err == nil {
t.Fatal("zero-width grid accepted")
}
form = grid_form()
form.Tabs[0].Controls[0].Choices[1].ID = "a"
if _, err := New_session(form); err == nil {
t.Fatal("duplicate values accepted")
}
}
func TestGridSpeechDetailOptions(t *testing.T) {
for _, trial := range []struct {
coordinates, position bool
want, filtered string
}{
{true, true, "Bat. row 1, column 2. 2 of 7", "Japan. row 1, column 2. 2 of 2"},
{true, false, "Bat. row 1, column 2", "Japan. row 1, column 2"},
{false, true, "Bat. 2 of 7", "Japan. 2 of 2"},
{false, false, "Bat", "Japan"},
} {
session, err := New_session(grid_form())
if err != nil {
t.Fatal(err)
}
session.Configure_grid(trial.coordinates, trial.position)
session.Handle(Key{Name: "tab"})
if got := session.Handle(Key{Name: "right"}).Speech; got != trial.want {
t.Fatalf("%+v: %q", trial, got)
}
if got := session.Describe(); got != "Items grid. "+trial.want {
t.Fatal(got)
}
session.Handle(Key{Text: "j"})
if got := session.Handle(Key{Name: "right"}).Speech; got != trial.filtered {
t.Fatal(got)
}
if got := session.Handle(Key{Name: "enter"}).Action; got != "choose" {
t.Fatal(got)
}
if got := session.Handle(Key{Text: "zzz"}).Speech; got != "Search jzzz. No matches" {
t.Fatal(got)
}
}
}
+53
View File
@@ -0,0 +1,53 @@
// Fenrir console screen reader.
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package tabbed
import "testing"
func TestListPrefixSearch(t *testing.T) {
session, err := New_session(Form{Type: TabForm, Title: "Picker", Tabs: []Tab{{ID: "items", Label: "Items", Controls: []Control{
{ID: "list", Label: "Item", Kind: List, Items: []string{"Default", "NetworkManager", "Ninjam", "Éclair"}, Action: "choose"},
{ID: "cancel", Label: "Cancel", Kind: Button, Action: "cancel"},
}}}})
if err != nil {
t.Fatal(err)
}
session.Handle(Key{Name: "tab"})
for _, step := range []struct {
key Key
want, prefix string
selected int
}{
{Key{Text: "N"}, "NetworkManager", "N", 1},
{Key{Text: "i"}, "Ninjam", "Ni", 2},
{Key{Text: "z"}, "No matches", "Ni", 2},
{Key{Text: "n"}, "Ninjam", "Nin", 2},
{Key{Name: "backspace"}, "Ninjam", "Ni", 2},
{Key{Name: "backspace"}, "NetworkManager", "N", 1},
{Key{Name: "backspace", Ctrl: true}, "Default", "", 0},
{Key{Text: "é"}, "Éclair", "é", 3},
{Key{Name: "down"}, "Éclair", "", 3},
{Key{Text: "ni"}, "Ninjam", "ni", 2},
{Key{Name: "up"}, "NetworkManager", "", 1},
{Key{Text: "d"}, "Default", "d", 0},
{Key{Text: "n", Ctrl: true}, "", "d", 0},
{Key{Text: "n", Alt: true}, "", "d", 0},
{Key{Text: "\n"}, "", "d", 0},
} {
got := session.Handle(step.key)
control := session.Values()["items.list"]
if got.Speech != step.want || got.Action != "" || control.Text != step.prefix || control.Selected != step.selected {
t.Fatalf("key %+v: result %+v, prefix %q, selection %d; want %q, %q, %d", step.key, got, control.Text, control.Selected, step.want, step.prefix, step.selected)
}
}
session.Handle(Key{Name: "tab"})
session.Handle(Key{Name: "tab", Shift: true})
if got := session.Handle(Key{Text: "ni"}).Speech; got != "Ninjam" {
t.Fatal("focus change did not clear search", got)
}
if got := session.Handle(Key{Name: "enter"}).Action; got != "choose" {
t.Fatal("search prevented activation", got)
}
}
+38
View File
@@ -0,0 +1,38 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package tabbed
import "testing"
func TestSessionCatalogIsolationAndLiteralGridSearch(t *testing.T) {
form := Form{Type: TabForm, Title: "Picker", Tabs: []Tab{{ID: "items", Label: "Items", Controls: []Control{{ID: "grid", Label: "Grid", Kind: Grid, Columns: 2, Choices: []Choice{{ID: "a", Label: "Alpha"}, {ID: "b", Label: "Beta", Literal: true}}}}}}}
translated, err := NewSession(form, Messages{Translate: func(text string) string {
if text == "Alpha" {
return "Translated"
}
if text == "Beta" {
return "Wrong"
}
return text
}})
if err != nil {
t.Fatal(err)
}
english, err := New_session(form)
if err != nil {
t.Fatal(err)
}
translated.Handle(Key{Name: "tab"})
english.Handle(Key{Name: "tab"})
if got := translated.Handle(Key{Text: "Trans"}).Speech; got != "Search Trans. Translated. row 1, column 1. 1 of 1" {
t.Fatal(got)
}
if got := english.Handle(Key{Text: "Al"}).Speech; got != "Search Al. Alpha. row 1, column 1. 1 of 1" {
t.Fatal(got)
}
translated.Handle(Key{Name: "backspace", Ctrl: true})
if got := translated.Handle(Key{Text: "Beta"}).Speech; got != "Search Beta. Beta. row 1, column 1. 1 of 1" {
t.Fatal(got)
}
}
+162
View File
@@ -0,0 +1,162 @@
// 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 tabbed
import (
"math"
"testing"
)
func TestInvalidNumberUpDownStepRejected(t *testing.T) {
for _, step := range []float64{-1, math.NaN(), math.Inf(1), math.Inf(-1)} {
form := test_form()
form.Tabs[0].Controls[1].UpDownStep = step
if _, err := New_session(form); err == nil {
t.Fatalf("invalid Up/Down step %v accepted", step)
}
}
}
func test_form() Form {
return Form{Type: TabForm, Title: "Settings", Tabs: []Tab{
{ID: "speech", Label: "Speech", Controls: []Control{
{ID: "enabled", Label: "Speech enabled", Kind: CheckBox, Checked: true},
{ID: "range", Label: "Range", Kind: Number, Number: 2.5, Min: -5, Max: 5, Step: 0.1},
{ID: "text", Label: "Text", Kind: Entry, Text: "a界c"},
{ID: "ok", Label: "OK", Kind: Button, Action: "save"},
{ID: "cancel", Label: "Cancel", Kind: Button, Action: "cancel"},
}},
{ID: "sound", Label: "Sound"},
}}
}
func TestTabFormTraversalControlsAndIsolation(t *testing.T) {
form := test_form()
session, err := New_session(form)
if err != nil {
t.Fatal(err)
}
if session.Open() != "Settings. Speech tab" {
t.Fatal(session.Open())
}
for _, name := range []string{"right", "left", "down", "up"} {
result := session.Handle(Key{Name: name})
if result.Speech == "" {
t.Fatal("silent tab", name)
}
}
if session.Describe() != "Speech tab" {
t.Fatal(session.Describe())
}
if got := session.Handle(Key{Name: "tab"}).Speech; got != "Speech enabled checked" {
t.Fatal(got)
}
if got := session.Handle(Key{Name: "space"}).Speech; got != "Speech enabled unchecked" {
t.Fatal(got)
}
if !form.Tabs[0].Controls[0].Checked {
t.Fatal("form mutated outside session")
}
session.Handle(Key{Name: "tab"})
for _, fixture := range []struct {
name string
want float64
}{
{"up", 3.5}, {"right", 3.6}, {"left", 3.5}, {"down", 2.5},
} {
session.Handle(Key{Name: fixture.name})
if got := session.Values()["speech.range"].Number; got != fixture.want {
t.Fatal(fixture, got)
}
}
for range 100 {
session.Handle(Key{Name: "up"})
}
if got := session.Values()["speech.range"].Number; got != 5 {
t.Fatal("upper bound", got)
}
for range 100 {
session.Handle(Key{Name: "down"})
}
if got := session.Values()["speech.range"].Number; got != -5 {
t.Fatal("lower bound", got)
}
session.Handle(Key{Name: "tab"})
session.Handle(Key{Name: "home"})
session.Handle(Key{Name: "right"})
session.Handle(Key{Name: "delete"})
session.Handle(Key{Text: "é"})
if got := session.Values()["speech.text"].Text; got != "aéc" {
t.Fatal("Unicode editing", got)
}
session.Handle(Key{Ctrl: true, Text: "a"})
session.Handle(Key{Text: "replacement"})
if got := session.Values()["speech.text"].Text; got != "replacement" {
t.Fatal("select all", got)
}
if got := session.Handle(Key{Name: "tab"}).Speech; got != "OK button" {
t.Fatal(got)
}
if got := session.Handle(Key{Name: "enter"}).Action; got != "save" {
t.Fatal(got)
}
if got := session.Handle(Key{Name: "tab"}).Speech; got != "Cancel button" {
t.Fatal(got)
}
if got := session.Handle(Key{Name: "enter"}).Action; got != "cancel" {
t.Fatal(got)
}
if got := session.Handle(Key{Name: "tab"}).Speech; got != "Speech tab" {
t.Fatal("tab cycle", got)
}
if got := session.Handle(Key{Name: "tab", Shift: true}).Speech; got != "Cancel button" {
t.Fatal("reverse cycle", got)
}
if !session.Handle(Key{Name: "escape"}).Cancelled {
t.Fatal("escape did not cancel")
}
}
func TestListCategoryFormReturnsSelectionWithoutSideEffects(t *testing.T) {
form := Form{Type: TabForm, Title: "Picker", Tabs: []Tab{{ID: "category", Label: "Category", Controls: []Control{{ID: "items", Label: "Items", Kind: List, Items: []string{"First", "Second"}, Action: "choose"}}}}}
session, err := New_session(form)
if err != nil {
t.Fatal(err)
}
session.Handle(Key{Name: "tab"})
if got := session.Handle(Key{Name: "up"}).Speech; got != "First" {
t.Fatal(got)
}
session.Handle(Key{Name: "down"})
if got := session.Handle(Key{Name: "enter"}).Action; got != "choose" {
t.Fatal(got)
}
values := session.Values()
values["category.items"].Items[1] = "changed"
if got := session.Describe(); got != "Items Second" {
t.Fatal("values aliased", got)
}
}
func TestUnknownFormTypeRejected(t *testing.T) {
form := test_form()
form.Type = "unknown"
if _, err := New_session(form); err == nil {
t.Fatal("unknown form type accepted")
}
}
+29
View File
@@ -0,0 +1,29 @@
package tabbed
import ui "git.stormux.org/storm/screenreader-common/internalui"
type Kind = ui.Kind
type Control = ui.Control
type Choice = ui.Choice
type Tab = ui.Tab
type FormType = ui.FormType
type Form = ui.Form
type Key = ui.Key
type Result = ui.Result
type Session = ui.Session
type Messages = ui.Messages
const (
CheckBox = ui.CheckBox
Number = ui.Number
Entry = ui.Entry
List = ui.List
Button = ui.Button
ComboBox = ui.ComboBox
Grid = ui.Grid
TabForm = ui.TabForm
FlatForm = ui.FlatForm
)
func New_session(f Form) (*Session, error) { return ui.New_session(f) }
func NewSession(f Form, m Messages) (*Session, error) { return ui.NewSession(f, m) }
+47
View File
@@ -0,0 +1,47 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package tabbed
import "testing"
func TestHiddenControlsRetainDraftAndRemainOutsideBothTabOrders(t *testing.T) {
session, err := New_session(test_form())
if err != nil {
t.Fatal(err)
}
session.Set_hidden("speech", "range", true)
for _, want := range []string{"enabled", "text", "ok", "cancel", ""} {
session.Handle(Key{Name: "tab"})
got, focused := session.Focused_control()
if focused != (want != "") || got.ID != want {
t.Fatal(got, want)
}
}
for _, want := range []string{"cancel", "ok", "text", "enabled", ""} {
session.Handle(Key{Name: "tab", Shift: true})
got, focused := session.Focused_control()
if focused != (want != "") || got.ID != want {
t.Fatal(got, want)
}
}
session.Handle(Key{Name: "tab"})
session.Handle(Key{Name: "tab"})
session.Handle(Key{Ctrl: true, Text: "a"})
session.Handle(Key{Text: "draft"})
session.Set_hidden("speech", "text", true)
if got, _ := session.Focused_control(); got.ID != "ok" {
t.Fatal("hidden focus retained", got)
}
session.Set_hidden("speech", "text", false)
session.Handle(Key{Name: "tab", Shift: true})
if got := session.Values()["speech.text"]; got.Text != "draft" || got.Hidden {
t.Fatal(got)
}
for _, id := range []string{"enabled", "range", "text", "ok", "cancel"} {
session.Set_hidden("speech", id, true)
}
if got := session.Handle(Key{Name: "tab"}).Speech; got != "Speech tab" {
t.Fatal("all-hidden tab trapped focus", got)
}
}