Use shared internal UI toolkit v0.2.0
This commit is contained in:
1 parent
f4226e705f
commit
a941e39f60
18 files changed
+154
-772
No files matched your search
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,8 @@ const (
|
||||
KeyKPEnter = 96
|
||||
KeyRightCtrl = 97
|
||||
KeyRightAlt = 100
|
||||
KeyPageUp = 104
|
||||
KeyPageDown = 109
|
||||
KeyHome = 102
|
||||
KeyUp = 103
|
||||
KeyLeft = 105
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)}
|
||||
}
|
||||
@@ -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]}
|
||||
}
|
||||
@@ -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},
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
Reference in new issue
Block a user