Web fixes.
This commit is contained in:
1 parent
914d174925
commit
453206b85b
11 files changed
+643
-25
No files matched your search
@@ -0,0 +1,228 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"git.stormux.org/storm/cthulhu/internal/review"
|
||||
)
|
||||
|
||||
func TestNativeBrowserActionOwnership(t *testing.T) {
|
||||
if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" {
|
||||
t.Skip("requires private browser action fixture")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
adapter := atspi.New()
|
||||
if err := adapter.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer adapter.Stop(context.Background())
|
||||
var focus atspi.ObjectID
|
||||
for attempt := 0; attempt < 25 && !focus.Valid(); attempt++ {
|
||||
if out, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", "Tab").CombinedOutput(); err != nil {
|
||||
t.Fatalf("fixture focus: %v %s", err, out)
|
||||
}
|
||||
timer := time.NewTimer(150 * time.Millisecond)
|
||||
drain:
|
||||
for {
|
||||
select {
|
||||
case event := <-adapter.Events():
|
||||
if isFocusGainEvent(event) && event.Source.Name == "Action fixture start" {
|
||||
focus = event.Source.ID()
|
||||
break drain
|
||||
}
|
||||
case <-timer.C:
|
||||
break drain
|
||||
case <-ctx.Done():
|
||||
t.Fatal(ctx.Err())
|
||||
}
|
||||
}
|
||||
timer.Stop()
|
||||
}
|
||||
if !focus.Valid() {
|
||||
t.Fatal("fixture heading focus missing")
|
||||
}
|
||||
tree, err := adapter.CaptureDocument(ctx, focus)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root, _ := tree.Root()
|
||||
document, err := tree.Document(ctx, root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
anchors := map[string]atspi.ObjectReference{}
|
||||
labels := map[string]atspi.ObjectReference{}
|
||||
for _, node := range document.Nodes {
|
||||
snapshot := node.Snapshot
|
||||
if snapshot.ElementID == "Basic" || snapshot.ElementID == "Advanced" {
|
||||
actions, err := adapter.AccessibleActions(ctx, node.Reference.ID())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Logf("%s anchor actions: %+v", snapshot.ElementID, actions.Actions)
|
||||
if snapshot.Role != "section" || snapshot.HTMLTag != "a" || snapshot.Focusable || !snapshot.SupportsClick {
|
||||
t.Fatalf("custom anchor lost activation semantics: %+v", snapshot)
|
||||
}
|
||||
anchors[strings.ToUpper(snapshot.ElementID)] = node.Reference
|
||||
}
|
||||
if snapshot.Role == "static" && (snapshot.Name == "BASIC" || snapshot.Name == "ADVANCED") {
|
||||
labels[snapshot.Name] = node.Reference
|
||||
actions, err := adapter.AccessibleActions(ctx, node.Reference.ID())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, action := range actions.Actions {
|
||||
found = found || action.Name == "clickAncestor"
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("label does not reproduce Chromium ancestor activation: %+v", actions)
|
||||
}
|
||||
}
|
||||
if snapshot.Role == "static" || snapshot.ElementID == "inactive" || snapshot.Role == "paragraph" || atspi.IsWebDocumentRole(snapshot.Role) {
|
||||
if snapshot.SupportsClick {
|
||||
t.Fatalf("passive text/container became a clickable: %+v", snapshot)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(anchors) != 2 || len(labels) != 2 {
|
||||
t.Fatalf("missing mode controls: %v, %v", anchors, labels)
|
||||
}
|
||||
cursor, _ := review.NewDocumentCursor(tree)
|
||||
if _, err := cursor.SyncFocus(ctx, focus); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
counts := map[string]int{}
|
||||
for step := 0; step < 30; step++ {
|
||||
command := review.CaretNextLine
|
||||
if step == 0 {
|
||||
command = review.CaretDocumentStart
|
||||
}
|
||||
move, err := cursor.MoveCaret(ctx, adapter, command)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !move.Moved {
|
||||
break
|
||||
}
|
||||
name := strings.TrimSpace(move.Text)
|
||||
if _, ok := labels[name]; ok {
|
||||
counts[name]++
|
||||
target, err := cursor.ActivationTarget()
|
||||
if err != nil || target != anchors[name] {
|
||||
t.Fatalf("label %s activates %v: %v", name, target, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if counts["BASIC"] != 1 || counts["ADVANCED"] != 1 {
|
||||
t.Fatalf("mode reading counts: %v", counts)
|
||||
}
|
||||
// Structural clickable navigation discovers the owners, not their labels.
|
||||
cursor, _ = review.NewDocumentCursor(tree)
|
||||
cursor.SyncFocus(ctx, focus)
|
||||
found := map[atspi.ObjectID]bool{}
|
||||
for step := 0; step < 12; step++ {
|
||||
move, err := cursor.Move(ctx, review.DocumentClickable, 0, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !move.Moved || found[move.Position.Object.ID()] {
|
||||
break
|
||||
}
|
||||
found[move.Position.Object.ID()] = true
|
||||
}
|
||||
for name, anchor := range anchors {
|
||||
if !found[anchor.ID()] || found[labels[name].ID()] {
|
||||
t.Fatalf("clickable discovery for %s: %v", name, found)
|
||||
}
|
||||
}
|
||||
for _, mode := range []string{"Basic", "Advanced"} {
|
||||
// Reacquire references after each iframe replacement. Enter's target
|
||||
// still comes from the text leaf, with native focus on the start heading.
|
||||
tree, err = adapter.CaptureDocument(ctx, focus)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root, _ = tree.Root()
|
||||
document, err = tree.Document(ctx, root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var label atspi.ObjectID
|
||||
for _, node := range document.Nodes {
|
||||
if node.Snapshot.Role == "static" && node.Snapshot.Name == strings.ToUpper(mode) {
|
||||
label = node.Reference.ID()
|
||||
}
|
||||
}
|
||||
cursor, _ = review.NewDocumentCursor(tree)
|
||||
if _, err := cursor.SyncFocus(ctx, label); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target, err := cursor.ActivationTarget()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := adapter.ActivateDocumentObject(ctx, target, focus); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
activated := false
|
||||
for !activated {
|
||||
select {
|
||||
case event := <-adapter.Events():
|
||||
activated = event.Type == atspi.AccessibleNameEvent && event.Source.Name == mode+" activated"
|
||||
case <-ctx.Done():
|
||||
t.Fatalf("%s action did not reach its delegated handler: %v", mode, ctx.Err())
|
||||
}
|
||||
}
|
||||
// The content iframe changes asynchronously. Capture until its new
|
||||
// document is available and ensure the old document is gone.
|
||||
for {
|
||||
tree, err = adapter.CaptureDocument(ctx, focus)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root, _ = tree.Root()
|
||||
document, err = tree.Document(ctx, root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ready := false
|
||||
for _, node := range document.Nodes {
|
||||
ready = ready || node.Snapshot.Name == mode+" mode content"
|
||||
}
|
||||
if ready {
|
||||
break
|
||||
}
|
||||
select {
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
case <-ctx.Done():
|
||||
t.Fatal("replacement content missing", ctx.Err())
|
||||
}
|
||||
}
|
||||
readNewContent := false
|
||||
for step := 0; step < 8; step++ {
|
||||
move, err := cursor.MoveCaret(ctx, adapter, review.CaretNextLine)
|
||||
if err != nil {
|
||||
t.Fatalf("reading after %s activation: %v", mode, err)
|
||||
}
|
||||
if strings.TrimSpace(move.Text) == mode+" mode content" {
|
||||
readNewContent = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !readNewContent {
|
||||
t.Fatalf("browse caret could not reach %s replacement content", mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"git.stormux.org/storm/cthulhu/internal/input"
|
||||
"git.stormux.org/storm/cthulhu/internal/platform/xorg"
|
||||
"git.stormux.org/storm/cthulhu/internal/presentation"
|
||||
"git.stormux.org/storm/cthulhu/internal/review"
|
||||
)
|
||||
|
||||
// Exercise real Xorg keys, native focus echoes, the review worker and the
|
||||
// event-loop presentation filter together. No audio or desktop reader is used.
|
||||
func TestNativeBrowserCaretLinkSounds(t *testing.T) {
|
||||
if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" {
|
||||
t.Skip("requires private browser sound fixture")
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 35*time.Second)
|
||||
defer cancel()
|
||||
adapter := atspi.New()
|
||||
adapter.SetCaptureSoundGeometry(true)
|
||||
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())
|
||||
commands := xorg.NewCommands([]string{"Insert", "KP_Insert"}, input.DesktopKeyboardLayout)
|
||||
if err := commands.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer commands.Stop(context.Background())
|
||||
worker, err := review.NewWorker(adapter, 2*time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := worker.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer worker.Stop(context.Background())
|
||||
browser := newBrowserModeController(listener, commands, adapter)
|
||||
if err := browser.Start(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer browser.Stop(context.Background())
|
||||
output := &ocrTestOutput{speech: make(chan speechRequest, 128)}
|
||||
options := presentation.Options{EnableSound: true, PlaySoundForRole: true,
|
||||
SpatializeObjectSounds: true, RoleSoundPresentation: "sound_and_speech"}
|
||||
loopDone, commandDone := make(chan struct{}), make(chan struct{})
|
||||
go func() {
|
||||
defer close(loopDone)
|
||||
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), output, adapter, nil, worker,
|
||||
20*time.Millisecond, nil, browser, nil, nil, options)
|
||||
}()
|
||||
go func() {
|
||||
defer close(commandDone)
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case event := <-commands.Commands():
|
||||
browser.observeCommand(event.Command)
|
||||
if command := documentCaretCommand(event.Command); command != 0 {
|
||||
worker.MoveDocumentCaret(command)
|
||||
} else if target, level, backwards := documentReviewCommand(event.Command); target != 0 {
|
||||
worker.MoveDocument(target, level, backwards)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
defer func() { cancel(); <-loopDone; <-commandDone }()
|
||||
key := 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)
|
||||
}
|
||||
}
|
||||
// Wait for a presentation, then retain late native focus echoes through a
|
||||
// quiet interval. Counting every link cue catches conflicting duplicates.
|
||||
collect := func(want string) []speechRequest {
|
||||
t.Helper()
|
||||
deadline := time.NewTimer(3 * time.Second)
|
||||
defer deadline.Stop()
|
||||
quiet := time.NewTimer(200 * time.Millisecond)
|
||||
defer quiet.Stop()
|
||||
matched := false
|
||||
var requests []speechRequest
|
||||
for {
|
||||
select {
|
||||
case request := <-output.speech:
|
||||
requests = append(requests, request)
|
||||
matched = matched || strings.Contains(request.Text, want)
|
||||
quiet.Reset(200 * time.Millisecond)
|
||||
case <-quiet.C:
|
||||
if matched {
|
||||
return requests
|
||||
}
|
||||
case <-deadline.C:
|
||||
t.Fatalf("missing %q; requests=%+v", want, requests)
|
||||
case <-ctx.Done():
|
||||
t.Fatal(ctx.Err())
|
||||
}
|
||||
}
|
||||
}
|
||||
heading := false
|
||||
for attempt := 0; attempt < 25 && !heading; attempt++ {
|
||||
key("Tab")
|
||||
timer := time.NewTimer(150 * time.Millisecond)
|
||||
drain:
|
||||
for {
|
||||
select {
|
||||
case request := <-output.speech:
|
||||
if strings.Contains(request.Text, "Sound fixture start") {
|
||||
heading = true
|
||||
break drain
|
||||
}
|
||||
case <-timer.C:
|
||||
break drain
|
||||
case <-ctx.Done():
|
||||
t.Fatal(ctx.Err())
|
||||
}
|
||||
}
|
||||
timer.Stop()
|
||||
}
|
||||
if !heading {
|
||||
t.Fatal("fixture heading focus missing")
|
||||
}
|
||||
check := func(chord, want string, pan *float64) float64 {
|
||||
t.Helper()
|
||||
key(chord)
|
||||
requests := collect(want)
|
||||
var pans []float64
|
||||
for _, request := range requests {
|
||||
for _, cue := range request.Sound {
|
||||
if cue.Name == "link" || cue.Name == "link_visited" {
|
||||
pans = append(pans, cue.Pan)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(pans) != 1 {
|
||||
t.Fatalf("%s produced %d link cues: %+v", chord, len(pans), requests)
|
||||
}
|
||||
if pan != nil && math.Abs(pans[0]-*pan) > 0.025 {
|
||||
t.Fatalf("%s pan=%g, Tab pan=%g", chord, pans[0], *pan)
|
||||
}
|
||||
return pans[0]
|
||||
}
|
||||
for _, size := range []string{"1200 800", "900 600"} {
|
||||
args := append([]string{"getwindowfocus", "windowsize"}, strings.Fields(size)...)
|
||||
if out, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil {
|
||||
t.Fatalf("resize: %v %s", err, out)
|
||||
}
|
||||
key("ctrl+Home")
|
||||
collect("Sound fixture start")
|
||||
names := []string{"Left link label", "Center link label", "Right link label"}
|
||||
pans := make([]float64, 3)
|
||||
for index, name := range names {
|
||||
pans[index] = check("Tab", name, nil)
|
||||
}
|
||||
if pans[0] > -0.5 || math.Abs(pans[1]) > 0.2 || pans[2] < 0.5 {
|
||||
t.Fatalf("native link positions: %v", pans)
|
||||
}
|
||||
key("ctrl+Home")
|
||||
collect("Sound fixture start")
|
||||
for index, name := range names {
|
||||
check("Down", name, &pans[index])
|
||||
}
|
||||
check("ctrl+Right", "link", &pans[2])
|
||||
check("ctrl+Left", "link", &pans[2])
|
||||
check("Up", names[1], &pans[1])
|
||||
check("k", names[2], &pans[2])
|
||||
t.Logf("window %s: Tab, arrows, words and structural links agree at pans %v", size, pans)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,56 @@ static gchar *cthulhu_capture_text(AtspiAccessible *source, gchar **beforeCaret,
|
||||
static void cthulhu_capture_document_metrics(AtspiAccessible *source, cthulhu_snapshot *snapshot);
|
||||
static gchar *cthulhu_table_cell_label(AtspiAccessible *source, guint depth, guint *remaining);
|
||||
|
||||
// Chromium exposes doDefault even on inert text. Only an anchor with a label
|
||||
// explicitly offering ancestor activation supplies evidence of a custom action.
|
||||
// Never turn every descendant of a delegated click handler into a clickable.
|
||||
static gboolean cthulhu_anchor_has_click_label(AtspiAccessible *source) {
|
||||
gint count = atspi_accessible_get_child_count(source, NULL);
|
||||
for (gint index = 0; index < MIN(count, 16); index++) {
|
||||
AtspiAccessible *child = atspi_accessible_get_child_at_index(source, index, NULL);
|
||||
if (child == NULL) continue;
|
||||
AtspiRole role = atspi_accessible_get_role(child, NULL);
|
||||
gboolean label = role == ATSPI_ROLE_STATIC || role == ATSPI_ROLE_TEXT;
|
||||
gboolean found = FALSE;
|
||||
AtspiStateSet *states = atspi_accessible_get_state_set(child);
|
||||
gboolean passive = states != NULL &&
|
||||
!atspi_state_set_contains(states, ATSPI_STATE_FOCUSABLE) &&
|
||||
!atspi_state_set_contains(states, ATSPI_STATE_EDITABLE);
|
||||
if (states != NULL) g_object_unref(states);
|
||||
if (passive && label) {
|
||||
AtspiAction *action = atspi_accessible_get_action_iface(child);
|
||||
if (action != NULL) {
|
||||
gint actions = MIN(atspi_action_get_n_actions(action, NULL), 16);
|
||||
for (gint i = 0; i < actions && !found; i++) {
|
||||
gchar *name = atspi_action_get_name(action, i, NULL);
|
||||
found = name != NULL && g_ascii_strcasecmp(name, "clickAncestor") == 0;
|
||||
g_free(name);
|
||||
}
|
||||
g_object_unref(action);
|
||||
}
|
||||
}
|
||||
g_object_unref(child);
|
||||
if (found) return TRUE;
|
||||
}
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
// Shared by capture and activation so a discovered target can actually be
|
||||
// invoked. Lower numbers retain the existing preference for direct actions.
|
||||
gint cthulhu_document_action_priority(AtspiAccessible *source, const gchar *name, gboolean anchor) {
|
||||
if (name == NULL) return 0;
|
||||
const gchar *direct[] = {"click", "click-ancestor", "press", "jump", "open", "activate"};
|
||||
for (guint index = 0; index < G_N_ELEMENTS(direct); index++) {
|
||||
if (g_ascii_strcasecmp(name, direct[index]) == 0) return index + 1;
|
||||
}
|
||||
if (!anchor) return 0;
|
||||
if (g_ascii_strcasecmp(name, "clickAncestor") == 0) return 7;
|
||||
if (g_ascii_strcasecmp(name, "doDefault") == 0) {
|
||||
if (cthulhu_anchor_has_click_label(source)) return 8;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Some toolkits expose an edit field's label only as a relation, leaving its
|
||||
// accessible name empty. Follow direct targets only, with a shared query and
|
||||
// output budget; never infer a label from neighbouring controls or field text.
|
||||
@@ -590,22 +640,14 @@ cthulhu_snapshot cthulhu_capture_snapshot(AtspiAccessible *source, gboolean capt
|
||||
if (error != NULL) {
|
||||
g_clear_error(&error);
|
||||
}
|
||||
for (gint index = 0; index < actionCount; index++) {
|
||||
for (gint index = 0; index < MIN(actionCount, 64); index++) {
|
||||
error = NULL;
|
||||
gchar *actionName = atspi_action_get_name(action, index, &error);
|
||||
if (error != NULL) {
|
||||
g_clear_error(&error);
|
||||
}
|
||||
// Web providers use different action names for activation. Keep
|
||||
// this list aligned with activateNativeDocumentObject so browse
|
||||
// Enter can reach objects exposing click-ancestor or press.
|
||||
if (actionName != NULL &&
|
||||
(g_ascii_strcasecmp(actionName, "click") == 0 ||
|
||||
g_ascii_strcasecmp(actionName, "click-ancestor") == 0 ||
|
||||
g_ascii_strcasecmp(actionName, "press") == 0 ||
|
||||
g_ascii_strcasecmp(actionName, "jump") == 0 ||
|
||||
g_ascii_strcasecmp(actionName, "open") == 0 ||
|
||||
g_ascii_strcasecmp(actionName, "activate") == 0)) {
|
||||
if (cthulhu_document_action_priority(source, actionName,
|
||||
g_strcmp0(snapshot.htmlTag, "a") == 0) != 0) {
|
||||
snapshot.supportsClick = TRUE;
|
||||
g_free(actionName);
|
||||
break;
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
"unsafe"
|
||||
@@ -109,7 +108,7 @@ func (adapter *Adapter) SetDocumentPosition(ctx context.Context, position Object
|
||||
}
|
||||
|
||||
// CaptureDocumentSoundGeometry refreshes only the presented object and its
|
||||
// optional linked heading target, after navigation has scrolled them into view.
|
||||
// optional semantic link target, after navigation has scrolled them into view.
|
||||
// It never changes the cached document semantics or retains new native handles.
|
||||
func (adapter *Adapter) CaptureDocumentSoundGeometry(ctx context.Context, object, link ObjectReference) ([2]SoundGeometry, error) {
|
||||
if !object.Valid() {
|
||||
@@ -231,7 +230,6 @@ func (adapter *Adapter) activateNativeDocumentObject(ctx context.Context, positi
|
||||
return errors.New("current document object has no accessible action")
|
||||
}
|
||||
defer C.g_object_unref(C.gpointer(action))
|
||||
names := []string{"click", "click-ancestor", "press", "jump", "open", "activate"}
|
||||
var nativeErr *C.GError
|
||||
count := int(C.atspi_action_get_n_actions(action, &nativeErr))
|
||||
if nativeErr != nil {
|
||||
@@ -241,18 +239,22 @@ func (adapter *Adapter) activateNativeDocumentObject(ctx context.Context, positi
|
||||
}
|
||||
// Read each action name once. Previously each preferred spelling caused
|
||||
// another full round of synchronous accessibility calls.
|
||||
actionNames := make([]string, min(max(count, 0), 64))
|
||||
for index := range actionNames {
|
||||
priorities := make([]int, min(max(count, 0), 64))
|
||||
anchor := C.gboolean(0)
|
||||
if snapshot.HTMLTag == "a" {
|
||||
anchor = 1
|
||||
}
|
||||
for index := range priorities {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
name := C.atspi_action_get_name(action, C.gint(index), nil)
|
||||
actionNames[index] = C.GoString(name)
|
||||
priorities[index] = int(C.cthulhu_document_action_priority(source, name, anchor))
|
||||
C.g_free(C.gpointer(name))
|
||||
}
|
||||
for _, wanted := range names {
|
||||
for index, name := range actionNames {
|
||||
if !strings.EqualFold(name, wanted) {
|
||||
for wanted := 1; wanted <= 8; wanted++ {
|
||||
for index, priority := range priorities {
|
||||
if priority != wanted {
|
||||
continue
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
|
||||
@@ -93,6 +93,7 @@ typedef struct {
|
||||
gchar *cthulhu_table_context(AtspiAccessible *source, gint *rows, gint *columns,
|
||||
gchar **tableName, gchar **tablePath,
|
||||
gint *rowIndex, gint *columnIndex, gboolean *guiTable);
|
||||
gint cthulhu_document_action_priority(AtspiAccessible *source, const gchar *name, gboolean anchor);
|
||||
cthulhu_snapshot cthulhu_capture_snapshot(AtspiAccessible *source, gboolean captureText,
|
||||
gboolean captureCaretCharacter,
|
||||
gboolean captureDocumentMetadata,
|
||||
|
||||
@@ -250,6 +250,14 @@ func captureNativeTree(ctx context.Context, root *C.AtspiAccessible, reviewBusNa
|
||||
// A visibility change can reveal children omitted by an earlier review
|
||||
// capture, even when the provider emits no children-changed event.
|
||||
cachedChildren := known && !cached.childrenInvalid && !cached.childrenUnavailable && (!reviewOnly || !cached.geometryInvalid)
|
||||
if document && snapshot.Role == "internal frame" {
|
||||
// Chromium can replace a sibling iframe document without an
|
||||
// invalidation for its retained frame object. Refresh this boundary
|
||||
// on recapture so a successful header action cannot leave the old
|
||||
// content document pinned in either our cache or libatspi's cache.
|
||||
cachedChildren = false
|
||||
C.atspi_accessible_clear_cache_single(source)
|
||||
}
|
||||
childrenUnavailable := false
|
||||
for _, id := range cached.children {
|
||||
if cache.sources[id] == nil {
|
||||
|
||||
@@ -7,11 +7,14 @@ package review
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"git.stormux.org/storm/cthulhu/internal/presentation"
|
||||
)
|
||||
|
||||
type soundGeometryNavigator struct {
|
||||
@@ -20,13 +23,24 @@ type soundGeometryNavigator struct {
|
||||
err error
|
||||
reads int
|
||||
t *testing.T
|
||||
link atspi.ObjectID
|
||||
offset int
|
||||
}
|
||||
|
||||
func (navigator *soundGeometryNavigator) SetDocumentCaretPosition(ctx context.Context, object atspi.ObjectReference, focus atspi.ObjectID, offset int) error {
|
||||
navigator.offset = offset
|
||||
return navigator.SetDocumentPosition(ctx, object, focus)
|
||||
}
|
||||
|
||||
func (navigator *soundGeometryNavigator) CaptureDocumentSoundGeometry(_ context.Context, object, link atspi.ObjectReference) ([2]atspi.SoundGeometry, error) {
|
||||
navigator.reads++
|
||||
select {
|
||||
case placed := <-navigator.calls:
|
||||
if placed.position != object || link.ID() != webID("/topic_link") {
|
||||
wantLink := navigator.link
|
||||
if !wantLink.Valid() {
|
||||
wantLink = webID("/topic_link")
|
||||
}
|
||||
if placed.position != object || link.ID() != wantLink {
|
||||
navigator.t.Fatal("geometry refresh did not follow placement of the selected heading and link")
|
||||
}
|
||||
default:
|
||||
@@ -35,6 +49,75 @@ func (navigator *soundGeometryNavigator) CaptureDocumentSoundGeometry(_ context.
|
||||
return navigator.geometry, navigator.err
|
||||
}
|
||||
|
||||
func TestCaretLinkSoundRefreshKeepsLeafAndOffsets(t *testing.T) {
|
||||
old := atspi.SoundGeometry{ObjectKnown: true, ReferenceKnown: true,
|
||||
Object: atspi.Rectangle{X: 900, Width: 80, Height: 20}, Reference: atspi.Rectangle{Width: 1000, Height: 600}}
|
||||
for _, command := range []DocumentCaretCommand{CaretNextLine, CaretPreviousLine, CaretNextWord, CaretPreviousWord, CaretDocumentStart, CaretDocumentEnd} {
|
||||
for _, state := range []string{"left", "center", "right", "disabled", "failure", "stale"} {
|
||||
t.Run(fmt.Sprintf("%v/%s", command, state), func(t *testing.T) {
|
||||
nodes := []atspi.TreeNode{
|
||||
{Snapshot: atspi.Snapshot{BusName: webID("/doc").BusName, Path: "/doc", Role: "document web"}, Children: []atspi.ObjectID{webID("/link")}},
|
||||
{Snapshot: atspi.Snapshot{BusName: webID("/link").BusName, Path: "/link", Role: "link", Name: "Wrapped link", Visited: true, SoundGeometry: old}, Children: []atspi.ObjectID{webID("/label")}},
|
||||
{Snapshot: atspi.Snapshot{BusName: webID("/label").BusName, Path: "/label", Role: "static", Name: "Wrapped link", CharacterCount: 12, SoundGeometry: old}},
|
||||
}
|
||||
tree, err := atspi.NewAccessibleTree(webID("/doc"), nodes)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cursor, _ := NewDocumentCursor(tree)
|
||||
position, err := cursor.SyncFocus(context.Background(), webID("/label"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, _ := tree.DocumentContext(position.Object)
|
||||
position.Offset = 8
|
||||
fresh := old
|
||||
fresh.Object.X = map[string]int32{"left": 0, "center": 460, "right": 920}[state]
|
||||
navigator := &soundGeometryNavigator{fakeDocumentNavigator: newFakeDocumentNavigator(), t: t, link: webID("/link")}
|
||||
want := fresh
|
||||
switch state {
|
||||
case "disabled":
|
||||
want = atspi.SoundGeometry{}
|
||||
case "failure":
|
||||
want = atspi.SoundGeometry{}
|
||||
navigator.err = context.DeadlineExceeded
|
||||
case "stale":
|
||||
want = atspi.SoundGeometry{}
|
||||
tree.Invalidate(webID("/link"))
|
||||
default:
|
||||
navigator.geometry = [2]atspi.SoundGeometry{fresh, fresh}
|
||||
}
|
||||
worker, _ := NewWorker(navigator, time.Second)
|
||||
worker.SyncFocus(webID("/doc"))
|
||||
result := worker.placeDocumentCaret(context.Background(), cursor, Result{
|
||||
Moved: true, DocumentEpoch: worker.DocumentEpoch(), DocumentFocus: webID("/doc"),
|
||||
DocumentPosition: position, DocumentCaretCommand: command, DocumentCaretContext: owner.Ancestors,
|
||||
})
|
||||
if result.Err != nil || result.CaretErr != nil || result.DocumentPosition.Object != position.Object ||
|
||||
result.DocumentPosition.Offset != 8 || navigator.offset != 8 || result.DocumentPosition.Snapshot.Role != "static" {
|
||||
t.Fatalf("sound refresh changed caret ownership: %+v", result)
|
||||
}
|
||||
link := result.DocumentCaretContext[1]
|
||||
if link.SoundGeometry != want || !link.Visited || owner.Ancestors[1].SoundGeometry != old {
|
||||
t.Fatalf("ancestor geometry/semantics: %+v", link)
|
||||
}
|
||||
if state == "failure" && !errors.Is(result.DocumentSoundErr, context.DeadlineExceeded) || state == "stale" && result.DocumentSoundErr == nil {
|
||||
t.Fatalf("missing refresh failure: %v", result.DocumentSoundErr)
|
||||
}
|
||||
options := presentation.Options{EnableSound: true, PlaySoundForRole: true, SpatializeObjectSounds: state != "disabled"}
|
||||
cues := presentation.SoundCues(link, options)
|
||||
if len(cues) != 1 || cues[0].Name != "link_visited" {
|
||||
t.Fatalf("link cue = %+v", cues)
|
||||
}
|
||||
wantPan := map[string]float64{"left": -1, "right": 1}[state]
|
||||
if math.Abs(cues[0].Pan-wantPan) > 0.001 {
|
||||
t.Fatalf("pan = %g, want %g", cues[0].Pan, wantPan)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentSoundGeometryRefreshPreservesSemanticsAndPlacement(t *testing.T) {
|
||||
oldGeometry := atspi.SoundGeometry{ObjectKnown: true, Object: atspi.Rectangle{X: 900, Y: 3000, Width: 50, Height: 15}}
|
||||
freshGeometry := atspi.SoundGeometry{
|
||||
|
||||
@@ -330,7 +330,7 @@ func (worker *Worker) jumpDocumentFragment(ctx context.Context, cursor *Document
|
||||
func (worker *Worker) placeDocumentWithRetry(ctx context.Context, cursor *DocumentCursor, result Result) Result {
|
||||
place := func(result Result) Result {
|
||||
if result.DocumentCaretCommand != 0 {
|
||||
return worker.placeDocumentCaret(ctx, result)
|
||||
return worker.placeDocumentCaret(ctx, cursor, result)
|
||||
}
|
||||
return worker.placeDocument(ctx, cursor, result)
|
||||
}
|
||||
@@ -489,7 +489,7 @@ func (worker *Worker) moveDocumentCaret(ctx context.Context, cursor *DocumentCur
|
||||
return worker.placeDocumentWithRetry(ctx, cursor, result), cursor
|
||||
}
|
||||
|
||||
func (worker *Worker) placeDocumentCaret(ctx context.Context, result Result) Result {
|
||||
func (worker *Worker) placeDocumentCaret(ctx context.Context, cursor *DocumentCursor, result Result) Result {
|
||||
if result.Err != nil || !result.Moved ||
|
||||
!worker.CurrentDocumentResult(result) || worker.focus() != result.DocumentFocus {
|
||||
return result
|
||||
@@ -512,6 +512,12 @@ func (worker *Worker) placeDocumentCaret(ctx context.Context, result Result) Res
|
||||
nativeOffset = 0
|
||||
}
|
||||
result.CaretErr = navigator.SetDocumentCaretPosition(deadline, result.DocumentPosition.Object, result.DocumentFocus, nativeOffset)
|
||||
if result.CaretErr == nil && worker.CurrentDocumentResult(result) {
|
||||
switch result.DocumentCaretCommand {
|
||||
case CaretNextLine, CaretPreviousLine, CaretNextWord, CaretPreviousWord, CaretDocumentStart, CaretDocumentEnd:
|
||||
result = worker.refreshDocumentSoundGeometry(deadline, cursor, result)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -755,17 +761,29 @@ func (worker *Worker) refreshDocumentSoundGeometry(ctx context.Context, cursor *
|
||||
// Geometry belongs to this presentation, not the document's initial viewport.
|
||||
// Failed refreshes must not reuse an old position after scrolling or resizing.
|
||||
result.DocumentPosition.Snapshot.SoundGeometry = atspi.SoundGeometry{}
|
||||
link := atspi.ObjectReference{}
|
||||
result.DocumentCaretContext = append([]atspi.Snapshot(nil), result.DocumentCaretContext...)
|
||||
var linkID atspi.ObjectID
|
||||
for index := range result.DocumentCaretContext {
|
||||
ancestor := &result.DocumentCaretContext[index]
|
||||
ancestor.SoundGeometry = atspi.SoundGeometry{}
|
||||
if !linkID.Valid() && ancestor.Role == "link" && ancestor.ID() != result.DocumentPosition.Object.ID() {
|
||||
linkID = ancestor.ID()
|
||||
}
|
||||
}
|
||||
if previous := result.DocumentPosition.LinkedHeading; previous != nil {
|
||||
copy := *previous
|
||||
copy.SoundGeometry = atspi.SoundGeometry{}
|
||||
result.DocumentPosition.LinkedHeading = ©
|
||||
linkID = copy.ID()
|
||||
}
|
||||
link := atspi.ObjectReference{}
|
||||
if linkID.Valid() {
|
||||
if cursor == nil {
|
||||
result.DocumentSoundErr = atspi.ErrStaleObject
|
||||
return result
|
||||
}
|
||||
var err error
|
||||
link, err = cursor.tree.Reference(copy.ID())
|
||||
link, err = cursor.tree.Reference(linkID)
|
||||
if err != nil {
|
||||
result.DocumentSoundErr = err
|
||||
return result
|
||||
@@ -778,6 +796,15 @@ func (worker *Worker) refreshDocumentSoundGeometry(ctx context.Context, cursor *
|
||||
if result.DocumentPosition.LinkedHeading != nil {
|
||||
result.DocumentPosition.LinkedHeading.SoundGeometry = geometry[1]
|
||||
}
|
||||
for index := range result.DocumentCaretContext {
|
||||
ancestor := &result.DocumentCaretContext[index]
|
||||
switch ancestor.ID() {
|
||||
case result.DocumentPosition.Object.ID():
|
||||
ancestor.SoundGeometry = geometry[0]
|
||||
case linkID:
|
||||
ancestor.SoundGeometry = geometry[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
Reference in new issue
Block a user