// 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)} }