225 lines
5.8 KiB
Go
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()}
|
|
}
|