Cthulhu is no longer a fork. Now it is a screen reader written in Golang.
This commit is contained in:
1 parent
a998fe67de
commit
069fc674dc
708 files changed
+112482
-532
No files matched your search
@@ -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}
|
||||
}
|
||||
Reference in new issue
Block a user