From 01f50c17e8873309c04b96fb40a14315cf752d0c Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Thu, 29 Oct 2026 19:20:29 -0400 Subject: [PATCH] Fenrir's emoji ported to cthulhu. --- README.md | 14 + .../Arch-Linux/cthulhu-git/PKGBUILD | 2 +- internal/app/emoji_menu.go | 55 ++ internal/app/emoji_menu_native_test.go | 117 ++++ internal/app/emoji_menu_test.go | 136 +++++ internal/app/internal_ui.go | 52 +- internal/app/internal_ui_native_test.go | 68 ++- internal/app/learn_mode.go | 2 + internal/app/runtime_commands.go | 2 +- internal/app/runtime_workers.go | 2 + internal/atspi/adapter.go | 2 + internal/atspi/adapter_tree_native.go | 3 + internal/atspi/text_input_native.go | 72 +++ internal/emoji/catalog.go | 554 ++++++++++++++++++ internal/emoji/catalog_test.go | 77 +++ internal/input/commands.go | 2 + internal/input/emoji_test.go | 39 ++ internal/interaction/grid.go | 224 +++++++ internal/interaction/grid_test.go | 29 + internal/interaction/session.go | 18 + internal/version/version.go | 2 +- tests/browser-emoji.html | 8 + tests/native_browser.py | 1 + tests/native_terminal.py | 33 ++ 24 files changed, 1505 insertions(+), 9 deletions(-) create mode 100644 internal/app/emoji_menu.go create mode 100644 internal/app/emoji_menu_native_test.go create mode 100644 internal/app/emoji_menu_test.go create mode 100644 internal/atspi/text_input_native.go create mode 100644 internal/emoji/catalog.go create mode 100644 internal/emoji/catalog_test.go create mode 100644 internal/input/emoji_test.go create mode 100644 internal/interaction/grid.go create mode 100644 internal/interaction/grid_test.go create mode 100644 tests/browser-emoji.html diff --git a/README.md b/README.md index 20b997d..fd9de81 100644 --- a/README.md +++ b/README.md @@ -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, diff --git a/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD b/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD index fb493c2..fa21a6f 100644 --- a/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD +++ b/distro-packages/Arch-Linux/cthulhu-git/PKGBUILD @@ -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" diff --git a/internal/app/emoji_menu.go b/internal/app/emoji_menu.go new file mode 100644 index 0000000..c2218ea --- /dev/null +++ b/internal/app/emoji_menu.go @@ -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) + } +} diff --git a/internal/app/emoji_menu_native_test.go b/internal/app/emoji_menu_native_test.go new file mode 100644 index 0000000..2485fd2 --- /dev/null +++ b/internal/app/emoji_menu_native_test.go @@ -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) + } + } +} diff --git a/internal/app/emoji_menu_test.go b/internal/app/emoji_menu_test.go new file mode 100644 index 0000000..b1050e8 --- /dev/null +++ b/internal/app/emoji_menu_test.go @@ -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) + } +} diff --git a/internal/app/internal_ui.go b/internal/app/internal_ui.go index 7a64559..6363274 100644 --- a/internal/app/internal_ui.go +++ b/internal/app/internal_ui.go @@ -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}) diff --git a/internal/app/internal_ui_native_test.go b/internal/app/internal_ui_native_test.go index 90883da..746091a 100644 --- a/internal/app/internal_ui_native_test.go +++ b/internal/app/internal_ui_native_test.go @@ -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 diff --git a/internal/app/learn_mode.go b/internal/app/learn_mode.go index 6cbf480..8fe51c5 100644 --- a/internal/app/learn_mode.go +++ b/internal/app/learn_mode.go @@ -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: diff --git a/internal/app/runtime_commands.go b/internal/app/runtime_commands.go index dd62fd0..5c39ba1 100644 --- a/internal/app/runtime_commands.go +++ b/internal/app/runtime_commands.go @@ -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") } diff --git a/internal/app/runtime_workers.go b/internal/app/runtime_workers.go index 9359c91..533fab7 100644 --- a/internal/app/runtime_workers.go +++ b/internal/app/runtime_workers.go @@ -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 diff --git a/internal/atspi/adapter.go b/internal/atspi/adapter.go index 90bbdef..245ab0c 100644 --- a/internal/atspi/adapter.go +++ b/internal/atspi/adapter.go @@ -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 diff --git a/internal/atspi/adapter_tree_native.go b/internal/atspi/adapter_tree_native.go index c44ccbe..4d20a51 100644 --- a/internal/atspi/adapter_tree_native.go +++ b/internal/atspi/adapter_tree_native.go @@ -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 { diff --git a/internal/atspi/text_input_native.go b/internal/atspi/text_input_native.go new file mode 100644 index 0000000..b2ca54b --- /dev/null +++ b/internal/atspi/text_input_native.go @@ -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 +*/ +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 +} diff --git a/internal/emoji/catalog.go b/internal/emoji/catalog.go new file mode 100644 index 0000000..35319e9 --- /dev/null +++ b/internal/emoji/catalog.go @@ -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}) +} diff --git a/internal/emoji/catalog_test.go b/internal/emoji/catalog_test.go new file mode 100644 index 0000000..9eca1ed --- /dev/null +++ b/internal/emoji/catalog_test.go @@ -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") + } +} diff --git a/internal/input/commands.go b/internal/input/commands.go index 76b83be..b476974 100644 --- a/internal/input/commands.go +++ b/internal/input/commands.go @@ -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, diff --git a/internal/input/emoji_test.go b/internal/input/emoji_test.go new file mode 100644 index 0000000..9c3bceb --- /dev/null +++ b/internal/input/emoji_test.go @@ -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") + } + } + } +} diff --git a/internal/interaction/grid.go b/internal/interaction/grid.go new file mode 100644 index 0000000..c9aab44 --- /dev/null +++ b/internal/interaction/grid.go @@ -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()} +} diff --git a/internal/interaction/grid_test.go b/internal/interaction/grid_test.go new file mode 100644 index 0000000..5d5db89 --- /dev/null +++ b/internal/interaction/grid_test.go @@ -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") + } +} diff --git a/internal/interaction/session.go b/internal/interaction/session.go index 00649d6..70342a1 100644 --- a/internal/interaction/session.go +++ b/internal/interaction/session.go @@ -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{} } diff --git a/internal/version/version.go b/internal/version/version.go index cb3a5af..97e26cf 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -21,7 +21,7 @@ import "strings" const Name = "Cthulhu" -const Version = "2026.10.28" +const Version = "2026.10.29" const Channel = "testing" var Build = Version diff --git a/tests/browser-emoji.html b/tests/browser-emoji.html new file mode 100644 index 0000000..f168b54 --- /dev/null +++ b/tests/browser-emoji.html @@ -0,0 +1,8 @@ + +Cthulhu Browser Probe + + + +
before after
+ + diff --git a/tests/native_browser.py b/tests/native_browser.py index c745426..63b2625 100644 --- a/tests/native_browser.py +++ b/tests/native_browser.py @@ -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"), diff --git a/tests/native_terminal.py b/tests/native_terminal.py index 0af789e..a9c3a28 100644 --- a/tests/native_terminal.py +++ b/tests/native_terminal.py @@ -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):