Fixed a bug with some edit field types that would cause every event to read the whole field.
This commit is contained in:
1 parent
736f5a0f68
commit
03e2b31e09
11 files changed
+282
-21
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user