Files

468 lines
13 KiB
Go

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