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