Fenrir's emoji ported to cthulhu.

This commit is contained in:
Storm Dragon committed 2026-10-29 19:20:29 -04:00
1 parent 3e2429b8a5
commit 01f50c17e8
24 files changed
+1505 -9

No files matched your search

+224
View File
@@ -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()}
}
+29
View File
@@ -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")
}
}
+18
View File
@@ -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{}
}