From e49ba198d7c58484406b66dacde39bfd93ebc463 Mon Sep 17 00:00:00 2001 From: Angus Kola Date: Thu, 12 Nov 2026 01:27:42 -0500 Subject: [PATCH] Add shared internal UI toolkit for both readers --- README.md | 63 ++++ UI-MIGRATION.md | 101 +++++ interaction/element.go | 39 ++ interaction/grid.go | 39 ++ interaction/grid_test.go | 29 ++ interaction/secret_test.go | 93 +++++ interaction/session.go | 300 +++++++++++++++ interaction/session_test.go | 191 ++++++++++ interaction/shared_grid_test.go | 44 +++ interaction/tabbed/choice_test.go | 120 ++++++ interaction/tabbed/dynamic_grid_test.go | 48 +++ interaction/tabbed/grid_test.go | 128 +++++++ interaction/tabbed/list_test.go | 53 +++ interaction/tabbed/messages_test.go | 38 ++ interaction/tabbed/session_test.go | 162 +++++++++ interaction/tabbed/shared.go | 29 ++ interaction/tabbed/visibility_test.go | 47 +++ internalui/choice_test.go | 120 ++++++ internalui/dynamic_grid_test.go | 48 +++ internalui/entry.go | 167 +++++++++ internalui/form.go | 92 +++++ internalui/grid.go | 107 ++++++ internalui/grid/grid.go | 60 +++ internalui/grid_test.go | 128 +++++++ internalui/list.go | 110 ++++++ internalui/list_test.go | 53 +++ internalui/message/messages.go | 27 ++ internalui/messages.go | 8 + internalui/messages_test.go | 38 ++ internalui/presentation.go | 160 ++++++++ internalui/session.go | 465 ++++++++++++++++++++++++ internalui/session_test.go | 162 +++++++++ internalui/unified_test.go | 155 ++++++++ internalui/visibility_test.go | 47 +++ 34 files changed, 3471 insertions(+) create mode 100644 UI-MIGRATION.md create mode 100644 interaction/element.go create mode 100644 interaction/grid.go create mode 100644 interaction/grid_test.go create mode 100644 interaction/secret_test.go create mode 100644 interaction/session.go create mode 100644 interaction/session_test.go create mode 100644 interaction/shared_grid_test.go create mode 100644 interaction/tabbed/choice_test.go create mode 100644 interaction/tabbed/dynamic_grid_test.go create mode 100644 interaction/tabbed/grid_test.go create mode 100644 interaction/tabbed/list_test.go create mode 100644 interaction/tabbed/messages_test.go create mode 100644 interaction/tabbed/session_test.go create mode 100644 interaction/tabbed/shared.go create mode 100644 interaction/tabbed/visibility_test.go create mode 100644 internalui/choice_test.go create mode 100644 internalui/dynamic_grid_test.go create mode 100644 internalui/entry.go create mode 100644 internalui/form.go create mode 100644 internalui/grid.go create mode 100644 internalui/grid/grid.go create mode 100644 internalui/grid_test.go create mode 100644 internalui/list.go create mode 100644 internalui/list_test.go create mode 100644 internalui/message/messages.go create mode 100644 internalui/messages.go create mode 100644 internalui/messages_test.go create mode 100644 internalui/presentation.go create mode 100644 internalui/session.go create mode 100644 internalui/session_test.go create mode 100644 internalui/unified_test.go create mode 100644 internalui/visibility_test.go diff --git a/README.md b/README.md index 64cb9f2..cabc7cd 100644 --- a/README.md +++ b/README.md @@ -28,3 +28,66 @@ checks. Leave submission to the contributor after their review and testing. ## License GPL-3.0-or-later. See [LICENSE](LICENSE). + +## Internal UI toolkit + +`internalui` is the single reader-independent toolkit. `Form` has either +`FlatForm` (one page without a tab strip) or `TabForm` layout. Both use the same +`Session`, controls, editor, validation and speech/braille presentation. + +Controls include entries, protected entries, lists, grids, checkboxes, buttons, +numeric fields and combo boxes. Forms support groups, hidden draft controls, +disabled controls, required fields, entry length limits and dynamic choices. +List and category-arrow navigation stops at boundaries; Tab/Shift+Tab cycles +through enabled visible controls in the current dialog/page. Home/End selects +list or category boundaries. Lists and grids support Page Up/Down. + +Entries support Unicode caret movement, Control+Left/Right word movement, +Shift selection, select-all, and host-mediated copy/cut/paste. The host must +write the clipboard successfully before deleting a cut selection. Password +fields mask speech and braille, refuse copy/cut, and drop their text references +on Close. Values and Focused_control may contain plaintext secrets: use them +only for submission, never generic logging or persistence. Go strings do not +provide guaranteed memory erasure. Clipboard I/O remains a host operation. +Fenrir uses its clipboard history; Cthulhu uses its existing desktop clipboard. + +NewSession accepts a session-local Messages translator. Literal choices bypass +translation. Result contains speech, braille and cursor information alongside +activation and clipboard requests. Presentation styles affect wording, not +control behavior. Hosts call Close on cancellation, replacement and shutdown; +the toolkit closes itself on Escape. Sessions belong to one event-loop/worker +and must not be accessed concurrently without external serialization. + +`interaction` translates the existing Cthulhu form protocol into this toolkit, +retaining JSON kinds and external input limits. Its NewUnifiedSession exposes +rich forms to core reader features; Cthulhu's OpenUI returns typed Controls +keyed by page.control and preserves local-only authorization. The old +`interaction/tabbed` import is a compatibility alias. Neither adapter contains +an independent widget engine. Fenrir imports internalui through its local +localization adapter. New widgets belong in internalui, so either reader can +use them without another implementation. No date picker or general table +widget is included yet; external application table reading is separate. + +Hosts retain keyboard capture, secure/local-only authorization, focus and +session cancellation, clipboard I/O, action execution, settings persistence, +and speech/braille delivery. The toolkit imports only the standard library +and other packages within this module. + +Run `go test -race ./...` here. Tests exercise both layouts, adapters, secrets, +editing, validation, independent sessions/catalogs and matching grid behavior. +Consumer repositories also retain application and input integration tests. + +### Local development and release + +For sibling checkouts, create a local Go workspace in their parent directory: + +```sh +go work init ./screenreader-common ./cthulhu ./fenrir +``` + +Do not commit machine-local workspace or replacement paths. A workspace makes +both local reader builds use these sources immediately. Published builds use +pinned module versions: after review and live acceptance, release/tag the +common module, then update `go.mod` and `go.sum` in **both** readers and test +with `GOWORK=off`. A shared source change does not update already installed +binaries; they must be rebuilt with the updated dependency. diff --git a/UI-MIGRATION.md b/UI-MIGRATION.md new file mode 100644 index 0000000..9f5dac2 --- /dev/null +++ b/UI-MIGRATION.md @@ -0,0 +1,101 @@ +# Internal UI migration and acceptance + +## Status + +The shared toolkit is released as v0.2.0. Cthulhu and Fenrir pin that version +in go.mod and go.sum; their normal builds do not require a local workspace. +A parent go.work may still select sibling checkouts for development. Verify +release dependencies and builds with GOWORK=off. + +The implementation is now consolidated into internalui: one session and widget +engine supports optional tabs, all controls, validation, rich editing, and +speech/braille results. The previous APIs are adapters, not separate engines. +New controls can be added to this package and used by either reader. There is +currently no general table or date widget. + +Intentional behavior changes: lists and category arrows no longer wrap; +checkbox Enter toggles rather than submitting a flat form; list search uses +Fenrir's editable prefix and rejects unmatched text; both editors use partial +selection, word movement, clipboard requests and Cthulhu-style editing feedback. +Tab/Shift+Tab still cycles through the dialog. Fenrir entries now have the shared +8192-rune default limit; forms can request a smaller limit. Fenrir clipboard +operations use its current clipboard-history item and existing persistence/sync +settings. Passwords cannot be copied or cut into that history. + +Desktop AT-SPI table reading, native GTK preferences rendering, and console +application reading remain separate from reader-owned controls. + +## Automated verification + +From the common checkout: `go test -race ./...`. + +From Cthulhu: test internal/interaction, internal/emoji, internal/app, +internal/tentaclewire and internal/preferences, build cmd/cthulhu, and run +`python3 tests/native_internal_ui.py` on its isolated desktop. + +From Fenrir: test internal/interaction, internal/emoji, internal/preferences, +internal/app and internal/input, and build cmd/fenrir. The native terminal +capture harness can use FENRIR_CAPTURE_TEST_ONLY=1 and FENRIR_TEST_BINARY +pointing at the rebuilt reader, under its private Xvfb desktop. + +These checks do not establish physical keyboard, audible speech or hardware +braille acceptance. Existing readers have not been restarted or installed. + +## Live Cthulhu check + +1. Quit Cthulhu with Cthulhu+Q. +2. Press Ctrl+Escape to enter i3 personal mode. +3. Press Shift+G to rebuild and start Cthulhu and its braille helper with the + debug log and test terminal. Ctrl+Escape and Shift+G are separate presses. +4. Open Emoji Menu with Cthulhu+Control+E. Expect the menu title and the first + category tab. Left/Right should change category; Tab should enter its grid. +5. Use Left/Right/Up/Down, Home and End. Expect the selected emoji label, + row/column and item count; braille should follow selection when connected. + Moving beyond an edge should leave selection at the appropriate boundary. +6. Type a known label prefix, then Backspace and Control+Backspace. Expect + filtered items, then search restoration, without typing into the terminal. +7. Tab to the copy checkbox, toggle it with Space, and Shift+Tab back to the + grid. Expect the checkbox state and restored grid selection. Escape should + close the menu and restore terminal keyboard input and normal braille. +8. Repeat an ordinary action menu or Tentacle form that you normally use. + Check Tab/Shift+Tab, editable text, activation and cancellation. + +Report the exact speech, braille content, focused control and key for any +unexpected result. Inspect ~/devel/cthulhu-go.log if this differs from expected. + +## Live Fenrir check + +The test binary is /tmp/fenrir-shared-ui. To avoid replacing the active console +reader, it can be started with `-x` in an X11 terminal supplying WINDOWID, as +supported by Fenrir. Use the desktop keyboard layout. + +1. Open Fenrir Menu with Fenrir+F. Navigate tabs and controls in both directions. + Check a checkbox, a numeric control and a combo; Escape should close without + saving your trial edits and return keys to the terminal. +2. Open Emoji Menu with Fenrir+Control+E. Check category navigation, grid arrows, + Home/End, prefix search and clearing. Expect existing coordinate/position + verbosity settings to be respected. +3. Close with Escape. Verify ordinary terminal typing, then reopen the menu. +4. If you use a translated catalog, verify translated labels and filtering; + dictionary words and user-provided choices should remain literal. + +Report the exact speech, active control and key for any difference from your +current reader. The toolkit now returns braille for both layouts; Fenrir currently consumes speech only. + +## Additional acceptance for consolidation + +1. In Fenrir's Applications menu, press Up at the first item and Down at the + last item. Expect the same item to remain selected. Home/End should reach + the ends. On the category strip, Left/Right should also stop at the ends. +2. In an editable settings field, type a disposable value such as “one two”. + Control+Shift+Left selects the final word; typing replaces that selection. + Control+A selects all; Control+C copies it to Fenrir's clipboard history. + Control+V inserts the current clipboard item. Control+X cuts the selection. + Cancel the form without saving the disposable settings value. +3. In Cthulhu, repeat the existing editable-form/clipboard workflow and emoji + copy-checkbox cancellation test. Expect masked secret output where a + password field is used, and restored application focus/braille on Escape. + +Automated fixtures also exercise rich numeric/tabbed forms through Cthulhu's +host, including local-only capture refusal, and protected entries through both +layouts. No new user-facing password dialog was added solely for testing. diff --git a/interaction/element.go b/interaction/element.go new file mode 100644 index 0000000..259da6f --- /dev/null +++ b/interaction/element.go @@ -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 +} diff --git a/interaction/grid.go b/interaction/grid.go new file mode 100644 index 0000000..d861b2d --- /dev/null +++ b/interaction/grid.go @@ -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 +} diff --git a/interaction/grid_test.go b/interaction/grid_test.go new file mode 100644 index 0000000..5d5db89 --- /dev/null +++ b/interaction/grid_test.go @@ -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") + } +} diff --git a/interaction/secret_test.go b/interaction/secret_test.go new file mode 100644 index 0000000..a0dc2da --- /dev/null +++ b/interaction/secret_test.go @@ -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") + } +} diff --git a/interaction/session.go b/interaction/session.go new file mode 100644 index 0000000..44f0d36 --- /dev/null +++ b/interaction/session.go @@ -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() } diff --git a/interaction/session_test.go b/interaction/session_test.go new file mode 100644 index 0000000..5a28cc5 --- /dev/null +++ b/interaction/session_test.go @@ -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) + } + }) + } +} diff --git a/interaction/shared_grid_test.go b/interaction/shared_grid_test.go new file mode 100644 index 0000000..4693983 --- /dev/null +++ b/interaction/shared_grid_test.go @@ -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) + } + } +} diff --git a/interaction/tabbed/choice_test.go b/interaction/tabbed/choice_test.go new file mode 100644 index 0000000..6bdb34c --- /dev/null +++ b/interaction/tabbed/choice_test.go @@ -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 . + +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) + } + } +} diff --git a/interaction/tabbed/dynamic_grid_test.go b/interaction/tabbed/dynamic_grid_test.go new file mode 100644 index 0000000..99d5115 --- /dev/null +++ b/interaction/tabbed/dynamic_grid_test.go @@ -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()) + } +} diff --git a/interaction/tabbed/grid_test.go b/interaction/tabbed/grid_test.go new file mode 100644 index 0000000..f3945a3 --- /dev/null +++ b/interaction/tabbed/grid_test.go @@ -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) + } + } +} diff --git a/interaction/tabbed/list_test.go b/interaction/tabbed/list_test.go new file mode 100644 index 0000000..978ef56 --- /dev/null +++ b/interaction/tabbed/list_test.go @@ -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) + } +} diff --git a/interaction/tabbed/messages_test.go b/interaction/tabbed/messages_test.go new file mode 100644 index 0000000..14f53ba --- /dev/null +++ b/interaction/tabbed/messages_test.go @@ -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) + } +} diff --git a/interaction/tabbed/session_test.go b/interaction/tabbed/session_test.go new file mode 100644 index 0000000..bef39ff --- /dev/null +++ b/interaction/tabbed/session_test.go @@ -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 . + +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") + } +} diff --git a/interaction/tabbed/shared.go b/interaction/tabbed/shared.go new file mode 100644 index 0000000..10af159 --- /dev/null +++ b/interaction/tabbed/shared.go @@ -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) } diff --git a/interaction/tabbed/visibility_test.go b/interaction/tabbed/visibility_test.go new file mode 100644 index 0000000..22700c3 --- /dev/null +++ b/interaction/tabbed/visibility_test.go @@ -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) + } +} diff --git a/internalui/choice_test.go b/internalui/choice_test.go new file mode 100644 index 0000000..57b6727 --- /dev/null +++ b/internalui/choice_test.go @@ -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 . + +package internalui + +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) + } + } +} diff --git a/internalui/dynamic_grid_test.go b/internalui/dynamic_grid_test.go new file mode 100644 index 0000000..9300bab --- /dev/null +++ b/internalui/dynamic_grid_test.go @@ -0,0 +1,48 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package internalui + +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()) + } +} diff --git a/internalui/entry.go b/internalui/entry.go new file mode 100644 index 0000000..18ee4e1 --- /dev/null +++ b/internalui/entry.go @@ -0,0 +1,167 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later +package internalui + +import ( + "strconv" + "strings" + "unicode" + "unicode/utf8" +) + +func (s *Session) characterCount(n int) string { + if n == 1 { + return s.messages.T("1 character") + } + return s.messages.Format("{0} characters", strconv.Itoa(n)) +} +func (s *Session) edit(control *Control, key Key) Result { + if key.Name == "enter" { + return Result{Action: control.Action} + } + characters := []rune(control.Text) + start, end := min(s.cursor, s.anchor), max(s.cursor, s.anchor) + if key.Ctrl && key.Name == "" { + switch strings.ToLower(key.Text) { + case "a": + s.anchor, s.cursor = 0, len(characters) + output := s.Presentation() + output.Speech = s.messages.T("Selected all") + return output + case "v": + return Result{Paste: true} + case "c", "x": + if control.Secret { + output := s.Presentation() + output.Speech = s.messages.T("Copy and cut are unavailable for password fields") + return output + } + if start == end { + return Result{} + } + text := string(characters[start:end]) + // Cutting is completed by the host only after clipboard write succeeds. + return Result{Copy: &text} + default: + return Result{} + } + } + if key.Name == "left" || key.Name == "right" || key.Name == "home" || key.Name == "end" { + next := s.cursor + switch key.Name { + case "left": + if !key.Shift && start != end { + next = start + } else { + next = max(0, next-1) + if key.Ctrl { + for next > 0 && next < len(characters) && unicode.IsSpace(characters[next]) { + next-- + } + for next > 0 && !unicode.IsSpace(characters[next-1]) { + next-- + } + } + } + case "right": + if !key.Shift && start != end { + next = end + } else { + next = min(len(characters), next+1) + if key.Ctrl { + for next < len(characters) && !unicode.IsSpace(characters[next]) { + next++ + } + for next < len(characters) && unicode.IsSpace(characters[next]) { + next++ + } + } + } + case "home": + next = 0 + case "end": + next = len(characters) + } + s.cursor = next + if !key.Shift { + s.anchor = next + } + output := s.Presentation() + if next == len(characters) { + output.Speech = s.messages.T("End of text") + } else if control.Secret { + output.Speech = s.messages.T("Hidden character") + } else { + output.Speech = string(characters[next]) + } + if key.Shift { + output.Speech = s.messages.Format("{0}, selected", output.Speech) + } + return output + } + if key.Name == "backspace" || key.Name == "delete" { + if start == end { + if key.Name == "backspace" { + start = max(0, start-1) + } else { + end = min(len(characters), end+1) + } + } + if start == end { + return Result{} + } + removed := string(characters[start:end]) + control.Text = string(append(characters[:start], characters[end:]...)) + s.cursor, s.anchor = start, start + output := s.Presentation() + if control.Secret { + output.Speech = s.messages.Format("Deleted {0}", s.characterCount(end-start)) + } else { + output.Speech = s.messages.Format("Deleted {0}", removed) + } + return output + } + if (key.Name == "" || key.Name == "space") && key.Text != "" && !key.Ctrl { + return s.Insert(key.Text) + } + return Result{} +} + +// Insert handles typing and paste atomically; invalid/oversized input leaves +// the field and selection untouched. +func (s *Session) Insert(text string) Result { + if s.closed { + return Result{} + } + if s.focus < 0 { + return Result{} + } + control := &s.form.Tabs[s.tab].Controls[s.focus] + if control.Kind != Entry { + return Result{} + } + if !utf8.ValidString(text) { + return Result{Speech: s.messages.T("Invalid text"), Braille: s.messages.T("Invalid text"), Cursor: -1} + } + characters := []rune(control.Text) + start, end := min(s.cursor, s.anchor), max(s.cursor, s.anchor) + inserted := []rune(text) + for _, character := range inserted { + if unicode.IsControl(character) { + return Result{Speech: s.messages.T("Only single-line text is accepted"), Braille: s.messages.T("Only single-line text is accepted"), Cursor: -1} + } + } + if len(characters)-(end-start)+len(inserted) > control.MaxLength { + return Result{Speech: s.messages.T("Text limit reached"), Braille: s.messages.T("Text limit reached"), Cursor: -1} + } + control.Text = string(characters[:start]) + text + string(characters[end:]) + s.cursor, s.anchor = start+len(inserted), start+len(inserted) + output := s.Presentation() + if control.Secret { + output.Speech = s.messages.Format("Inserted {0}", s.characterCount(len(inserted))) + } else { + output.Speech = text + } + return output +} diff --git a/internalui/form.go b/internalui/form.go new file mode 100644 index 0000000..1c232ad --- /dev/null +++ b/internalui/form.go @@ -0,0 +1,92 @@ +// 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 . + +package internalui + +type Kind int + +const ( + CheckBox Kind = iota + Number + Entry + List + Button + ComboBox + Grid +) + +type Control struct { + ID, Label string + Group string + Hidden bool // Retains draft values while excluded from navigation. + Kind Kind + Checked bool + Number float64 + Min, Max, Step float64 + UpDownStep float64 // Zero uses the default step of 1 for Up and Down. + Text string + Items []string + Selected int + Action string + Choices []Choice + Columns int // Grid width; Text is its prefix filter. + DeleteAction string + Disabled, Required, Secret bool + MaxLength int + SelectAction bool // Return the selected choice ID on activation. + SkipValidation bool // Explicit cancel/back actions. +} + +type Choice struct { + ID, Label, Unavailable string + Disabled bool + Literal bool // User/dictionary data must not be translated. +} + +type Tab struct { + ID, Label string + Controls []Control +} + +type FormType string + +const TabForm FormType = "tab_form" +const FlatForm FormType = "flat_form" + +type Form struct { + Type FormType + Title string + Tabs []Tab + Detailed bool // Include edit roles and list positions in braille. +} + +type Key struct { + Name string + Text string + Shift, Ctrl, Alt, AltGr bool + Repeat bool +} + +type Result struct { + Speech string + Braille string + Cursor int + Paste bool + Copy *string + Action string + Cancelled bool +} diff --git a/internalui/grid.go b/internalui/grid.go new file mode 100644 index 0000000..703c036 --- /dev/null +++ b/internalui/grid.go @@ -0,0 +1,107 @@ +// Fenrir console screen reader. +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package internalui + +import ( + gridmodel "git.stormux.org/storm/screenreader-common/internalui/grid" + "strings" + "unicode" + "unicode/utf8" +) + +// Grid selections index Choices, even while the visible results are filtered. +// Choice IDs remain opaque to the model and can carry a host-owned value. +func (s *Session) grid_matches(c Control) []int { + var matches []int + prefix := strings.ToLower(c.Text) + for index, choice := range c.Choices { + if !choice.Disabled && strings.HasPrefix(strings.ToLower(s.choice_label(choice)), prefix) { + matches = append(matches, index) + } + } + return matches +} + +func (s *Session) filter_grid(c *Control) { + c.Selected = -1 + if matches := s.grid_matches(*c); len(matches) > 0 { + c.Selected = matches[0] + } +} + +func (s *Session) describe_grid(c Control) string { + matches := s.grid_matches(c) + for position, index := range matches { + if index == c.Selected { + label := s.choice_label(c.Choices[index]) + return gridmodel.Describe(label, position, len(matches), c.Columns, s.announceGridCoordinates, s.announceItemPosition, s.messages) + } + } + return s.messages.T("No matches") +} + +func (s *Session) grid(c *Control, key Key) Result { + if key.Ctrl && key.Name != "backspace" { + return Result{} + } + if key.Name == "backspace" || (key.Name == "" || key.Name == "space") && key.Text != "" && !key.Ctrl { + runes := []rune(c.Text) + if key.Name == "backspace" { + if key.Ctrl { + runes = nil + } else if len(runes) > 0 { + runes = runes[:len(runes)-1] + } + } else { + if !utf8.ValidString(key.Text) { + return Result{} + } + for _, char := range key.Text { + if !unicode.IsPrint(char) { + return Result{} + } + } + if len(runes)+len([]rune(key.Text)) > 4096 { + return Result{} + } + runes = append(runes, []rune(key.Text)...) + } + c.Text = string(runes) + s.filter_grid(c) + query := s.messages.T("Search cleared") + if c.Text != "" { + query = s.messages.Format("Search {0}", c.Text) + } + return Result{Speech: query + ". " + s.describe_grid(*c)} + } + matches := s.grid_matches(*c) + if len(matches) == 0 { + return Result{Speech: s.messages.T("No matches")} + } + position := 0 + for current, index := range matches { + if index == c.Selected { + position = current + break + } + } + if key.Name == "enter" { + return Result{Action: c.Action} + } + if key.Name == "delete" { + if key.Repeat { + return Result{} + } + return Result{Action: c.DeleteAction} + } + var handled bool + position, handled = gridmodel.Move(position, len(matches), c.Columns, key.Name) + if !handled { + return Result{} + } + + c.Selected = matches[position] + return Result{Speech: s.describe_grid(*c)} +} diff --git a/internalui/grid/grid.go b/internalui/grid/grid.go new file mode 100644 index 0000000..c8888ac --- /dev/null +++ b/internalui/grid/grid.go @@ -0,0 +1,60 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package grid owns selection movement and announcements for both form layouts. +package grid + +import ( + "git.stormux.org/storm/screenreader-common/internalui/message" + "strconv" +) + +// Move navigates the filtered positions, preserving the column where possible. +// The host maps the returned position back to its stable item identity. +func Move(position, count, columns int, key string) (int, bool) { + if count <= 0 || columns <= 0 { + return position, false + } + switch key { + case "left": + position = max(0, position-1) + case "right": + position = min(count-1, position+1) + case "up": + if position >= columns { + position -= columns + } + case "down": + if position/columns < (count-1)/columns { + position = min(count-1, position+columns) + } + case "home": + position = 0 + case "end": + position = count - 1 + case "pageup": + position = max(0, position-columns*5) + case "pagedown": + position = min(count-1, position+columns*5) + default: + return position, false + } + return position, true +} + +// Describe is shared speech policy. Layouts choose which optional details to +// request; localization belongs to the session, never to a global catalog. +func Describe(label string, position, count, columns int, coordinates, itemPosition bool, messages message.Messages) string { + row, column := strconv.Itoa(position/columns+1), strconv.Itoa(position%columns+1) + item, total := strconv.Itoa(position+1), strconv.Itoa(count) + switch { + case coordinates && itemPosition: + return messages.Format("{0}. row {1}, column {2}. {3} of {4}", label, row, column, item, total) + case coordinates: + return messages.Format("{0}. row {1}, column {2}", label, row, column) + case itemPosition: + return messages.Format("{0}. {1} of {2}", label, item, total) + default: + return label + } +} diff --git a/internalui/grid_test.go b/internalui/grid_test.go new file mode 100644 index 0000000..48784b5 --- /dev/null +++ b/internalui/grid_test.go @@ -0,0 +1,128 @@ +// Fenrir console screen reader. +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package internalui + +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) + } + } +} diff --git a/internalui/list.go b/internalui/list.go new file mode 100644 index 0000000..f4bdc48 --- /dev/null +++ b/internalui/list.go @@ -0,0 +1,110 @@ +// Fenrir console screen reader. +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package internalui + +import ( + "strings" + "unicode" + "unicode/utf8" +) + +// Lists use Text as a prefix search without filtering or activating items. +func (s *Session) list(control *Control, key Key) Result { + if key.Ctrl && key.Name != "backspace" { + return Result{} + } + if key.Name == "backspace" || (key.Name == "" || key.Name == "space") && key.Text != "" && !key.Ctrl { + prefix := []rune(control.Text) + if key.Name == "backspace" { + if key.Ctrl { + prefix = nil + } else if len(prefix) > 0 { + prefix = prefix[:len(prefix)-1] + } + } else { + if !utf8.ValidString(key.Text) { + return Result{} + } + for _, char := range key.Text { + if !unicode.IsPrint(char) { + return Result{} + } + } + if len(prefix)+len([]rune(key.Text)) > 4096 { + return Result{} + } + prefix = append(prefix, []rune(key.Text)...) + } + query := strings.ToLower(string(prefix)) + for index, item := range s.list_labels(*control) { + if s.list_disabled(*control, index) { + continue + } + if strings.HasPrefix(strings.ToLower(item), query) { + control.Text, control.Selected = string(prefix), index + return Result{Speech: item} + } + } + // Reject unmatched input so a typo leaves the last valid search intact. + return Result{Speech: s.messages.T("No matches")} + } + labels := s.list_labels(*control) + if key.Name == "enter" { + if control.SelectAction && control.Selected >= 0 && len(control.Choices) > 0 { + return Result{Action: control.Choices[control.Selected].ID} + } + return Result{Action: control.Action} + } + delta := 0 + switch key.Name { + case "up": + delta = -1 + case "down": + delta = 1 + case "pageup": + delta = -10 + case "pagedown": + delta = 10 + case "home": + control.Selected = -1 + delta = 1 + case "end": + control.Selected = len(labels) + delta = -1 + default: + return Result{} + } + step := 1 + if delta < 0 { + step = -1 + delta = -delta + } + for range delta { + for next := control.Selected + step; next >= 0 && next < len(labels); next += step { + if !s.list_disabled(*control, next) { + control.Selected = next + break + } + } + } + control.Text = "" + if control.Selected < 0 || control.Selected >= len(labels) { + return Result{} + } + return Result{Speech: labels[control.Selected]} +} +func (s *Session) list_labels(c Control) []string { + if len(c.Choices) == 0 { + return c.Items + } + labels := make([]string, len(c.Choices)) + for i, v := range c.Choices { + labels[i] = s.choice_label(v) + } + return labels +} +func (s *Session) list_disabled(c Control, index int) bool { + return len(c.Choices) > 0 && c.Choices[index].Disabled +} diff --git a/internalui/list_test.go b/internalui/list_test.go new file mode 100644 index 0000000..9622f12 --- /dev/null +++ b/internalui/list_test.go @@ -0,0 +1,53 @@ +// Fenrir console screen reader. +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package internalui + +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) + } +} diff --git a/internalui/message/messages.go b/internalui/message/messages.go new file mode 100644 index 0000000..49df1d6 --- /dev/null +++ b/internalui/message/messages.go @@ -0,0 +1,27 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package message + +import ( + "strconv" + "strings" +) + +// Messages belongs to one session. Translate must be safe for the host's +// calling context; a nil function selects the built-in English messages. +type Messages struct{ Translate func(string) string } + +func (m Messages) T(message string) string { + if m.Translate != nil { + return m.Translate(message) + } + return message +} +func (m Messages) Format(message string, fields ...string) string { + replacements := []string{"{{", "{", "}}", "}"} + for index, value := range fields { + replacements = append(replacements, "{"+strconv.Itoa(index)+"}", value) + } + return strings.NewReplacer(replacements...).Replace(m.T(message)) +} diff --git a/internalui/messages.go b/internalui/messages.go new file mode 100644 index 0000000..a698d7d --- /dev/null +++ b/internalui/messages.go @@ -0,0 +1,8 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package internalui + +import "git.stormux.org/storm/screenreader-common/internalui/message" + +type Messages = message.Messages diff --git a/internalui/messages_test.go b/internalui/messages_test.go new file mode 100644 index 0000000..7fe487e --- /dev/null +++ b/internalui/messages_test.go @@ -0,0 +1,38 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package internalui + +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) + } +} diff --git a/internalui/presentation.go b/internalui/presentation.go new file mode 100644 index 0000000..edd590f --- /dev/null +++ b/internalui/presentation.go @@ -0,0 +1,160 @@ +// 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 . + +package internalui + +import ( + "strconv" + "strings" + "unicode/utf8" +) + +func (s *Session) Open() string { return s.messages.T(s.form.Title) + ". " + s.Describe() } + +func (s *Session) describe() string { + if s.closed { + return "" + } + tab := s.form.Tabs[s.tab] + if s.focus == -1 { + return s.messages.Format("{0} tab", s.messages.T(tab.Label)) + } + control := tab.Controls[s.focus] + label := s.messages.T(control.Label) + switch control.Kind { + case CheckBox: + state := "unchecked" + if control.Checked { + state = "checked" + } + return label + " " + s.messages.T(state) + case Number: + return label + " " + strconv.FormatFloat(control.Number, 'f', -1, 64) + case Entry: + return s.Presentation().Speech + case List: + if control.Selected < 0 { + return label + } + return label + " " + s.list_labels(control)[control.Selected] + case Grid: + return s.messages.Format("{0} grid. {1}", label, s.describe_grid(control)) + case ComboBox: + return s.messages.Format("{0} combo box {1}", label, s.describe_choice(control)) + case Button: + return s.messages.Format("{0} button", label) + } + return label +} + +func (s *Session) describe_choice(control Control) string { + choice := control.Choices[control.Selected] + text := s.choice_label(choice) + if choice.Unavailable != "" { + text += ". " + s.messages.T("unavailable") + ": " + choice.Unavailable + } + return text +} + +func (s *Session) choice_label(choice Choice) string { + if choice.Literal { + return choice.Label + } + return s.messages.T(choice.Label) +} + +func (s *Session) focused_group() string { + if s.focus < 0 { + return "" + } + return s.form.Tabs[s.tab].Controls[s.focus].Group +} + +func (s *Session) describe_focus(previousGroup string) string { + if group := s.focused_group(); group != "" && group != previousGroup { + return s.messages.T(group) + ". " + s.Describe() + } + return s.Describe() +} + +func (s *Session) Describe() string { return s.Presentation().Speech } +func (s *Session) Presentation() Result { + if s.closed { + return Result{} + } + if s.focus < 0 { + text := s.messages.Format("{0} tab", s.messages.T(s.form.Tabs[s.tab].Label)) + return Result{Speech: text, Braille: text, Cursor: -1} + } + c := s.form.Tabs[s.tab].Controls[s.focus] + label := s.messages.T(c.Label) + if c.Kind == Entry { + template := "{0}, edit: {1}" + if !s.form.Detailed { + template = "{0} entry {1}" + } + value := c.Text + if c.Secret { + template = "{0}, password edit: {1}" + value = strings.Repeat("*", utf8.RuneCountInString(c.Text)) + } + text := s.messages.Format(template, label, value) + marker := s.messages.Format(template, label, "\x00") + offset := strings.IndexByte(marker, 0) + cursor := -1 + if offset >= 0 { + cursor = len([]rune(marker[:offset])) + s.cursor + } + if s.cursor != s.anchor { + n := s.cursor - s.anchor + if n < 0 { + n = -n + } + text += s.messages.Format(" (selected {0} characters)", strconv.Itoa(n)) + } + speech := text + if c.Secret { + speech = s.messages.Format("{0}, password edit, {1}", label, s.characterCount(utf8.RuneCountInString(c.Text))) + } + return Result{Speech: speech, Braille: text, Cursor: cursor} + } + text := s.describe() + speech := text + if s.form.Detailed { + switch c.Kind { + case List: + if c.Selected >= 0 { + speech = s.list_labels(c)[c.Selected] + text = s.messages.Format("{0}: {1}, {2} of {3}", label, speech, strconv.Itoa(c.Selected+1), strconv.Itoa(len(s.list_labels(c)))) + } + case CheckBox: + state := "unchecked" + if c.Checked { + state = "checked" + } + text = label + ", " + s.messages.T(state) + speech = text + case Button: + text = s.messages.Format("{0}, button", label) + speech = text + case Grid: + text = s.describe_grid(c) + speech = text + } + } + return Result{Speech: speech, Braille: text, Cursor: -1} +} diff --git a/internalui/session.go b/internalui/session.go new file mode 100644 index 0000000..25d81f7 --- /dev/null +++ b/internalui/session.go @@ -0,0 +1,465 @@ +// 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 . + +// Package internalui models reader-owned forms without device, speech or +// settings dependencies. Hosts own capture and perform the returned actions. +package internalui + +import ( + "fmt" + "math" + "strings" + "unicode" + "unicode/utf8" +) + +type Session struct { + messages Messages + form Form + tab, focus, cursor int // focus -1 is the tab strip. + announceGridCoordinates, announceItemPosition bool + anchor int + closed bool +} + +func New_session(form Form) (*Session, error) { return NewSession(form, Messages{}) } + +func NewSession(form Form, messages Messages) (*Session, error) { + s := &Session{messages: messages} + if form.Type != TabForm && form.Type != FlatForm { + 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) + } + if control.Secret && control.Kind != Entry { + return nil, fmt.Errorf("only entries may be secret") + } + if control.MaxLength == 0 { + control.MaxLength = 8192 + } + if control.MaxLength < 1 || control.MaxLength > 8192 || len([]rune(control.Text)) > control.MaxLength || !utf8.ValidString(control.Text) { + return nil, fmt.Errorf("invalid entry length or text") + } + if control.Kind == Entry { + for _, r := range control.Text { + if unicode.IsControl(r) { + return nil, fmt.Errorf("entries must contain single-line text") + } + } + } + control.Items = append([]string(nil), control.Items...) + control.Choices = append([]Choice(nil), control.Choices...) + if control.Kind == ComboBox || control.Kind == Grid || control.Kind == List && len(control.Choices) > 0 { + if !control.Disabled && !(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 == List || control.Kind == ComboBox) && len(control.Choices) > 0 { + if control.Selected < 0 || control.Selected >= len(control.Choices) || control.Choices[control.Selected].Disabled { + control.Selected = -1 + for i, choice := range control.Choices { + if !choice.Disabled { + control.Selected = i + break + } + } + } + if control.Selected < 0 { + control.Disabled = true + } + } + if control.Kind == Grid { + if control.Columns < 1 { + return nil, fmt.Errorf("invalid grid width %q", control.ID) + } + s.filter_grid(control) + } + if control.Kind == List && !control.Disabled && len(control.Choices) == 0 && (len(control.Items) == 0 || control.Selected < 0 || control.Selected >= len(control.Items)) { + return nil, fmt.Errorf("invalid list %q", control.ID) + } + } + } + s.form, s.focus = form, -1 + if form.Type == FlatForm { + if len(form.Tabs) != 1 { + return nil, fmt.Errorf("flat forms require one page") + } + s.move_focus(1) + if s.closed || s.focus < 0 { + return nil, fmt.Errorf("form has no enabled controls") + } + } + s.announceGridCoordinates, s.announceItemPosition = true, true + return s, 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 { + if s.closed { + return nil + } + 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.closed || 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...) + s.filter_grid(control) + matches := s.grid_matches(*control) + 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) + strip := s.form.Type == TabForm + size := count + if strip { + size++ + } + if size == 0 { + s.focus = -1 + return + } + for range size { + if strip { + s.focus = (s.focus+1+delta+size)%size - 1 + } else { + s.focus = (s.focus + delta + size) % size + } + if s.focus == -1 || !s.form.Tabs[s.tab].Controls[s.focus].Hidden && !s.form.Tabs[s.tab].Controls[s.focus].Disabled { + break + } + } + if s.focus >= 0 && (s.form.Tabs[s.tab].Controls[s.focus].Hidden || s.form.Tabs[s.tab].Controls[s.focus].Disabled) { + s.focus = -1 + } + if s.focus >= 0 { + s.cursor = len([]rune(s.form.Tabs[s.tab].Controls[s.focus].Text)) + s.anchor = s.cursor + } +} + +// 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 s.closed { + return Result{} + } + if key.Name == "escape" { + s.Close() + 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 { + if s.form.Type == FlatForm { + return Result{} + } + delta := 0 + switch key.Name { + case "left", "up": + delta = -1 + case "right", "down": + delta = 1 + case "home": + s.tab = 0 + case "end": + s.tab = len(s.form.Tabs) - 1 + case "enter", "space": + s.move_focus(1) + } + if delta != 0 { + s.tab = min(len(s.form.Tabs)-1, max(0, s.tab+delta)) + } + if delta != 0 || key.Name == "enter" || key.Name == "space" || key.Name == "home" || key.Name == "end" { + 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: + name := key.Name + if name == "left" { + name = "up" + } + if name == "right" { + name = "down" + } + switch name { + case "up", "down", "home", "end", "pageup", "pagedown": + default: + return Result{} + } + s.list(control, Key{Name: name}) + return Result{Speech: s.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 + } + } +} + +func (s *Session) Handle(key Key) Result { + r := s.handle(key) + if r.Action != "" && r.Action != "cancel" { + c, _ := s.Focused_control() + if !c.SkipValidation { + if problem := s.Validate(); problem != "" { + return Result{Speech: problem, Braille: problem, Cursor: -1} + } + } + } + if r.Speech != "" && r.Braille == "" { + p := s.Presentation() + r.Braille, r.Cursor = p.Braille, p.Cursor + } + return r +} +func (s *Session) Validate() string { + for _, tab := range s.form.Tabs { + for _, c := range tab.Controls { + if c.Required && !c.Hidden && !c.Disabled && c.Kind == Entry && strings.TrimSpace(c.Text) == "" { + return s.messages.Format("{0} is required", s.messages.T(c.Label)) + } + } + } + return "" +} +func (s *Session) Close() { + s.closed = true + for ti := range s.form.Tabs { + for ci := range s.form.Tabs[ti].Controls { + c := &s.form.Tabs[ti].Controls[ci] + if c.Secret { + c.Text = "" + } + } + } + s.cursor, s.anchor = 0, 0 +} +func (s *Session) SetChecked(id string, checked bool) { + for ti := range s.form.Tabs { + for ci := range s.form.Tabs[ti].Controls { + c := &s.form.Tabs[ti].Controls[ci] + if c.ID == id && c.Kind == CheckBox { + c.Checked = checked + } + } + } +} + +// Select restores a host-owned reading caret without moving focus. +func (s *Session) Select(tabID, id string, index int) { + for ti := range s.form.Tabs { + if s.form.Tabs[ti].ID != tabID { + continue + } + for ci := range s.form.Tabs[ti].Controls { + c := &s.form.Tabs[ti].Controls[ci] + if c.ID == id && index >= 0 && index < len(c.Choices) && !c.Choices[index].Disabled { + c.Selected = index + } + } + } +} diff --git a/internalui/session_test.go b/internalui/session_test.go new file mode 100644 index 0000000..db0ef41 --- /dev/null +++ b/internalui/session_test.go @@ -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 . + +package internalui + +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") + } +} diff --git a/internalui/unified_test.go b/internalui/unified_test.go new file mode 100644 index 0000000..346698c --- /dev/null +++ b/internalui/unified_test.go @@ -0,0 +1,155 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later +package internalui + +import ( + "strings" + "testing" +) + +func TestRichEditorAndValidationInBothLayouts(t *testing.T) { + for _, layout := range []FormType{FlatForm, TabForm} { + t.Run(string(layout), func(t *testing.T) { + form := Form{Type: layout, Title: "Connection", Tabs: []Tab{{ID: "page", Label: "Page", Controls: []Control{ + {ID: "secret", Label: "Password", Kind: Entry, Secret: true, Required: true, MaxLength: 12, Text: "one two 猫"}, + {ID: "submit", Label: "Connect", Kind: Button, Action: "connect"}, + {ID: "cancel", Label: "Cancel", Kind: Button, Action: "cancel"}, + }}}} + s, err := New_session(form) + if err != nil { + t.Fatal(err) + } + if layout == TabForm { + s.Handle(Key{Name: "tab"}) + } + s.Handle(Key{Name: "left", Ctrl: true, Shift: true}) + for _, key := range []string{"c", "x"} { + r := s.Handle(Key{Ctrl: true, Text: key}) + if r.Copy != nil || strings.Contains(r.Speech, "猫") || strings.Contains(r.Braille, "猫") { + t.Fatal("secret disclosure") + } + } + s.Handle(Key{Name: "backspace"}) + if got := s.Values()["page.secret"].Text; got != "one two " { + t.Fatal(got) + } + s.Handle(Key{Ctrl: true, Text: "a"}) + s.Handle(Key{Name: "delete"}) + s.Handle(Key{Name: "tab"}) + r := s.Handle(Key{Name: "enter"}) + if r.Action != "" || r.Speech != "Password is required" { + t.Fatal(r) + } + s.Handle(Key{Name: "tab", Shift: true}) + if !s.Handle(Key{Ctrl: true, Text: "v"}).Paste { + t.Fatal("missing paste request") + } + if r := s.Insert("密é"); r.Speech != "Inserted 2 characters" || strings.Contains(r.Braille, "密") { + t.Fatal("paste disclosure") + } + before := s.Values()["page.secret"].Text + for _, text := range []string{"bad\nvalue", string([]byte{255}), strings.Repeat("x", 13)} { + s.Insert(text) + if s.Values()["page.secret"].Text != before { + t.Fatal("invalid paste changed field") + } + } + s.Handle(Key{Name: "tab"}) + if s.Handle(Key{Name: "enter"}).Action != "connect" { + t.Fatal("valid submit refused") + } + s.Close() + if s.Values() != nil || s.form.Tabs[0].Controls[0].Text != "" || s.Insert("x") != (Result{}) || s.Presentation() != (Result{}) { + t.Fatal("closed session retained secret") + } + if form.Tabs[0].Controls[0].Text != "one two 猫" { + t.Fatal("caller state mutated") + } + }) + } +} +func TestPartialSelectionCopyAndWordMovement(t *testing.T) { + s, err := New_session(Form{Type: TabForm, Title: "Edit", Tabs: []Tab{{ID: "p", Label: "Page", Controls: []Control{{ID: "e", Label: "Entry", Kind: Entry, Text: "one two 猫"}}}}}) + if err != nil { + t.Fatal(err) + } + s.Handle(Key{Name: "tab"}) + s.Handle(Key{Name: "left", Ctrl: true, Shift: true}) + r := s.Handle(Key{Ctrl: true, Text: "x"}) + if r.Copy == nil || *r.Copy != "猫" || s.Values()["p.e"].Text != "one two 猫" { + t.Fatal("cut modified before host approval") + } + s.Handle(Key{Name: "delete"}) + s.Handle(Key{Name: "home"}) + s.Handle(Key{Name: "right", Ctrl: true}) + s.Insert("é") + if s.Values()["p.e"].Text != "one étwo " { + t.Fatal(s.Values()) + } +} +func TestNonWrappingListsAndTabs(t *testing.T) { + s, err := New_session(Form{Type: TabForm, Title: "Picker", Tabs: []Tab{ + {ID: "a", Label: "First", Controls: []Control{{ID: "list", Label: "List", Kind: List, Items: []string{"One", "Two"}}}}, + {ID: "b", Label: "Last"}, + }}) + if err != nil { + t.Fatal(err) + } + if s.Handle(Key{Name: "left"}).Speech != "First tab" { + t.Fatal("tab wrapped") + } + s.Handle(Key{Name: "right"}) + if s.Handle(Key{Name: "right"}).Speech != "Last tab" { + t.Fatal("tab wrapped") + } + s.Handle(Key{Name: "left"}) + s.Handle(Key{Name: "tab"}) + if s.Handle(Key{Name: "up"}).Speech != "One" { + t.Fatal("list wrapped") + } + s.Handle(Key{Name: "end"}) + if s.Handle(Key{Name: "down"}).Speech != "Two" { + t.Fatal("list wrapped") + } +} + +func TestDisabledChoicesAndEmptyEditor(t *testing.T) { + for _, kind := range []Kind{List, ComboBox} { + s, err := New_session(Form{Type: FlatForm, Title: "Choice", Tabs: []Tab{{ID: "p", Label: "Page", Controls: []Control{{ID: "c", Label: "Choice", Kind: kind, Choices: []Choice{{ID: "a", Label: "Disabled", Disabled: true}, {ID: "b", Label: "Enabled"}, {ID: "c", Label: "Disabled", Disabled: true}}}}}}}) + if err != nil { + t.Fatal(err) + } + for _, key := range []string{"up", "down", "home", "end"} { + s.Handle(Key{Name: key}) + if s.Values()["p.c"].Selected != 1 { + t.Fatal("disabled choice selected") + } + } + } + s, err := New_session(Form{Type: FlatForm, Title: "Edit", Tabs: []Tab{{ID: "p", Label: "Page", Controls: []Control{{ID: "e", Label: "Entry", Kind: Entry}}}}}) + if err != nil { + t.Fatal(err) + } + for _, key := range []string{"left", "right"} { + s.Handle(Key{Name: key, Ctrl: true, Shift: true}) + } + if s.Values()["p.e"].Text != "" { + t.Fatal("empty word navigation changed text") + } +} + +func TestTranslatedEntryBrailleCursorFollowsValue(t *testing.T) { + s, err := NewSession(Form{Type: FlatForm, Title: "Edit", Tabs: []Tab{{ID: "p", Label: "Page", Controls: []Control{{ID: "e", Label: "Label", Kind: Entry, Text: "猫é"}}}}}, Messages{Translate: func(text string) string { + if text == "{0} entry {1}" { + return "Value {1}: {0}" + } + return text + }}) + if err != nil { + t.Fatal(err) + } + r := s.Presentation() + if r.Speech != "Value 猫é: Label" || r.Braille != r.Speech || r.Cursor != 8 { + t.Fatal(r) + } +} diff --git a/internalui/visibility_test.go b/internalui/visibility_test.go new file mode 100644 index 0000000..e9adefb --- /dev/null +++ b/internalui/visibility_test.go @@ -0,0 +1,47 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package internalui + +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) + } +}