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, without changing the selection. Tab and Shift+Tab move between the list, Cancel,
and the tab selector. The active override is initially selected. 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 Start the application normally, then select its override. This menu does not
launch programs. Choose **Default** to restore the settings and reading regions launch programs. Choose **Default** to restore the settings and reading regions
that were in effect before the override. Overrides are built into Fenrir and 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") { if !a.ui.active || !strings.Contains(voice.messages[len(voice.messages)-1], "Default") {
t.Fatal(voice.messages) t.Fatal(voice.messages)
} }
a.ui.handle(interaction.Key{Name: "down"}) for _, step := range []struct{ key, want string }{
a.ui.handle(interaction.Key{Name: "down"}) {"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 { if len(a.overrides) != 0 {
t.Fatal("navigation activated override") 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) { func TestOverrideCannotSaveOrContaminateDraftAndRestoresSession(t *testing.T) {
a, voice := persistence_application(t, true) a, voice := persistence_application(t, true)
a.handle_command(command.NextQuickValue) // Preserve a pre-existing unsaved adjustment. a.handle_command(command.NextQuickValue) // Preserve a pre-existing unsaved adjustment.
@@ -755,6 +755,7 @@ msgid "No chat message received"
msgstr "Keine Chatnachricht empfangen" msgstr "Keine Chatnachricht empfangen"
#: 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" msgid "No matches"
msgstr "Keine Treffer" msgstr "Keine Treffer"
@@ -763,6 +763,7 @@ msgid "No chat message received"
msgstr "No se ha recibido ningún mensaje de chat" msgstr "No se ha recibido ningún mensaje de chat"
#: 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" msgid "No matches"
msgstr "Sin coincidencias" msgstr "Sin coincidencias"
+1 -1
View File
@@ -721,7 +721,7 @@ msgstr ""
msgid "No chat message received" msgid "No chat message received"
msgstr "" 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" msgid "No matches"
msgstr "" msgstr ""
@@ -759,6 +759,7 @@ msgid "No chat message received"
msgstr "Nie odebrano wiadomości czatu" msgstr "Nie odebrano wiadomości czatu"
#: 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" msgid "No matches"
msgstr "Brak dopasowań" msgstr "Brak dopasowań"
@@ -761,6 +761,7 @@ msgid "No chat message received"
msgstr "Nenhuma mensagem de chat recebida" msgstr "Nenhuma mensagem de chat recebida"
#: 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" msgid "No matches"
msgstr "Nenhum resultado" msgstr "Nenhum resultado"
@@ -754,6 +754,7 @@ msgid "No chat message received"
msgstr "Сообщения чата не получены" 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" msgid "No matches"
msgstr "Совпадений нет" 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) { 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 count := len(s.form.Tabs[s.tab].Controls) + 1
for range count { for range count {
s.focus = (s.focus+1+delta+count)%count - 1 s.focus = (s.focus+1+delta+count)%count - 1
@@ -223,6 +226,9 @@ func (s *Session) Handle(key Key) Result {
if control.Kind == Grid { if control.Kind == Grid {
return s.grid(control, key) return s.grid(control, key)
} }
if control.Kind == List {
return s.list(control, key)
}
if key.Ctrl { if key.Ctrl {
return Result{} return Result{}
} }
@@ -249,17 +255,6 @@ func (s *Session) Handle(key Key) Result {
return Result{} return Result{}
} }
control.Number = math.Max(control.Min, math.Min(control.Max, math.Round((control.Number+delta)*1e9)/1e9)) 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: case ComboBox:
switch key.Name { switch key.Name {
case "up", "left": case "up", "left":
+1 -1
View File
@@ -126,7 +126,7 @@ func TestListCategoryFormReturnsSelectionWithoutSideEffects(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
session.Handle(Key{Name: "tab"}) 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) t.Fatal(got)
} }
if got := session.Handle(Key{Name: "enter"}).Action; got != "choose" { if got := session.Handle(Key{Name: "enter"}).Action; got != "choose" {