Merge from testing.
This commit is contained in:
commit
44581c4038
23 files changed
+1504
-8
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package interaction
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// GridTab holds one category. Filtering and selection belong to each session.
|
||||
type GridTab struct {
|
||||
ID, Label string
|
||||
Items []Item
|
||||
}
|
||||
type gridPage struct {
|
||||
GridTab
|
||||
query string
|
||||
selected int
|
||||
}
|
||||
type gridSession struct {
|
||||
pages []gridPage
|
||||
tab, focus, columns int // focus: 0 tab strip, 1 grid, 2 checkbox
|
||||
checkbox Element
|
||||
closed bool
|
||||
}
|
||||
|
||||
// NewGridSession is a core tabbed grid with a shared checkbox. External forms
|
||||
// keep their existing protocol and limits. Navigation follows Fenrir's picker.
|
||||
func NewGridSession(title string, tabs []GridTab, columns int, checkbox Element) (*Session, error) {
|
||||
if title == "" || len(tabs) == 0 || columns < 1 || columns > 64 || checkbox.Kind != CheckBox || checkbox.ID == "" || checkbox.Label == "" {
|
||||
return nil, errors.New("invalid tabbed grid")
|
||||
}
|
||||
grid := &gridSession{columns: columns, checkbox: checkbox}
|
||||
ids := map[string]bool{}
|
||||
for _, tab := range tabs {
|
||||
if tab.ID == "" || tab.Label == "" || ids[tab.ID] {
|
||||
return nil, errors.New("invalid grid tab")
|
||||
}
|
||||
ids[tab.ID] = true
|
||||
items := map[string]bool{}
|
||||
for _, item := range tab.Items {
|
||||
if item.ID == "" || item.Label == "" || items[item.ID] {
|
||||
return nil, errors.New("invalid grid item")
|
||||
}
|
||||
items[item.ID] = true
|
||||
}
|
||||
tab.Items = append([]Item(nil), tab.Items...)
|
||||
page := gridPage{GridTab: tab}
|
||||
page.filter()
|
||||
grid.pages = append(grid.pages, page)
|
||||
}
|
||||
return &Session{title: title, grid: grid}, nil
|
||||
}
|
||||
func (page *gridPage) matches() []int {
|
||||
var result []int
|
||||
for index, item := range page.Items {
|
||||
if !item.Disabled && strings.HasPrefix(strings.ToLower(item.Label), strings.ToLower(page.query)) {
|
||||
result = append(result, index)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
func (page *gridPage) filter() {
|
||||
page.selected = -1
|
||||
if matches := page.matches(); len(matches) > 0 {
|
||||
page.selected = matches[0]
|
||||
}
|
||||
}
|
||||
func (grid *gridSession) values() map[string]Value {
|
||||
page := &grid.pages[grid.tab]
|
||||
values := map[string]Value{grid.checkbox.ID: {Checked: grid.checkbox.Checked}}
|
||||
if grid.focus == 1 && page.selected >= 0 {
|
||||
values["grid"] = Value{Selected: page.Items[page.selected].ID, Text: page.Items[page.selected].Label}
|
||||
}
|
||||
return values
|
||||
}
|
||||
func (grid *gridSession) presentation() Presentation {
|
||||
if grid.closed {
|
||||
return Presentation{}
|
||||
}
|
||||
page := &grid.pages[grid.tab]
|
||||
text := page.Label + " tab"
|
||||
switch grid.focus {
|
||||
case 1:
|
||||
text = "No matches"
|
||||
for position, index := range page.matches() {
|
||||
if index == page.selected {
|
||||
text = fmt.Sprintf("%s. row %d, column %d. %d of %d", page.Items[index].Label, position/grid.columns+1, position%grid.columns+1, position+1, len(page.matches()))
|
||||
break
|
||||
}
|
||||
}
|
||||
case 2:
|
||||
text = grid.checkbox.Label + ", unchecked"
|
||||
if grid.checkbox.Checked {
|
||||
text = grid.checkbox.Label + ", checked"
|
||||
}
|
||||
}
|
||||
return Presentation{Speech: text, Braille: text, Cursor: -1}
|
||||
}
|
||||
func (grid *gridSession) handle(event Event) Effect {
|
||||
if grid.closed {
|
||||
return Effect{}
|
||||
}
|
||||
if event.Key == Escape {
|
||||
grid.closed = true
|
||||
return Effect{Cancelled: true}
|
||||
}
|
||||
if event.Alt {
|
||||
return Effect{}
|
||||
}
|
||||
if event.Key == Tab && !event.Control {
|
||||
delta := 1
|
||||
if event.Shift {
|
||||
delta = -1
|
||||
}
|
||||
grid.focus = (grid.focus + delta + 3) % 3
|
||||
return Effect{Presentation: grid.presentation()}
|
||||
}
|
||||
page := &grid.pages[grid.tab]
|
||||
if grid.focus == 0 {
|
||||
if event.Control {
|
||||
return Effect{}
|
||||
}
|
||||
switch event.Key {
|
||||
case Up, Left:
|
||||
grid.tab = (grid.tab + len(grid.pages) - 1) % len(grid.pages)
|
||||
case Down, Right:
|
||||
grid.tab = (grid.tab + 1) % len(grid.pages)
|
||||
case Enter:
|
||||
grid.focus = 1
|
||||
case Text:
|
||||
if event.Text != " " {
|
||||
return Effect{}
|
||||
}
|
||||
grid.focus = 1
|
||||
default:
|
||||
return Effect{}
|
||||
}
|
||||
return Effect{Presentation: grid.presentation()}
|
||||
}
|
||||
if grid.focus == 2 {
|
||||
if !event.Control && (event.Key == Enter || event.Key == Text && event.Text == " ") {
|
||||
grid.checkbox.Checked = !grid.checkbox.Checked
|
||||
return Effect{Presentation: grid.presentation()}
|
||||
}
|
||||
return Effect{}
|
||||
}
|
||||
if event.Control && event.Key != Backspace {
|
||||
return Effect{}
|
||||
}
|
||||
if event.Key == Backspace || event.Key == Text && event.Text != "" {
|
||||
query := []rune(page.query)
|
||||
if event.Key == Backspace {
|
||||
if event.Control {
|
||||
query = nil
|
||||
} else if len(query) > 0 {
|
||||
query = query[:len(query)-1]
|
||||
}
|
||||
} else {
|
||||
if !utf8.ValidString(event.Text) || len(query)+len([]rune(event.Text)) > 4096 {
|
||||
return Effect{}
|
||||
}
|
||||
for _, character := range event.Text {
|
||||
if !unicode.IsPrint(character) {
|
||||
return Effect{}
|
||||
}
|
||||
}
|
||||
query = append(query, []rune(event.Text)...)
|
||||
}
|
||||
page.query = string(query)
|
||||
page.filter()
|
||||
output := grid.presentation()
|
||||
prefix := "Search cleared"
|
||||
if page.query != "" {
|
||||
prefix = "Search " + page.query
|
||||
}
|
||||
output.Speech = prefix + ". " + output.Speech
|
||||
return Effect{Presentation: output}
|
||||
}
|
||||
matches := page.matches()
|
||||
if len(matches) == 0 {
|
||||
return Effect{Presentation: grid.presentation()}
|
||||
}
|
||||
position := 0
|
||||
for current, index := range matches {
|
||||
if index == page.selected {
|
||||
position = current
|
||||
break
|
||||
}
|
||||
}
|
||||
switch event.Key {
|
||||
case Left:
|
||||
position = max(0, position-1)
|
||||
case Right:
|
||||
position = min(len(matches)-1, position+1)
|
||||
case Up:
|
||||
if position >= grid.columns {
|
||||
position -= grid.columns
|
||||
}
|
||||
case Down:
|
||||
if position/grid.columns < (len(matches)-1)/grid.columns {
|
||||
position = min(len(matches)-1, position+grid.columns)
|
||||
}
|
||||
case Home:
|
||||
position = 0
|
||||
case End:
|
||||
position = len(matches) - 1
|
||||
case PageUp:
|
||||
position = max(0, position-grid.columns*5)
|
||||
case PageDown:
|
||||
position = min(len(matches)-1, position+grid.columns*5)
|
||||
case Enter:
|
||||
return Effect{Activated: "select-grid-item"}
|
||||
default:
|
||||
return Effect{}
|
||||
}
|
||||
page.selected = matches[position]
|
||||
return Effect{Presentation: grid.presentation()}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package interaction
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestGridRowsBoundsAndDisabledItems(t *testing.T) {
|
||||
items := []Item{{ID: "1", Label: "One"}, {ID: "2", Label: "Two"}, {ID: "3", Label: "Three"}, {ID: "4", Label: "Four"}, {ID: "5", Label: "Five", Disabled: true}, {ID: "6", Label: "Six"}}
|
||||
session, err := NewGridSession("Picker", []GridTab{{ID: "all", Label: "All", Items: items}}, 3, Element{ID: "copy", Label: "Copy", Kind: CheckBox})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session.Handle(Event{Key: Tab})
|
||||
for _, step := range []struct {
|
||||
key Key
|
||||
want string
|
||||
}{{Right, "2"}, {Right, "3"}, {Down, "6"}, {Down, "6"}, {Up, "2"}, {Home, "1"}, {Left, "1"}, {End, "6"}, {Right, "6"}, {PageUp, "1"}, {PageDown, "6"}} {
|
||||
session.Handle(Event{Key: step.key})
|
||||
if got := session.Values()["grid"].Selected; got != step.want {
|
||||
t.Fatalf("%v selected %q want %q", step.key, got, step.want)
|
||||
}
|
||||
}
|
||||
if result := session.Handle(Event{Key: Escape}); !result.Cancelled {
|
||||
t.Fatal("escape not cancelled")
|
||||
}
|
||||
if session.Handle(Event{Key: Enter}).Activated != "" || session.Presentation().Speech != "" {
|
||||
t.Fatal("closed grid responded")
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,7 @@ type elementState struct {
|
||||
prefix string
|
||||
}
|
||||
type Session struct {
|
||||
grid *gridSession
|
||||
title string
|
||||
elements []elementState
|
||||
focus int
|
||||
@@ -175,6 +176,9 @@ func new_session(form Form, maxItems, maxElements int) (*Session, error) {
|
||||
}
|
||||
|
||||
func (session *Session) Presentation() Presentation {
|
||||
if session.grid != nil {
|
||||
return session.grid.presentation()
|
||||
}
|
||||
if session.closed {
|
||||
return Presentation{}
|
||||
}
|
||||
@@ -208,6 +212,11 @@ func (session *Session) Presentation() Presentation {
|
||||
}
|
||||
|
||||
func (session *Session) Opening() Presentation {
|
||||
if session.grid != nil {
|
||||
output := session.grid.presentation()
|
||||
output.Speech = session.title + ". " + output.Speech
|
||||
return output
|
||||
}
|
||||
output := session.Presentation()
|
||||
if !session.closed && len(session.elements) > 1 && session.elements[session.focus].element.Kind == List {
|
||||
output.Speech = session.elements[session.focus].element.Label + ". " + output.Speech
|
||||
@@ -217,6 +226,9 @@ func (session *Session) Opening() Presentation {
|
||||
}
|
||||
|
||||
func (session *Session) Values() map[string]Value {
|
||||
if session.grid != nil {
|
||||
return session.grid.values()
|
||||
}
|
||||
values := make(map[string]Value, len(session.elements))
|
||||
for _, state := range session.elements {
|
||||
value := Value{Text: state.element.Text, Checked: state.element.Checked}
|
||||
@@ -229,6 +241,9 @@ func (session *Session) Values() map[string]Value {
|
||||
}
|
||||
|
||||
func (session *Session) Handle(event Event) Effect {
|
||||
if session.grid != nil {
|
||||
return session.grid.handle(event)
|
||||
}
|
||||
if session.closed {
|
||||
return Effect{}
|
||||
}
|
||||
@@ -459,6 +474,9 @@ func (session *Session) edit(event Event) Effect {
|
||||
// Insert handles typing and paste atomically; invalid/oversized input leaves
|
||||
// the field and selection untouched.
|
||||
func (session *Session) Insert(text string) Effect {
|
||||
if session.grid != nil {
|
||||
return Effect{}
|
||||
}
|
||||
if session.closed {
|
||||
return Effect{}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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"),
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user