Type searching added to applications menu.

This commit is contained in:
Storm Dragon committed 2026-10-28 04:04:55 -04:00
1 parent d3bcbbeacf
commit f88e5b81a2
12 files changed
+162 -15

No files matched your search

+6
View File
@@ -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
+30 -2
View File
@@ -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"
+1 -1
View File
@@ -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 "Совпадений нет"
+60
View File
@@ -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]}
}
+53
View File
@@ -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)
}
}
+6 -11
View File
@@ -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":
+1 -1
View File
@@ -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" {