Fenrir's emoji ported to cthulhu.
This commit is contained in:
1 parent
3e2429b8a5
commit
01f50c17e8
24 files changed
+1505
-9
No files matched your search
@@ -0,0 +1,224 @@
|
||||
// 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()}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package interaction
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestGridRowsBoundsAndDisabledItems(t *testing.T) {
|
||||
items := []Item{{ID: "1", Label: "One"}, {ID: "2", Label: "Two"}, {ID: "3", Label: "Three"}, {ID: "4", Label: "Four"}, {ID: "5", Label: "Five", Disabled: true}, {ID: "6", Label: "Six"}}
|
||||
session, err := NewGridSession("Picker", []GridTab{{ID: "all", Label: "All", Items: items}}, 3, Element{ID: "copy", Label: "Copy", Kind: CheckBox})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session.Handle(Event{Key: Tab})
|
||||
for _, step := range []struct {
|
||||
key Key
|
||||
want string
|
||||
}{{Right, "2"}, {Right, "3"}, {Down, "6"}, {Down, "6"}, {Up, "2"}, {Home, "1"}, {Left, "1"}, {End, "6"}, {Right, "6"}, {PageUp, "1"}, {PageDown, "6"}} {
|
||||
session.Handle(Event{Key: step.key})
|
||||
if got := session.Values()["grid"].Selected; got != step.want {
|
||||
t.Fatalf("%v selected %q want %q", step.key, got, step.want)
|
||||
}
|
||||
}
|
||||
if result := session.Handle(Event{Key: Escape}); !result.Cancelled {
|
||||
t.Fatal("escape not cancelled")
|
||||
}
|
||||
if session.Handle(Event{Key: Enter}).Activated != "" || session.Presentation().Speech != "" {
|
||||
t.Fatal("closed grid responded")
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,7 @@ type elementState struct {
|
||||
prefix string
|
||||
}
|
||||
type Session struct {
|
||||
grid *gridSession
|
||||
title string
|
||||
elements []elementState
|
||||
focus int
|
||||
@@ -175,6 +176,9 @@ func new_session(form Form, maxItems, maxElements int) (*Session, error) {
|
||||
}
|
||||
|
||||
func (session *Session) Presentation() Presentation {
|
||||
if session.grid != nil {
|
||||
return session.grid.presentation()
|
||||
}
|
||||
if session.closed {
|
||||
return Presentation{}
|
||||
}
|
||||
@@ -208,6 +212,11 @@ func (session *Session) Presentation() Presentation {
|
||||
}
|
||||
|
||||
func (session *Session) Opening() Presentation {
|
||||
if session.grid != nil {
|
||||
output := session.grid.presentation()
|
||||
output.Speech = session.title + ". " + output.Speech
|
||||
return output
|
||||
}
|
||||
output := session.Presentation()
|
||||
if !session.closed && len(session.elements) > 1 && session.elements[session.focus].element.Kind == List {
|
||||
output.Speech = session.elements[session.focus].element.Label + ". " + output.Speech
|
||||
@@ -217,6 +226,9 @@ func (session *Session) Opening() Presentation {
|
||||
}
|
||||
|
||||
func (session *Session) Values() map[string]Value {
|
||||
if session.grid != nil {
|
||||
return session.grid.values()
|
||||
}
|
||||
values := make(map[string]Value, len(session.elements))
|
||||
for _, state := range session.elements {
|
||||
value := Value{Text: state.element.Text, Checked: state.element.Checked}
|
||||
@@ -229,6 +241,9 @@ func (session *Session) Values() map[string]Value {
|
||||
}
|
||||
|
||||
func (session *Session) Handle(event Event) Effect {
|
||||
if session.grid != nil {
|
||||
return session.grid.handle(event)
|
||||
}
|
||||
if session.closed {
|
||||
return Effect{}
|
||||
}
|
||||
@@ -459,6 +474,9 @@ func (session *Session) edit(event Event) Effect {
|
||||
// Insert handles typing and paste atomically; invalid/oversized input leaves
|
||||
// the field and selection untouched.
|
||||
func (session *Session) Insert(text string) Effect {
|
||||
if session.grid != nil {
|
||||
return Effect{}
|
||||
}
|
||||
if session.closed {
|
||||
return Effect{}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user