Cthulhu is no longer a fork. Now it is a screen reader written in Golang.

This commit is contained in:
Storm Dragon committed 2026-10-14 00:16:34 -04:00
1 parent a998fe67de
commit 069fc674dc
708 files changed
+112482 -532

No files matched your search

+467
View File
@@ -0,0 +1,467 @@
// 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"
"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"`
}
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, caret, anchor int
prefix string
}
type Session struct {
title string
elements []elementState
focus int
closed bool
}
const maxTextLength = 8192
func NewSession(form Form) (*Session, error) {
if form.Title == "" || len(form.Title) > 1024 || len(form.Elements) == 0 || len(form.Elements) > 64 {
return nil, errors.New("a titled form with 1 to 64 controls is required")
}
session := &Session{title: form.Title, 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.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, caret: len([]rune(element.Text)), anchor: len([]rune(element.Text))}
itemIDs := map[string]bool{}
if len(element.Items) > 256 {
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")
}
return session, nil
}
func (session *Session) Presentation() Presentation {
if session.closed {
return Presentation{}
}
state := &session.elements[session.focus]
element := state.element
text := element.Label
cursor := -1
switch element.Kind {
case List:
text += fmt.Sprintf(": %s, %d of %d", element.Items[state.selected].Label, state.selected+1, len(element.Items))
case Entry:
text += ", edit: " + element.Text
cursor = len([]rune(element.Label+", edit: ")) + state.caret
if state.caret != state.anchor {
text += fmt.Sprintf(" (selected %d characters)", abs(state.caret-state.anchor))
}
case CheckBox:
if element.Checked {
text += ", checked"
} else {
text += ", unchecked"
}
case Button:
text += ", button"
}
speech := text
if element.Kind == List {
speech = element.Items[state.selected].Label
}
return Presentation{Speech: speech, Braille: text, Cursor: cursor}
}
func (session *Session) Opening() Presentation {
output := session.Presentation()
if !session.closed && len(session.elements) > 1 && session.elements[session.focus].element.Kind == List {
output.Speech = session.elements[session.focus].element.Label + ". " + output.Speech
}
output.Speech = session.title + ". " + output.Speech
return output
}
func (session *Session) Values() map[string]Value {
values := make(map[string]Value, len(session.elements))
for _, state := range session.elements {
value := Value{Text: state.element.Text, Checked: state.element.Checked}
if state.element.Kind == List && state.selected >= 0 {
value.Selected = state.element.Items[state.selected].ID
}
values[state.element.ID] = value
}
return values
}
func (session *Session) Handle(event Event) Effect {
if session.closed {
return Effect{}
}
if event.Key == Escape {
session.closed = true
return Effect{Cancelled: true}
}
if event.Key == Tab {
step := 1
if event.Shift {
step = -1
}
for range len(session.elements) {
session.focus = (session.focus + step + len(session.elements)) % len(session.elements)
if !session.elements[session.focus].element.Disabled {
break
}
}
output := session.Presentation()
// A list in a multi-control form needs its field label when Tab enters
// it. A menu's only control uses its opening title instead.
state := &session.elements[session.focus]
if len(session.elements) > 1 && state.element.Kind == List {
output.Speech = state.element.Label + ". " + output.Speech
}
return Effect{Presentation: output}
}
state := &session.elements[session.focus]
if event.Alt {
return Effect{}
}
if event.Key == Enter || state.element.Kind == Button && event.Key == Text && event.Text == " " && !event.Control {
for _, candidate := range session.elements {
if candidate.element.Kind == Entry && candidate.element.Required && !candidate.element.Disabled && strings.TrimSpace(candidate.element.Text) == "" {
return Effect{Presentation: Presentation{Speech: candidate.element.Label + " is required", Braille: candidate.element.Label + " is required", Cursor: -1}}
}
}
id := state.element.ID
if state.element.Kind == List {
id = state.element.Items[state.selected].ID
}
return Effect{Activated: id}
}
switch state.element.Kind {
case List:
step := 0
switch event.Key {
case Up:
step = -1
case Down:
step = 1
case PageUp:
step = -10
case PageDown:
step = 10
case Home:
state.selected = 0
step = 1
case End:
state.selected = len(state.element.Items) - 1
step = -1
case Text:
if event.Control || event.Text == "" {
return Effect{}
}
prefix := state.prefix + strings.ToLower(event.Text)
if !state.find_prefix(prefix) {
prefix = strings.ToLower(event.Text)
state.find_prefix(prefix)
}
state.prefix = prefix
return Effect{Presentation: session.Presentation()}
default:
return Effect{}
}
state.prefix = ""
if event.Key == Home || event.Key == End {
for state.element.Items[state.selected].Disabled {
state.selected += step
}
} else {
for count := 0; count < abs(step); count++ {
previous := state.selected
for next := previous + sign(step); next >= 0 && next < len(state.element.Items); next += sign(step) {
if !state.element.Items[next].Disabled {
state.selected = next
break
}
}
}
}
case CheckBox:
if event.Key != Text || event.Text != " " || event.Control {
return Effect{}
}
state.element.Checked = !state.element.Checked
case Button:
return Effect{}
case Entry:
return session.edit(event)
}
return Effect{Presentation: session.Presentation()}
}
func (state *elementState) find_prefix(prefix string) bool {
for offset := 1; offset <= len(state.element.Items); offset++ {
index := (state.selected + offset) % len(state.element.Items)
item := state.element.Items[index]
if !item.Disabled && strings.HasPrefix(strings.ToLower(item.Label), prefix) {
state.selected = index
return true
}
}
return false
}
func sign(value int) int {
if value < 0 {
return -1
}
return 1
}
func abs(value int) int {
if value < 0 {
return -value
}
return value
}
func (session *Session) edit(event Event) Effect {
state := &session.elements[session.focus]
characters := []rune(state.element.Text)
start, end := min(state.caret, state.anchor), max(state.caret, state.anchor)
if event.Control && event.Key == Text {
switch strings.ToLower(event.Text) {
case "a":
state.anchor, state.caret = 0, len(characters)
output := session.Presentation()
output.Speech = "Selected all"
return Effect{Presentation: output}
case "v":
return Effect{Paste: true}
case "c", "x":
if start == end {
return Effect{}
}
text := string(characters[start:end])
// Cutting is completed by the host only after clipboard write succeeds.
return Effect{Copy: &text}
default:
return Effect{}
}
}
if event.Key == Left || event.Key == Right || event.Key == Home || event.Key == End {
next := state.caret
switch event.Key {
case Left:
if !event.Shift && start != end {
next = start
} else {
next = max(0, next-1)
if event.Control {
for next > 0 && unicode.IsSpace(characters[next]) {
next--
}
for next > 0 && !unicode.IsSpace(characters[next-1]) {
next--
}
}
}
case Right:
if !event.Shift && start != end {
next = end
} else {
next = min(len(characters), next+1)
if event.Control {
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)
}
state.caret = next
if !event.Shift {
state.anchor = next
}
output := session.Presentation()
if next == len(characters) {
output.Speech = "End of text"
} else {
output.Speech = string(characters[next])
}
if event.Shift {
output.Speech += ", selected"
}
return Effect{Presentation: output}
}
if event.Key == Backspace || event.Key == Delete {
if start == end {
if event.Key == Backspace {
start = max(0, start-1)
} else {
end = min(len(characters), end+1)
}
}
if start == end {
return Effect{}
}
removed := string(characters[start:end])
state.element.Text = string(append(characters[:start], characters[end:]...))
state.caret, state.anchor = start, start
output := session.Presentation()
output.Speech = "Deleted " + removed
return Effect{Presentation: output}
}
if event.Key == Text && event.Text != "" && !event.Control {
return session.Insert(event.Text)
}
return Effect{}
}
// Insert handles typing and paste atomically; invalid/oversized input leaves
// the field and selection untouched.
func (session *Session) Insert(text string) Effect {
if session.closed {
return Effect{}
}
state := &session.elements[session.focus]
if state.element.Kind != Entry {
return Effect{}
}
if !utf8.ValidString(text) {
return Effect{Presentation: Presentation{Speech: "Invalid text", Braille: "Invalid text", Cursor: -1}}
}
characters := []rune(state.element.Text)
start, end := min(state.caret, state.anchor), max(state.caret, state.anchor)
inserted := []rune(text)
for _, character := range inserted {
if unicode.IsControl(character) {
return Effect{Presentation: Presentation{Speech: "Only single-line text is accepted", Braille: "Only single-line text is accepted", Cursor: -1}}
}
}
if len(characters)-(end-start)+len(inserted) > state.element.MaxLength {
return Effect{Presentation: Presentation{Speech: "Text limit reached", Braille: "Text limit reached", Cursor: -1}}
}
state.element.Text = string(characters[:start]) + text + string(characters[end:])
state.caret, state.anchor = start+len(inserted), start+len(inserted)
output := session.Presentation()
output.Speech = text
return Effect{Presentation: output}
}
+173
View File
@@ -0,0 +1,173 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package interaction
import (
"strings"
"testing"
)
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)
}
})
}
}