Fixed a bug with some edit field types that would cause every event to read the whole field.

This commit is contained in:
Storm Dragon committed 2026-10-23 12:21:34 -04:00
1 parent 736f5a0f68
commit 03e2b31e09
11 files changed
+282 -21

No files matched your search

+148
View File
@@ -0,0 +1,148 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"log/slog"
"os"
"os/exec"
"strings"
"sync/atomic"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/platform/xorg"
"git.stormux.org/storm/cthulhu/internal/presentation"
)
func TestNativeBrowserEditorTyping(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" {
t.Skip("requires private browser session")
}
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
adapter := atspi.New()
if err := adapter.Start(ctx); err != nil {
t.Fatal(err)
}
defer adapter.Stop(context.Background())
listener := xorg.New()
if err := listener.Start(ctx); err != nil {
t.Fatal(err)
}
defer listener.Stop(context.Background())
var focus atomic.Pointer[atspi.Snapshot]
var insertion atomic.Pointer[atspi.Event]
observed := &browserEventProbe{Adapter: adapter, events: make(chan atspi.Event, 64)}
relayDone := make(chan struct{})
go func() {
defer close(relayDone)
for {
select {
case event := <-adapter.Events():
if isFocusGainEvent(event) {
focus.Store(&event.Source)
}
if event.Type == atspi.TextInsertedEvent && event.Source.Role == "paragraph" {
insertion.Store(&event)
}
select {
case observed.events <- event:
case <-ctx.Done():
return
}
case <-ctx.Done():
return
}
}
}()
defer func() { cancel(); <-relayDone }()
queue := &fakeSpeechQueue{spoken: make(chan string, 128)}
done := make(chan struct{})
go func() {
defer close(done)
options := presentation.DefaultOptions()
options.EnableKeyEcho = false
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, observed, nil, nil,
20*time.Millisecond, nil, listener, nil, nil, options)
}()
defer func() { cancel(); <-done }()
keys := func(chord string) {
t.Helper()
if out, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", chord).CombinedOutput(); err != nil {
t.Fatalf("keys: %v %s", err, out)
}
}
collect := func() []string {
t.Helper()
timer := time.NewTimer(180 * time.Millisecond)
defer timer.Stop()
var texts []string
for {
select {
case text := <-queue.spoken:
texts = append(texts, text)
case <-timer.C:
return texts
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
}
for attempt := 0; attempt < 20; attempt++ {
keys("Tab")
collect()
if current := focus.Load(); current != nil && current.Name == "Message composer" {
break
}
}
editor := focus.Load()
if editor == nil || editor.Name != "Message composer" || !editor.Editable || editor.Role != "entry" {
t.Fatalf("composer focus = %+v", editor)
}
keys("End")
collect()
for _, letter := range []string{"h", "e", "l", "l", "o"} {
keys(letter)
if got := collect(); len(got) != 0 {
t.Fatalf("typing %q with echo disabled spoke %q", letter, got)
}
}
if event := insertion.Load(); event == nil || event.Source.EditableRoot != editor.ID() {
t.Fatalf("paragraph insertion lost editor ownership: %+v", event)
}
keys("Left")
if got := collect(); len(got) != 1 || got[0] != "o" {
t.Fatalf("character reading = %q", got)
}
keys("Down")
if got := collect(); len(got) != 1 || got[0] != "Following line" {
t.Fatalf("next line reading = %q", got)
}
keys("Up")
if got := collect(); len(got) != 1 || got[0] != "Draft:hello" {
t.Fatalf("line reading = %q", got)
}
keys("End")
collect()
keys("BackSpace")
if got := collect(); len(got) != 1 || got[0] != "o" {
t.Fatalf("deletion = %q", got)
}
keys("Tab")
if got := collect(); len(got) == 0 || !strings.Contains(strings.Join(got, " "), "Other editor") {
t.Fatalf("next editor focus = %q", got)
}
keys("x")
if got := collect(); len(got) != 0 {
t.Fatalf("typing in second editor spoke %q", got)
}
if current, event := focus.Load(), insertion.Load(); current == nil || current.Name != "Other editor" ||
event == nil || event.Source.EditableRoot != current.ID() || event.Source.EditableRoot == editor.ID() {
t.Fatalf("second editor ownership: focus=%+v insertion=%+v", current, event)
}
}
+57
View File
@@ -13,8 +13,65 @@ import (
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/braille"
"git.stormux.org/storm/cthulhu/internal/presentation"
)
func TestEditorParagraphUsesConfiguredEcho(t *testing.T) {
for _, echo := range []string{"disabled", "character", "word", "sentence"} {
t.Run(echo, func(t *testing.T) {
options := presentation.DefaultOptions()
payload, contextText, want := "o", "Hello", "o"
switch echo {
case "disabled":
options.EnableKeyEcho, want = false, ""
case "character":
options.EnableEchoByCharacter = true
case "word":
options.EnableEchoByWord = true
payload, contextText, want = " ", "Hello ", "Hello"
case "sentence":
options.EnableEchoBySentence = true
payload, contextText, want = " ", "Hello. ", "Hello."
}
queue := &fakeSpeechQueue{spoken: make(chan string, 16)}
adapter := &fakeAdapter{events: make(chan atspi.Event, 16)}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
processEvents(ctx, slog.New(slog.DiscardHandler), queue, adapter, 0, options)
}()
defer func() { cancel(); <-done }()
editor := atspi.Snapshot{BusName: ":1.7", Path: "/editor", Name: "Composer", Role: "entry", Editable: true}
paragraph := atspi.Snapshot{BusName: editor.BusName, Path: "/paragraph", Role: "paragraph", Editable: true,
EditableRoot: editor.ID(), Text: contextText, TextBeforeCaret: contextText}
adapter.events <- atspi.NewEvent(atspi.FocusEvent, editor)
adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, paragraph, payload)
adapter.events <- atspi.NewEvent(atspi.CaretEvent, paragraph)
adapter.events <- atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{BusName: editor.BusName, Path: "/next", Role: "button", Name: "Next"})
// Once focus leaves, delayed events from that editor stay quiet.
adapter.events <- atspi.NewEventWithPayload(atspi.TextInsertedEvent, paragraph, "delayed edit")
adapter.events <- atspi.NewEvent(atspi.CaretEvent, paragraph)
adapter.events <- atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{BusName: editor.BusName, Path: "/end", Role: "button", Name: "End"})
wants := []string{"Composer, entry"}
if want != "" {
wants = append(wants, want)
}
wants = append(wants, "Next, button", "End, button")
for _, want := range wants {
select {
case got := <-queue.spoken:
if got != want {
t.Fatalf("speech = %q, want %q", got, want)
}
case <-time.After(time.Second):
t.Fatalf("missing %q", want)
}
}
})
}
}
func TestEditableDocumentParagraphEvents(t *testing.T) {
queue := &fakeSpeechQueue{spoken: make(chan string, 16)}
adapter := &fakeAdapter{events: make(chan atspi.Event, 16)}
@@ -116,7 +116,7 @@ func (adapter *Adapter) captureNativeEventSnapshot(event *C.AtspiEvent, target *
snapshot := snapshotFromNativeWithCaret(target, captureCaret, eventType == CaretEvent, false, adapter.captureSound.Load(), adapter.captureTextAttributes.Load())
if snapshot.Editable && (eventType == CaretEvent || eventType == TextInsertedEvent ||
eventType == TextDeletedEvent || eventType == TextSelectionChangedEvent) {
if editor := C.cthulhu_editable_document(target); editor != nil {
if editor := C.cthulhu_editable_root(target); editor != nil {
snapshot.EditableRoot = nativeObjectID(editor)
C.g_object_unref(C.gpointer(editor))
}
+14 -15
View File
@@ -581,25 +581,24 @@ AtspiAccessible *cthulhu_window_ancestor(AtspiAccessible *source) {
return NULL;
}
// Editable documents can retain native focus while their paragraphs emit
// typing/caret/selection events. Stop at the nearest document boundary so an
// unrelated outer editor cannot claim events from a nested read-only document.
AtspiAccessible *cthulhu_editable_document(AtspiAccessible *source) {
// Editors can retain native focus while their paragraphs emit typing/caret/
// selection events. Use the nearest focusable writable ancestor or editable
// document. Never cross a document, password, or read-only editor boundary.
AtspiAccessible *cthulhu_editable_root(AtspiAccessible *source) {
AtspiAccessible *current = source != NULL ? g_object_ref(source) : NULL;
for (gint depth = 0; current != NULL && depth < 64; depth++) {
AtspiRole role = atspi_accessible_get_role(current, NULL);
if (role == ATSPI_ROLE_DOCUMENT_WEB || role == ATSPI_ROLE_DOCUMENT_FRAME) {
AtspiStateSet *states = atspi_accessible_get_state_set(current);
gboolean editable = states != NULL &&
atspi_state_set_contains(states, ATSPI_STATE_EDITABLE) &&
!atspi_state_set_contains(states, ATSPI_STATE_READ_ONLY) &&
!atspi_state_set_contains(states, ATSPI_STATE_DEFUNCT);
if (states != NULL) g_object_unref(states);
if (editable) return current;
g_object_unref(current);
return NULL;
}
if (role == ATSPI_ROLE_FRAME || role == ATSPI_ROLE_WINDOW || role == ATSPI_ROLE_APPLICATION) break;
AtspiStateSet *states = atspi_accessible_get_state_set(current);
gboolean blocked = states == NULL || role == ATSPI_ROLE_PASSWORD_TEXT ||
atspi_state_set_contains(states, ATSPI_STATE_READ_ONLY) ||
atspi_state_set_contains(states, ATSPI_STATE_DEFUNCT);
gboolean editable = !blocked && atspi_state_set_contains(states, ATSPI_STATE_EDITABLE);
gboolean focusable = !blocked && atspi_state_set_contains(states, ATSPI_STATE_FOCUSABLE);
if (states != NULL) g_object_unref(states);
gboolean document = role == ATSPI_ROLE_DOCUMENT_WEB || role == ATSPI_ROLE_DOCUMENT_FRAME;
if (editable && (focusable || document)) return current;
if (blocked || document || focusable) break;
AtspiAccessible *parent = atspi_accessible_get_parent(current, NULL);
g_object_unref(current);
current = parent;
+1 -1
View File
@@ -131,7 +131,7 @@ const gchar *cthulhu_accessible_bus_name(AtspiAccessible *source);
AtspiAccessible *cthulhu_window_ancestor(AtspiAccessible *source);
gboolean cthulhu_may_be_background_notification(AtspiAccessible *source);
AtspiAccessible *cthulhu_document_ancestor(AtspiAccessible *source);
AtspiAccessible *cthulhu_editable_document(AtspiAccessible *source);
AtspiAccessible *cthulhu_editable_root(AtspiAccessible *source);
AtspiEventListenerCB cthulhu_event_callback_pointer(void);
gboolean cthulhu_request_event_loop_quit(void);
gboolean cthulhu_request_tree_capture(gpointer userData);
+2 -2
View File
@@ -90,8 +90,8 @@ type Snapshot struct {
Checked bool
Defunct bool
Editable bool
// EditableRoot identifies the nearest editable web document, copied while
// capturing native events. Paragraph events can belong to its native focus.
// EditableRoot identifies the nearest focusable writable editor or editable
// web document. Paragraph events can belong to its native focus.
EditableRoot ObjectID
Expandable bool
Expanded bool