Merge from testing.

This commit is contained in:
Storm Dragon committed 2026-10-29 21:44:43 -04:00
commit 44581c4038
23 files changed
+1504 -8

No files matched your search

+14
View File
@@ -127,6 +127,20 @@ browse mode, and sticky focus mode still keeps focus mode. Use Cthulhu+A to
switch back to browse mode when finished interacting.
See [automatic focus verification](docs/testing/structural-focus.md).
## Emoji menu
On Xorg, press **Cthulhu+Control+E** in either keyboard layout to open the
Emoji Menu. Choose a category with arrows, then Tab to the searchable grid.
Type a name prefix, navigate with arrows, and press Enter to select.
**Copy selected emoji to clipboard** is unchecked by default. Unchecked types
the emoji at the current input focus, including terminals and browser fields,
without using the clipboard. Browse mode blocks typing. Checked copies the
emoji without typing it. The choice is remembered for the current reader
session. Escape cancels.
See [emoji menu verification](docs/testing/emoji-menu.md) for live checks.
## Key echo
The Key Echo preferences page offers three radio choices: Disable key echo,
@@ -4,7 +4,7 @@
pkgbase=cthulhu-git
pkgname=cthulhu-git
_pkgname=cthulhu
pkgver=2026.10.26.r569.g7723bd7
pkgver=2026.10.29.r579.g74afa42
pkgrel=1
pkgdesc="Desktop-agnostic screen reader with speech, Braille, and Tentacle support"
url="https://git.stormux.org/storm/cthulhu"
+55
View File
@@ -0,0 +1,55 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"time"
"git.stormux.org/storm/cthulhu/internal/interaction"
)
type emojiTextSource interface {
TypeText(context.Context, string) error
}
func (ui *internalUI) emoji_insertion_allowed() bool {
if owner, ok := ui.keys.(interface{ BrowseNavigationEnabled() bool }); ok {
return !owner.BrowseNavigationEnabled()
}
return true
}
func (ui *internalUI) select_emoji(ctx context.Context, selection interaction.Value, copyChecked bool) {
if ctx.Err() != nil || ui.gate.Locked() || ui.gate.Suspended() {
return
}
actionCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
var message string
if copyChecked {
message = "Copy failed"
if ui.emojiCopier != nil && ui.emojiCopier.Copy(actionCtx, selection.Selected) == nil {
message = "Copied " + selection.Text + " to clipboard"
}
} else if !ui.emoji_insertion_allowed() {
message = "Emoji insertion unavailable in browse mode"
} else {
message = "Emoji insertion unavailable"
if ui.emojiSource != nil && ui.emojiSource.TypeText(actionCtx, selection.Selected) == nil {
message = "Inserted " + selection.Text
}
}
ui.emoji_message(message)
}
func (ui *internalUI) emoji_message(message string) {
if ui.gate.Locked() || ui.gate.Suspended() {
return
}
ui.speech.Enqueue(speechRequest{Text: message, Role: "system", Cause: "emoji"})
if ui.braille != nil {
ui.braille.EnqueueMessage(message)
}
}
+117
View File
@@ -0,0 +1,117 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"os"
"os/exec"
"strings"
"sync/atomic"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/platform/xorg"
)
func TestNativeBrowserEmoji(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" {
t.Skip("requires private browser fixture")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
adapter := atspi.New()
keys := xorg.NewCommands([]string{"Insert"}, input.DesktopKeyboardLayout)
for _, service := range []interface {
Start(context.Context) error
Stop(context.Context) error
}{adapter, keys} {
if err := service.Start(ctx); err != nil {
t.Fatal(err)
}
defer service.Stop(context.Background())
}
output := &ocrTestOutput{speech: make(chan speechRequest, 256)}
ui := &internalUI{keys: keys, emojiSource: adapter, emojiCopier: xclipOCRCopier{}, speech: output, gate: newFocusGate(keys), events: make(chan input.CommandEvent, 32), done: make(chan struct{})}
var focus atomic.Pointer[atspi.Snapshot]
go ui.run(ctx)
relayDone := make(chan struct{})
go func() {
defer close(relayDone)
for {
select {
case <-ctx.Done():
return
case event := <-keys.Commands():
ui.Handle(event)
case event := <-adapter.Events():
if isFocusGainEvent(event) {
focus.Store(&event.Source)
}
}
}
}()
defer func() { cancel(); <-ui.done; <-relayDone }()
key := func(names ...string) {
t.Helper()
if data, err := exec.CommandContext(ctx, "xdotool", append([]string{"key"}, names...)...).CombinedOutput(); err != nil {
t.Fatalf("keys: %v %s", err, data)
}
}
waitSpeech := func(prefix string) {
t.Helper()
for {
select {
case message := <-output.speech:
t.Logf("UI: %s", message.Text)
if strings.HasPrefix(message.Text, prefix) {
return
}
case <-ctx.Done():
t.Fatalf("missing %q", prefix)
}
}
}
for _, name := range []string{"Emoji input", "Emoji textarea", "Emoji editor"} {
for attempt := 0; attempt < 12; attempt++ {
key("Tab")
deadline := time.Now().Add(200 * time.Millisecond)
for time.Now().Before(deadline) {
if current := focus.Load(); current != nil && current.Name == name {
break
}
time.Sleep(5 * time.Millisecond)
}
if current := focus.Load(); current != nil && current.Name == name {
break
}
}
current := focus.Load()
if current == nil || current.Name != name {
t.Fatalf("field %s not focused: %+v", name, current)
}
key("ctrl+a", "Insert+ctrl+e")
waitSpeech("Emoji Menu. All tab")
key("Tab", "a", "s", "t", "r", "o", "n", "a", "u", "t")
key("Return")
waitSpeech("Inserted Astronaut")
// Read back from the start, independently of the insertion's caret placement.
key("ctrl+Home")
var text string
for {
content, err := adapter.ReadFocusedText(ctx, current.ID())
text = content.Text
want := "🧑‍🚀"
if err == nil && text == want {
break
}
if ctx.Err() != nil {
t.Fatalf("%s replacement: %q %v", name, text, err)
}
time.Sleep(10 * time.Millisecond)
}
}
}
+136
View File
@@ -0,0 +1,136 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"errors"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/interaction"
)
type emojiTestSource struct {
keys *uiTestKeys
calls chan string
err error
}
func (source *emojiTestSource) TypeText(_ context.Context, text string) error {
if source.keys.InternalUISession() != 0 {
source.calls <- "capture retained"
} else {
source.calls <- text
}
return source.err
}
func TestEmojiHostCancelCheckboxMemoryAndClipboard(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
keys := &uiTestKeys{}
source := &emojiTestSource{keys: keys, calls: make(chan string, 4)}
copier := &ocrTestCopier{copied: make(chan string, 4)}
output := &ocrTestOutput{speech: make(chan speechRequest, 64)}
ui := &internalUI{keys: keys, emojiSource: source, emojiCopier: copier, speech: output, gate: &focusGate{}, events: make(chan input.CommandEvent, 32), done: make(chan struct{})}
go ui.run(ctx)
defer func() { cancel(); <-ui.done }()
wait := func(prefix string) {
t.Helper()
for {
select {
case message := <-output.speech:
if len(message.Text) >= len(prefix) && message.Text[:len(prefix)] == prefix {
return
}
case <-ctx.Done():
t.Fatalf("missing %q", prefix)
}
}
}
open := func() {
t.Helper()
ui.Handle(input.CommandEvent{Command: input.EmojiMenu})
wait("Emoji Menu. All tab")
}
key := func(symbol uint32, text string) {
ui.Handle(input.CommandEvent{Command: input.InternalUIKey, UISession: 1, Chord: input.CommandChord{Symbol: symbol}, Text: text})
}
open()
key(0xff09, "")
key(0, "goat")
key(0xff0d, "")
wait("Inserted Goat")
if got := <-source.calls; got != "🐐" {
t.Fatal(got)
}
select {
case got := <-copier.copied:
t.Fatalf("default copied: %q", got)
default:
}
// Toggle copying on, cancel, and verify the next invocation retains the choice.
open()
key(0xff09, "")
key(0xff09, "")
key(0, " ")
key(0xff1b, "")
for keys.InternalUISession() != 0 {
if ctx.Err() != nil {
t.Fatal("cancel not processed")
}
time.Sleep(time.Millisecond)
}
open()
key(0xff09, "")
key(0, "astronaut")
key(0xff0d, "")
wait("Copied Astronaut to clipboard")
if got := <-copier.copied; got != "🧑‍🚀" {
t.Fatal(got)
}
select {
case got := <-source.calls:
t.Fatalf("copy mode typed: %q", got)
default:
}
}
func TestEmojiTypingFailure(t *testing.T) {
source := &emojiTestSource{keys: &uiTestKeys{}, calls: make(chan string, 1), err: errors.New("unsupported")}
output := &ocrTestOutput{speech: make(chan speechRequest, 1)}
ui := &internalUI{keys: source.keys, emojiSource: source, speech: output, gate: &focusGate{}}
ui.select_emoji(context.Background(), interaction.Value{Selected: "🐐", Text: "Goat"}, false)
if got := (<-output.speech).Text; got != "Emoji insertion unavailable" {
t.Fatal(got)
}
}
type emojiBrowseKeys struct{ uiTestKeys }
func (*emojiBrowseKeys) BrowseNavigationEnabled() bool { return true }
func TestEmojiBrowseModeBlocksTypingAndAllowsCopy(t *testing.T) {
keys := &emojiBrowseKeys{}
source := &emojiTestSource{keys: &keys.uiTestKeys, calls: make(chan string, 1)}
copier := &ocrTestCopier{copied: make(chan string, 1)}
output := &ocrTestOutput{speech: make(chan speechRequest, 2)}
ui := &internalUI{keys: keys, emojiSource: source, emojiCopier: copier, speech: output, gate: &focusGate{}}
ui.select_emoji(context.Background(), interaction.Value{Selected: "🐐", Text: "Goat"}, false)
if got := (<-output.speech).Text; got != "Emoji insertion unavailable in browse mode" {
t.Fatal(got)
}
ui.select_emoji(context.Background(), interaction.Value{Selected: "🐐", Text: "Goat"}, true)
if got := (<-output.speech).Text; got != "Copied Goat to clipboard" {
t.Fatal(got)
}
select {
case got := <-source.calls:
t.Fatalf("typed in browse mode: %q", got)
default:
}
if got := <-copier.copied; got != "🐐" {
t.Fatal(got)
}
}
+47 -5
View File
@@ -15,6 +15,7 @@ import (
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/braille"
"git.stormux.org/storm/cthulhu/internal/emoji"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/interaction"
"git.stormux.org/storm/cthulhu/internal/review"
@@ -51,10 +52,12 @@ type internalUIRequest struct {
// keeps AT-SPI/clipboard latency off the native input and command consumers.
// Core clients can Open a form and receive its submitted values/cancellation.
type internalUI struct {
documents documentListSource
keys internalUIKeys
actions accessibleActionSource
interest interface {
emojiSource emojiTextSource
emojiCopier ocrTextCopier
documents documentListSource
keys internalUIKeys
actions accessibleActionSource
interest interface {
ObjectOfInterest(context.Context) (review.Interest, error)
}
speech speechEnqueuer
@@ -107,7 +110,7 @@ func (ui *internalUI) Notice(ctx context.Context, value tentaclewire.Presentatio
}
func (ui *internalUI) Handle(event input.CommandEvent) bool {
_, browserList := input.LookupDocumentList(event.Command)
if ui == nil || !browserList && event.Command != input.ShowActions && event.Command != input.InternalUIKey && event.Command != input.InternalUIDismiss {
if ui == nil || !browserList && event.Command != input.ShowActions && event.Command != input.EmojiMenu && event.Command != input.InternalUIKey && event.Command != input.InternalUIDismiss {
return false
}
select {
@@ -136,9 +139,16 @@ func (ui *internalUI) run(ctx context.Context) {
var sessionID uint64
var target atspi.ActionTarget
var documentList review.DocumentListSnapshot
var emojiActive bool
// The UI worker owns this session preference; it resets on reader restart.
copyEmoji := false
var result chan internalUIResult
var requestDone <-chan struct{}
finish := func(reply internalUIResult) error {
if emojiActive && session != nil {
copyEmoji = session.Values()["copy"].Checked
}
emojiActive = false
var closeErr error
if sessionID != 0 {
closeCtx, cancel := context.WithTimeout(ctx, time.Second)
@@ -223,6 +233,28 @@ func (ui *internalUI) run(ctx context.Context) {
ui.present(session.Opening())
continue
}
if event.Command == input.EmojiMenu {
if sessionID != 0 {
continue
}
lookupCtx, cancel := context.WithTimeout(ctx, time.Second)
var err error
sessionID, err = ui.keys.BeginInternalUI(lookupCtx)
cancel()
if err != nil || sessionID == 0 || ui.keys.InternalUISession() != sessionID {
finish(internalUIResult{Cancelled: true})
ui.emoji_message("Emoji Menu unavailable")
continue
}
session, err = emoji.NewSession(copyEmoji)
if err != nil {
finish(internalUIResult{Err: err})
continue
}
emojiActive = true
ui.present(session.Opening())
continue
}
if event.Command == input.ShowActions {
if sessionID != 0 {
finish(internalUIResult{Cancelled: true})
@@ -323,6 +355,16 @@ func (ui *internalUI) run(ctx context.Context) {
}
if effect.Activated != "" {
values := session.Values()
if emojiActive {
if effect.Activated != "select-grid-item" || values["grid"].Selected == "" {
continue
}
closeErr := finish(internalUIResult{})
if closeErr == nil && !ui.gate.Locked() && !ui.gate.Suspended() {
ui.select_emoji(ctx, values["grid"], values["copy"].Checked)
}
continue
}
actionTarget := target
selectedList := documentList
closeErr := finish(internalUIResult{Activated: effect.Activated, Values: values})
+67 -1
View File
@@ -42,7 +42,7 @@ func TestNativeInternalUIActionsAndEntry(t *testing.T) {
defer service.Stop(context.Background())
}
output := &ocrTestOutput{speech: make(chan speechRequest, 256)}
ui := &internalUI{keys: keys, actions: adapter, interest: worker, speech: output, gate: newFocusGate(keys), events: make(chan input.CommandEvent, 32), requests: make(chan internalUIRequest, 1), done: make(chan struct{})}
ui := &internalUI{keys: keys, emojiSource: adapter, emojiCopier: xclipOCRCopier{}, actions: adapter, interest: worker, speech: output, gate: newFocusGate(keys), events: make(chan input.CommandEvent, 32), requests: make(chan internalUIRequest, 1), done: make(chan struct{})}
go ui.run(ctx)
focused := make(chan struct{}, 1)
done := make(chan struct{})
@@ -176,6 +176,71 @@ copied:
}
time.Sleep(10 * time.Millisecond)
}
// Exercise the real shortcut, modal capture, insertion and clipboard opt-out.
waitSpeech := func(prefix string) {
t.Helper()
for {
select {
case message := <-output.speech:
t.Logf("UI speech: %s", message.Text)
if strings.HasPrefix(message.Text, prefix) {
return
}
case <-ctx.Done():
t.Fatalf("missing speech %q", prefix)
}
}
}
key("Insert+ctrl+e")
waitSpeech("Emoji Menu. All tab")
key("Tab", "g", "o", "a", "t", "Return")
waitSpeech("Inserted Goat")
_, text = read()
if text != "q🐐" {
t.Fatalf("emoji insertion: %q", text)
}
if clipboard, err := read_internal_clipboard(ctx); err != nil || clipboard != "aB!" {
t.Fatalf("clipboard changed with copying off: %q %v", clipboard, err)
}
// The next invocation remembers copying off and replaces a selection with a
// complete multi-codepoint emoji, using character rather than byte offsets.
key("ctrl+a", "Insert+ctrl+e")
waitSpeech("Emoji Menu. All tab")
key("Tab", "a", "s", "t", "r", "o", "n", "a", "u", "t", "Return")
waitSpeech("Inserted Astronaut")
_, text = read()
if text != "🧑‍🚀" {
t.Fatalf("emoji selection replacement: %q", text)
}
if clipboard, err := read_internal_clipboard(ctx); err != nil || clipboard != "aB!" {
t.Fatalf("clipboard choice not remembered: %q %v", clipboard, err)
}
// A cancelled picker has no application or clipboard side effects.
key("Insert+ctrl+e")
waitSpeech("Emoji Menu. All tab")
key("Tab", "g", "Escape")
for keys.InternalUIActive() {
if ctx.Err() != nil {
t.Fatal("picker did not cancel")
}
time.Sleep(time.Millisecond)
}
_, text = read()
if text != "🧑‍🚀" {
t.Fatalf("cancel inserted text: %q", text)
}
// Restore the earlier fixture state for the remaining transfer checks.
key("ctrl+a", "q")
for {
_, text = read()
if text == "q" {
break
}
if ctx.Err() != nil {
t.Fatal("typing after emoji did not resume")
}
time.Sleep(time.Millisecond)
}
if err := adapter.PerformAccessibleAction(ctx, originalActions.Target, "click"); err == nil {
t.Fatal("stale focus action was performed")
}
@@ -205,6 +270,7 @@ copied:
t.Fatalf("virtual transfer=%+v %v", item, err)
}
key("ctrl+Tab")
for {
if _, err = adapter.ReadTransferItem(ctx, worker.Focus(), -1); err != nil {
break
+2
View File
@@ -152,6 +152,8 @@ func learnCommandDescription(command input.Command) string {
return input.CommandLabel(command)
}
switch command {
case input.EmojiMenu:
return "Open the Emoji Menu"
case input.ShowActions:
return "Show the actions available for the current object"
case input.ToggleLearnMode:
+1 -1
View File
@@ -142,7 +142,7 @@ func (worker commandRuntime) run(ctx context.Context) error {
continue
}
_, browserList := input.LookupDocumentList(command)
if worker.internalUI != nil && (browserList || command == input.ShowActions || command == input.InternalUIKey || command == input.InternalUIDismiss) {
if worker.internalUI != nil && (browserList || command == input.ShowActions || command == input.EmojiMenu || command == input.InternalUIKey || command == input.InternalUIDismiss) {
stopRun()
return errors.New("internal UI event queue overflow")
}
+2
View File
@@ -66,6 +66,8 @@ func startRuntimeWorkers(ctx context.Context, command commandRuntime, inputListe
}
if keys, ok := command.commands.(internalUIKeys); ok {
ui := &internalUI{notices: make(chan internalUINotice, 4), keys: keys, documents: command.objectReview, actions: command.actionSource, interest: command.objectReview, speech: speech, braille: braille, gate: command.focusGate, events: make(chan input.CommandEvent, 32), requests: make(chan internalUIRequest, 1), done: make(chan struct{})}
ui.emojiSource, _ = command.actionSource.(emojiTextSource)
ui.emojiCopier = xclipOCRCopier{}
command.internalUI = ui
if command.tentacles != nil {
command.tentacles.forms = ui
+2
View File
@@ -203,6 +203,8 @@ type treeCaptureResult struct {
type treeCaptureRequest struct {
soundObjects [2]ObjectReference
transferItem bool
typeText bool
insertionText string
listActions bool
actionTarget *ActionTarget
actionName string
+3
View File
@@ -415,6 +415,9 @@ func cthulhuAtspiTreeCaptureCallback(userData unsafe.Pointer) {
if request.transferItem {
item, err := adapter.nativeTransferItem(request.ctx, request.reviewFocus, request.positionOffset)
request.result <- treeCaptureResult{item: item, err: err}
} else if request.typeText {
err := adapter.native_type_text(request.ctx, request.insertionText)
request.result <- treeCaptureResult{err: err}
} else if request.listActions || request.actionTarget != nil {
object := request.reviewFocus
if request.actionTarget != nil {
+72
View File
@@ -0,0 +1,72 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package atspi
/*
#include "adapter_native.h"
#include <stdlib.h>
*/
import "C"
import (
"context"
"errors"
"strings"
"time"
"unicode/utf8"
"unsafe"
)
// TypeText sends a UTF-8 string to the current input focus. It does not require
// an accessible edit control, move focus, or use the clipboard. The host owns
// browse-mode policy and must release its modal keyboard capture first.
func (adapter *Adapter) TypeText(ctx context.Context, text string) error {
if text == "" || utf8.RuneCountInString(text) > 32 || !utf8.ValidString(text) || strings.ContainsRune(text, 0) {
return errors.New("invalid typing text")
}
_, err := adapter.documentOperation(ctx, treeCaptureRequest{typeText: true, insertionText: text})
return err
}
func (adapter *Adapter) native_type_text(ctx context.Context, text string) error {
if ctx.Err() != nil {
return ctx.Err()
}
if adapter.secure.Load() {
return errors.New("screen lock active")
}
// The registry may reuse one temporary X keycode for Unicode characters.
// Send one character at a time and pace keymap changes so clients consume
// each mapping before the next one. In particular, preserve invisible ZWJs
// and variation selectors instead of turning them into the following emoji.
for _, character := range text {
if err := ctx.Err(); err != nil {
return err
}
if adapter.secure.Load() {
return errors.New("screen lock active")
}
raw := C.CString(string(character))
var nativeErr *C.GError
typed := C.atspi_generate_keyboard_event(0, raw, C.ATSPI_KEY_STRING, &nativeErr)
C.free(unsafe.Pointer(raw))
if nativeErr != nil {
err := nativeError("type text", nativeErr)
C.g_error_free(nativeErr)
return err
}
if typed == 0 {
return errors.New("text typing failed")
}
timer := time.NewTimer(20 * time.Millisecond)
select {
case <-ctx.Done():
timer.Stop()
return ctx.Err()
case <-timer.C:
}
}
return nil
}
+554
View File
@@ -0,0 +1,554 @@
// Emoji catalog adapted from Fenrir for Cthulhu.
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
// Package emoji provides the bundled picker catalog and its internal UI form.
package emoji
import (
"sort"
"git.stormux.org/storm/cthulhu/internal/interaction"
)
const MenuTitle = "Emoji Menu"
// Catalog values originate in Python Fenrir commands/vmenu-profiles/KEY/emoji.
// Duplicate aliases are omitted; names describe the stored Unicode sequence.
var categories = []struct {
id, label string
choices []interaction.Item
}{
{"people", "People", []interaction.Item{
{ID: "😠", Label: "Angry"},
{ID: "😁", Label: "Beaming"},
{ID: "😘", Label: "Blowing kiss"},
{ID: "😎", Label: "Cool"},
{ID: "😭", Label: "Crying"},
{ID: "😈", Label: "Devil"},
{ID: "😵", Label: "Dizzy"},
{ID: "🦵", Label: "Leg"},
{ID: "🧑‍🚀", Label: "Astronaut"},
{ID: "🤯", Label: "Exploding head"},
{ID: "🤬", Label: "Face with symbols"},
{ID: "😀", Label: "Grinning"},
{ID: "😅", Label: "Grinning sweat"},
{ID: "😍", Label: "Heart eyes"},
{ID: "🥰", Label: "Hearts around"},
{ID: "👿", Label: "Imp"},
{ID: "😂", Label: "Laughing"},
{ID: "🤢", Label: "Nauseated"},
{ID: "🥺", Label: "Pleading"},
{ID: "💩", Label: "Poop"},
{ID: "🤣", Label: "Rolling laughing"},
{ID: "😢", Label: "Sad"},
{ID: "😋", Label: "Savoring food"},
{ID: "😱", Label: "Screaming"},
{ID: "😊", Label: "Smiling"},
{ID: "😏", Label: "Smirking"},
{ID: "🤩", Label: "Star-struck"},
{ID: "👍", Label: "Thumbs up"},
{ID: "😉", Label: "Winking"},
{ID: "🧑‍💻", Label: "Technologist"},
{ID: "🥳", Label: "Partying face"},
{ID: "🤔", Label: "Thinking face"},
{ID: "🫠", Label: "Melting face"},
{ID: "🥲", Label: "Smiling face with tear"},
{ID: "🤠", Label: "Cowboy hat face"},
{ID: "🥱", Label: "Yawning face"},
{ID: "🫡", Label: "Saluting face"},
{ID: "🤝", Label: "Handshake"},
{ID: "👏", Label: "Clapping hands"},
{ID: "🙌", Label: "Raising hands"},
{ID: "🫶", Label: "Heart hands"},
{ID: "👌", Label: "OK hand"},
{ID: "👎", Label: "Thumbs down"},
{ID: "💪", Label: "Flexed biceps"},
{ID: "🦶", Label: "Foot"},
{ID: "👶", Label: "Baby"},
{ID: "👽", Label: "Alien"},
{ID: "👼", Label: "Baby Angel"},
{ID: "👈", Label: "Backhand Index Pointing Left"},
{ID: "👉", Label: "Backhand Index Pointing Right"},
{ID: "🤙", Label: "Call Me Hand"},
{ID: "🤡", Label: "Clown Face"},
{ID: "🤞", Label: "Crossed Fingers"},
{ID: "🧝", Label: "Elf"},
{ID: "🧐", Label: "Face With Monocle"},
{ID: "😛", Label: "Face With Tongue"},
{ID: "🧚", Label: "Fairy"},
{ID: "🧞", Label: "Genie"},
{ID: "👻", Label: "Ghost"},
{ID: "👺", Label: "Goblin"},
{ID: "😃", Label: "Grinning Face With Big Eyes"},
{ID: "😄", Label: "Grinning Face With Smiling Eyes"},
{ID: "🤟", Label: "Love You Gesture"},
{ID: "🧙", Label: "Mage"},
{ID: "🧜", Label: "Merperson"},
{ID: "🤶", Label: "Mrs. Claus"},
{ID: "🤓", Label: "Nerd Face"},
{ID: "👹", Label: "Ogre"},
{ID: "🤌", Label: "Pinched Fingers"},
{ID: "🤏", Label: "Pinching Hand"},
{ID: "🤚", Label: "Raised Back Of Hand"},
{ID: "✋", Label: "Raised Hand"},
{ID: "🤖", Label: "Robot"},
{ID: "🙂", Label: "Slightly Smiling Face"},
{ID: "😇", Label: "Smiling Face With Halo"},
{ID: "🦸", Label: "Superhero"},
{ID: "🦹", Label: "Supervillain"},
{ID: "🙃", Label: "Upside Down Face"},
{ID: "🧛", Label: "Vampire"},
{ID: "🖖", Label: "Vulcan Salute"},
{ID: "👋", Label: "Waving Hand"},
{ID: "🤪", Label: "Zany Face"},
{ID: "🧟", Label: "Zombie"},
{ID: "🤭", Label: "Face With Hand Over Mouth"},
{ID: "👂", Label: "Ear"},
{ID: "👃", Label: "Nose"},
{ID: "👣", Label: "Footprints"},
{ID: "👀", Label: "Eyes"},
{ID: "🧠", Label: "Brain"},
{ID: "🦷", Label: "Tooth"},
{ID: "🦾", Label: "Mechanical Arm"},
{ID: "🦿", Label: "Mechanical Leg"},
}},
{"nature", "Nature", []interaction.Item{
{ID: "🦋", Label: "Butterfly"},
{ID: "🐱", Label: "Cat"},
{ID: "🌸", Label: "Cherry blossom"},
{ID: "🐶", Label: "Dog"},
{ID: "🌙", Label: "Moon"},
{ID: "🌈", Label: "Rainbow"},
{ID: "🌹", Label: "Rose"},
{ID: "☀️", Label: "Sun"},
{ID: "🌻", Label: "Sunflower"},
{ID: "🌳", Label: "Tree"},
{ID: "🐺", Label: "Wolf"},
{ID: "🐝", Label: "Bee"},
{ID: "🐞", Label: "Lady beetle"},
{ID: "🐢", Label: "Turtle"},
{ID: "🐍", Label: "Snake"},
{ID: "🐙", Label: "Octopus"},
{ID: "🐬", Label: "Dolphin"},
{ID: "🐳", Label: "Spouting whale"},
{ID: "🐠", Label: "Tropical fish"},
{ID: "🦈", Label: "Shark"},
{ID: "🐘", Label: "Elephant"},
{ID: "🦒", Label: "Giraffe"},
{ID: "🦓", Label: "Zebra"},
{ID: "🦌", Label: "Deer"},
{ID: "🐴", Label: "Horse face"},
{ID: "🐮", Label: "Cow face"},
{ID: "🐷", Label: "Pig face"},
{ID: "🐭", Label: "Mouse face"},
{ID: "🐹", Label: "Hamster"},
{ID: "🦊", Label: "Fox"},
{ID: "🐻", Label: "Bear"},
{ID: "🦡", Label: "Badger"},
{ID: "🦇", Label: "Bat"},
{ID: "🦫", Label: "Beaver"},
{ID: "🐗", Label: "Boar"},
{ID: "🐪", Label: "Camel"},
{ID: "🐿️", Label: "Chipmunk"},
{ID: "🐄", Label: "Cow"},
{ID: "🐕", Label: "Dog"},
{ID: "🐑", Label: "Ewe"},
{ID: "🐐", Label: "Goat"},
{ID: "🦍", Label: "Gorilla"},
{ID: "🦮", Label: "Guide Dog"},
{ID: "🦔", Label: "Hedgehog"},
{ID: "🦛", Label: "Hippopotamus"},
{ID: "🦘", Label: "Kangaroo"},
{ID: "🐨", Label: "Koala"},
{ID: "🐆", Label: "Leopard"},
{ID: "🦁", Label: "Lion"},
{ID: "🦙", Label: "Llama"},
{ID: "🐵", Label: "Monkey Face"},
{ID: "🦧", Label: "Orangutan"},
{ID: "🦦", Label: "Otter"},
{ID: "🐂", Label: "Ox"},
{ID: "🐼", Label: "Panda"},
{ID: "🐾", Label: "Paw Prints"},
{ID: "🐩", Label: "Poodle"},
{ID: "🦝", Label: "Raccoon"},
{ID: "🐏", Label: "Ram"},
{ID: "🐀", Label: "Rat"},
{ID: "🦏", Label: "Rhinoceros"},
{ID: "🐕‍🦺", Label: "Service Dog"},
{ID: "🦨", Label: "Skunk"},
{ID: "🦥", Label: "Sloth"},
{ID: "🐯", Label: "Tiger Face"},
{ID: "🦄", Label: "Unicorn"},
{ID: "🐃", Label: "Water Buffalo"},
{ID: "🦢", Label: "Swan"},
{ID: "🦉", Label: "Owl"},
{ID: "🦚", Label: "Peacock"},
{ID: "🦜", Label: "Parrot"},
{ID: "🕊️", Label: "Dove"},
{ID: "🦅", Label: "Eagle"},
{ID: "🦆", Label: "Duck"},
{ID: "🐸", Label: "Frog"},
{ID: "🐊", Label: "Crocodile"},
{ID: "🦎", Label: "Lizard"},
{ID: "🐲", Label: "Dragon Face"},
{ID: "🐉", Label: "Dragon"},
{ID: "🐋", Label: "Whale"},
{ID: "🐟", Label: "Fish"},
{ID: "🐡", Label: "Blowfish"},
{ID: "🐌", Label: "Snail"},
{ID: "🐛", Label: "Bug"},
{ID: "🐜", Label: "Ant"},
{ID: "🪲", Label: "Beetle"},
{ID: "🦗", Label: "Cricket"},
{ID: "🦂", Label: "Scorpion"},
{ID: "🦟", Label: "Mosquito"},
{ID: "🦠", Label: "Microbe"},
{ID: "💐", Label: "Bouquet"},
{ID: "💮", Label: "White Flower"},
{ID: "🏵️", Label: "Rosette"},
{ID: "🥀", Label: "Wilted Flower"},
{ID: "🌺", Label: "Hibiscus"},
{ID: "🌼", Label: "Blossom"},
{ID: "🌷", Label: "Tulip"},
{ID: "🌱", Label: "Seedling"},
{ID: "🌲", Label: "Evergreen Tree"},
{ID: "🌴", Label: "Palm Tree"},
{ID: "🌵", Label: "Cactus"},
{ID: "🌾", Label: "Sheaf Of Rice"},
{ID: "🌿", Label: "Herb"},
{ID: "🍀", Label: "Four Leaf Clover"},
{ID: "🍁", Label: "Maple Leaf"},
{ID: "🍂", Label: "Fallen Leaf"},
{ID: "🍃", Label: "Leaf Fluttering In Wind"},
{ID: "🍄", Label: "Mushroom"},
{ID: "🌰", Label: "Chestnut"},
{ID: "🌑", Label: "New Moon"},
{ID: "🌔", Label: "Waxing Gibbous Moon"},
{ID: "🌕", Label: "Full Moon"},
{ID: "🌞", Label: "Sun With Face"},
{ID: "☁️", Label: "Cloud"},
{ID: "🌪️", Label: "Tornado"},
{ID: "🌬️", Label: "Wind Face"},
{ID: "☔", Label: "Umbrella With Rain Drops"},
{ID: "❄️", Label: "Snowflake"},
{ID: "☄️", Label: "Comet"},
{ID: "💧", Label: "Droplet"},
{ID: "🌊", Label: "Water Wave"},
}},
{"food", "Food", []interaction.Item{
{ID: "🍎", Label: "Apple"},
{ID: "🥑", Label: "Avocado"},
{ID: "🍺", Label: "Beer"},
{ID: "🎂", Label: "Birthday cake"},
{ID: "☕", Label: "Coffee"},
{ID: "🍩", Label: "Donut"},
{ID: "🍔", Label: "Hamburger"},
{ID: "🍕", Label: "Pizza"},
{ID: "🍓", Label: "Strawberry"},
{ID: "🌮", Label: "Taco"},
{ID: "🍐", Label: "Pear"},
{ID: "🍊", Label: "Tangerine"},
{ID: "🍋", Label: "Lemon"},
{ID: "🍌", Label: "Banana"},
{ID: "🍉", Label: "Watermelon"},
{ID: "🍇", Label: "Grapes"},
{ID: "🍒", Label: "Cherries"},
{ID: "🍑", Label: "Peach"},
{ID: "🥭", Label: "Mango"},
{ID: "🍍", Label: "Pineapple"},
{ID: "🥥", Label: "Coconut"},
{ID: "🥝", Label: "Kiwi fruit"},
{ID: "🍅", Label: "Tomato"},
{ID: "🥕", Label: "Carrot"},
{ID: "🌽", Label: "Corn"},
{ID: "🥔", Label: "Potato"},
{ID: "🍞", Label: "Bread"},
{ID: "🥐", Label: "Croissant"},
{ID: "🥞", Label: "Pancakes"},
{ID: "🧀", Label: "Cheese"},
{ID: "🥖", Label: "Baguette Bread"},
{ID: "🍱", Label: "Bento Box"},
{ID: "🫐", Label: "Blueberries"},
{ID: "🥦", Label: "Broccoli"},
{ID: "🧈", Label: "Butter"},
{ID: "🥫", Label: "Canned Food"},
{ID: "🍛", Label: "Curry Rice"},
{ID: "🍡", Label: "Dango"},
{ID: "🥟", Label: "Dumpling"},
{ID: "🍆", Label: "Eggplant"},
{ID: "🥠", Label: "Fortune Cookie"},
{ID: "🍤", Label: "Fried Shrimp"},
{ID: "🍏", Label: "Green Apple"},
{ID: "🥗", Label: "Green Salad"},
{ID: "🦞", Label: "Lobster"},
{ID: "🥮", Label: "Moon Cake"},
{ID: "🍢", Label: "Oden"},
{ID: "🦪", Label: "Oyster"},
{ID: "🍿", Label: "Popcorn"},
{ID: "🥨", Label: "Pretzel"},
{ID: "🍙", Label: "Rice Ball"},
{ID: "🍘", Label: "Rice Cracker"},
{ID: "🍠", Label: "Roasted Sweet Potato"},
{ID: "🧂", Label: "Salt"},
{ID: "🍝", Label: "Spaghetti"},
{ID: "🍜", Label: "Steaming Bowl"},
{ID: "🍣", Label: "Sushi"},
{ID: "🥡", Label: "Takeout Box"},
{ID: "🥯", Label: "Bagel"},
{ID: "🧇", Label: "Waffle"},
{ID: "🥘", Label: "Shallow Pan Of Food"},
{ID: "🫕", Label: "Fondue"},
{ID: "🍨", Label: "Ice Cream"},
{ID: "🍧", Label: "Shaved Ice"},
{ID: "🍪", Label: "Cookie"},
{ID: "🧁", Label: "Cupcake"},
{ID: "🥧", Label: "Pie"},
{ID: "🍫", Label: "Chocolate Bar"},
{ID: "🍬", Label: "Candy"},
{ID: "🍭", Label: "Lollipop"},
{ID: "🍮", Label: "Custard"},
{ID: "🍯", Label: "Honey Pot"},
{ID: "🍼", Label: "Baby Bottle"},
{ID: "🥛", Label: "Glass Of Milk"},
{ID: "🍶", Label: "Sake"},
{ID: "🍷", Label: "Wine Glass"},
{ID: "🍸", Label: "Cocktail Glass"},
{ID: "🍹", Label: "Tropical Drink"},
{ID: "🥂", Label: "Clinking Glasses"},
{ID: "🥃", Label: "Tumbler Glass"},
{ID: "🥤", Label: "Cup With Straw"},
}},
{"activity", "Activity", []interaction.Item{
{ID: "⚽", Label: "Soccer ball"},
{ID: "🏀", Label: "Basketball"},
{ID: "🏈", Label: "American football"},
{ID: "⚾", Label: "Baseball"},
{ID: "🎾", Label: "Tennis ball"},
{ID: "🏐", Label: "Volleyball"},
{ID: "🥏", Label: "Flying disc"},
{ID: "🏉", Label: "Rugby football"},
{ID: "🎱", Label: "Pool 8 ball"},
{ID: "🏓", Label: "Table tennis"},
{ID: "🏸", Label: "Badminton"},
{ID: "🏒", Label: "Ice hockey"},
{ID: "🥅", Label: "Goal net"},
{ID: "🥍", Label: "Lacrosse"},
{ID: "🏏", Label: "Cricket game"},
{ID: "🛹", Label: "Skateboard"},
{ID: "🛷", Label: "Sled"},
{ID: "🏆", Label: "Trophy"},
{ID: "🎮", Label: "Video game"},
{ID: "🎯", Label: "Direct hit"},
{ID: "🥇", Label: "1st Place Medal"},
{ID: "🥈", Label: "2nd Place Medal"},
{ID: "🥉", Label: "3rd Place Medal"},
{ID: "🥌", Label: "Curling Stone"},
{ID: "🤿", Label: "Diving Mask"},
{ID: "🎣", Label: "Fishing Pole"},
{ID: "⛸️", Label: "Ice Skate"},
{ID: "🪁", Label: "Kite"},
{ID: "🏌️", Label: "Person Golfing"},
{ID: "🏄", Label: "Person Surfing"},
{ID: "🏊", Label: "Person Swimming"},
{ID: "🛼", Label: "Roller Skate"},
{ID: "🎽", Label: "Running Shirt"},
{ID: "⛷️", Label: "Skier"},
{ID: "🎿", Label: "Skis"},
{ID: "🏔️", Label: "Snow Capped Mountain"},
{ID: "🏂", Label: "Snowboarder"},
{ID: "🏅", Label: "Sports Medal"},
}},
{"travel", "Travel", []interaction.Item{
{ID: "🚗", Label: "Automobile"},
{ID: "🚌", Label: "Bus"},
{ID: "🚕", Label: "Taxi"},
{ID: "🚓", Label: "Police car"},
{ID: "🚑", Label: "Ambulance"},
{ID: "🚒", Label: "Fire engine"},
{ID: "🚲", Label: "Bicycle"},
{ID: "🚀", Label: "Rocket"},
{ID: "✈️", Label: "Airplane"},
{ID: "⚓", Label: "Anchor"},
{ID: "🛫", Label: "Airplane Departure"},
{ID: "🛺", Label: "Auto Rickshaw"},
{ID: "🛶", Label: "Canoe"},
{ID: "🚧", Label: "Construction"},
{ID: "🚁", Label: "Helicopter"},
{ID: "🛴", Label: "Kick Scooter"},
{ID: "🚈", Label: "Light Rail"},
{ID: "🚐", Label: "Minibus"},
{ID: "🚝", Label: "Monorail"},
{ID: "🏍️", Label: "Motorcycle"},
{ID: "🚍", Label: "Oncoming Bus"},
{ID: "🛻", Label: "Pickup Truck"},
{ID: "🚃", Label: "Railway Car"},
{ID: "⛵", Label: "Sailboat"},
{ID: "🚢", Label: "Ship"},
{ID: "🛩️", Label: "Small Airplane"},
{ID: "🚤", Label: "Speedboat"},
{ID: "🚉", Label: "Station"},
{ID: "🚜", Label: "Tractor"},
{ID: "🚆", Label: "Train"},
{ID: "🚎", Label: "Trolleybus"},
}},
{"objects", "Objects", []interaction.Item{
{ID: "⌚", Label: "Watch"},
{ID: "📱", Label: "Mobile phone"},
{ID: "💻", Label: "Laptop"},
{ID: "🖥️", Label: "Desktop computer"},
{ID: "🖨️", Label: "Printer"},
{ID: "⌨️", Label: "Keyboard"},
{ID: "🖱️", Label: "Computer mouse"},
{ID: "💾", Label: "Floppy disk"},
{ID: "💿", Label: "Optical disk"},
{ID: "🎥", Label: "Movie camera"},
{ID: "🧮", Label: "Abacus"},
{ID: "🪗", Label: "Accordion"},
{ID: "🪕", Label: "Banjo"},
{ID: "🔋", Label: "Battery"},
{ID: "📷", Label: "Camera"},
{ID: "📸", Label: "Camera With Flash"},
{ID: "🕯️", Label: "Candle"},
{ID: "🎬", Label: "Clapper Board"},
{ID: "🪔", Label: "Diya Lamp"},
{ID: "🥁", Label: "Drum"},
{ID: "📀", Label: "Dvd"},
{ID: "🔌", Label: "Electric Plug"},
{ID: "📽️", Label: "Film Projector"},
{ID: "🧯", Label: "Fire Extinguisher"},
{ID: "🔦", Label: "Flashlight"},
{ID: "🎸", Label: "Guitar"},
{ID: "🎧", Label: "Headphone"},
{ID: "💡", Label: "Light Bulb"},
{ID: "🪘", Label: "Long Drum"},
{ID: "🎤", Label: "Microphone"},
{ID: "🎹", Label: "Musical Keyboard"},
{ID: "🎼", Label: "Musical Score"},
{ID: "🛢️", Label: "Oil Drum"},
{ID: "📻", Label: "Radio"},
{ID: "🎷", Label: "Saxophone"},
{ID: "🛍️", Label: "Shopping Bags"},
{ID: "🛒", Label: "Shopping Cart"},
{ID: "☎️", Label: "Telephone"},
{ID: "📺", Label: "Television"},
{ID: "🎺", Label: "Trumpet"},
{ID: "📹", Label: "Video Camera"},
{ID: "🎻", Label: "Violin"},
{ID: "👓", Label: "Glasses"},
{ID: "🕶️", Label: "Sunglasses"},
{ID: "🥽", Label: "Goggles"},
{ID: "🥼", Label: "Lab Coat"},
{ID: "🦺", Label: "Safety Vest"},
{ID: "👔", Label: "Necktie"},
{ID: "👖", Label: "Jeans"},
{ID: "🧣", Label: "Scarf"},
{ID: "🧤", Label: "Gloves"},
{ID: "🧥", Label: "Coat"},
{ID: "🧦", Label: "Socks"},
{ID: "🥾", Label: "Hiking Boot"},
}},
{"holidays", "Holidays", []interaction.Item{
{ID: "👾", Label: "Alien monster"},
{ID: "🐈‍⬛", Label: "Black cat"},
{ID: "🐰", Label: "Bunny"},
{ID: "🎄", Label: "Christmas tree"},
{ID: "⚰️", Label: "Coffin"},
{ID: "🥚", Label: "Egg"},
{ID: "🎆", Label: "Fireworks"},
{ID: "🎁", Label: "Gift"},
{ID: "🎃", Label: "Jack o'lantern"},
{ID: "🎅", Label: "Santa"},
{ID: "☘️", Label: "Shamrock"},
{ID: "💀", Label: "Skull"},
{ID: "⛄", Label: "Snowman"},
{ID: "🕷", Label: "Spider"},
{ID: "🦃", Label: "Turkey"},
{ID: "🕸️", Label: "Web"},
{ID: "🧙‍♀️", Label: "Witch"},
{ID: "🧨", Label: "Firecracker"},
{ID: "🎊", Label: "Confetti ball"},
{ID: "🎈", Label: "Balloon"},
{ID: "🎀", Label: "Ribbon"},
{ID: "🪄", Label: "Magic wand"},
{ID: "🔮", Label: "Crystal ball"},
{ID: "🪅", Label: "Piñata"},
{ID: "🪦", Label: "Headstone"},
}},
{"symbols", "Symbols", []interaction.Item{
{ID: "🦴", Label: "Bone"},
{ID: "⛓️", Label: "Chains"},
{ID: "✅", Label: "Checkmark"},
{ID: "🗡️", Label: "Dagger"},
{ID: "🔥", Label: "Fire"},
{ID: "🙏", Label: "Folded hands"},
{ID: "❤️", Label: "Heart"},
{ID: "⚡", Label: "High voltage"},
{ID: "💯", Label: "Hundred points"},
{ID: "✌️", Label: "Peace"},
{ID: "🤘", Label: "Rock on"},
{ID: "☠️", Label: "Skull crossbones"},
{ID: "✨", Label: "Sparkles"},
{ID: "⭐", Label: "Star"},
{ID: "⚔️", Label: "Sword"},
{ID: "💔", Label: "Broken heart"},
{ID: "❤️‍🔥", Label: "Heart on fire"},
{ID: "❤️‍🩹", Label: "Mending heart"},
{ID: "💛", Label: "Yellow heart"},
{ID: "💚", Label: "Green heart"},
{ID: "💙", Label: "Blue heart"},
{ID: "💜", Label: "Purple heart"},
{ID: "🖤", Label: "Black heart"},
{ID: "🤎", Label: "Brown heart"},
{ID: "🤍", Label: "White heart"},
{ID: "🧡", Label: "Orange Heart"},
{ID: "🩷", Label: "Pink Heart"},
}},
{"flags", "Flags", []interaction.Item{
{ID: "🇦🇷", Label: "Argentina"},
{ID: "🇦🇺", Label: "Australia"},
{ID: "🇧🇪", Label: "Belgium"},
{ID: "🇧🇷", Label: "Brazil"},
{ID: "🇨🇦", Label: "Canada"},
{ID: "🇨🇳", Label: "China"},
{ID: "🇩🇰", Label: "Denmark"},
{ID: "🇫🇮", Label: "Finland"},
{ID: "🇫🇷", Label: "France"},
{ID: "🇩🇪", Label: "Germany"},
{ID: "🇬🇷", Label: "Greece"},
{ID: "🇮🇳", Label: "India"},
{ID: "🇮🇪", Label: "Ireland"},
{ID: "🇮🇱", Label: "Israel"},
{ID: "🇮🇹", Label: "Italy"},
{ID: "🇯🇵", Label: "Japan"},
{ID: "🇲🇽", Label: "Mexico"},
{ID: "🇳🇱", Label: "Netherlands"},
{ID: "🇳🇴", Label: "Norway"},
{ID: "🇵🇱", Label: "Poland"},
{ID: "🇵🇹", Label: "Portugal"},
{ID: "🇷🇺", Label: "Russia"},
{ID: "🇿🇦", Label: "South Africa"},
{ID: "🇰🇷", Label: "South Korea"},
{ID: "🇪🇸", Label: "Spain"},
{ID: "🇸🇪", Label: "Sweden"},
{ID: "🇨🇭", Label: "Switzerland"},
{ID: "🇹🇷", Label: "Turkey"},
{ID: "🇺🇦", Label: "Ukraine"},
{ID: "🇬🇧", Label: "United Kingdom"},
{ID: "🇺🇸", Label: "United States"},
}},
}
// NewSession builds a private picker with independent category searches.
func NewSession(copyChecked bool) (*interaction.Session, error) {
var all []interaction.Item
for _, category := range categories {
all = append(all, category.choices...)
}
sort.SliceStable(all, func(i, j int) bool { return all[i].Label < all[j].Label })
tabs := []interaction.GridTab{{ID: "all", Label: "All", Items: all}}
for _, category := range categories {
tabs = append(tabs, interaction.GridTab{ID: category.id, Label: category.label, Items: category.choices})
}
return interaction.NewGridSession(MenuTitle, tabs, 6, interaction.Element{ID: "copy", Label: "Copy selected emoji to clipboard", Kind: interaction.CheckBox, Checked: copyChecked})
}
+77
View File
@@ -0,0 +1,77 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package emoji
import (
"strings"
"testing"
"git.stormux.org/storm/cthulhu/internal/interaction"
)
func TestCatalogUniquenessAndSequences(t *testing.T) {
seen := map[string]bool{}
strip := strings.NewReplacer("\ufe0e", "", "\ufe0f", "")
for _, category := range categories {
for _, item := range category.choices {
id := strip.Replace(item.ID)
if seen[id] {
t.Fatalf("duplicate presentation: %q", item.ID)
}
seen[id] = true
}
}
if len(seen) != 500 || !seen["🧑‍🚀"] || !seen["🐐"] {
t.Fatalf("catalog missing entries: %d", len(seen))
}
}
func TestPickerSearchSelectionAndIndependentCategories(t *testing.T) {
session, err := NewSession(true)
if err != nil {
t.Fatal(err)
}
if got := session.Opening().Speech; got != "Emoji Menu. All tab" {
t.Fatal(got)
}
session.Handle(interaction.Event{Key: interaction.Tab})
session.Handle(interaction.Event{Text: "astronaut"})
if got := session.Values()["grid"].Selected; got != "🧑‍🚀" {
t.Fatalf("sequence changed: %q", got)
}
// Empty results must not activate or retain a previous selection.
session.Handle(interaction.Event{Text: "zz"})
if session.Values()["grid"].Selected != "" || session.Handle(interaction.Event{Key: interaction.Enter}).Activated != "" {
t.Fatal("stale selection after empty search")
}
session.Handle(interaction.Event{Key: interaction.Backspace, Control: true})
session.Handle(interaction.Event{Text: "goat"})
if session.Values()["grid"].Selected != "🐐" {
t.Fatal("search not reset")
}
// Forward and reverse Tab include the checkbox and category strip.
session.Handle(interaction.Event{Key: interaction.Tab})
session.Handle(interaction.Event{Text: " "})
if session.Values()["copy"].Checked {
t.Fatal("checkbox did not toggle")
}
session.Handle(interaction.Event{Key: interaction.Tab})
session.Handle(interaction.Event{Key: interaction.Right})
session.Handle(interaction.Event{Key: interaction.Tab})
if strings.Contains(session.Presentation().Speech, "Goat") {
t.Fatal("category search leaked")
}
session.Handle(interaction.Event{Key: interaction.Tab, Shift: true})
session.Handle(interaction.Event{Key: interaction.Left})
session.Handle(interaction.Event{Key: interaction.Tab})
if session.Values()["grid"].Selected != "🐐" {
t.Fatal("category search not retained")
}
if got := session.Handle(interaction.Event{Key: interaction.Enter}).Activated; got != "select-grid-item" {
t.Fatal(got)
}
other, _ := NewSession(true)
other.Handle(interaction.Event{Key: interaction.Tab})
if other.Values()["grid"].Selected == "🐐" {
t.Fatal("new picker inherited search")
}
}
+2
View File
@@ -23,6 +23,7 @@ const (
CycleProgressBarOutput Command = "cycle-progress-bar-output"
PresentVersion Command = "present-version"
ToggleLearnMode Command = "toggle-learn-mode"
EmojiMenu Command = "emoji-menu"
ShowActions Command = "show-actions"
InternalUIKey Command = "internal-ui-key"
InternalUIDismiss Command = "internal-ui-dismiss"
@@ -202,6 +203,7 @@ func (state LockState) Text() string {
func DefaultCommandBindings(layout KeyboardLayout) CommandBindings {
bindings := CommandBindings{
{Symbol: 'e', Modifiers: controlModifier, CthulhuModifier: true}: EmojiMenu,
{Symbol: 'a', Modifiers: 1, CthulhuModifier: true}: ShowActions,
{Symbol: leftKeySymbol, CthulhuModifier: true}: SelectPreviousSpeechSetting,
{Symbol: rightKeySymbol, CthulhuModifier: true}: SelectNextSpeechSetting,
+39
View File
@@ -0,0 +1,39 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package input
import "testing"
func TestEmojiShortcutBothLayoutsAndReleaseOrders(t *testing.T) {
for _, layout := range []KeyboardLayout{DesktopKeyboardLayout, LaptopKeyboardLayout} {
chord := CommandChord{Symbol: 'e', Modifiers: controlModifier, CthulhuModifier: true}
if got := DefaultCommandBindings(layout)[chord]; got != EmojiMenu {
t.Fatalf("%v: %s", layout, got)
}
if got := BrowseCommandBindings(layout)[chord]; got != "" && got != EmojiMenu {
t.Fatalf("browse conflict: %s", got)
}
for _, releaseFirst := range []bool{false, true} {
router := NewCommandRouter([]uint32{0xff63}, DefaultCommandBindings(layout))
router.Handle(0xff63, 118, 0, true)
router.Handle(leftControlKeySymbol, 37, controlModifier, true)
if consumed, cmd := router.Handle('e', 26, controlModifier, true); !consumed || cmd != EmojiMenu {
t.Fatalf("shortcut: %v %s", consumed, cmd)
}
if _, cmd := router.Handle('e', 26, controlModifier, true); cmd != "" {
t.Fatal("repeat reopened menu")
}
if releaseFirst {
router.Handle(0xff63, 118, controlModifier, false)
}
router.Handle('e', 26, controlModifier, false)
router.Handle(leftControlKeySymbol, 37, 0, false)
if !releaseFirst {
router.Handle(0xff63, 118, 0, false)
}
if consumed, cmd := router.Handle('e', 26, 0, true); consumed || cmd != "" {
t.Fatal("typing did not resume")
}
}
}
}
+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{}
}
+8
View File
@@ -0,0 +1,8 @@
<!doctype html>
<html lang="en"><meta charset="utf-8"><title>Cthulhu Browser Probe</title>
<body>
<label>Emoji input <input value="before after"></label>
<label>Emoji textarea <textarea>before after</textarea></label>
<div contenteditable="true" role="textbox" aria-label="Emoji editor">before after</div>
<button>After fields</button>
</body></html>
+1
View File
@@ -12,6 +12,7 @@ from native_test_session import run_private_session
BROWSER_PROOFS = {
"emoji": ("./internal/app", "^TestNativeBrowserEmoji$", 35, 40, "browser-emoji.html"),
"structural-focus": ("./internal/app", "^TestNativeBrowserStructuralFocus$", 45, 50),
"disabled-fields": ("./internal/app", "^TestNativeBrowserDisabledFieldsRemainReadable$", 40, 70, "browser-disabled-fields.html"),
"live-atomic": ("./internal/atspi", "^TestNativeBrowserAtomicLiveRegion$", 30, 35, "browser-live.html"),
+33
View File
@@ -115,6 +115,39 @@ enabled = false
["xdotool", "search", "--onlyvisible", "--name", "^" + TITLE + "$"], text=True).splitlines()[0]
subprocess.run(["xdotool", "windowactivate", "--sync", windowId], check=True)
settle()
# The unchecked default types into the real shell, without touching
# clipboard contents or submitting a command.
subprocess.run(["xclip", "-selection", "clipboard", "-in"],
input="emoji clipboard fixture", text=True, check=True)
key("Insert+ctrl+e")
wait_for(lambda: any(row["text"].startswith("Emoji Menu. All tab")
for row in server.snapshot()), "emoji menu did not open")
for chord in ("Tab", "a", "s", "t", "r", "o", "n", "a", "u", "t", "Return"):
key(chord)
wait_for(lambda: text.getText(0, -1).rstrip().endswith(PROMPT + "🧑‍🚀"),
"complete emoji sequence did not reach the shell")
clipboard = subprocess.check_output(["xclip", "-selection", "clipboard", "-out"], text=True)
if clipboard != "emoji clipboard fixture":
raise AssertionError("emoji typing changed clipboard contents")
key("ctrl+u")
wait_for(lambda: text.getText(0, -1).rstrip().endswith(PROMPT.rstrip()),
"emoji command line did not clear")
# Checked means copy only, including when the terminal has input focus.
offset = len(server.snapshot())
key("Insert+ctrl+e")
wait_for(lambda: any(row["text"].startswith("Emoji Menu. All tab")
for row in server.snapshot()[offset:]), "second emoji menu did not open")
for chord in ("Tab", "g", "o", "a", "t", "Tab", "space", "shift+Tab", "Return"):
key(chord)
wait_for(lambda: any(row["text"] == "Copied Goat to clipboard"
for row in server.snapshot()[offset:]), "emoji was not copied")
if subprocess.check_output(["xclip", "-selection", "clipboard", "-out"], text=True) != "🐐":
raise AssertionError("emoji clipboard sequence changed")
if not text.getText(0, -1).rstrip().endswith(PROMPT.rstrip()):
raise AssertionError("copy mode also typed into the terminal")
report["emoji"] = {"typed_sequence": "🧑‍🚀", "copy_only": "🐐", "clipboard_preserved": True}
print("PASS: emoji typing, full sequence, clipboard preservation and copy-only mode", flush=True)
settle()
logOffset = len(logs())
speechOffset = len(server.snapshot())
for row in range(16):