diff --git a/go.mod b/go.mod index b19ecd96..eefe0ee8 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 07a26897..fedb755f 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/app/application.go b/internal/app/application.go index 3bbac789..9b449c45 100644 --- a/internal/app/application.go +++ b/internal/app/application.go @@ -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} diff --git a/internal/app/application_overrides_test.go b/internal/app/application_overrides_test.go index eb96135c..713a7e3b 100644 --- a/internal/app/application_overrides_test.go +++ b/internal/app/application_overrides_test.go @@ -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 { diff --git a/internal/app/internal_ui.go b/internal/app/internal_ui.go index ef6d2557..e89322a1 100644 --- a/internal/app/internal_ui.go +++ b/internal/app/internal_ui.go @@ -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) } diff --git a/internal/app/internal_ui_clipboard_test.go b/internal/app/internal_ui_clipboard_test.go new file mode 100644 index 00000000..a5eada04 --- /dev/null +++ b/internal/app/internal_ui_clipboard_test.go @@ -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") + } +} diff --git a/internal/input/router.go b/internal/input/router.go index fb2e9a71..0184204c 100644 --- a/internal/input/router.go +++ b/internal/input/router.go @@ -54,6 +54,8 @@ const ( KeyKPEnter = 96 KeyRightCtrl = 97 KeyRightAlt = 100 + KeyPageUp = 104 + KeyPageDown = 109 KeyHome = 102 KeyUp = 103 KeyLeft = 105 diff --git a/internal/input/ui.go b/internal/input/ui.go index 78451a85..05c733f6 100644 --- a/internal/input/ui.go +++ b/internal/input/ui.go @@ -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: diff --git a/internal/input/ui_test.go b/internal/input/ui_test.go index 23fc1c4c..18fd672d 100644 --- a/internal/input/ui_test.go +++ b/internal/input/ui_test.go @@ -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) + } +} diff --git a/internal/interaction/entry.go b/internal/interaction/entry.go deleted file mode 100644 index 10a0450a..00000000 --- a/internal/interaction/entry.go +++ /dev/null @@ -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 . - -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()} -} diff --git a/internal/interaction/form.go b/internal/interaction/form.go deleted file mode 100644 index 424a522b..00000000 --- a/internal/interaction/form.go +++ /dev/null @@ -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 . - -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 -} diff --git a/internal/interaction/grid.go b/internal/interaction/grid.go deleted file mode 100644 index d6b394e0..00000000 --- a/internal/interaction/grid.go +++ /dev/null @@ -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)} -} diff --git a/internal/interaction/list.go b/internal/interaction/list.go deleted file mode 100644 index 0190652e..00000000 --- a/internal/interaction/list.go +++ /dev/null @@ -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]} -} diff --git a/internal/interaction/list_test.go b/internal/interaction/list_test.go index 8982a6ab..cd085a1e 100644 --- a/internal/interaction/list_test.go +++ b/internal/interaction/list_test.go @@ -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}, diff --git a/internal/interaction/presentation.go b/internal/interaction/presentation.go deleted file mode 100644 index a35d8b87..00000000 --- a/internal/interaction/presentation.go +++ /dev/null @@ -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 . - -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() -} diff --git a/internal/interaction/session.go b/internal/interaction/session.go deleted file mode 100644 index ab38becf..00000000 --- a/internal/interaction/session.go +++ /dev/null @@ -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 . - -// 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 - } - } -} diff --git a/internal/interaction/session_test.go b/internal/interaction/session_test.go index b1109f88..2fbb1537 100644 --- a/internal/interaction/session_test.go +++ b/internal/interaction/session_test.go @@ -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) } diff --git a/internal/interaction/shared.go b/internal/interaction/shared.go new file mode 100644 index 00000000..b2426640 --- /dev/null +++ b/internal/interaction/shared.go @@ -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}) +}