Type searching added to applications menu.
This commit is contained in:
1 parent
d3bcbbeacf
commit
f88e5b81a2
12 files changed
+162
-15
No files matched your search
@@ -15,6 +15,12 @@ activates it and closes the menu. Escape or the Cancel button closes the menu
|
||||
without changing the selection. Tab and Shift+Tab move between the list, Cancel,
|
||||
and the tab selector. The active override is initially selected.
|
||||
|
||||
Type the start of a name to jump to it: **n** selects NetworkManager, then **i**
|
||||
selects Ninjam. Searching ignores letter case and does not activate the item.
|
||||
Backspace removes a search character; Control+Backspace clears the search.
|
||||
Up, Down, or moving focus away from the list also clears the search. Unmatched
|
||||
characters announce **No matches** and leave the last matching search intact.
|
||||
|
||||
Start the application normally, then select its override. This menu does not
|
||||
launch programs. Choose **Default** to restore the settings and reading regions
|
||||
that were in effect before the override. Overrides are built into Fenrir and
|
||||
|
||||
@@ -118,8 +118,17 @@ func TestApplicationsMenuActivatesOnlyOnEnterAndReleasesCapture(t *testing.T) {
|
||||
if !a.ui.active || !strings.Contains(voice.messages[len(voice.messages)-1], "Default") {
|
||||
t.Fatal(voice.messages)
|
||||
}
|
||||
a.ui.handle(interaction.Key{Name: "down"})
|
||||
a.ui.handle(interaction.Key{Name: "down"})
|
||||
for _, step := range []struct{ key, want string }{
|
||||
{"down", "NetworkManager"},
|
||||
{"down", "Ninjam"},
|
||||
{"down", "Default"},
|
||||
{"up", "Ninjam"},
|
||||
} {
|
||||
a.ui.handle(interaction.Key{Name: step.key})
|
||||
if got := voice.messages[len(voice.messages)-1]; got != step.want {
|
||||
t.Fatalf("%s announcement = %q, want %q", step.key, got, step.want)
|
||||
}
|
||||
}
|
||||
if len(a.overrides) != 0 {
|
||||
t.Fatal("navigation activated override")
|
||||
}
|
||||
@@ -155,6 +164,25 @@ func TestApplicationsMenuActivatesOnlyOnEnterAndReleasesCapture(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplicationsMenuPrefixSearch(t *testing.T) {
|
||||
a, voice := persistence_application(t, true)
|
||||
override_read(t, a, override_screen(1, "ninjam"))
|
||||
a.open_applications()
|
||||
for _, step := range []struct{ text, want string }{{"n", "NetworkManager"}, {"i", "Ninjam"}} {
|
||||
a.ui.handle(interaction.Key{Text: step.text})
|
||||
if got := voice.messages[len(voice.messages)-1]; got != step.want {
|
||||
t.Fatalf("typed %q: got %q, want %q", step.text, got, step.want)
|
||||
}
|
||||
if len(a.overrides) != 0 || !a.ui.active {
|
||||
t.Fatal("search activated an override or closed the menu")
|
||||
}
|
||||
}
|
||||
a.ui.handle(interaction.Key{Name: "enter"})
|
||||
if a.ui.active || a.overrides[1] == nil || a.overrides[1].id != "ninjam" {
|
||||
t.Fatal("Enter did not activate the search result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverrideCannotSaveOrContaminateDraftAndRestoresSession(t *testing.T) {
|
||||
a, voice := persistence_application(t, true)
|
||||
a.handle_command(command.NextQuickValue) // Preserve a pre-existing unsaved adjustment.
|
||||
|
||||
@@ -755,6 +755,7 @@ msgid "No chat message received"
|
||||
msgstr "Keine Chatnachricht empfangen"
|
||||
|
||||
#: internal/interaction/grid.go:43 internal/interaction/grid.go:79
|
||||
#: internal/interaction/list.go:46
|
||||
msgid "No matches"
|
||||
msgstr "Keine Treffer"
|
||||
|
||||
|
||||
@@ -763,6 +763,7 @@ msgid "No chat message received"
|
||||
msgstr "No se ha recibido ningún mensaje de chat"
|
||||
|
||||
#: internal/interaction/grid.go:43 internal/interaction/grid.go:79
|
||||
#: internal/interaction/list.go:46
|
||||
msgid "No matches"
|
||||
msgstr "Sin coincidencias"
|
||||
|
||||
|
||||
@@ -721,7 +721,7 @@ msgstr ""
|
||||
msgid "No chat message received"
|
||||
msgstr ""
|
||||
|
||||
#: internal/interaction/grid.go:43 internal/interaction/grid.go:79
|
||||
#: internal/interaction/grid.go:43 internal/interaction/grid.go:79 internal/interaction/list.go:46
|
||||
msgid "No matches"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -759,6 +759,7 @@ msgid "No chat message received"
|
||||
msgstr "Nie odebrano wiadomości czatu"
|
||||
|
||||
#: internal/interaction/grid.go:43 internal/interaction/grid.go:79
|
||||
#: internal/interaction/list.go:46
|
||||
msgid "No matches"
|
||||
msgstr "Brak dopasowań"
|
||||
|
||||
|
||||
@@ -761,6 +761,7 @@ msgid "No chat message received"
|
||||
msgstr "Nenhuma mensagem de chat recebida"
|
||||
|
||||
#: internal/interaction/grid.go:43 internal/interaction/grid.go:79
|
||||
#: internal/interaction/list.go:46
|
||||
msgid "No matches"
|
||||
msgstr "Nenhum resultado"
|
||||
|
||||
|
||||
@@ -754,6 +754,7 @@ msgid "No chat message received"
|
||||
msgstr "Сообщения чата не получены"
|
||||
|
||||
#: internal/interaction/grid.go:43 internal/interaction/grid.go:79
|
||||
#: internal/interaction/list.go:46
|
||||
msgid "No matches"
|
||||
msgstr "Совпадений нет"
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// 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]}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Fenrir console screen reader.
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package interaction
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestListPrefixSearch(t *testing.T) {
|
||||
session, err := New_session(Form{Type: TabForm, Title: "Picker", Tabs: []Tab{{ID: "items", Label: "Items", Controls: []Control{
|
||||
{ID: "list", Label: "Item", Kind: List, Items: []string{"Default", "NetworkManager", "Ninjam", "Éclair"}, Action: "choose"},
|
||||
{ID: "cancel", Label: "Cancel", Kind: Button, Action: "cancel"},
|
||||
}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session.Handle(Key{Name: "tab"})
|
||||
for _, step := range []struct {
|
||||
key Key
|
||||
want, prefix string
|
||||
selected int
|
||||
}{
|
||||
{Key{Text: "N"}, "NetworkManager", "N", 1},
|
||||
{Key{Text: "i"}, "Ninjam", "Ni", 2},
|
||||
{Key{Text: "z"}, "No matches", "Ni", 2},
|
||||
{Key{Text: "n"}, "Ninjam", "Nin", 2},
|
||||
{Key{Name: "backspace"}, "Ninjam", "Ni", 2},
|
||||
{Key{Name: "backspace"}, "NetworkManager", "N", 1},
|
||||
{Key{Name: "backspace", Ctrl: true}, "Default", "", 0},
|
||||
{Key{Text: "é"}, "Éclair", "é", 3},
|
||||
{Key{Name: "down"}, "Default", "", 0},
|
||||
{Key{Text: "ni"}, "Ninjam", "ni", 2},
|
||||
{Key{Name: "up"}, "NetworkManager", "", 1},
|
||||
{Key{Text: "d"}, "Default", "d", 0},
|
||||
{Key{Text: "n", Ctrl: true}, "", "d", 0},
|
||||
{Key{Text: "n", Alt: true}, "", "d", 0},
|
||||
{Key{Text: "\n"}, "", "d", 0},
|
||||
} {
|
||||
got := session.Handle(step.key)
|
||||
control := session.Values()["items.list"]
|
||||
if got.Speech != step.want || got.Action != "" || control.Text != step.prefix || control.Selected != step.selected {
|
||||
t.Fatalf("key %+v: result %+v, prefix %q, selection %d; want %q, %q, %d", step.key, got, control.Text, control.Selected, step.want, step.prefix, step.selected)
|
||||
}
|
||||
}
|
||||
session.Handle(Key{Name: "tab"})
|
||||
session.Handle(Key{Name: "tab", Shift: true})
|
||||
if got := session.Handle(Key{Text: "ni"}).Speech; got != "Ninjam" {
|
||||
t.Fatal("focus change did not clear search", got)
|
||||
}
|
||||
if got := session.Handle(Key{Name: "enter"}).Action; got != "choose" {
|
||||
t.Fatal("search prevented activation", got)
|
||||
}
|
||||
}
|
||||
@@ -145,6 +145,9 @@ func (s *Session) Set_choices(tabID, controlID string, choices []Choice) {
|
||||
}
|
||||
|
||||
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
|
||||
@@ -223,6 +226,9 @@ func (s *Session) Handle(key Key) Result {
|
||||
if control.Kind == Grid {
|
||||
return s.grid(control, key)
|
||||
}
|
||||
if control.Kind == List {
|
||||
return s.list(control, key)
|
||||
}
|
||||
if key.Ctrl {
|
||||
return Result{}
|
||||
}
|
||||
@@ -249,17 +255,6 @@ func (s *Session) Handle(key Key) Result {
|
||||
return Result{}
|
||||
}
|
||||
control.Number = math.Max(control.Min, math.Min(control.Max, math.Round((control.Number+delta)*1e9)/1e9))
|
||||
case List:
|
||||
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{}
|
||||
}
|
||||
case ComboBox:
|
||||
switch key.Name {
|
||||
case "up", "left":
|
||||
|
||||
@@ -126,7 +126,7 @@ func TestListCategoryFormReturnsSelectionWithoutSideEffects(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session.Handle(Key{Name: "tab"})
|
||||
if got := session.Handle(Key{Name: "up"}).Speech; got != "Items Second" {
|
||||
if got := session.Handle(Key{Name: "up"}).Speech; got != "Second" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
if got := session.Handle(Key{Name: "enter"}).Action; got != "choose" {
|
||||
|
||||
Reference in new issue
Block a user