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