468 lines
13 KiB
Go
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}
|
|
}
|