More browser improvements.

This commit is contained in:
ak47 committed 2026-10-16 23:48:58 -04:00
1 parent ebb9ad48d0
commit f753e9b505
28 files changed
+603 -81

No files matched your search

+43
View File
@@ -0,0 +1,43 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"errors"
"log/slog"
"testing"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/review"
)
func TestBrowserActivationResultsStaySilent(t *testing.T) {
for _, activationErr := range []error{nil, review.ErrNoCurrent, atspi.ErrStaleObject, errors.New("action refused")} {
queue := &fakeSpeechQueue{spoken: make(chan string, 8), interrupt: make(chan struct{}, 8)}
focus := atspi.Snapshot{BusName: ":1.42", Path: "/clickable", Role: "section"}
browser := newBrowserModeController(nil, &modeKeys{}, nil)
browser.ready, browser.mode, browser.focus = true, review.BrowseMode, focus.ID()
state := &eventLoopState{}
state.current = focus
handler := eventReviewHandler{state: state,
logger: slog.New(slog.DiscardHandler), speech: queue,
objectReview: &review.Worker{}, browser: browser, focusGate: &focusGate{}, syncForeground: func() {}}
handler.handle(review.Result{DocumentActivate: true, DocumentFocus: focus.ID(), Err: activationErr})
if len(queue.spoken) != 0 || len(queue.interrupt) != 0 {
t.Fatalf("activation %v produced feedback or interrupted page speech", activationErr)
}
}
}
func TestBrowserNavigationDoesNotSuppressLiveNames(t *testing.T) {
browser := newBrowserModeController(nil, &modeKeys{}, nil)
browser.navigation = atspi.NewObjectID(":1.42", "/heading")
browser.navigationDocument = atspi.NewObjectID(":1.42", "/document")
event := atspi.NewEvent(atspi.AccessibleNameEvent, atspi.Snapshot{
BusName: ":1.42", Path: "/status", Role: "status bar", Name: "Download complete", LiveRegion: true})
if browser.suppressNavigationEcho(event) {
t.Fatal("reader navigation swallowed a live-region name update")
}
}
@@ -52,6 +52,11 @@ func TestProcessEventsCoalescesWebButtonNames(t *testing.T) {
case <-time.After(time.Second):
t.Fatal("missing focus Braille")
}
// Structural navigation also owns this button. Updates must still reach
// Braille and the coalescer while delayed caret/focus echoes are suppressed.
browser.mutex.Lock()
browser.navigation, browser.navigationDocument = button.ID(), browser.document
browser.mutex.Unlock()
// Match the reported page's six updates per second for longer than the
// quiet window. Frame delivery acknowledges each event's processing.
ticker := time.NewTicker(150 * time.Millisecond)
@@ -75,6 +80,8 @@ func TestProcessEventsCoalescesWebButtonNames(t *testing.T) {
default:
}
}
// Native input releases echo ownership before the next focus event.
browser.pointer_input()
// A redundant native focus notification must not discard the final name.
event.Type = atspi.FocusEvent
adapter.events <- event
+2 -1
View File
@@ -424,7 +424,8 @@ func (browser *browserModeController) suppressNavigationEcho(event atspi.Event)
}
target, document := browser.navigation, browser.navigationDocument
browser.mutex.Unlock()
if document.Valid() && event.Type == atspi.AccessibleNameEvent &&
if document.Valid() && event.Type == atspi.AccessibleNameEvent && !event.Source.LiveRegion &&
event.Source.Role != "button" && event.Source.Role != "push button" && event.Source.Role != "toggle button" &&
event.Source.ID().BusName == document.BusName {
return true
}
-4
View File
@@ -450,10 +450,6 @@ func presentDocumentResult(logger *slog.Logger, speech speechEnqueuer, result re
}
if result.CaretErr != nil {
logger.Warn("document caret synchronization failed", "error", result.CaretErr)
spoken = spoken.Append("Could not move the browser caret.", ". ")
text = spoken.Text
fallbackSpeech = fallbackSpeech.Append("Could not move the browser caret.", ". ")
fallbackText = fallbackSpeech.Text
}
}
speech.Interrupt()
-2
View File
@@ -112,8 +112,6 @@ func (handler *eventReviewHandler) handle(result review.Result) {
}
if result.Err != nil {
logger.Debug("document activation failed", "error", result.Err)
speech.Interrupt()
speech.Enqueue(speechRequest{Text: "Could not activate current item.", Role: "system", Cause: "document-activate"})
} else {
logger.Debug("document activation completed", "elapsed", result.Elapsed)
}
+1 -1
View File
@@ -141,7 +141,7 @@ func TestNativeLinkedHeadings(t *testing.T) {
if err != nil || !move.Moved || move.Position.LinkedHeading != nil {
t.Fatalf("plain-text heading with multiple links: %+v, %v", move, err)
}
if target, err := cursor.ActivationTarget(); !errors.Is(err, review.ErrNoCurrent) {
if target, err := cursor.ActivationTarget(); target.Valid() || !errors.Is(err, review.ErrNoDocumentAction) {
t.Fatalf("ambiguous activation target = %v, %v", target, err)
}
}
+1 -1
View File
@@ -92,7 +92,7 @@ func TestPauseChunksPreservePresentationDecorations(t *testing.T) {
presentDocumentResult(logger, queue, review.Result{Moved: true, DocumentWrapped: true,
DocumentPosition: review.DocumentPosition{Snapshot: atspi.Snapshot{Name: "Read, draft", Role: "link"}},
CaretErr: errors.New("caret unavailable")}, options)
}, []string{"Read, draft", "link", "Wrapping to top.", "Could not move the browser caret."}},
}, []string{"Read, draft", "link", "Wrapping to top."}},
{"tooltip", func(queue *fakeSpeechQueue) {
handler := accessibilityEventHandler{state: &eventLoopState{}, speech: queue, live: live}
event := atspi.NewEvent(atspi.ShowingStateEvent, atspi.Snapshot{Name: "Save, draft", Role: "tool tip"})
+37 -36
View File
@@ -127,42 +127,43 @@ type Adapter struct {
foregroundRecovery int // GLib thread: bounded retries while a new client publishes AT-SPI
recoveringForeground bool
events chan Event
treeRequests chan treeCaptureRequest
overflow atomic.Uint64
treeInvalidations atomic.Uint64
secureMutex sync.Mutex
secureContext context.Context
secureCancel context.CancelFunc
secureEpoch atomic.Uint64
secure atomic.Bool
secureReset atomic.Bool
secureResync atomic.Bool
captureSound atomic.Bool
captureTextAttributes atomic.Bool
running atomic.Bool
ready atomic.Bool
loopDone chan struct{}
stopMutex sync.Mutex
treeMutex sync.Mutex
tree *AccessibleTree
documentTree *AccessibleTree
documentTextTree *AccessibleTree
nativeDocument map[ObjectID]*C.AtspiAccessible
documentLineCache map[ObjectID]DocumentCaretText // GLib owner; at most 64 bounded text objects
nativeReview map[ObjectID]*C.AtspiAccessible
nativeReviewBus string
navigationEcho map[ObjectID]uint8
navigationCaretOffset int
nativeFocus []nativeFocusReference // owned only by the GLib thread
mouseApplication *C.AtspiAccessible // GLib owner; released on shutdown
mouseApplicationPID int
documentWindows map[ObjectID]nativeDocumentWindow
documentWindowIDs []ObjectID
selectedTabWindow ObjectID
activeDocument ObjectID
callbackHandle cgo.Handle
callbackData *C.cthulhu_callback_data
events chan Event
treeRequests chan treeCaptureRequest
overflow atomic.Uint64
treeInvalidations atomic.Uint64
secureMutex sync.Mutex
secureContext context.Context
secureCancel context.CancelFunc
secureEpoch atomic.Uint64
secure atomic.Bool
secureReset atomic.Bool
secureResync atomic.Bool
captureSound atomic.Bool
captureTextAttributes atomic.Bool
running atomic.Bool
ready atomic.Bool
loopDone chan struct{}
stopMutex sync.Mutex
treeMutex sync.Mutex
tree *AccessibleTree
documentTree *AccessibleTree
documentTextTree *AccessibleTree
nativeDocument map[ObjectID]*C.AtspiAccessible
documentLineCache map[ObjectID]DocumentCaretText // GLib owner; at most 64 bounded text objects
nativeReview map[ObjectID]*C.AtspiAccessible
nativeReviewBus string
navigationEcho map[ObjectID]uint8
navigationCaretOffset int
navigationSelectionOffsets map[int]bool
nativeFocus []nativeFocusReference // owned only by the GLib thread
mouseApplication *C.AtspiAccessible // GLib owner; released on shutdown
mouseApplicationPID int
documentWindows map[ObjectID]nativeDocumentWindow
documentWindowIDs []ObjectID
selectedTabWindow ObjectID
activeDocument ObjectID
callbackHandle cgo.Handle
callbackData *C.cthulhu_callback_data
}
type nativeFocusReference struct {
+12 -1
View File
@@ -507,8 +507,19 @@ cthulhu_snapshot cthulhu_capture_snapshot(AtspiAccessible *source, gboolean capt
if (error != NULL) {
g_clear_error(&error);
}
if (g_strcmp0(actionName, "click") == 0) {
// 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)) {
snapshot.supportsClick = TRUE;
g_free(actionName);
break;
}
g_free(actionName);
}
+34 -7
View File
@@ -207,13 +207,27 @@ func (adapter *Adapter) activateNativeDocumentObject(ctx context.Context, positi
}
defer C.g_object_unref(C.gpointer(action))
names := []string{"click", "click-ancestor", "press", "jump", "open", "activate"}
count := int(C.atspi_action_get_n_actions(action, nil))
var nativeErr *C.GError
count := int(C.atspi_action_get_n_actions(action, &nativeErr))
if nativeErr != nil {
err := nativeError("get document actions", nativeErr)
C.g_error_free(nativeErr)
return err
}
// 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 {
if err := ctx.Err(); err != nil {
return err
}
name := C.atspi_action_get_name(action, C.gint(index), nil)
actionNames[index] = C.GoString(name)
C.g_free(C.gpointer(name))
}
for _, wanted := range names {
for index := 0; index < count; index++ {
name := C.atspi_action_get_name(action, C.gint(index), nil)
matched := strings.EqualFold(C.GoString(name), wanted)
C.g_free(C.gpointer(name))
if !matched {
for index, name := range actionNames {
if !strings.EqualFold(name, wanted) {
continue
}
if err := ctx.Err(); err != nil {
@@ -449,9 +463,10 @@ func (adapter *Adapter) setNativeDocumentPosition(ctx context.Context, position
}
echoIDs = append(echoIDs, documentCaretEchoAncestors(tree, position)...)
adapter.navigationCaretOffset = offset
adapter.navigationSelectionOffsets = nil
adapter.navigationEcho = make(map[ObjectID]uint8, len(echoIDs))
for _, id := range echoIDs {
adapter.navigationEcho[id] = 7
adapter.navigationEcho[id] = 15
}
component := C.atspi_accessible_get_component_iface(source)
if component != nil {
@@ -491,6 +506,18 @@ func (adapter *Adapter) setNativeDocumentPosition(ctx context.Context, position
adapter.navigationCaretOffset = int(caretOffset)
// Python clears the previous selection before moving its browse caret.
for selections := min(int(C.atspi_text_get_n_selections(text, nil)), 16); selections > 0; selections-- {
// Removing a selection can first collapse to its start or end. Those
// intermediate caret events must not overwrite the requested position,
// particularly when routing Braille before the result reaches the UI.
selection := C.atspi_text_get_selection(text, 0, nil)
if selection != nil {
if adapter.navigationSelectionOffsets == nil {
adapter.navigationSelectionOffsets = make(map[int]bool)
}
adapter.navigationSelectionOffsets[int(selection.start_offset)] = true
adapter.navigationSelectionOffsets[int(selection.end_offset)] = true
C.g_free(C.gpointer(selection))
}
C.atspi_text_remove_selection(text, 0, nil)
}
moved := C.atspi_text_set_caret_offset(text, caretOffset, &nativeErr)
@@ -517,17 +517,7 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
}
}
focusEvent.SnapshotDuration = time.Since(snapshotStarted)
bit := uint8(0)
switch eventType {
case FocusEvent:
bit = 1
case FocusedStateEvent:
bit = 2
case CaretEvent:
if focusEvent.Detail1 == adapter.navigationCaretOffset {
bit = 4
}
}
bit := documentNavigationEchoBit(focusEvent, adapter.navigationCaretOffset, adapter.navigationSelectionOffsets)
if adapter.navigationEcho[snapshot.ID()]&bit != 0 {
focusEvent.NavigationEcho = true
adapter.navigationEcho[snapshot.ID()] &^= bit
+22
View File
@@ -4,6 +4,28 @@
package atspi
func documentNavigationEchoBit(event Event, offset int, selectionOffsets map[int]bool) uint8 {
switch event.Type {
case FocusEvent:
return 1
case FocusedStateEvent:
return 2
case CaretEvent:
// New native selection is user input, even if its endpoint happens
// to match a previously requested caret or collapsed selection.
if event.Source.TextCaretKnown && event.Source.SelectionEnd > event.Source.SelectionStart {
return 0
}
if event.Detail1 == offset {
return 4
}
if event.Source.TextCaretKnown && selectionOffsets[event.Detail1] {
return 8
}
}
return 0
}
// Chromium can echo a static text caret on its enclosing label/paragraph.
// Include only a single-child chain sharing the complete text offset domain.
// Composite parents have their own hypertext offsets and cannot be matched.
@@ -0,0 +1,26 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package atspi
import "testing"
func TestDocumentRoutingSelectionCollapseEcho(t *testing.T) {
for _, test := range []struct {
offset, start, end int
want uint8
}{
{0, 0, 0, 8}, // Selection removal temporarily collapses at zero.
{1, 0, 0, 4}, // Requested route completes at one.
{2, 0, 0, 0}, // An unrelated native movement is not an echo.
{0, 0, 1, 0}, // A fresh native selection must never be swallowed.
{1, 0, 1, 0},
} {
event := NewEvent(CaretEvent, Snapshot{TextCaretKnown: true, SelectionStart: test.start, SelectionEnd: test.end})
event.Detail1 = test.offset
if got := documentNavigationEchoBit(event, 1, map[int]bool{0: true, 1: true}); got != test.want {
t.Fatalf("offset %d, selection %d:%d: bit %d, want %d", test.offset, test.start, test.end, got, test.want)
}
}
}
+14 -2
View File
@@ -54,6 +54,10 @@ type DocumentMoveResult struct {
Wrapped bool
}
// ErrNoDocumentAction is a valid reading location without an unambiguous
// activation target. It does not require a document recapture.
var ErrNoDocumentAction = errors.New("current document item has no activation target")
type documentPlace struct {
document atspi.ObjectReference
object atspi.ObjectReference
@@ -65,6 +69,8 @@ type documentPlace struct {
// A single text child can be replaced while its paragraph remains live.
// Retain the generation-checked parent rather than search by label/index.
textParent atspi.ObjectReference
// Bounded surviving ancestry is a navigation fallback, never an action target.
recoveryParents []atspi.ObjectReference
}
// DocumentCursor remembers at most 16 document positions in least-recently-
@@ -158,13 +164,13 @@ func (cursor *DocumentCursor) ActivationTarget() (atspi.ObjectReference, error)
return link.Reference, nil
}
if ambiguous {
return atspi.ObjectReference{}, ErrNoCurrent
return atspi.ObjectReference{}, ErrNoDocumentAction
}
}
if clickable.Valid() {
return clickable, nil
}
return atspi.ObjectReference{}, ErrNoCurrent
return atspi.ObjectReference{}, ErrNoDocumentAction
}
func (cursor *DocumentCursor) SyncFocus(ctx context.Context, focus atspi.ObjectID) (DocumentPosition, error) {
@@ -209,11 +215,15 @@ func (cursor *DocumentCursor) sync_focus(ctx context.Context, focus atspi.Object
place = saved
place.focus, place.object = ref, replacement.Reference
place.offset = min(saved.offset, max(0, replacement.Snapshot.CharacterCount-1))
} else if parent, ok := survivingDocumentParent(doc, saved); ok {
recovery = "surviving-parent"
place.object = parent.Reference
}
}
cursor.places = append(cursor.places[:index], cursor.places[index+1:]...)
break
}
remember_text_parent(doc, &place)
if len(cursor.places) == 16 {
cursor.places = cursor.places[1:]
}
@@ -376,6 +386,7 @@ func (cursor *DocumentCursor) Move(ctx context.Context, target DocumentTarget, l
place.object = node.Reference
place.origin = StructuralNavigation
place.offset = 0
remember_text_parent(doc, place)
return DocumentMoveResult{Position: documentPosition(doc, *place), Moved: true}, nil
}
}
@@ -403,6 +414,7 @@ func (cursor *DocumentCursor) Move(ctx context.Context, target DocumentTarget, l
place.object = node.Reference
place.origin = StructuralNavigation
place.offset = 0
remember_text_parent(doc, place)
return DocumentMoveResult{
Position: documentPosition(doc, *place),
Moved: true,
@@ -6,6 +6,7 @@ package review
import (
"context"
"errors"
"testing"
"time"
@@ -18,6 +19,75 @@ type refreshingActivator struct {
staleOnce bool
}
func TestActivationDoesNotRecaptureNonClickableText(t *testing.T) {
cursor := newCaretTextCursor(t, caretTextReader{webID("/text"): "Ordinary paragraph"})
capturer := &fakeCapturer{calls: make(chan captureCall, 1)}
worker, _ := NewWorker(capturer, time.Second)
worker.SyncFocus(webID("/text"))
result, _ := worker.activateDocumentCaret(context.Background(), cursor, 0)
if !errors.Is(result.Err, ErrNoDocumentAction) || len(capturer.calls) != 0 {
t.Fatalf("plain text triggered a capture: %+v", result)
}
}
func TestActivationRejectsReplacedDocumentGeneration(t *testing.T) {
tree := activationTree(t, true)
cursor, _ := NewDocumentCursor(tree)
cursor.SyncFocus(context.Background(), webID("/focused"))
cursor.places[0].object, _ = tree.Reference(webID("/selected"))
tree.Invalidate(webID("/doc"))
nodes := []atspi.TreeNode{
{Snapshot: atspi.Snapshot{BusName: webID("/doc").BusName, Path: "/doc", Role: "document web"}, Children: []atspi.ObjectID{webID("/focused"), webID("/selected")}},
{Snapshot: atspi.Snapshot{BusName: webID("/focused").BusName, Path: "/focused", Role: "link"}},
{Snapshot: atspi.Snapshot{BusName: webID("/selected").BusName, Path: "/selected", Role: "link"}},
}
if err := tree.Replace(webID("/doc"), nodes); err != nil {
t.Fatal(err)
}
activator := &refreshingActivator{readAllCapturer: &readAllCapturer{tree: tree}}
worker, _ := NewWorker(activator, time.Second)
worker.SyncFocus(webID("/focused"))
result, _ := worker.activateDocumentCaret(context.Background(), cursor, 0)
if result.Err == nil || activator.target.Valid() {
t.Fatalf("activation crossed a document generation: %+v, %v", result, activator.target)
}
}
func TestBrowseNamedCustomClickableAndActivateAncestor(t *testing.T) {
reader := caretTextReader{webID("/start"): "Before action", webID("/label"): "Visible label", webID("/after"): "After action"}
for _, explicit := range []bool{false, true} {
nodes := []atspi.TreeNode{
{Snapshot: atspi.Snapshot{BusName: webID("/doc").BusName, Path: "/doc", Role: "document web"}, Children: []atspi.ObjectID{webID("/start"), webID("/custom"), webID("/after")}},
{Snapshot: atspi.Snapshot{BusName: webID("/start").BusName, Path: "/start", Role: "static", CharacterCount: 13}},
{Snapshot: atspi.Snapshot{BusName: webID("/custom").BusName, Path: "/custom", Role: "section", Name: "Perform action", SupportsClick: true, ExplicitName: explicit}, Children: []atspi.ObjectID{webID("/label")}},
{Snapshot: atspi.Snapshot{BusName: webID("/label").BusName, Path: "/label", Role: "static", CharacterCount: 13}},
{Snapshot: atspi.Snapshot{BusName: webID("/after").BusName, Path: "/after", Role: "static", CharacterCount: 12}},
}
tree, err := atspi.NewAccessibleTree(webID("/doc"), nodes)
if err != nil {
t.Fatal(err)
}
cursor, _ := NewDocumentCursor(tree)
cursor.SyncFocus(context.Background(), webID("/start"))
move, err := cursor.MoveCaret(context.Background(), reader, CaretNextLine)
want := webID("/label")
if explicit {
want = webID("/custom")
}
if err != nil || !move.Moved || move.Position.Object.ID() != want {
t.Fatalf("custom clickable stop: %+v, %v", move, err)
}
target, err := cursor.ActivationTarget()
if err != nil || target.ID() != webID("/custom") {
t.Fatalf("activation target = %v, %v", target, err)
}
move, err = cursor.MoveCaret(context.Background(), reader, CaretNextLine)
if err != nil || move.Text != "After action" {
t.Fatalf("clickable trapped navigation: %+v, %v", move, err)
}
}
}
func (activator *refreshingActivator) ActivateDocumentObject(_ context.Context, target atspi.ObjectReference, _ atspi.ObjectID) error {
if activator.staleOnce {
activator.staleOnce = false
@@ -103,6 +103,7 @@ func (worker *Worker) route_document_braille(ctx context.Context, cursor *Docume
place := &cursor.places[len(cursor.places)-1]
place.object, place.offset, place.origin = node.Reference, route.offset, CaretNavigation
place.tableCell = atspi.ObjectID{}
remember_text_parent(doc, place)
snapshot, err := cursor.tree.Resolve(node.Reference)
result.DocumentPosition = DocumentPosition{Document: owner.TopLevel, Object: node.Reference, Snapshot: snapshot, Offset: route.offset, Origin: CaretNavigation}
cursor.mu.Unlock()
+6
View File
@@ -20,6 +20,12 @@ func wholeCaretNode(snapshot atspi.Snapshot) bool {
if snapshot.Role == "image" {
return snapshot.Name != "" || snapshot.Description != ""
}
// An explicitly named custom clickable is an atomic stop just like a
// named link. Ordinary clickable text remains readable by character/word.
if snapshot.SupportsClick && snapshot.ExplicitName && strings.TrimSpace(snapshot.Name) != "" &&
!snapshot.Editable && !atspi.IsWebDocumentRole(snapshot.Role) {
return true
}
switch snapshot.Role {
case "button", "check box", "check menu item", "list box", "menu item", "page tab",
"radio menu item", "radio button", "push button", "toggle button":
+37 -6
View File
@@ -12,17 +12,48 @@ import "git.stormux.org/storm/cthulhu/internal/atspi"
// generations cannot establish that the old offset still belongs here.
func remember_text_parent(doc atspi.DocumentSnapshot, place *documentPlace) {
place.textParent = atspi.ObjectReference{}
for _, parent := range doc.Nodes {
if !replacement_text_parent(parent) || parent.Children[0] != place.object.ID() {
place.recoveryParents = nil
// Nodes are in provider preorder. Walking backwards follows the parent
// chain in one scan, without rescanning all nodes for each ancestor.
var parentID atspi.ObjectID
plainText := false
for index := len(doc.Nodes) - 1; index >= 0; index-- {
node := doc.Nodes[index]
if node.Reference == place.object {
parentID, plainText = node.Parent, replacement_text_leaf(node)
continue
}
for _, child := range doc.Nodes {
if child.Reference == place.object && replacement_text_leaf(child) {
place.textParent = parent.Reference
return
if !parentID.Valid() || node.Reference.ID() != parentID {
continue
}
if len(place.recoveryParents) == 0 && plainText && replacement_text_parent(node) {
place.textParent = node.Reference
}
if node.Reference == place.document || len(place.recoveryParents) == 32 {
break
}
place.recoveryParents = append(place.recoveryParents, node.Reference)
parentID = node.Parent
}
}
// Navigation may restart within a surviving container when a page replaces
// several text children at once. Never transfer offsets or use this fallback
// for actions, and never cross a document generation, hidden or editable area.
func survivingDocumentParent(doc atspi.DocumentSnapshot, saved documentPlace) (atspi.DocumentNode, bool) {
if saved.document != doc.Context.TopLevel {
return atspi.DocumentNode{}, false
}
for _, parent := range saved.recoveryParents {
for _, node := range doc.Nodes {
if node.Reference == parent && !node.Hidden && !node.ParentEditable &&
!node.Snapshot.Editable && !node.Snapshot.Focusable && !node.Snapshot.SupportsClick &&
!atspi.IsWebDocumentRole(node.Snapshot.Role) {
return node, true
}
}
}
return atspi.DocumentNode{}, false
}
func replacement_text_node(doc atspi.DocumentSnapshot, saved documentPlace) (atspi.DocumentNode, bool) {
@@ -7,6 +7,7 @@ package review
import (
"context"
"testing"
"time"
"unicode/utf8"
"git.stormux.org/storm/cthulhu/internal/atspi"
@@ -59,6 +60,8 @@ func TestDocumentCaretRecoversSingleReplacedTextChild(t *testing.T) {
wantRecovery := "saved-object-missing"
if kind == "replaced document" {
wantRecovery = "document-replaced"
} else if kind == "multiple children" {
wantRecovery = "surviving-parent"
}
if recovery != wantRecovery {
t.Fatalf("recovery = %q, want %q", recovery, wantRecovery)
@@ -78,3 +81,75 @@ func TestDocumentCaretRecoversSingleReplacedTextChild(t *testing.T) {
})
}
}
func TestDocumentRecoveryKeepsVirtualPlaceWhenNativeAnchorDisappears(t *testing.T) {
ctx := context.Background()
reader := caretTextReader{webID("/text"): "First paragraph", webID("/next"): "Selected paragraph"}
cursor := newCaretTextCursor(t, reader)
move, err := cursor.MoveCaret(ctx, reader, CaretNextLine)
if err != nil || move.Position.Object.ID() != webID("/next") {
t.Fatalf("select second paragraph: %+v, %v", move, err)
}
worker, _ := NewWorker(&readAllCapturer{tree: cursor.tree}, time.Second)
worker.SyncFocus(webID("/text"))
nodes := []atspi.TreeNode{
{Snapshot: atspi.Snapshot{BusName: webID("/doc").BusName, Path: "/doc", Role: "document web"}, Children: []atspi.ObjectID{webID("/next")}},
{Snapshot: atspi.Snapshot{BusName: webID("/next").BusName, Path: "/next", Role: "static", CharacterCount: len(reader[webID("/next")])}},
}
if err := cursor.tree.Replace(webID("/doc"), nodes); err != nil {
t.Fatal(err)
}
result := Result{DocumentFocus: webID("/text")}
if !worker.recoverCapturedDocumentFocus(cursor, cursor.tree, &result) {
t.Fatal("removed native anchor was not recovered")
}
if _, err := cursor.SyncFocus(ctx, result.DocumentFocus); err != nil {
t.Fatal(err)
}
move, err = cursor.MoveCaret(ctx, reader, CaretNextCharacter)
if err != nil || move.Text != "e" || move.Position.Object.ID() != webID("/next") || move.Position.Offset != 1 {
t.Fatalf("virtual position reset after native anchor removal: %+v, %v", move, err)
}
}
func TestDocumentRecoveryUsesSurvivingContainerWithoutTransferringOffset(t *testing.T) {
for _, command := range []DocumentCaretCommand{CaretNextLine, CaretPreviousLine} {
t.Run(command.String(), func(t *testing.T) {
ctx := context.Background()
reader := caretTextReader{webID("/before"): "Previous paragraph", webID("/old"): "Old contents", webID("/new"): "Replacement contents"}
node := func(path, role string, children ...atspi.ObjectID) atspi.TreeNode {
return atspi.TreeNode{Snapshot: atspi.Snapshot{BusName: webID(path).BusName, Path: path, Role: role,
CharacterCount: len(reader[webID(path)])}, Children: children}
}
nodes := []atspi.TreeNode{node("/doc", "document web", webID("/before"), webID("/section")),
node("/before", "static"), node("/section", "section", webID("/paragraph")),
node("/paragraph", "paragraph", webID("/old")), node("/old", "static")}
tree, err := atspi.NewAccessibleTree(webID("/doc"), nodes)
if err != nil {
t.Fatal(err)
}
cursor, _ := NewDocumentCursor(tree)
cursor.SyncFocus(ctx, webID("/doc"))
cursor.MoveCaret(ctx, reader, CaretNextLine)
cursor.MoveCaret(ctx, reader, CaretNextLine)
cursor.MoveCaret(ctx, reader, CaretNextWord)
nodes[2].Children = []atspi.ObjectID{webID("/new")}
nodes = append(nodes[:3], node("/new", "static"))
if err := tree.Replace(webID("/doc"), nodes); err != nil {
t.Fatal(err)
}
position, recovery, err := cursor.sync_focus(ctx, webID("/doc"))
if err != nil || recovery != "surviving-parent" || position.Object.ID() != webID("/section") || position.Offset != 0 {
t.Fatalf("container recovery: %+v, %s, %v", position, recovery, err)
}
move, err := cursor.MoveCaret(ctx, reader, command)
want := "Replacement contents"
if command == CaretPreviousLine {
want = "Previous paragraph"
}
if err != nil || !move.Moved || move.Text != want {
t.Fatalf("navigation after replacement: %+v, %v", move, err)
}
})
}
}
+1 -1
View File
@@ -104,7 +104,7 @@ func TestLinkedHeadingNavigationAndActivation(t *testing.T) {
}
target, err := cursor.ActivationTarget()
if tc.multiple {
if move.Position.LinkedHeading != nil || !errors.Is(err, ErrNoCurrent) {
if move.Position.LinkedHeading != nil || !errors.Is(err, ErrNoDocumentAction) {
t.Fatalf("ambiguous heading resolved: %+v, %v", move.Position.LinkedHeading, err)
}
} else if err != nil || target.ID() != webID(tc.link) || move.Position.LinkedHeading == nil || !move.Position.LinkedHeading.Visited {
+2 -1
View File
@@ -7,6 +7,7 @@ package review
import (
"context"
"errors"
"reflect"
"strings"
"testing"
"time"
@@ -206,7 +207,7 @@ func TestDocumentFragmentErrorsDoNotMoveCursor(t *testing.T) {
reader.err = failure
before := cursor.places[0]
_, err := cursor.MoveCaret(context.Background(), reader, CaretNextLine)
if !errors.Is(err, failure) || cursor.places[0] != before {
if !errors.Is(err, failure) || !reflect.DeepEqual(cursor.places[0], before) {
t.Fatalf("error %v: %v; changed cursor", failure, err)
}
}
+26 -6
View File
@@ -150,7 +150,13 @@ func (worker *Worker) jumpDocumentFragment(ctx context.Context, cursor *Document
}
func (worker *Worker) placeDocumentWithRetry(ctx context.Context, cursor *DocumentCursor, result Result) Result {
result = worker.placeDocument(ctx, cursor, result)
place := func(result Result) Result {
if result.DocumentCaretCommand != 0 {
return worker.placeDocumentCaret(ctx, result)
}
return worker.placeDocument(ctx, cursor, result)
}
result = place(result)
if !documentCaretNeedsRecapture(result.CaretErr) || !worker.CurrentDocumentResult(result) {
return result
}
@@ -160,7 +166,9 @@ func (worker *Worker) placeDocumentWithRetry(ctx context.Context, cursor *Docume
oldPosition := result.DocumentPosition
result.DocumentDiagnostics.CaptureAttempts++
captureContext, cancel := context.WithTimeout(ctx, worker.captureTimeout)
captureStarted := time.Now()
tree, err := worker.capturer.CaptureDocument(captureContext, result.DocumentFocus)
result.CaptureDuration += time.Since(captureStarted)
cancel()
if err != nil || tree == nil || !worker.CurrentDocumentResult(result) {
return result
@@ -181,7 +189,7 @@ func (worker *Worker) placeDocumentWithRetry(ctx context.Context, cursor *Docume
result.DocumentPosition.Document = owner.TopLevel
result.DocumentPosition.Snapshot = snapshot
result.CaretErr = nil
result = worker.placeDocument(ctx, cursor, result)
result = place(result)
if result.Err == nil && result.CaretErr == nil && cursor != nil {
cursor.rebindPlace(tree, oldPosition, result.DocumentPosition)
}
@@ -249,7 +257,7 @@ func (worker *Worker) moveDocumentCaret(ctx context.Context, cursor *DocumentCur
}
result.Err = move()
if !documentNeedsCapture(result.Err) {
return worker.placeDocumentCaret(ctx, result), cursor
return worker.placeDocumentWithRetry(ctx, cursor, result), cursor
}
result.CaptureReason = result.Err
result.DocumentDiagnostics.CaptureAttempts++
@@ -277,7 +285,7 @@ func (worker *Worker) moveDocumentCaret(ctx context.Context, cursor *DocumentCur
result.Err = move()
if result.Moved || !documentNeedsCapture(result.Err) ||
!worker.CurrentDocumentResult(result) || worker.focus() != result.DocumentFocus {
return worker.placeDocumentCaret(ctx, result), cursor
return worker.placeDocumentWithRetry(ctx, cursor, result), cursor
}
// A dynamic page can replace the captured target between recapture and
// SyncFocus. Retry once only if the first attempt did not move the caret.
@@ -295,7 +303,7 @@ func (worker *Worker) moveDocumentCaret(ctx context.Context, cursor *DocumentCur
cursor, _ = NewDocumentCursor(tree)
}
result.Err = move()
return worker.placeDocumentCaret(ctx, result), cursor
return worker.placeDocumentWithRetry(ctx, cursor, result), cursor
}
func (worker *Worker) placeDocumentCaret(ctx context.Context, result Result) Result {
@@ -416,7 +424,8 @@ func (worker *Worker) refreshDocumentCursor(ctx context.Context, cursor *Documen
return cursor, err
}
if saved.document.Valid() && (saved.focus.ID() == focus || saved.object.ID() == focus) {
if saved.document.ID() != position.Document.ID() {
if saved.document.ID() != position.Document.ID() ||
cursor.tree == tree && saved.document != position.Document {
return cursor, atspi.ErrStaleObject
}
target, err := tree.Reference(saved.object.ID())
@@ -450,6 +459,17 @@ func (worker *Worker) recoverCapturedDocumentFocus(cursor *DocumentCursor, tree
if !worker.recoverDocumentFocus(result.DocumentFocus, root.ID(), result.DocumentEpoch) {
return false
}
// Losing the native anchor does not invalidate a virtual position elsewhere
// in the same generation. Rebind only the anchor so SyncFocus can retain
// the selected text or recover its surviving parent after this capture.
if cursor.tree == tree {
cursor.mu.Lock()
place := &cursor.places[len(cursor.places)-1]
if place.document == root && place.focus.ID() == result.DocumentFocus {
place.focus = root
}
cursor.mu.Unlock()
}
result.DocumentFocus = root.ID()
result.DocumentDiagnostics.FocusRecovered = true
return true
+31
View File
@@ -39,6 +39,37 @@ type boundaryNavigator struct {
offset int
}
type refreshingCaretNavigator struct {
*readAllCapturer
caretTextReader
placements []int
}
func (navigator *refreshingCaretNavigator) SetDocumentCaretPosition(_ context.Context, _ atspi.ObjectReference, _ atspi.ObjectID, offset int) error {
navigator.placements = append(navigator.placements, offset)
if len(navigator.placements) == 1 {
return atspi.ErrStaleSnapshot
}
return nil
}
func TestWorkerRetriesNativeCaretPlacementWithoutRepeatingMovement(t *testing.T) {
text := caretTextReader{webID("/text"): "First line"}
cursor := newCaretTextCursor(t, text)
navigator := &refreshingCaretNavigator{readAllCapturer: &readAllCapturer{tree: cursor.tree}, caretTextReader: text}
worker, _ := NewWorker(navigator, time.Second)
worker.SyncFocus(webID("/text"))
result, _ := worker.moveDocumentCaret(context.Background(), cursor, CaretNextCharacter, 0)
if result.Err != nil || result.CaretErr != nil || result.DocumentCaretText != "i" || result.DocumentPosition.Offset != 1 ||
len(navigator.placements) != 2 || navigator.placements[0] != 1 || navigator.placements[1] != 1 {
t.Fatalf("retry skipped or lost requested character: %+v, placements %v", result, navigator.placements)
}
next, _ := worker.moveDocumentCaret(context.Background(), cursor, CaretNextCharacter, 0)
if next.Err != nil || next.DocumentCaretText != "r" || next.DocumentPosition.Offset != 2 {
t.Fatalf("retry lost the saved position: %+v", next)
}
}
func (navigator *boundaryNavigator) SetDocumentCaretPosition(_ context.Context, position atspi.ObjectReference, focus atspi.ObjectID, offset int) error {
navigator.position, navigator.focus, navigator.offset = position, focus, offset
return nil