Use shared internal UI toolkit v0.2.0

This commit is contained in:
ak47 committed 2026-11-12 01:31:19 -05:00
1 parent f4226e705f
commit a941e39f60
18 files changed
+154 -772

No files matched your search

+1 -1
View File
@@ -3,7 +3,7 @@ module git.stormux.org/storm/fenrir
go 1.27.2
require (
git.stormux.org/storm/screenreader-common v0.1.1
git.stormux.org/storm/screenreader-common v0.2.0
github.com/jfreymuth/pulse v0.1.3
github.com/leonelquinteros/gotext v1.8.0
github.com/pelletier/go-toml/v2 v2.4.3
+2
View File
@@ -1,5 +1,7 @@
git.stormux.org/storm/screenreader-common v0.1.1 h1:r/qcWUv4ODZ+XzXw1J+qe4hVnyr7MqH0W7L5tp6XOcE=
git.stormux.org/storm/screenreader-common v0.1.1/go.mod h1:iLmD0KXRvR4ar3U80JMwbaNUOyR82/gY7xHUY+zeIoY=
git.stormux.org/storm/screenreader-common v0.2.0 h1:PbGwyIi/nT0qwvscje4pvIIsAxDLBYwLV1eTXT36hxk=
git.stormux.org/storm/screenreader-common v0.2.0/go.mod h1:iLmD0KXRvR4ar3U80JMwbaNUOyR82/gY7xHUY+zeIoY=
github.com/jfreymuth/pulse v0.1.3 h1:bc5TdxiB8E+2INnFjFWWgyfgXtz2IyNNNCX+Wt/ZD14=
github.com/jfreymuth/pulse v0.1.3/go.mod h1:cpYspI6YljhkUf1WLXLLDmeaaPFc3CnGLjDZf9dZ4no=
github.com/leonelquinteros/gotext v1.8.0 h1:wwpxINWmWq3JN3WFI9BoSupBFrvAqxZTtf3qppijYkU=
+2
View File
@@ -133,6 +133,8 @@ func new_application(ctx context.Context, source Source, keyboards Keyboards, vo
}
a.configure_reader()
a.clipboards = new_clipboard(options.Settings.Clipboard, a.engine, options.Clipboard, a.present, func() string { return a.voice.last })
a.ui.readClipboard = a.clipboards.history.Current
a.ui.writeClipboard = func(text string) bool { return a.clipboards.add(text, true) }
a.clipboards.historyStore = options.ClipboardHistory
a.restore_clipboard()
a.spellMenu = &spellcheckMenu{ui: a.ui, service: options.Spellcheck, clipboards: a.clipboards}
+2 -2
View File
@@ -222,8 +222,8 @@ func TestApplicationsMenuActivatesOnlyOnEnterAndReleasesCapture(t *testing.T) {
{"down", "Ninjam"},
{"down", "Rogue"},
{"down", "Codex"},
{"down", "Default"},
{"up", "Codex"},
{"down", "Codex"},
{"up", "Rogue"},
} {
a.ui.handle(interaction.Key{Name: step.key})
if got := voice.messages[len(voice.messages)-1]; got != step.want {
+35
View File
@@ -20,6 +20,7 @@ package app
import (
"context"
"log/slog"
"strings"
"sync"
"time"
@@ -33,6 +34,8 @@ import (
// changes run on the app loop; workers return generation-tagged presentation.
// Admitted settings writes complete through the application save coordinator.
type internalUI struct {
readClipboard func() (string, bool)
writeClipboard func(string) bool
catalog func(preferences.Discovery)
ctx context.Context
keys Keyboards
@@ -87,6 +90,9 @@ func (ui *internalUI) begin(name string) (uint64, error) {
}
ui.stop_test()
ui.generation++
if ui.session != nil {
ui.session.Close()
}
ui.active, ui.session, ui.action = true, nil, nil
ui.changed, ui.onClosed = nil, nil
ui.name = name
@@ -112,6 +118,9 @@ func (ui *internalUI) close() {
ui.generation++
name := ui.name
ui.name = ""
if ui.session != nil {
ui.session.Close()
}
ui.active, ui.session, ui.action = false, nil, nil
if ui.onClosed != nil {
ui.onClosed()
@@ -144,6 +153,9 @@ func (ui *internalUI) update(update uiUpdate) {
if update.close {
ui.close()
} else {
if ui.session != nil && ui.session != update.session {
ui.session.Close()
}
ui.session, ui.action = update.session, update.action
if update.changed != nil {
ui.changed = update.changed
@@ -171,6 +183,29 @@ func (ui *internalUI) handle(key interaction.Key) {
} // Loading or saving still owns every key.
ui.configure_session(ui.session)
result := ui.session.Handle(key)
if result.Paste {
text, ok := "", false
if ui.readClipboard != nil {
text, ok = ui.readClipboard()
}
if ok {
result = ui.session.Insert(text)
} else {
result.Speech = i18n.T("Clipboard text unavailable")
}
}
if result.Copy != nil {
copied := ui.writeClipboard != nil && ui.writeClipboard(*result.Copy)
if !copied {
result.Speech = i18n.T("Copy failed")
} else {
result.Speech = i18n.T("Copied")
if strings.EqualFold(key.Text, "x") {
result = ui.session.Handle(interaction.Key{Name: "delete"})
result.Speech = i18n.T("Cut")
}
}
}
if ui.changed != nil {
ui.changed(ui.session)
}
@@ -0,0 +1,52 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"git.stormux.org/storm/fenrir/internal/interaction"
"testing"
)
func TestInternalUIClipboardCutFailurePasteAndSecretClose(t *testing.T) {
ui := new_internal_ui(context.Background(), &fakeKeys{}, &fakeVoice{})
s, err := interaction.New_session(interaction.Form{Type: interaction.TabForm, Title: "Edit", Tabs: []interaction.Tab{{ID: "p", Label: "Page", Controls: []interaction.Control{{ID: "e", Label: "Text", Kind: interaction.Entry, Text: "one two"}, {ID: "password", Label: "Password", Kind: interaction.Entry, Secret: true, Text: "private"}}}}})
if err != nil {
t.Fatal(err)
}
ui.active, ui.session = true, s
ui.handle(interaction.Key{Name: "tab"})
ui.handle(interaction.Key{Name: "left", Ctrl: true, Shift: true})
copies := 0
ui.writeClipboard = func(text string) bool {
copies++
if text != "two" {
t.Fatal(text)
}
return false
}
ui.handle(interaction.Key{Ctrl: true, Text: "x"})
if s.Values()["p.e"].Text != "one two" {
t.Fatal("failed cut deleted text")
}
ui.writeClipboard = func(text string) bool { copies++; return text == "two" }
ui.handle(interaction.Key{Ctrl: true, Text: "x"})
if s.Values()["p.e"].Text != "one " {
t.Fatal("successful cut failed")
}
ui.readClipboard = func() (string, bool) { return "猫", true }
ui.handle(interaction.Key{Ctrl: true, Text: "v"})
if s.Values()["p.e"].Text != "one 猫" {
t.Fatal("paste failed")
}
ui.handle(interaction.Key{Name: "tab"})
ui.handle(interaction.Key{Ctrl: true, Text: "a"})
ui.handle(interaction.Key{Ctrl: true, Text: "c"})
if copies != 2 {
t.Fatal("secret copied")
}
ui.close()
if s.Values() != nil {
t.Fatal("close retained editor")
}
}
+2
View File
@@ -54,6 +54,8 @@ const (
KeyKPEnter = 96
KeyRightCtrl = 97
KeyRightAlt = 100
KeyPageUp = 104
KeyPageDown = 109
KeyHome = 102
KeyUp = 103
KeyLeft = 105
+4
View File
@@ -66,6 +66,10 @@ func (r *Router) ui_key(code uint16) Decision {
key.Name = "left"
case KeyRight:
key.Name = "right"
case KeyPageUp:
key.Name = "pageup"
case KeyPageDown:
key.Name = "pagedown"
case KeyHome:
key.Name = "home"
case KeyEnd:
+15
View File
@@ -116,3 +116,18 @@ func TestDesktopTOMLValidated(t *testing.T) {
}
}
}
func TestCapturedPageKeysReachInternalUI(t *testing.T) {
router := desktop_router(t)
router.Capture(true)
for _, step := range []struct {
code uint16
name string
}{{KeyPageUp, "pageup"}, {KeyPageDown, "pagedown"}} {
result := router.Key(1, step.code, 1, false, false)
if result.UI == nil || result.UI.Name != step.name || result.Forward {
t.Fatal(result)
}
router.Key(1, step.code, 0, false, false)
}
}
-91
View File
@@ -1,91 +0,0 @@
// Fenrir console screen reader.
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify it
// under the terms of the GNU General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option)
// any later version.
//
// This program is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
// more details.
//
// You should have received a copy of the GNU General Public License along with
// this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
package interaction
import (
"git.stormux.org/storm/fenrir/internal/i18n"
"strings"
"unicode"
)
func (s *Session) edit(control *Control, key Key) Result {
runes := []rune(control.Text)
if key.Ctrl {
if strings.EqualFold(key.Text, "a") {
s.selectAll = true
return Result{Speech: i18n.T("selected") + " " + control.Text}
}
return Result{}
}
switch key.Name {
case "enter":
return Result{Action: control.Action}
case "left":
s.cursor = max(0, s.cursor-1)
s.selectAll = false
case "right":
s.cursor = min(len(runes), s.cursor+1)
s.selectAll = false
case "home":
s.cursor = 0
s.selectAll = false
case "end":
s.cursor = len(runes)
s.selectAll = false
case "backspace", "delete":
if s.selectAll {
runes = nil
s.cursor = 0
} else if key.Name == "backspace" && s.cursor > 0 {
runes = append(runes[:s.cursor-1], runes[s.cursor:]...)
s.cursor--
} else if key.Name == "delete" && s.cursor < len(runes) {
runes = append(runes[:s.cursor], runes[s.cursor+1:]...)
}
s.selectAll = false
default:
text := []rune(key.Text)
if len(text) == 0 {
return Result{}
}
for _, char := range text {
if !unicode.IsPrint(char) {
return Result{}
}
}
length := len(runes)
if s.selectAll {
length = 0
}
if length+len(text) > 4096 {
return Result{}
}
if s.selectAll {
runes = nil
s.cursor = 0
}
runes = append(runes[:s.cursor], append(text, runes[s.cursor:]...)...)
s.cursor += len(text)
s.selectAll = false
}
control.Text = string(runes)
if (key.Name == "left" || key.Name == "right" || key.Name == "home" || key.Name == "end") && s.cursor < len(runes) {
return Result{Speech: string(runes[s.cursor])}
}
return Result{Speech: s.Describe()}
}
-81
View File
@@ -1,81 +0,0 @@
// Fenrir console screen reader.
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify it
// under the terms of the GNU General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option)
// any later version.
//
// This program is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
// more details.
//
// You should have received a copy of the GNU General Public License along with
// this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
package interaction
type Kind int
const (
CheckBox Kind = iota
Number
Entry
List
Button
ComboBox
Grid
)
type Control struct {
ID, Label string
Group string
Hidden bool // Retains draft values while excluded from navigation.
Kind Kind
Checked bool
Number float64
Min, Max, Step float64
UpDownStep float64 // Zero uses the default step of 1 for Up and Down.
Text string
Items []string
Selected int
Action string
Choices []Choice
Columns int // Grid width; Text is its prefix filter.
DeleteAction string
}
type Choice struct {
ID, Label, Unavailable string
Literal bool // User/dictionary data must not be translated.
}
type Tab struct {
ID, Label string
Controls []Control
}
type FormType string
const TabForm FormType = "tab_form"
type Form struct {
Type FormType
Title string
Tabs []Tab
}
type Key struct {
Name string
Text string
Shift, Ctrl, Alt, AltGr bool
Repeat bool
}
type Result struct {
Speech string
Action string
Cancelled bool
}
-128
View File
@@ -1,128 +0,0 @@
// Fenrir console screen reader.
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package interaction
import (
"strconv"
"strings"
"unicode"
"git.stormux.org/storm/fenrir/internal/i18n"
)
// Grid selections index Choices, even while the visible results are filtered.
// Choice IDs remain opaque to the model and can carry a host-owned value.
func (c Control) grid_matches() []int {
var matches []int
prefix := strings.ToLower(c.Text)
for index, choice := range c.Choices {
if strings.HasPrefix(strings.ToLower(choice_label(choice)), prefix) {
matches = append(matches, index)
}
}
return matches
}
func (c *Control) filter_grid() {
c.Selected = -1
if matches := c.grid_matches(); len(matches) > 0 {
c.Selected = matches[0]
}
}
func (s *Session) describe_grid(c Control) string {
matches := c.grid_matches()
for position, index := range matches {
if index == c.Selected {
label := choice_label(c.Choices[index])
row, column := strconv.Itoa(position/c.Columns+1), strconv.Itoa(position%c.Columns+1)
item, total := strconv.Itoa(position+1), strconv.Itoa(len(matches))
switch {
case s.announceGridCoordinates && s.announceItemPosition:
return i18n.Format("{0}. row {1}, column {2}. {3} of {4}", label, row, column, item, total)
case s.announceGridCoordinates:
return i18n.Format("{0}. row {1}, column {2}", label, row, column)
case s.announceItemPosition:
return i18n.Format("{0}. {1} of {2}", label, item, total)
default:
return label
}
}
}
return i18n.T("No matches")
}
func (s *Session) grid(c *Control, key Key) Result {
if key.Ctrl && key.Name != "backspace" {
return Result{}
}
if key.Name == "backspace" || (key.Name == "" || key.Name == "space") && key.Text != "" && !key.Ctrl {
runes := []rune(c.Text)
if key.Name == "backspace" {
if key.Ctrl {
runes = nil
} else if len(runes) > 0 {
runes = runes[:len(runes)-1]
}
} else {
for _, char := range key.Text {
if !unicode.IsPrint(char) {
return Result{}
}
}
if len(runes)+len([]rune(key.Text)) > 4096 {
return Result{}
}
runes = append(runes, []rune(key.Text)...)
}
c.Text = string(runes)
c.filter_grid()
query := i18n.T("Search cleared")
if c.Text != "" {
query = i18n.Format("Search {0}", c.Text)
}
return Result{Speech: query + ". " + s.describe_grid(*c)}
}
matches := c.grid_matches()
if len(matches) == 0 {
return Result{Speech: i18n.T("No matches")}
}
position := 0
for current, index := range matches {
if index == c.Selected {
position = current
break
}
}
switch key.Name {
case "left":
position = max(0, position-1)
case "right":
position = min(len(matches)-1, position+1)
case "up":
if position >= c.Columns {
position -= c.Columns
}
case "down":
if position/c.Columns < (len(matches)-1)/c.Columns {
position = min(len(matches)-1, position+c.Columns)
}
case "home":
position = 0
case "end":
position = len(matches) - 1
case "enter":
return Result{Action: c.Action}
case "delete":
if key.Repeat {
return Result{}
}
return Result{Action: c.DeleteAction}
default:
return Result{}
}
c.Selected = matches[position]
return Result{Speech: s.describe_grid(*c)}
}
-60
View File
@@ -1,60 +0,0 @@
// Fenrir console screen reader.
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package interaction
import (
"strings"
"unicode"
"git.stormux.org/storm/fenrir/internal/i18n"
)
// Lists use Text as a prefix search without filtering or activating items.
func (s *Session) list(control *Control, key Key) Result {
if key.Ctrl && key.Name != "backspace" {
return Result{}
}
if key.Name == "backspace" || (key.Name == "" || key.Name == "space") && key.Text != "" && !key.Ctrl {
prefix := []rune(control.Text)
if key.Name == "backspace" {
if key.Ctrl {
prefix = nil
} else if len(prefix) > 0 {
prefix = prefix[:len(prefix)-1]
}
} else {
for _, char := range key.Text {
if !unicode.IsPrint(char) {
return Result{}
}
}
if len(prefix)+len([]rune(key.Text)) > 4096 {
return Result{}
}
prefix = append(prefix, []rune(key.Text)...)
}
query := strings.ToLower(string(prefix))
for index, item := range control.Items {
if strings.HasPrefix(strings.ToLower(item), query) {
control.Text, control.Selected = string(prefix), index
return Result{Speech: item}
}
}
// Reject unmatched input so a typo leaves the last valid search intact.
return Result{Speech: i18n.T("No matches")}
}
switch key.Name {
case "up":
control.Selected = (control.Selected + len(control.Items) - 1) % len(control.Items)
case "down":
control.Selected = (control.Selected + 1) % len(control.Items)
case "enter":
return Result{Action: control.Action}
default:
return Result{}
}
control.Text = ""
return Result{Speech: control.Items[control.Selected]}
}
+1 -1
View File
@@ -28,7 +28,7 @@ func TestListPrefixSearch(t *testing.T) {
{Key{Name: "backspace"}, "NetworkManager", "N", 1},
{Key{Name: "backspace", Ctrl: true}, "Default", "", 0},
{Key{Text: "é"}, "Éclair", "é", 3},
{Key{Name: "down"}, "Default", "", 0},
{Key{Name: "down"}, "Éclair", "", 3},
{Key{Text: "ni"}, "Ninjam", "ni", 2},
{Key{Name: "up"}, "NetworkManager", "", 1},
{Key{Text: "d"}, "Default", "d", 0},
-86
View File
@@ -1,86 +0,0 @@
// Fenrir console screen reader.
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify it
// under the terms of the GNU General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option)
// any later version.
//
// This program is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
// more details.
//
// You should have received a copy of the GNU General Public License along with
// this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
package interaction
import (
"strconv"
"git.stormux.org/storm/fenrir/internal/i18n"
)
func (s *Session) Open() string { return i18n.T(s.form.Title) + ". " + s.Describe() }
func (s *Session) Describe() string {
tab := s.form.Tabs[s.tab]
if s.focus == -1 {
return i18n.Format("{0} tab", i18n.T(tab.Label))
}
control := tab.Controls[s.focus]
label := i18n.T(control.Label)
switch control.Kind {
case CheckBox:
state := "unchecked"
if control.Checked {
state = "checked"
}
return label + " " + i18n.T(state)
case Number:
return label + " " + strconv.FormatFloat(control.Number, 'f', -1, 64)
case Entry:
return i18n.Format("{0} entry {1}", label, control.Text)
case List:
return label + " " + control.Items[control.Selected]
case Grid:
return i18n.Format("{0} grid. {1}", label, s.describe_grid(control))
case ComboBox:
return i18n.Format("{0} combo box {1}", label, describe_choice(control))
case Button:
return i18n.Format("{0} button", label)
}
return label
}
func describe_choice(control Control) string {
choice := control.Choices[control.Selected]
text := choice_label(choice)
if choice.Unavailable != "" {
text += ". " + i18n.T("unavailable") + ": " + choice.Unavailable
}
return text
}
func choice_label(choice Choice) string {
if choice.Literal {
return choice.Label
}
return i18n.T(choice.Label)
}
func (s *Session) focused_group() string {
if s.focus < 0 {
return ""
}
return s.form.Tabs[s.tab].Controls[s.focus].Group
}
func (s *Session) describe_focus(previousGroup string) string {
if group := s.focused_group(); group != "" && group != previousGroup {
return i18n.T(group) + ". " + s.Describe()
}
return s.Describe()
}
-321
View File
@@ -1,321 +0,0 @@
// Fenrir console screen reader.
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify it
// under the terms of the GNU General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option)
// any later version.
//
// This program is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
// more details.
//
// You should have received a copy of the GNU General Public License along with
// this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
// Package interaction models reader-owned forms without device, speech or
// settings dependencies. Hosts own capture and perform the returned actions.
package interaction
import (
"fmt"
"math"
)
type Session struct {
form Form
tab, focus, cursor int // focus -1 is the tab strip.
announceGridCoordinates, announceItemPosition bool
selectAll bool
}
func New_session(form Form) (*Session, error) {
if form.Type != TabForm {
return nil, fmt.Errorf("unsupported form type %q", form.Type)
}
if form.Title == "" || len(form.Tabs) == 0 {
return nil, fmt.Errorf("form requires a title and tabs")
}
// Every session owns its mutable values, including nested slices.
form.Tabs = append([]Tab(nil), form.Tabs...)
ids := make(map[string]bool)
for tabIndex := range form.Tabs {
tab := &form.Tabs[tabIndex]
if tab.ID == "" || tab.Label == "" || ids[tab.ID] {
return nil, fmt.Errorf("invalid or duplicate tab %q", tab.ID)
}
ids[tab.ID] = true
tab.Controls = append([]Control(nil), tab.Controls...)
controlIDs := make(map[string]bool)
for controlIndex := range tab.Controls {
control := &tab.Controls[controlIndex]
if control.ID == "" || control.Label == "" || controlIDs[control.ID] || control.Kind < CheckBox || control.Kind > Grid {
return nil, fmt.Errorf("invalid control %q", control.ID)
}
controlIDs[control.ID] = true
if control.Kind == Number {
if control.UpDownStep == 0 {
control.UpDownStep = 1
}
if math.IsNaN(control.UpDownStep) || math.IsInf(control.UpDownStep, 0) || control.UpDownStep < 0 {
return nil, fmt.Errorf("invalid vertical step %q", control.ID)
}
}
if control.Kind == Number && (math.IsNaN(control.Number) || math.IsInf(control.Number, 0) || math.IsNaN(control.Min) || math.IsInf(control.Min, 0) || math.IsNaN(control.Max) || math.IsInf(control.Max, 0) || math.IsNaN(control.Step) || math.IsInf(control.Step, 0) || control.Min > control.Max || control.Step <= 0 || control.Number < control.Min || control.Number > control.Max) {
return nil, fmt.Errorf("invalid range %q", control.ID)
}
control.Items = append([]string(nil), control.Items...)
control.Choices = append([]Choice(nil), control.Choices...)
if control.Kind == ComboBox || control.Kind == Grid {
if !(control.Kind == Grid && len(control.Choices) == 0) && (len(control.Choices) == 0 || control.Selected < 0 || control.Selected >= len(control.Choices)) {
return nil, fmt.Errorf("invalid choices in %q", control.ID)
}
choiceIDs := make(map[string]bool)
for _, choice := range control.Choices {
if choice.Label == "" || choiceIDs[choice.ID] {
return nil, fmt.Errorf("invalid choice in %q", control.ID)
}
choiceIDs[choice.ID] = true
}
}
if control.Kind == Grid {
if control.Columns < 1 {
return nil, fmt.Errorf("invalid grid width %q", control.ID)
}
control.filter_grid()
}
if control.Kind == List && (len(control.Items) == 0 || control.Selected < 0 || control.Selected >= len(control.Items)) {
return nil, fmt.Errorf("invalid list %q", control.ID)
}
}
}
return &Session{form: form, focus: -1, announceGridCoordinates: true, announceItemPosition: true}, nil
}
// Configure_grid controls spoken detail without changing grid navigation or filtering.
func (s *Session) Configure_grid(coordinates, position bool) {
s.announceGridCoordinates, s.announceItemPosition = coordinates, position
}
// Values returns a copy suitable for validation or submission by the host.
func (s *Session) Values() map[string]Control {
values := make(map[string]Control)
for _, tab := range s.form.Tabs {
for _, control := range tab.Controls {
control.Items = append([]string(nil), control.Items...)
control.Choices = append([]Choice(nil), control.Choices...)
values[tab.ID+"."+control.ID] = control
}
}
return values
}
// Focused_control returns an independent value for the host's current action.
func (s *Session) Focused_control() (Control, bool) {
if s.focus < 0 {
return Control{}, false
}
control := s.form.Tabs[s.tab].Controls[s.focus]
control.Items = append([]string(nil), control.Items...)
control.Choices = append([]Choice(nil), control.Choices...)
return control, true
}
// Set_choices updates an asynchronous grid without moving keyboard focus or
// discarding its search. Keep the selected word when it still matches.
func (s *Session) Set_choices(tabID, controlID string, choices []Choice) {
for tabIndex := range s.form.Tabs {
tab := &s.form.Tabs[tabIndex]
if tab.ID != tabID {
continue
}
for index := range tab.Controls {
control := &tab.Controls[index]
if control.ID != controlID || control.Kind != Grid {
continue
}
selected := ""
position := control.Selected
if position >= 0 && position < len(control.Choices) {
selected = control.Choices[position].ID
}
control.Choices = append([]Choice(nil), choices...)
control.filter_grid()
matches := control.grid_matches()
for _, match := range matches {
if control.Choices[match].ID == selected {
control.Selected = match
return
}
}
if len(matches) > 0 {
control.Selected = matches[min(max(position, 0), len(matches)-1)]
}
return
}
}
}
func (s *Session) move_focus(delta int) {
if s.focus >= 0 && s.form.Tabs[s.tab].Controls[s.focus].Kind == List {
s.form.Tabs[s.tab].Controls[s.focus].Text = ""
}
count := len(s.form.Tabs[s.tab].Controls) + 1
for range count {
s.focus = (s.focus+1+delta+count)%count - 1
if s.focus == -1 || !s.form.Tabs[s.tab].Controls[s.focus].Hidden {
break
}
}
s.selectAll = false
if s.focus >= 0 {
s.cursor = len([]rune(s.form.Tabs[s.tab].Controls[s.focus].Text))
}
}
// Set_hidden changes navigation without rebuilding the form or losing entry
// edits. If the focused control disappears, move forward to a visible target.
func (s *Session) Set_hidden(tabID, controlID string, hidden bool) {
for tabIndex := range s.form.Tabs {
tab := &s.form.Tabs[tabIndex]
if tab.ID != tabID {
continue
}
for index := range tab.Controls {
if tab.Controls[index].ID != controlID {
continue
}
tab.Controls[index].Hidden = hidden
if hidden && s.tab == tabIndex && s.focus == index {
s.move_focus(1)
}
return
}
}
}
func (s *Session) Handle(key Key) Result {
if key.Name == "escape" {
return Result{Cancelled: true}
}
if key.Name == "tab" && !key.Ctrl && !key.Alt {
previousGroup := s.focused_group()
delta := 1
if key.Shift {
delta = -1
}
s.move_focus(delta)
return Result{Speech: s.describe_focus(previousGroup)}
}
if key.Alt {
return Result{}
}
if key.Ctrl && s.focus == -1 {
return Result{}
}
if s.focus == -1 {
delta := 0
switch key.Name {
case "left", "up":
delta = -1
case "right", "down":
delta = 1
case "enter", "space":
s.move_focus(1)
}
if delta != 0 {
s.tab = (s.tab + delta + len(s.form.Tabs)) % len(s.form.Tabs)
}
if delta != 0 || key.Name == "enter" || key.Name == "space" {
return Result{Speech: s.describe_focus("")}
}
return Result{}
}
control := &s.form.Tabs[s.tab].Controls[s.focus]
if control.Kind == Entry {
return s.edit(control, key)
}
if control.Kind == Grid {
return s.grid(control, key)
}
if control.Kind == List {
return s.list(control, key)
}
if key.Ctrl {
return Result{}
}
switch control.Kind {
case CheckBox:
switch key.Name {
case "enter", "space":
control.Checked = !control.Checked
default:
return Result{}
}
case Number:
delta := 0.0
switch key.Name {
case "up":
delta = control.UpDownStep
case "down":
delta = -control.UpDownStep
case "right":
delta = control.Step
case "left":
delta = -control.Step
default:
return Result{}
}
control.Number = math.Max(control.Min, math.Min(control.Max, math.Round((control.Number+delta)*1e9)/1e9))
case ComboBox:
switch key.Name {
case "up", "left":
control.Selected = max(0, control.Selected-1)
case "down", "right":
control.Selected = min(len(control.Choices)-1, control.Selected+1)
case "home":
control.Selected = 0
case "end":
control.Selected = len(control.Choices) - 1
default:
return Result{}
}
return Result{Speech: describe_choice(*control)}
case Button:
if key.Name == "enter" || key.Name == "space" {
return Result{Action: control.Action}
}
return Result{}
}
return Result{Speech: s.Describe()}
}
// Replace_choices updates a dependent combo without changing focus.
func (s *Session) Replace_choices(tabID, controlID string, choices []Choice, selected string) {
if len(choices) == 0 {
return
}
for ti := range s.form.Tabs {
tab := &s.form.Tabs[ti]
if tab.ID != tabID {
continue
}
for ci := range tab.Controls {
control := &tab.Controls[ci]
if control.ID != controlID || control.Kind != ComboBox {
continue
}
control.Choices = append([]Choice(nil), choices...)
control.Selected = 0
for index, choice := range choices {
if choice.ID == selected {
control.Selected = index
break
}
}
return
}
}
}
+2 -1
View File
@@ -139,9 +139,10 @@ func TestListCategoryFormReturnsSelectionWithoutSideEffects(t *testing.T) {
t.Fatal(err)
}
session.Handle(Key{Name: "tab"})
if got := session.Handle(Key{Name: "up"}).Speech; got != "Second" {
if got := session.Handle(Key{Name: "up"}).Speech; got != "First" {
t.Fatal(got)
}
session.Handle(Key{Name: "down"})
if got := session.Handle(Key{Name: "enter"}).Action; got != "choose" {
t.Fatal(got)
}
+36
View File
@@ -0,0 +1,36 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
// Package interaction connects the shared UI toolkit to Fenrir's catalog.
package interaction
import (
"git.stormux.org/storm/fenrir/internal/i18n"
shared "git.stormux.org/storm/screenreader-common/internalui"
)
type Kind = shared.Kind
type Control = shared.Control
type Choice = shared.Choice
type Tab = shared.Tab
type FormType = shared.FormType
type Form = shared.Form
type Key = shared.Key
type Result = shared.Result
type Session = shared.Session
const (
CheckBox = shared.CheckBox
Number = shared.Number
Entry = shared.Entry
List = shared.List
Button = shared.Button
ComboBox = shared.ComboBox
Grid = shared.Grid
TabForm = shared.TabForm
FlatForm = shared.FlatForm
)
func New_session(form Form) (*Session, error) {
return shared.NewSession(form, shared.Messages{Translate: i18n.T})
}