129 lines
3.2 KiB
Go
129 lines
3.2 KiB
Go
// 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)}
|
|
}
|