Files

225 lines
5.8 KiB
Go

// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package interaction
import (
"errors"
"fmt"
"strings"
"unicode"
"unicode/utf8"
)
// GridTab holds one category. Filtering and selection belong to each session.
type GridTab struct {
ID, Label string
Items []Item
}
type gridPage struct {
GridTab
query string
selected int
}
type gridSession struct {
pages []gridPage
tab, focus, columns int // focus: 0 tab strip, 1 grid, 2 checkbox
checkbox Element
closed bool
}
// NewGridSession is a core tabbed grid with a shared checkbox. External forms
// keep their existing protocol and limits. Navigation follows Fenrir's picker.
func NewGridSession(title string, tabs []GridTab, columns int, checkbox Element) (*Session, error) {
if title == "" || len(tabs) == 0 || columns < 1 || columns > 64 || checkbox.Kind != CheckBox || checkbox.ID == "" || checkbox.Label == "" {
return nil, errors.New("invalid tabbed grid")
}
grid := &gridSession{columns: columns, checkbox: checkbox}
ids := map[string]bool{}
for _, tab := range tabs {
if tab.ID == "" || tab.Label == "" || ids[tab.ID] {
return nil, errors.New("invalid grid tab")
}
ids[tab.ID] = true
items := map[string]bool{}
for _, item := range tab.Items {
if item.ID == "" || item.Label == "" || items[item.ID] {
return nil, errors.New("invalid grid item")
}
items[item.ID] = true
}
tab.Items = append([]Item(nil), tab.Items...)
page := gridPage{GridTab: tab}
page.filter()
grid.pages = append(grid.pages, page)
}
return &Session{title: title, grid: grid}, nil
}
func (page *gridPage) matches() []int {
var result []int
for index, item := range page.Items {
if !item.Disabled && strings.HasPrefix(strings.ToLower(item.Label), strings.ToLower(page.query)) {
result = append(result, index)
}
}
return result
}
func (page *gridPage) filter() {
page.selected = -1
if matches := page.matches(); len(matches) > 0 {
page.selected = matches[0]
}
}
func (grid *gridSession) values() map[string]Value {
page := &grid.pages[grid.tab]
values := map[string]Value{grid.checkbox.ID: {Checked: grid.checkbox.Checked}}
if grid.focus == 1 && page.selected >= 0 {
values["grid"] = Value{Selected: page.Items[page.selected].ID, Text: page.Items[page.selected].Label}
}
return values
}
func (grid *gridSession) presentation() Presentation {
if grid.closed {
return Presentation{}
}
page := &grid.pages[grid.tab]
text := page.Label + " tab"
switch grid.focus {
case 1:
text = "No matches"
for position, index := range page.matches() {
if index == page.selected {
text = fmt.Sprintf("%s. row %d, column %d. %d of %d", page.Items[index].Label, position/grid.columns+1, position%grid.columns+1, position+1, len(page.matches()))
break
}
}
case 2:
text = grid.checkbox.Label + ", unchecked"
if grid.checkbox.Checked {
text = grid.checkbox.Label + ", checked"
}
}
return Presentation{Speech: text, Braille: text, Cursor: -1}
}
func (grid *gridSession) handle(event Event) Effect {
if grid.closed {
return Effect{}
}
if event.Key == Escape {
grid.closed = true
return Effect{Cancelled: true}
}
if event.Alt {
return Effect{}
}
if event.Key == Tab && !event.Control {
delta := 1
if event.Shift {
delta = -1
}
grid.focus = (grid.focus + delta + 3) % 3
return Effect{Presentation: grid.presentation()}
}
page := &grid.pages[grid.tab]
if grid.focus == 0 {
if event.Control {
return Effect{}
}
switch event.Key {
case Up, Left:
grid.tab = (grid.tab + len(grid.pages) - 1) % len(grid.pages)
case Down, Right:
grid.tab = (grid.tab + 1) % len(grid.pages)
case Enter:
grid.focus = 1
case Text:
if event.Text != " " {
return Effect{}
}
grid.focus = 1
default:
return Effect{}
}
return Effect{Presentation: grid.presentation()}
}
if grid.focus == 2 {
if !event.Control && (event.Key == Enter || event.Key == Text && event.Text == " ") {
grid.checkbox.Checked = !grid.checkbox.Checked
return Effect{Presentation: grid.presentation()}
}
return Effect{}
}
if event.Control && event.Key != Backspace {
return Effect{}
}
if event.Key == Backspace || event.Key == Text && event.Text != "" {
query := []rune(page.query)
if event.Key == Backspace {
if event.Control {
query = nil
} else if len(query) > 0 {
query = query[:len(query)-1]
}
} else {
if !utf8.ValidString(event.Text) || len(query)+len([]rune(event.Text)) > 4096 {
return Effect{}
}
for _, character := range event.Text {
if !unicode.IsPrint(character) {
return Effect{}
}
}
query = append(query, []rune(event.Text)...)
}
page.query = string(query)
page.filter()
output := grid.presentation()
prefix := "Search cleared"
if page.query != "" {
prefix = "Search " + page.query
}
output.Speech = prefix + ". " + output.Speech
return Effect{Presentation: output}
}
matches := page.matches()
if len(matches) == 0 {
return Effect{Presentation: grid.presentation()}
}
position := 0
for current, index := range matches {
if index == page.selected {
position = current
break
}
}
switch event.Key {
case Left:
position = max(0, position-1)
case Right:
position = min(len(matches)-1, position+1)
case Up:
if position >= grid.columns {
position -= grid.columns
}
case Down:
if position/grid.columns < (len(matches)-1)/grid.columns {
position = min(len(matches)-1, position+grid.columns)
}
case Home:
position = 0
case End:
position = len(matches) - 1
case PageUp:
position = max(0, position-grid.columns*5)
case PageDown:
position = min(len(matches)-1, position+grid.columns*5)
case Enter:
return Effect{Activated: "select-grid-item"}
default:
return Effect{}
}
page.selected = matches[position]
return Effect{Presentation: grid.presentation()}
}