Make web document navigation survive live-updating pages
Recreated-node caret recovery, tolerant caret validation, per-root retained document trees and cursors across tab switches, document warm-up, coalesced structural moves, and hung-provider fast-fail with an 'Application is not responding' announcement.
This commit is contained in:
1 parent
8ce5e5cdad
commit
7eef6e8be6
30 files changed
+1484
-174
No files matched your search
@@ -69,6 +69,16 @@ type documentPlace struct {
|
||||
// A text child can be replaced while its parent and adjacent siblings live.
|
||||
// Generation-checked boundaries distinguish replacement from insertion.
|
||||
textParent, textPrevious, textNext atspi.ObjectReference
|
||||
// Ticking pages can recreate every child of the surviving text parent at
|
||||
// once, retiring all sibling references. The child index and count then
|
||||
// re-resolve the same navigation line under that generation-checked
|
||||
// parent. Count zero means no index was retained.
|
||||
textChildIndex, textChildCount int
|
||||
// The same bounded evidence for non-text children (recreated button
|
||||
// rows and containers): immediate parent, adjacent siblings, and the
|
||||
// child's index and count under that generation-checked parent.
|
||||
parent, parentPrevious, parentNext atspi.ObjectReference
|
||||
parentChildIndex, parentChildCount int
|
||||
// Bounded surviving ancestry is a navigation fallback, never an action target.
|
||||
recoveryParents []atspi.ObjectReference
|
||||
}
|
||||
@@ -109,6 +119,25 @@ func (cursor *DocumentCursor) rebindPlace(tree *atspi.AccessibleTree, oldPositio
|
||||
cursor.active = newPosition.Document
|
||||
}
|
||||
|
||||
// capture_anchor returns the saved document identity for a recapture when
|
||||
// the page destroyed the focused anchor itself. The anchor only locates the
|
||||
// document to capture; position recovery still runs against the fresh tree.
|
||||
func (cursor *DocumentCursor) capture_anchor(focus atspi.ObjectID) (atspi.ObjectID, bool) {
|
||||
if cursor == nil {
|
||||
return atspi.ObjectID{}, false
|
||||
}
|
||||
cursor.mu.Lock()
|
||||
defer cursor.mu.Unlock()
|
||||
if len(cursor.places) == 0 {
|
||||
return atspi.ObjectID{}, false
|
||||
}
|
||||
document := cursor.places[len(cursor.places)-1].document.ID()
|
||||
if !document.Valid() || document.BusName != focus.BusName || document == focus {
|
||||
return atspi.ObjectID{}, false
|
||||
}
|
||||
return document, true
|
||||
}
|
||||
|
||||
func NewDocumentCursor(tree *atspi.AccessibleTree) (*DocumentCursor, error) {
|
||||
if tree == nil {
|
||||
return nil, errors.New("create document cursor: nil accessible tree")
|
||||
@@ -215,8 +244,13 @@ func (cursor *DocumentCursor) sync_focus(ctx context.Context, focus atspi.Object
|
||||
recovery = "preserved"
|
||||
place = saved
|
||||
place.focus = ref
|
||||
} else if replacement, ok := replacement_text_node(doc, saved); ok {
|
||||
recovery = "replaced-text-child"
|
||||
} else if replacement, kind, ok := replacement_text_node(doc, saved); ok {
|
||||
recovery = kind
|
||||
place = saved
|
||||
place.focus, place.object = ref, replacement.Reference
|
||||
place.offset = min(saved.offset, max(0, replacement.Snapshot.CharacterCount-1))
|
||||
} else if replacement, kind, ok := replacement_child_node(doc, saved); ok {
|
||||
recovery = kind
|
||||
place = saved
|
||||
place.focus, place.object = ref, replacement.Reference
|
||||
place.offset = min(saved.offset, max(0, replacement.Snapshot.CharacterCount-1))
|
||||
@@ -232,7 +266,13 @@ func (cursor *DocumentCursor) sync_focus(ctx context.Context, focus atspi.Object
|
||||
if !ok {
|
||||
return DocumentPosition{}, "saved-object-missing", atspi.ErrStaleObject
|
||||
}
|
||||
if err := cursor.tree.ValidateDocumentRange(ctx, doc, placeIndex, placeIndex); err != nil {
|
||||
validatePlace := cursor.tree.ValidateDocumentRange
|
||||
if place.origin != StructuralNavigation {
|
||||
// Caret and focus-derived places are read live on the next move;
|
||||
// ticking text on the anchored object must not discard the place.
|
||||
validatePlace = cursor.tree.ValidateDocumentCaretRange
|
||||
}
|
||||
if err := validatePlace(ctx, doc, placeIndex, placeIndex); err != nil {
|
||||
return DocumentPosition{}, "document-unresolved", err
|
||||
}
|
||||
if removeIndex >= 0 {
|
||||
|
||||
@@ -37,7 +37,7 @@ func BenchmarkDocumentWorkerHeadingNavigation(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
for _, backwards := range []bool{false, true} {
|
||||
result, next := worker.moveDocument(ctx, cursor, DocumentHeading, 2, backwards, 0)
|
||||
result, next := worker.moveDocument(ctx, cursor, DocumentHeading, 2, backwards, 0, 0)
|
||||
cursor = next
|
||||
want := ids[middle+20]
|
||||
if backwards {
|
||||
|
||||
@@ -101,7 +101,7 @@ func (cursor *DocumentCursor) MoveCaret(ctx context.Context, reader DocumentCare
|
||||
if !found || doc.Nodes[current].Reference != place.object {
|
||||
return DocumentCaretMove{}, atspi.ErrStaleObject
|
||||
}
|
||||
if err := cursor.tree.ValidateDocumentRange(ctx, doc, current, current); err != nil {
|
||||
if err := cursor.tree.ValidateDocumentCaretRange(ctx, doc, current, current); err != nil {
|
||||
return DocumentCaretMove{}, err
|
||||
}
|
||||
if command >= TableLeft {
|
||||
@@ -120,7 +120,7 @@ func (cursor *DocumentCursor) MoveCaret(ctx context.Context, reader DocumentCare
|
||||
}
|
||||
indices := cursor.caretRanges
|
||||
if len(indices) == 0 {
|
||||
if err := cursor.tree.ValidateDocumentRange(ctx, doc, 0, len(doc.Nodes)-1); err != nil {
|
||||
if err := cursor.tree.ValidateDocumentCaretRange(ctx, doc, 0, len(doc.Nodes)-1); err != nil {
|
||||
return DocumentCaretMove{}, err
|
||||
}
|
||||
return DocumentCaretMove{Position: documentPosition(doc, *place)}, nil
|
||||
@@ -149,7 +149,7 @@ func (cursor *DocumentCursor) MoveCaret(ctx context.Context, reader DocumentCare
|
||||
// during recovery would skip the line. Synthetic whole-object stops
|
||||
// still need validation because they bypass the native text reader.
|
||||
if !navigationReader.did_read(place.object) {
|
||||
if err := cursor.tree.ValidateDocumentRange(ctx, doc, current, target); err != nil {
|
||||
if err := cursor.tree.ValidateDocumentCaretRange(ctx, doc, current, target); err != nil {
|
||||
return DocumentCaretMove{}, err
|
||||
}
|
||||
}
|
||||
@@ -162,7 +162,7 @@ func (cursor *DocumentCursor) MoveCaret(ctx context.Context, reader DocumentCare
|
||||
if command == CaretPreviousCharacter || command == CaretPreviousWord || command == CaretPreviousLine || command == CaretDocumentStart {
|
||||
boundary = 0
|
||||
}
|
||||
if err := cursor.tree.ValidateDocumentRange(ctx, doc, current, boundary); err != nil {
|
||||
if err := cursor.tree.ValidateDocumentCaretRange(ctx, doc, current, boundary); err != nil {
|
||||
return DocumentCaretMove{}, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,10 +42,27 @@ func move_caret_object_line(ctx context.Context, reader DocumentCaretReader, doc
|
||||
offset = indices[position].end - 1
|
||||
}
|
||||
initialPosition := position
|
||||
// Structural navigation already announced its target's whole line,
|
||||
// including embedded children such as a heading's link. A following
|
||||
// Down must leave that subtree, not descend into it and repeat the
|
||||
// announcement as a new line. The target's own first range still uses
|
||||
// the same-current logic below, so a long structural paragraph keeps
|
||||
// advancing line by line within its own text.
|
||||
structuralEnd := -1
|
||||
if place.origin == StructuralNavigation {
|
||||
structuralEnd = doc.SubtreeEnd(current)
|
||||
}
|
||||
for attempts := 0; attempts < len(indices)+1; attempts++ {
|
||||
if position < 0 || position >= len(indices) {
|
||||
return DocumentCaretMove{Position: documentPosition(doc, *place)}, nil
|
||||
}
|
||||
if command == CaretNextLine && structuralEnd >= current &&
|
||||
indices[position].index >= current && indices[position].index <= structuralEnd &&
|
||||
!(position == initialPosition && indices[position].index == current) {
|
||||
position++
|
||||
offset = 0
|
||||
continue
|
||||
}
|
||||
node := doc.Nodes[indices[position].index]
|
||||
if named_link_label(node.Snapshot) != "" && (command == CaretLineStart || command == CaretLineEnd) {
|
||||
offset = 0
|
||||
|
||||
@@ -335,7 +335,7 @@ func TestDocumentCaretNamedLinkCharacterBoundaries(t *testing.T) {
|
||||
}
|
||||
worker.SyncFocus(webID("/text"))
|
||||
for index, want := range []string{"猫", " "} {
|
||||
result, _ := worker.moveDocumentCaret(ctx, cursor, CaretNextCharacter, 0)
|
||||
result, _ := worker.moveDocumentCaret(ctx, cursor, CaretNextCharacter, 0, 0)
|
||||
if result.Err != nil || result.CaretErr != nil || result.DocumentCaretText != want ||
|
||||
result.DocumentPosition.Offset != index+1 || navigator.offset != 0 || navigator.position != result.DocumentPosition.Object {
|
||||
t.Fatalf("label offset changed native anchor or lost review progress: %+v; native offset=%d", result, navigator.offset)
|
||||
@@ -560,3 +560,76 @@ func TestDocumentCaretContinuesFromStructuralHeading(t *testing.T) {
|
||||
t.Fatalf("Ctrl+Home = %+v, %v", start, err)
|
||||
}
|
||||
}
|
||||
|
||||
// A heading whose text lives in an embedded link child (YouTube cards):
|
||||
// structural navigation already announced the whole heading, so Down must
|
||||
// leave the heading instead of landing on its link and repeating the title.
|
||||
func TestDocumentCaretContinuesFromStructuralLinkedHeading(t *testing.T) {
|
||||
reader := caretTextReader{
|
||||
webID("/first_text"): "First heading",
|
||||
webID("/old_text"): "Old position",
|
||||
webID("/second"): "\ufffc",
|
||||
webID("/second_link"): "Second heading",
|
||||
webID("/next_text"): "Text after second heading",
|
||||
}
|
||||
node := func(path, role string, children ...string) atspi.TreeNode {
|
||||
snapshot := atspi.NewSnapshot(path, path, role, "", "")
|
||||
snapshot.BusName = webID(path).BusName
|
||||
snapshot.CharacterCount = utf8.RuneCountInString(reader[webID(path)])
|
||||
if role == "heading" {
|
||||
snapshot.HeadingLevel = 2
|
||||
}
|
||||
result := atspi.TreeNode{Snapshot: snapshot}
|
||||
for _, child := range children {
|
||||
result.Children = append(result.Children, webID(child))
|
||||
}
|
||||
return result
|
||||
}
|
||||
second := node("/second", "heading", "/second_link")
|
||||
second.Snapshot.TextChildren = []atspi.TextChild{{Offset: 0, Child: webID("/second_link")}}
|
||||
// Chromium exposes the card link as an explicitly named whole stop with
|
||||
// no native text of its own (native_text_end=0 in the live log).
|
||||
link := node("/second_link", "link")
|
||||
link.Snapshot.Name = "Second heading"
|
||||
link.Snapshot.ExplicitName = true
|
||||
link.Snapshot.CharacterCount = 0
|
||||
link.Snapshot.Focusable = true
|
||||
tree, err := atspi.NewAccessibleTree(webID("/root"), []atspi.TreeNode{
|
||||
node("/root", "frame", "/doc"),
|
||||
node("/doc", "document web", "/first", "/old", "/second", "/next"),
|
||||
node("/first", "heading", "/first_text"),
|
||||
node("/first_text", "static"),
|
||||
node("/old", "paragraph", "/old_text"),
|
||||
node("/old_text", "static"),
|
||||
second, link,
|
||||
node("/next", "paragraph", "/next_text"),
|
||||
node("/next_text", "static"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cursor, err := NewDocumentCursor(tree)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
if _, err := cursor.SyncFocus(ctx, webID("/old_text")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jump, err := cursor.Move(ctx, DocumentHeading, 0, false)
|
||||
if err != nil || jump.Position.Object.ID() != webID("/second") {
|
||||
t.Fatalf("heading jump = %+v, %v", jump, err)
|
||||
}
|
||||
line, err := cursor.MoveCaret(ctx, reader, CaretNextLine)
|
||||
if err != nil || !line.Moved || line.Text != "Text after second heading" || line.Position.Object.ID() != webID("/next_text") {
|
||||
t.Fatalf("Down after linked heading = %+v, %v; want the following line, not the heading link again", line, err)
|
||||
}
|
||||
jump, err = cursor.Move(ctx, DocumentHeading, 0, true)
|
||||
if err != nil || jump.Position.Object.ID() != webID("/second") {
|
||||
t.Fatalf("heading jump back = %+v, %v", jump, err)
|
||||
}
|
||||
line, err = cursor.MoveCaret(ctx, reader, CaretPreviousLine)
|
||||
if err != nil || !line.Moved || line.Text != "Old position" || line.Position.Object.ID() != webID("/old_text") {
|
||||
t.Fatalf("Up after linked heading = %+v, %v", line, err)
|
||||
}
|
||||
}
|
||||
@@ -103,7 +103,7 @@ func TestWorkerNativeCaretHandoffPrecedesNextArrow(t *testing.T) {
|
||||
if syncResult.Err != nil || syncResult.DocumentPosition.Offset != 11 {
|
||||
t.Fatalf("handoff = %+v", syncResult)
|
||||
}
|
||||
result, _ := worker.moveDocumentCaret(context.Background(), cursor, CaretNextCharacter, 0)
|
||||
result, _ := worker.moveDocumentCaret(context.Background(), cursor, CaretNextCharacter, 0, 0)
|
||||
if result.Err != nil || result.DocumentPosition.Offset != 12 || result.DocumentCaretText != "p" || navigator.offset != 12 {
|
||||
t.Fatalf("move after selection = %+v; native offset = %d", result, navigator.offset)
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ func (reader *navigationTextReader) ReadDocumentCaret(ctx context.Context, objec
|
||||
if !found || reader.document.Nodes[index].Reference != object {
|
||||
return atspi.DocumentCaretText{}, atspi.ErrStaleObject
|
||||
}
|
||||
if err := reader.tree.ValidateDocumentRange(ctx, reader.document, reader.anchor, index); err != nil {
|
||||
if err := reader.tree.ValidateDocumentCaretRange(ctx, reader.document, reader.anchor, index); err != nil {
|
||||
return atspi.DocumentCaretText{}, err
|
||||
}
|
||||
text, err := reader.DocumentCaretReader.ReadDocumentCaret(ctx, object, offset, unit)
|
||||
@@ -57,7 +57,7 @@ func (reader *navigationCharacterReader) ReadDocumentCharacters(ctx context.Cont
|
||||
if !found || reader.document.Nodes[index].Reference != object {
|
||||
return atspi.DocumentCaretText{}, atspi.ErrStaleObject
|
||||
}
|
||||
if err := reader.tree.ValidateDocumentRange(ctx, reader.document, reader.anchor, index); err != nil {
|
||||
if err := reader.tree.ValidateDocumentCaretRange(ctx, reader.document, reader.anchor, index); err != nil {
|
||||
return atspi.DocumentCaretText{}, err
|
||||
}
|
||||
return reader.source.ReadDocumentCharacters(ctx, object, offset)
|
||||
|
||||
@@ -12,19 +12,29 @@ import (
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
)
|
||||
|
||||
// Text and geometry staleness used to force a recapture before a caret move
|
||||
// could commit onto the dirty target. On constantly ticking pages the
|
||||
// target (and everything between caret and target) is re-invalidated faster
|
||||
// than capture-and-retry can run, which trapped the caret outright. Caret
|
||||
// moves read text and line bounds live, so the live read now wins for those
|
||||
// two; semantic snapshot staleness (names are spoken from the snapshot)
|
||||
// still refreshes first.
|
||||
func TestDocumentCaretDefersUnrelatedUpdatesUntilTraversed(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
invalidate func(*atspi.AccessibleTree)
|
||||
want error
|
||||
}{
|
||||
{"text", func(tree *atspi.AccessibleTree) { tree.InvalidateText(webID("/next")) }, atspi.ErrStaleText},
|
||||
{"text", func(tree *atspi.AccessibleTree) { tree.InvalidateText(webID("/next")) }, nil},
|
||||
{"semantics", func(tree *atspi.AccessibleTree) { tree.InvalidateSnapshot(webID("/next")) }, atspi.ErrStaleSnapshot},
|
||||
{"geometry", func(tree *atspi.AccessibleTree) { tree.InvalidateGeometry(webID("/next")) }, atspi.ErrStaleGeometry},
|
||||
{"geometry", func(tree *atspi.AccessibleTree) { tree.InvalidateGeometry(webID("/next")) }, nil},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
reader := caretTextReader{webID("/text"): "Alpha", webID("/next"): "Next"}
|
||||
cursor := newCaretTextCursor(t, reader)
|
||||
// The provider text moves on after capture; the live read must
|
||||
// be what the move reports, not the captured length or text.
|
||||
reader[webID("/next")] = "Next updated"
|
||||
test.invalidate(cursor.tree)
|
||||
if _, err := cursor.SyncFocus(context.Background(), webID("/text")); err != nil {
|
||||
t.Fatal("unrelated update blocked focus synchronization", err)
|
||||
@@ -33,7 +43,13 @@ func TestDocumentCaretDefersUnrelatedUpdatesUntilTraversed(t *testing.T) {
|
||||
if err != nil || !move.Moved || move.Text != "l" {
|
||||
t.Fatalf("local character move: %+v %v", move, err)
|
||||
}
|
||||
_, err = cursor.MoveCaret(context.Background(), reader, CaretNextLine)
|
||||
move, err = cursor.MoveCaret(context.Background(), reader, CaretNextLine)
|
||||
if test.want == nil {
|
||||
if err != nil || !move.Moved || move.Text != "Next updated" || move.Position.Object.ID() != webID("/next") {
|
||||
t.Fatalf("live target read: %+v %v, want Next updated", move, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if !errors.Is(err, test.want) {
|
||||
t.Fatalf("dirty target read: %v, want %v", err, test.want)
|
||||
}
|
||||
@@ -44,6 +60,9 @@ func TestDocumentCaretDefersUnrelatedUpdatesUntilTraversed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Crossing now follows captured child order with generation-checked
|
||||
// endpoints and live reads; structural navigation still refreshes stale
|
||||
// child order before promising the next target.
|
||||
func TestDocumentCaretKeepsLocalTextThroughParentInsertion(t *testing.T) {
|
||||
reader := caretTextReader{webID("/text"): "Alpha", webID("/next"): "Next"}
|
||||
cursor := newCaretTextCursor(t, reader)
|
||||
@@ -52,8 +71,9 @@ func TestDocumentCaretKeepsLocalTextThroughParentInsertion(t *testing.T) {
|
||||
if err != nil || !move.Moved || move.Text != "l" {
|
||||
t.Fatalf("parent insertion blocked live text: %+v %v", move, err)
|
||||
}
|
||||
if _, err := cursor.MoveCaret(context.Background(), reader, CaretNextLine); !errors.Is(err, atspi.ErrIncompleteTree) {
|
||||
t.Fatal("crossed stale child order", err)
|
||||
move, err = cursor.MoveCaret(context.Background(), reader, CaretNextLine)
|
||||
if err != nil || !move.Moved || move.Text != "Next" || move.Position.Object.ID() != webID("/next") {
|
||||
t.Fatalf("crossing changed child order: %+v %v", move, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,28 +8,43 @@ import "git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
|
||||
// Orca recovers removed caret contexts from the surviving parent. Here the
|
||||
// bounded case is one plain text leaf between the same adjacent siblings.
|
||||
// Retain only its parent and two generation-checked boundary references;
|
||||
// labels and child indices alone cannot establish the old reading position.
|
||||
// Retain its parent and two generation-checked boundary references. When a
|
||||
// ticking page recreates every child at once, those boundaries retire too;
|
||||
// the child's index and the parent's child count, checked against the same
|
||||
// generation-checked parent, then distinguish recreation from insertion.
|
||||
// Labels alone still cannot establish the old reading position, and neither
|
||||
// recovery is ever used for activation.
|
||||
func remember_text_parent(doc atspi.DocumentSnapshot, place *documentPlace) {
|
||||
place.textParent = atspi.ObjectReference{}
|
||||
place.textPrevious, place.textNext = atspi.ObjectReference{}, atspi.ObjectReference{}
|
||||
place.textChildIndex, place.textChildCount = 0, 0
|
||||
place.parent = atspi.ObjectReference{}
|
||||
place.parentPrevious, place.parentNext = atspi.ObjectReference{}, atspi.ObjectReference{}
|
||||
place.parentChildIndex, place.parentChildCount = 0, 0
|
||||
place.recoveryParents = nil
|
||||
node, ok := doc.Node(place.object.ID())
|
||||
if !ok || node.Reference != place.object {
|
||||
return
|
||||
}
|
||||
plainText := replacement_text_leaf(node)
|
||||
firstParent := true
|
||||
for parentID := node.Parent; parentID.Valid(); {
|
||||
parent, ok := doc.Node(parentID)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if firstParent {
|
||||
firstParent = false
|
||||
remember_child_slot(doc, place, parent, &place.parent, &place.parentPrevious, &place.parentNext,
|
||||
&place.parentChildIndex, &place.parentChildCount)
|
||||
}
|
||||
if len(place.recoveryParents) == 0 && plainText && replacement_text_parent(parent) {
|
||||
place.textParent = parent.Reference
|
||||
for childIndex, id := range parent.Children {
|
||||
if id != place.object.ID() {
|
||||
continue
|
||||
}
|
||||
place.textChildIndex, place.textChildCount = childIndex, len(parent.Children)
|
||||
if childIndex > 0 {
|
||||
if sibling, ok := doc.Node(parent.Children[childIndex-1]); ok {
|
||||
place.textPrevious = sibling.Reference
|
||||
@@ -42,6 +57,7 @@ func remember_text_parent(doc atspi.DocumentSnapshot, place *documentPlace) {
|
||||
}
|
||||
if childIndex > 0 && !place.textPrevious.Valid() || childIndex+1 < len(parent.Children) && !place.textNext.Valid() {
|
||||
place.textParent = atspi.ObjectReference{}
|
||||
place.textChildIndex, place.textChildCount = 0, 0
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -73,9 +89,9 @@ func survivingDocumentParent(doc atspi.DocumentSnapshot, saved documentPlace) (a
|
||||
return atspi.DocumentNode{}, false
|
||||
}
|
||||
|
||||
func replacement_text_node(doc atspi.DocumentSnapshot, saved documentPlace) (atspi.DocumentNode, bool) {
|
||||
func replacement_text_node(doc atspi.DocumentSnapshot, saved documentPlace) (atspi.DocumentNode, string, bool) {
|
||||
if saved.document != doc.Context.TopLevel || !saved.textParent.Valid() || saved.origin != CaretNavigation {
|
||||
return atspi.DocumentNode{}, false
|
||||
return atspi.DocumentNode{}, "", false
|
||||
}
|
||||
byID := make(map[atspi.ObjectID]*atspi.DocumentNode, len(doc.Nodes))
|
||||
for index := range doc.Nodes {
|
||||
@@ -104,11 +120,132 @@ func replacement_text_node(doc atspi.DocumentSnapshot, saved documentPlace) (ats
|
||||
}
|
||||
child := byID[id]
|
||||
if previous == saved.textPrevious && next == saved.textNext && child != nil && replacement_text_leaf(*child) {
|
||||
return *child, true
|
||||
return *child, "replaced-text-child", true
|
||||
}
|
||||
}
|
||||
// Every sibling reference retired together: the page recreated the
|
||||
// parent's children in place. Same child count and index under the
|
||||
// same generation-checked parent is the same navigation line. A
|
||||
// changed count stays ambiguous and falls back to the parent, as
|
||||
// does any saved sibling that still lives: a reorder or a single
|
||||
// replaced neighbor is a structural change, not a recreation.
|
||||
siblingsRetired := true
|
||||
for _, sibling := range []atspi.ObjectReference{saved.textPrevious, saved.textNext} {
|
||||
if !sibling.Valid() {
|
||||
continue
|
||||
}
|
||||
if node, ok := byID[sibling.ID()]; ok && node.Reference == sibling {
|
||||
siblingsRetired = false
|
||||
}
|
||||
}
|
||||
if siblingsRetired && saved.textChildCount > 0 && len(parent.Children) == saved.textChildCount &&
|
||||
saved.textChildIndex >= 0 && saved.textChildIndex < len(parent.Children) {
|
||||
if child := byID[parent.Children[saved.textChildIndex]]; child != nil && replacement_text_leaf(*child) {
|
||||
return *child, "replaced-text-child-index", true
|
||||
}
|
||||
}
|
||||
return atspi.DocumentNode{}, "", false
|
||||
}
|
||||
return atspi.DocumentNode{}, false
|
||||
return atspi.DocumentNode{}, "", false
|
||||
}
|
||||
|
||||
// remember_child_slot records a child's slot under its immediate parent:
|
||||
// the generation-checked parent, adjacent sibling references, and index
|
||||
// and count. Any unresolved boundary clears the slot; indices alone are
|
||||
// never enough evidence on their own.
|
||||
func remember_child_slot(doc atspi.DocumentSnapshot, place *documentPlace, parent atspi.DocumentNode,
|
||||
slotParent, slotPrevious, slotNext *atspi.ObjectReference, slotIndex, slotCount *int) {
|
||||
for childIndex, id := range parent.Children {
|
||||
if id != place.object.ID() {
|
||||
continue
|
||||
}
|
||||
*slotParent = parent.Reference
|
||||
*slotIndex, *slotCount = childIndex, len(parent.Children)
|
||||
if childIndex > 0 {
|
||||
if sibling, ok := doc.Node(parent.Children[childIndex-1]); ok {
|
||||
*slotPrevious = sibling.Reference
|
||||
}
|
||||
}
|
||||
if childIndex+1 < len(parent.Children) {
|
||||
if sibling, ok := doc.Node(parent.Children[childIndex+1]); ok {
|
||||
*slotNext = sibling.Reference
|
||||
}
|
||||
}
|
||||
if childIndex > 0 && !slotPrevious.Valid() || childIndex+1 < len(parent.Children) && !slotNext.Valid() {
|
||||
*slotParent = atspi.ObjectReference{}
|
||||
*slotIndex, *slotCount = 0, 0
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// replacement_child_node generalizes the replaced-text-child recovery to
|
||||
// any child (recreated button rows, containers) under the same bounded
|
||||
// evidence: either the generation-checked sibling boundaries still match,
|
||||
// or every sibling retired together with the child count unchanged, so
|
||||
// the same index under the same surviving parent is the recreation. Orca
|
||||
// recovers removed children the same way (parent plus removed index);
|
||||
// activation never uses this fallback.
|
||||
func replacement_child_node(doc atspi.DocumentSnapshot, saved documentPlace) (atspi.DocumentNode, string, bool) {
|
||||
if saved.document != doc.Context.TopLevel || !saved.parent.Valid() || saved.origin == NativeNavigation {
|
||||
return atspi.DocumentNode{}, "", false
|
||||
}
|
||||
// Plain text leaves keep their stricter replacement rules above; the
|
||||
// generic slot must not widen them for the same parent.
|
||||
if saved.textParent.Valid() && saved.parent == saved.textParent {
|
||||
return atspi.DocumentNode{}, "", false
|
||||
}
|
||||
byID := make(map[atspi.ObjectID]*atspi.DocumentNode, len(doc.Nodes))
|
||||
for index := range doc.Nodes {
|
||||
node := &doc.Nodes[index]
|
||||
byID[node.Reference.ID()] = node
|
||||
}
|
||||
parent, ok := doc.Node(saved.parent.ID())
|
||||
if !ok || parent.Reference != saved.parent || len(parent.Children) == 0 ||
|
||||
parent.Hidden || parent.ParentEditable || parent.Snapshot.Editable ||
|
||||
parent.Snapshot.Focusable || parent.Snapshot.SupportsClick {
|
||||
return atspi.DocumentNode{}, "", false
|
||||
}
|
||||
usable := func(child *atspi.DocumentNode) bool {
|
||||
return child != nil && !child.Hidden && !child.ParentEditable && !child.Snapshot.Editable &&
|
||||
child.Snapshot.Role != "password text"
|
||||
}
|
||||
for index, id := range parent.Children {
|
||||
var previous, next atspi.ObjectReference
|
||||
if index > 0 {
|
||||
sibling := byID[parent.Children[index-1]]
|
||||
if sibling == nil {
|
||||
continue
|
||||
}
|
||||
previous = sibling.Reference
|
||||
}
|
||||
if index+1 < len(parent.Children) {
|
||||
sibling := byID[parent.Children[index+1]]
|
||||
if sibling == nil {
|
||||
continue
|
||||
}
|
||||
next = sibling.Reference
|
||||
}
|
||||
if child := byID[id]; previous == saved.parentPrevious && next == saved.parentNext && usable(child) {
|
||||
return *child, "replaced-child", true
|
||||
}
|
||||
}
|
||||
siblingsRetired := true
|
||||
for _, sibling := range []atspi.ObjectReference{saved.parentPrevious, saved.parentNext} {
|
||||
if !sibling.Valid() {
|
||||
continue
|
||||
}
|
||||
if node, ok := byID[sibling.ID()]; ok && node.Reference == sibling {
|
||||
siblingsRetired = false
|
||||
}
|
||||
}
|
||||
if siblingsRetired && saved.parentChildCount > 0 && len(parent.Children) == saved.parentChildCount &&
|
||||
saved.parentChildIndex >= 0 && saved.parentChildIndex < len(parent.Children) {
|
||||
if child := byID[parent.Children[saved.parentChildIndex]]; usable(child) {
|
||||
return *child, "replaced-child-index", true
|
||||
}
|
||||
}
|
||||
return atspi.DocumentNode{}, "", false
|
||||
}
|
||||
|
||||
func replacement_text_parent(node atspi.DocumentNode) bool {
|
||||
|
||||
@@ -82,6 +82,158 @@ func TestDocumentCaretRecoversSingleReplacedTextChild(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A ticking page (incremental game) recreates every text leaf under a
|
||||
// surviving parent at once: sibling identities all change, but child count
|
||||
// and order do not. Recovery must restore the same line so the next caret
|
||||
// move progresses instead of restarting at the parent's first line forever.
|
||||
func TestDocumentCaretRecoversRecreatedTextSiblings(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
reader := caretTextReader{
|
||||
webID("/l1a"): "Counter one 100", webID("/l2a"): "Counter two 200", webID("/l3a"): "Counter three 300",
|
||||
webID("/l1b"): "Counter one 101", webID("/l2b"): "Counter two 201", webID("/l3b"): "Counter three 301",
|
||||
webID("/l1c"): "Counter one 102", webID("/l2c"): "Counter two 202", webID("/l3c"): "Counter three 302",
|
||||
}
|
||||
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: utf8.RuneCountInString(reader[webID(path)])}, Children: children}
|
||||
}
|
||||
generation := func(suffix string) []atspi.TreeNode {
|
||||
return []atspi.TreeNode{
|
||||
node("/doc", "document web", webID("/paragraph")),
|
||||
node("/paragraph", "paragraph", webID("/l1"+suffix), webID("/l2"+suffix), webID("/l3"+suffix)),
|
||||
node("/l1"+suffix, "static"), node("/l2"+suffix, "static"), node("/l3"+suffix, "static"),
|
||||
}
|
||||
}
|
||||
tree, err := atspi.NewAccessibleTree(webID("/doc"), generation("a"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cursor, _ := NewDocumentCursor(tree)
|
||||
if _, err := cursor.SyncFocus(ctx, webID("/doc")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if move, err := cursor.MoveCaret(ctx, reader, CaretNextLine); err != nil || move.Text != "Counter one 100" {
|
||||
t.Fatalf("first line = %+v, %v", move, err)
|
||||
}
|
||||
if move, err := cursor.MoveCaret(ctx, reader, CaretNextLine); err != nil || move.Text != "Counter two 200" ||
|
||||
move.Position.Object.ID() != webID("/l2a") {
|
||||
t.Fatalf("second line = %+v, %v", move, err)
|
||||
}
|
||||
if err := tree.Replace(webID("/doc"), generation("b")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
position, recovery, err := cursor.sync_focus(ctx, webID("/doc"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if position.Object.ID() != webID("/l2b") {
|
||||
t.Fatalf("recovery = %q onto %v, want recreated second line /l2b", recovery, position.Object.ID())
|
||||
}
|
||||
if move, err := cursor.MoveCaret(ctx, reader, CaretNextLine); err != nil || !move.Moved || move.Text != "Counter three 301" {
|
||||
t.Fatalf("line after tick = %+v, %v; caret did not progress", move, err)
|
||||
}
|
||||
if err := tree.Replace(webID("/doc"), generation("c")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := cursor.sync_focus(ctx, webID("/doc")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if move, err := cursor.MoveCaret(ctx, reader, CaretNextLine); err != nil || move.Moved {
|
||||
t.Fatalf("past last ticking line = %+v, %v; want boundary, not a repeat", move, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Every visible counter on a ticking page marks its text stale on each game
|
||||
// tick. A caret move reads its target line live, so text staleness between
|
||||
// caret and target (or on the current line) must not by itself force a
|
||||
// recapture-and-retry cycle that a faster tick can always outrun.
|
||||
// The text-leaf index recovery does not cover recreated controls: a game
|
||||
// can rebuild a whole button row at once. The same bounded evidence (same
|
||||
// generation-checked parent, every sibling retired, same child count)
|
||||
// re-resolves the caret's button so the next move progresses instead of
|
||||
// restarting at the row's container.
|
||||
func TestDocumentCaretRecoversRecreatedChildByIndex(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
reader := caretTextReader{}
|
||||
node := func(path, role, name string, children ...atspi.ObjectID) atspi.TreeNode {
|
||||
snapshot := atspi.Snapshot{BusName: webID(path).BusName, Path: path, Role: role, Name: name}
|
||||
if role == "button" {
|
||||
snapshot.Focusable = true
|
||||
}
|
||||
return atspi.TreeNode{Snapshot: snapshot, Children: children}
|
||||
}
|
||||
generation := func(suffix string) []atspi.TreeNode {
|
||||
return []atspi.TreeNode{
|
||||
node("/doc", "document web", "", webID("/section")),
|
||||
node("/section", "section", "", webID("/b1"+suffix), webID("/b2"+suffix), webID("/b3"+suffix)),
|
||||
node("/b1"+suffix, "button", "Buy one"), node("/b2"+suffix, "button", "Buy two"), node("/b3"+suffix, "button", "Buy three"),
|
||||
}
|
||||
}
|
||||
tree, err := atspi.NewAccessibleTree(webID("/doc"), generation("a"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cursor, _ := NewDocumentCursor(tree)
|
||||
if _, err := cursor.SyncFocus(ctx, webID("/doc")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"/b1a", "/b2a"} {
|
||||
move, err := cursor.MoveCaret(ctx, reader, CaretNextLine)
|
||||
if err != nil || !move.Moved || move.Position.Object.ID() != webID(want) {
|
||||
t.Fatalf("move onto %s = %+v, %v", want, move, err)
|
||||
}
|
||||
}
|
||||
if err := tree.Replace(webID("/doc"), generation("b")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
position, recovery, err := cursor.sync_focus(ctx, webID("/doc"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if position.Object.ID() != webID("/b2b") {
|
||||
t.Fatalf("recovery = %q onto %v, want recreated middle button /b2b", recovery, position.Object.ID())
|
||||
}
|
||||
move, err := cursor.MoveCaret(ctx, reader, CaretNextLine)
|
||||
if err != nil || !move.Moved || move.Position.Object.ID() != webID("/b3b") {
|
||||
t.Fatalf("move after recreation = %+v, %v; caret did not progress", move, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentCaretMoveCrossesTextInvalidatedLines(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
reader := caretTextReader{
|
||||
webID("/first"): "Counter first 100", webID("/second"): "Counter second 200",
|
||||
}
|
||||
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: utf8.RuneCountInString(reader[webID(path)])}, Children: children}
|
||||
}
|
||||
tree, err := atspi.NewAccessibleTree(webID("/doc"), []atspi.TreeNode{
|
||||
node("/doc", "document web", webID("/paragraph")),
|
||||
node("/paragraph", "paragraph", webID("/first"), webID("/second")),
|
||||
node("/first", "static"), node("/second", "static"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cursor, _ := NewDocumentCursor(tree)
|
||||
if _, err := cursor.SyncFocus(ctx, webID("/doc")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if move, err := cursor.MoveCaret(ctx, reader, CaretNextLine); err != nil || move.Text != "Counter first 100" {
|
||||
t.Fatalf("first line = %+v, %v", move, err)
|
||||
}
|
||||
tree.InvalidateText(webID("/first"))
|
||||
tree.InvalidateText(webID("/second"))
|
||||
if _, _, err := cursor.sync_focus(ctx, webID("/doc")); err != nil {
|
||||
t.Fatalf("sync across ticking text: %v", err)
|
||||
}
|
||||
move, err := cursor.MoveCaret(ctx, reader, CaretNextLine)
|
||||
if err != nil || !move.Moved || move.Text != "Counter second 200" {
|
||||
t.Fatalf("move across ticking text = %+v, %v", move, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentRecoveryKeepsVirtualPlaceWhenNativeAnchorDisappears(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
reader := caretTextReader{webID("/text"): "First paragraph", webID("/next"): "Selected paragraph"}
|
||||
|
||||
@@ -23,12 +23,23 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// WarmDocument captures and syncs the currently focused document in the
|
||||
// background so the first navigation key does not pay for a cold capture.
|
||||
// Best-effort: a full queue or a focus outside web content is ignored by
|
||||
// the worker, and no result is delivered.
|
||||
func (worker *Worker) WarmDocument() {
|
||||
select {
|
||||
case worker.requests <- request{documentEpoch: worker.documentEpoch.Load(), documentLoadEpoch: worker.documentLoadEpoch.Load(), warmDocument: true, queuedAt: time.Now()}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (worker *Worker) MoveDocumentCaret(command DocumentCaretCommand) error {
|
||||
if !command.valid() {
|
||||
return ErrInvalidDirection
|
||||
}
|
||||
select {
|
||||
case worker.requests <- request{documentEpoch: worker.documentEpoch.Load(), caretCommand: command, queuedAt: time.Now()}:
|
||||
case worker.requests <- request{documentEpoch: worker.documentEpoch.Load(), documentLoadEpoch: worker.documentLoadEpoch.Load(), caretCommand: command, queuedAt: time.Now()}:
|
||||
return nil
|
||||
default:
|
||||
return ErrRequestQueueFull
|
||||
@@ -37,7 +48,7 @@ func (worker *Worker) MoveDocumentCaret(command DocumentCaretCommand) error {
|
||||
|
||||
func (worker *Worker) ActivateDocumentCaret() error {
|
||||
select {
|
||||
case worker.requests <- request{documentEpoch: worker.documentEpoch.Load(), activate: true, queuedAt: time.Now()}:
|
||||
case worker.requests <- request{documentEpoch: worker.documentEpoch.Load(), documentLoadEpoch: worker.documentLoadEpoch.Load(), activate: true, queuedAt: time.Now()}:
|
||||
return nil
|
||||
default:
|
||||
return ErrRequestQueueFull
|
||||
@@ -49,7 +60,7 @@ func (worker *Worker) ReadAll(ctx context.Context) error {
|
||||
return errors.New("read all: nil context")
|
||||
}
|
||||
select {
|
||||
case worker.requests <- request{documentEpoch: worker.documentEpoch.Load(), readAll: ctx, queuedAt: time.Now()}:
|
||||
case worker.requests <- request{documentEpoch: worker.documentEpoch.Load(), documentLoadEpoch: worker.documentLoadEpoch.Load(), readAll: ctx, queuedAt: time.Now()}:
|
||||
return nil
|
||||
default:
|
||||
return ErrRequestQueueFull
|
||||
@@ -61,7 +72,7 @@ func (worker *Worker) JumpToDocumentFragment(fragment string) error {
|
||||
return ErrInvalidDirection
|
||||
}
|
||||
select {
|
||||
case worker.requests <- request{documentEpoch: worker.documentEpoch.Load(), fragment: fragment, queuedAt: time.Now()}:
|
||||
case worker.requests <- request{documentEpoch: worker.documentEpoch.Load(), documentLoadEpoch: worker.documentLoadEpoch.Load(), fragment: fragment, queuedAt: time.Now()}:
|
||||
return nil
|
||||
default:
|
||||
return ErrRequestQueueFull
|
||||
|
||||
@@ -25,7 +25,7 @@ func (cursor *DocumentCursor) moveTable(ctx context.Context, doc atspi.DocumentS
|
||||
}
|
||||
table := doc.Nodes[tableIndex]
|
||||
tableEnd := doc.SubtreeEnd(tableIndex)
|
||||
if err := cursor.tree.ValidateDocumentRange(ctx, doc, tableIndex, tableEnd); err != nil {
|
||||
if err := cursor.tree.ValidateDocumentCaretRange(ctx, doc, tableIndex, tableEnd); err != nil {
|
||||
return DocumentCaretMove{}, err
|
||||
}
|
||||
var current atspi.DocumentNode
|
||||
|
||||
@@ -31,14 +31,177 @@ func (worker *Worker) MoveDocument(target DocumentTarget, level int, backwards b
|
||||
return ErrInvalidDirection
|
||||
}
|
||||
select {
|
||||
case worker.requests <- request{documentEpoch: worker.documentEpoch.Load(), target: target, backwards: backwards, level: level, queuedAt: time.Now()}:
|
||||
case worker.requests <- request{documentEpoch: worker.documentEpoch.Load(), documentLoadEpoch: worker.documentLoadEpoch.Load(), target: target, backwards: backwards, level: level, queuedAt: time.Now()}:
|
||||
return nil
|
||||
default:
|
||||
return ErrRequestQueueFull
|
||||
}
|
||||
}
|
||||
|
||||
func (worker *Worker) moveDocument(ctx context.Context, cursor *DocumentCursor, target DocumentTarget, level int, backwards bool, epoch uint64) (Result, *DocumentCursor) {
|
||||
// sameDocumentRepeat reports whether two queued requests are the same
|
||||
// structural move in the same document generation, so they can execute as
|
||||
// one batch in order. Caret steps are excluded: every queued character,
|
||||
// word and line step keeps its own ordered result.
|
||||
func sameDocumentRepeat(first, next request) bool {
|
||||
if first.documentEpoch != next.documentEpoch {
|
||||
return false
|
||||
}
|
||||
return first.target != 0 && first.target == next.target &&
|
||||
first.level == next.level && first.backwards == next.backwards
|
||||
}
|
||||
|
||||
const maxRetainedDocumentCursors = 4
|
||||
|
||||
// cursorForTree returns the retained browse cursor for a captured tree,
|
||||
// creating and retaining one on first use. The adapter retains one tree
|
||||
// object per document root, so the pointer identifies the document.
|
||||
func (worker *Worker) cursorForTree(tree *atspi.AccessibleTree, loadEpoch uint64) (*DocumentCursor, bool, error) {
|
||||
if tree == nil {
|
||||
return nil, false, errors.New("document cursor: nil tree")
|
||||
}
|
||||
if cursor := worker.documentCursors[tree]; cursor != nil && worker.documentCursorEpochs[tree] == loadEpoch {
|
||||
worker.touchDocumentCursor(tree)
|
||||
return cursor, false, nil
|
||||
}
|
||||
cursor, err := NewDocumentCursor(tree)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
worker.rememberDocumentCursor(cursor, loadEpoch)
|
||||
return cursor, true, nil
|
||||
}
|
||||
|
||||
func (worker *Worker) rememberDocumentCursor(cursor *DocumentCursor, loadEpoch uint64) {
|
||||
if cursor == nil || cursor.tree == nil {
|
||||
return
|
||||
}
|
||||
if worker.documentCursors == nil {
|
||||
worker.documentCursors = make(map[*atspi.AccessibleTree]*DocumentCursor)
|
||||
worker.documentCursorEpochs = make(map[*atspi.AccessibleTree]uint64)
|
||||
}
|
||||
worker.documentCursors[cursor.tree] = cursor
|
||||
worker.documentCursorEpochs[cursor.tree] = loadEpoch
|
||||
worker.touchDocumentCursor(cursor.tree)
|
||||
for len(worker.documentCursorOrder) > maxRetainedDocumentCursors {
|
||||
oldest := worker.documentCursorOrder[0]
|
||||
delete(worker.documentCursors, oldest)
|
||||
delete(worker.documentCursorEpochs, oldest)
|
||||
worker.documentCursorOrder = worker.documentCursorOrder[1:]
|
||||
}
|
||||
}
|
||||
|
||||
func (worker *Worker) touchDocumentCursor(tree *atspi.AccessibleTree) {
|
||||
for index, retained := range worker.documentCursorOrder {
|
||||
if retained == tree {
|
||||
worker.documentCursorOrder = append(worker.documentCursorOrder[:index], worker.documentCursorOrder[index+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
worker.documentCursorOrder = append(worker.documentCursorOrder, tree)
|
||||
}
|
||||
|
||||
// retainedCursorsExcept lists retained cursors most recently used first.
|
||||
func (worker *Worker) retainedCursorsExcept(current *DocumentCursor, loadEpoch uint64) []*DocumentCursor {
|
||||
cursors := make([]*DocumentCursor, 0, len(worker.documentCursorOrder))
|
||||
for index := len(worker.documentCursorOrder) - 1; index >= 0; index-- {
|
||||
tree := worker.documentCursorOrder[index]
|
||||
if cursor := worker.documentCursors[tree]; cursor != nil && cursor != current && worker.documentCursorEpochs[tree] == loadEpoch {
|
||||
cursors = append(cursors, cursor)
|
||||
}
|
||||
}
|
||||
return cursors
|
||||
}
|
||||
|
||||
// trialRetainedCursors retries move on retained cursors for other
|
||||
// documents before paying for a capture. Returning to a tab or workspace
|
||||
// usually validates against its retained tree with no provider work at
|
||||
// all; a cursor whose tree changed meanwhile simply falls through to the
|
||||
// caller's capture path.
|
||||
func (worker *Worker) trialRetainedCursors(cursor **DocumentCursor, loadEpoch uint64, result *Result, move func() error) {
|
||||
if !documentNeedsCapture(result.Err) {
|
||||
return
|
||||
}
|
||||
for _, retained := range worker.retainedCursorsExcept(*cursor, loadEpoch) {
|
||||
*cursor = retained
|
||||
result.Err = move()
|
||||
if !documentNeedsCapture(result.Err) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// warmDocument captures and syncs the focused document so the first
|
||||
// navigation key afterwards is warm. It returns the cursor to keep as the
|
||||
// active document; failures (including focus in browser chrome) keep the
|
||||
// previous cursor and stay silent.
|
||||
func (worker *Worker) warmDocument(ctx context.Context, cursor *DocumentCursor, loadEpoch uint64) *DocumentCursor {
|
||||
focus := worker.focus()
|
||||
if !focus.Valid() {
|
||||
return cursor
|
||||
}
|
||||
candidates := worker.retainedCursorsExcept(cursor, loadEpoch)
|
||||
if cursor != nil {
|
||||
candidates = append([]*DocumentCursor{cursor}, candidates...)
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if _, err := candidate.SyncFocus(ctx, focus); err == nil {
|
||||
worker.touchDocumentCursor(candidate.tree)
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
// Warm-up is speculative: never let it occupy the provider thread for
|
||||
// the full interactive capture budget on an unresponsive document.
|
||||
warmContext, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
tree, _, err := worker.captureDocumentForMove(warmContext, cursor, focus)
|
||||
if err != nil || tree == nil {
|
||||
return cursor
|
||||
}
|
||||
warm, _, err := worker.cursorForTree(tree, loadEpoch)
|
||||
if err != nil {
|
||||
return cursor
|
||||
}
|
||||
if _, err := warm.SyncFocus(ctx, focus); err != nil {
|
||||
return cursor
|
||||
}
|
||||
return warm
|
||||
}
|
||||
|
||||
// captureDocumentForMove captures through the focus anchor. When the page
|
||||
// destroyed that anchor, capture keyed on it fails with ErrObjectNotFound on
|
||||
// every keypress; fall back to the cursor's saved document so the existing
|
||||
// anchor-recovery path can rebind focus within the same generation.
|
||||
func (worker *Worker) captureDocumentForMove(ctx context.Context, cursor *DocumentCursor, focus atspi.ObjectID) (*atspi.AccessibleTree, time.Duration, error) {
|
||||
capture := func(anchor atspi.ObjectID) (*atspi.AccessibleTree, time.Duration, error) {
|
||||
captureContext, cancel := context.WithTimeout(ctx, worker.documentCaptureTimeout)
|
||||
defer cancel()
|
||||
started := time.Now()
|
||||
tree, err := worker.capturer.CaptureDocument(captureContext, anchor)
|
||||
return tree, time.Since(started), err
|
||||
}
|
||||
tree, duration, err := capture(focus)
|
||||
if err == nil || !errors.Is(err, atspi.ErrObjectNotFound) {
|
||||
return tree, duration, err
|
||||
}
|
||||
anchor, ok := cursor.capture_anchor(focus)
|
||||
if !ok {
|
||||
return tree, duration, err
|
||||
}
|
||||
fallbackTree, fallbackDuration, fallbackErr := capture(anchor)
|
||||
if fallbackErr != nil {
|
||||
return tree, duration + fallbackDuration, err
|
||||
}
|
||||
return fallbackTree, duration + fallbackDuration, nil
|
||||
}
|
||||
|
||||
func (worker *Worker) moveDocument(ctx context.Context, cursor *DocumentCursor, target DocumentTarget, level int, backwards bool, epoch, loadEpoch uint64) (Result, *DocumentCursor) {
|
||||
return worker.moveDocumentRepeated(ctx, cursor, target, level, backwards, epoch, loadEpoch, 1)
|
||||
}
|
||||
|
||||
// moveDocumentRepeated executes coalesced identical keys as one batch: one
|
||||
// focus sync, one capture cycle and one native placement for the final
|
||||
// position, landing the full step count from the original position.
|
||||
func (worker *Worker) moveDocumentRepeated(ctx context.Context, cursor *DocumentCursor, target DocumentTarget, level int, backwards bool, epoch, loadEpoch uint64, repeat int) (Result, *DocumentCursor) {
|
||||
result := Result{DocumentEpoch: epoch, DocumentTarget: target, DocumentBackwards: backwards, DocumentLevel: level, DocumentFocus: worker.focus()}
|
||||
if !result.DocumentFocus.Valid() {
|
||||
result.Err = ErrNoCurrent
|
||||
@@ -57,13 +220,27 @@ func (worker *Worker) moveDocument(ctx context.Context, cursor *DocumentCursor,
|
||||
return err
|
||||
}
|
||||
attempt.Stage = "move"
|
||||
move, err := cursor.Move(ctx, target, level, backwards)
|
||||
result.DocumentPosition = move.Position
|
||||
result.Moved = move.Moved
|
||||
result.DocumentWrapped = move.Wrapped
|
||||
return err
|
||||
moved := false
|
||||
for range repeat {
|
||||
move, err := cursor.Move(ctx, target, level, backwards)
|
||||
if err != nil {
|
||||
if moved {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
result.DocumentPosition = move.Position
|
||||
result.Moved = move.Moved
|
||||
result.DocumentWrapped = result.DocumentWrapped || move.Wrapped
|
||||
if !move.Moved {
|
||||
break
|
||||
}
|
||||
moved = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
result.Err = move()
|
||||
worker.trialRetainedCursors(&cursor, loadEpoch, &result, move)
|
||||
if !documentNeedsCapture(result.Err) {
|
||||
return worker.placeDocumentWithRetry(ctx, cursor, result), cursor
|
||||
}
|
||||
@@ -71,11 +248,8 @@ func (worker *Worker) moveDocument(ctx context.Context, cursor *DocumentCursor,
|
||||
// allowing a constantly mutating page to loop indefinitely.
|
||||
result.CaptureReason = result.Err
|
||||
result.DocumentDiagnostics.CaptureAttempts++
|
||||
captureContext, cancel := context.WithTimeout(ctx, worker.documentCaptureTimeout)
|
||||
captureStarted := time.Now()
|
||||
tree, err := worker.capturer.CaptureDocument(captureContext, result.DocumentFocus)
|
||||
result.CaptureDuration = time.Since(captureStarted)
|
||||
cancel()
|
||||
tree, captureDuration, err := worker.captureDocumentForMove(ctx, cursor, result.DocumentFocus)
|
||||
result.CaptureDuration = captureDuration
|
||||
if err != nil {
|
||||
result.Err = err
|
||||
return result, cursor
|
||||
@@ -86,8 +260,9 @@ func (worker *Worker) moveDocument(ctx context.Context, cursor *DocumentCursor,
|
||||
}
|
||||
recovered := worker.recoverCapturedDocumentFocus(cursor, tree, &result)
|
||||
if cursor == nil || cursor.tree != tree {
|
||||
result.DocumentDiagnostics.CursorRecreated = true
|
||||
cursor, _ = NewDocumentCursor(tree)
|
||||
retained, created, _ := worker.cursorForTree(tree, loadEpoch)
|
||||
result.DocumentDiagnostics.CursorRecreated = created
|
||||
cursor = retained
|
||||
}
|
||||
if !recovered {
|
||||
result.DocumentFocus = worker.focus()
|
||||
@@ -98,15 +273,14 @@ func (worker *Worker) moveDocument(ctx context.Context, cursor *DocumentCursor,
|
||||
// A second replacement can race the first capture. Retry once while
|
||||
// the navigation has not moved, so the heading is not skipped.
|
||||
result.DocumentDiagnostics.CaptureAttempts++
|
||||
retryContext, retryCancel := context.WithTimeout(ctx, worker.documentCaptureTimeout)
|
||||
retryStarted := time.Now()
|
||||
tree, err = worker.capturer.CaptureDocument(retryContext, result.DocumentFocus)
|
||||
result.CaptureDuration += time.Since(retryStarted)
|
||||
retryCancel()
|
||||
var retryDuration time.Duration
|
||||
tree, retryDuration, err = worker.captureDocumentForMove(ctx, cursor, result.DocumentFocus)
|
||||
result.CaptureDuration += retryDuration
|
||||
if err == nil && tree != nil && worker.CurrentDocumentResult(result) && worker.focus() == result.DocumentFocus {
|
||||
if cursor == nil || cursor.tree != tree {
|
||||
result.DocumentDiagnostics.CursorRecreated = true
|
||||
cursor, _ = NewDocumentCursor(tree)
|
||||
retained, created, _ := worker.cursorForTree(tree, loadEpoch)
|
||||
result.DocumentDiagnostics.CursorRecreated = created
|
||||
cursor = retained
|
||||
}
|
||||
result.Err = move()
|
||||
}
|
||||
@@ -114,7 +288,7 @@ func (worker *Worker) moveDocument(ctx context.Context, cursor *DocumentCursor,
|
||||
return worker.placeDocumentWithRetry(ctx, cursor, result), cursor
|
||||
}
|
||||
|
||||
func (worker *Worker) jumpDocumentFragment(ctx context.Context, cursor *DocumentCursor, fragment string, epoch uint64) (Result, *DocumentCursor) {
|
||||
func (worker *Worker) jumpDocumentFragment(ctx context.Context, cursor *DocumentCursor, fragment string, epoch, loadEpoch uint64) (Result, *DocumentCursor) {
|
||||
result := Result{DocumentEpoch: epoch, DocumentFragment: true, DocumentFocus: worker.focus()}
|
||||
if !result.DocumentFocus.Valid() {
|
||||
result.Err = ErrNoCurrent
|
||||
@@ -133,7 +307,7 @@ func (worker *Worker) jumpDocumentFragment(ctx context.Context, cursor *Document
|
||||
result.Err = errors.New("capture document tree: nil tree")
|
||||
return result, cursor
|
||||
}
|
||||
cursor, err = NewDocumentCursor(tree)
|
||||
cursor, _, err = worker.cursorForTree(tree, loadEpoch)
|
||||
if err != nil {
|
||||
result.Err = err
|
||||
return result, cursor
|
||||
@@ -171,11 +345,8 @@ func (worker *Worker) placeDocumentWithRetry(ctx context.Context, cursor *Docume
|
||||
// capture; repeating the navigation command could skip to another target.
|
||||
oldPosition := result.DocumentPosition
|
||||
result.DocumentDiagnostics.CaptureAttempts++
|
||||
captureContext, cancel := context.WithTimeout(ctx, worker.documentCaptureTimeout)
|
||||
captureStarted := time.Now()
|
||||
tree, err := worker.capturer.CaptureDocument(captureContext, result.DocumentFocus)
|
||||
result.CaptureDuration += time.Since(captureStarted)
|
||||
cancel()
|
||||
tree, captureDuration, err := worker.captureDocumentForMove(ctx, cursor, result.DocumentFocus)
|
||||
result.CaptureDuration += captureDuration
|
||||
if err != nil || tree == nil || !worker.CurrentDocumentResult(result) {
|
||||
return result
|
||||
}
|
||||
@@ -211,7 +382,7 @@ func documentCaretNeedsRecapture(err error) bool {
|
||||
errors.Is(err, atspi.ErrStaleSnapshot) || errors.Is(err, atspi.ErrDeadObject)
|
||||
}
|
||||
|
||||
func (worker *Worker) moveDocumentCaret(ctx context.Context, cursor *DocumentCursor, command DocumentCaretCommand, epoch uint64) (Result, *DocumentCursor) {
|
||||
func (worker *Worker) moveDocumentCaret(ctx context.Context, cursor *DocumentCursor, command DocumentCaretCommand, epoch, loadEpoch uint64) (Result, *DocumentCursor) {
|
||||
result := Result{DocumentEpoch: epoch, DocumentCaretCommand: command, DocumentFocus: worker.focus()}
|
||||
if !result.DocumentFocus.Valid() {
|
||||
result.Err = ErrNoCurrent
|
||||
@@ -266,16 +437,14 @@ func (worker *Worker) moveDocumentCaret(ctx context.Context, cursor *DocumentCur
|
||||
return err
|
||||
}
|
||||
result.Err = move()
|
||||
worker.trialRetainedCursors(&cursor, loadEpoch, &result, move)
|
||||
if !documentNeedsCapture(result.Err) {
|
||||
return worker.placeDocumentWithRetry(ctx, cursor, result), cursor
|
||||
}
|
||||
result.CaptureReason = result.Err
|
||||
result.DocumentDiagnostics.CaptureAttempts++
|
||||
captureContext, cancel := context.WithTimeout(ctx, worker.documentCaptureTimeout)
|
||||
captureStarted := time.Now()
|
||||
tree, err := worker.capturer.CaptureDocument(captureContext, result.DocumentFocus)
|
||||
result.CaptureDuration = time.Since(captureStarted)
|
||||
cancel()
|
||||
tree, captureDuration, err := worker.captureDocumentForMove(ctx, cursor, result.DocumentFocus)
|
||||
result.CaptureDuration = captureDuration
|
||||
if err != nil {
|
||||
result.Err = err
|
||||
return result, cursor
|
||||
@@ -286,8 +455,9 @@ func (worker *Worker) moveDocumentCaret(ctx context.Context, cursor *DocumentCur
|
||||
}
|
||||
recovered := worker.recoverCapturedDocumentFocus(cursor, tree, &result)
|
||||
if cursor == nil || cursor.tree != tree {
|
||||
result.DocumentDiagnostics.CursorRecreated = true
|
||||
cursor, _ = NewDocumentCursor(tree)
|
||||
retained, created, _ := worker.cursorForTree(tree, loadEpoch)
|
||||
result.DocumentDiagnostics.CursorRecreated = created
|
||||
cursor = retained
|
||||
}
|
||||
if !recovered {
|
||||
result.DocumentFocus = worker.focus()
|
||||
@@ -300,17 +470,16 @@ func (worker *Worker) moveDocumentCaret(ctx context.Context, cursor *DocumentCur
|
||||
// A dynamic page can replace the captured target between recapture and
|
||||
// SyncFocus. Retry once only if the first attempt did not move the caret.
|
||||
result.DocumentDiagnostics.CaptureAttempts++
|
||||
retryContext, retryCancel := context.WithTimeout(ctx, worker.documentCaptureTimeout)
|
||||
retryStarted := time.Now()
|
||||
tree, err = worker.capturer.CaptureDocument(retryContext, result.DocumentFocus)
|
||||
result.CaptureDuration += time.Since(retryStarted)
|
||||
retryCancel()
|
||||
var retryDuration time.Duration
|
||||
tree, retryDuration, err = worker.captureDocumentForMove(ctx, cursor, result.DocumentFocus)
|
||||
result.CaptureDuration += retryDuration
|
||||
if err != nil || tree == nil || !worker.CurrentDocumentResult(result) || worker.focus() != result.DocumentFocus {
|
||||
return result, cursor
|
||||
}
|
||||
if cursor == nil || cursor.tree != tree {
|
||||
result.DocumentDiagnostics.CursorRecreated = true
|
||||
cursor, _ = NewDocumentCursor(tree)
|
||||
retained, created, _ := worker.cursorForTree(tree, loadEpoch)
|
||||
result.DocumentDiagnostics.CursorRecreated = created
|
||||
cursor = retained
|
||||
}
|
||||
result.Err = move()
|
||||
return worker.placeDocumentWithRetry(ctx, cursor, result), cursor
|
||||
@@ -436,6 +605,7 @@ func (worker *Worker) refresh_document_cursor(ctx context.Context, cursor *Docum
|
||||
if cursor != nil && len(cursor.places) > 0 {
|
||||
saved = cursor.places[len(cursor.places)-1]
|
||||
}
|
||||
loadEpoch := worker.documentLoadEpoch.Load()
|
||||
captureContext, cancel := context.WithTimeout(ctx, worker.documentCaptureTimeout)
|
||||
defer cancel()
|
||||
tree, err := worker.capturer.CaptureDocument(captureContext, focus)
|
||||
@@ -473,6 +643,7 @@ func (worker *Worker) refresh_document_cursor(ctx context.Context, cursor *Docum
|
||||
place := &fresh.places[len(fresh.places)-1]
|
||||
place.object, place.offset, place.origin = target, saved.offset, saved.origin
|
||||
}
|
||||
worker.rememberDocumentCursor(fresh, loadEpoch)
|
||||
return fresh, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ package review
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -59,12 +60,12 @@ func TestWorkerRetriesNativeCaretPlacementWithoutRepeatingMovement(t *testing.T)
|
||||
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)
|
||||
result, _ := worker.moveDocumentCaret(context.Background(), cursor, CaretNextCharacter, 0, 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)
|
||||
next, _ := worker.moveDocumentCaret(context.Background(), cursor, CaretNextCharacter, 0, 0)
|
||||
if next.Err != nil || next.DocumentCaretText != "r" || next.DocumentPosition.Offset != 2 {
|
||||
t.Fatalf("retry lost the saved position: %+v", next)
|
||||
}
|
||||
@@ -179,7 +180,7 @@ func TestDocumentCaretMovePlacesNativeCaretForTabTraversal(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
result, _ := worker.moveDocumentCaret(context.Background(), cursor, command, worker.documentEpoch.Load())
|
||||
result, _ := worker.moveDocumentCaret(context.Background(), cursor, command, worker.documentEpoch.Load(), worker.documentLoadEpoch.Load())
|
||||
if result.Err != nil || result.CaretErr != nil || !result.Moved ||
|
||||
navigator.position != result.DocumentPosition.Object || navigator.focus != webID("/text") || navigator.offset != result.DocumentPosition.Offset {
|
||||
t.Fatalf("command %v: result=%+v, placement=%+v", command, result, navigator)
|
||||
@@ -277,7 +278,7 @@ func TestDocumentCaretSpeaksReadTextWhenAncestorDisappears(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
worker.SyncFocus(webID("/text"))
|
||||
result, _ := worker.moveDocumentCaret(context.Background(), cursor, CaretNextCharacter, 0)
|
||||
result, _ := worker.moveDocumentCaret(context.Background(), cursor, CaretNextCharacter, 0, 0)
|
||||
if result.Err != nil || !result.Moved || result.DocumentCaretText != "l" || result.DocumentPosition.Offset != 1 {
|
||||
t.Fatalf("stale ancestor after caret read: %+v", result)
|
||||
}
|
||||
@@ -305,7 +306,7 @@ func TestDocumentCaretRetriesStaleRecaptureWithoutSkipping(t *testing.T) {
|
||||
worker.SyncFocus(webID("/text"))
|
||||
done := make(chan Result, 1)
|
||||
go func() {
|
||||
result, _ := worker.moveDocumentCaret(context.Background(), oldCursor, CaretNextCharacter, 0)
|
||||
result, _ := worker.moveDocumentCaret(context.Background(), oldCursor, CaretNextCharacter, 0, 0)
|
||||
done <- result
|
||||
}()
|
||||
initial := <-reader.calls
|
||||
@@ -321,6 +322,210 @@ func TestDocumentCaretRetriesStaleRecaptureWithoutSkipping(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Keys pressed while a capture is in flight queue up. Identical queued
|
||||
// document moves must execute as one batch: one placement and one result,
|
||||
// landing the full count of steps from the original position.
|
||||
func TestWorkerCoalescesQueuedIdenticalDocumentMoves(t *testing.T) {
|
||||
tree, _ := webCursor(t)
|
||||
navigator := newFakeDocumentNavigator()
|
||||
worker, cancel := startWorker(t, navigator, time.Second)
|
||||
defer worker.Stop(context.Background())
|
||||
defer cancel()
|
||||
worker.SyncFocus(webID("/first"))
|
||||
if err := worker.MoveDocument(DocumentHeading, 0, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
initial := <-navigator.fakeCapturer.calls
|
||||
// Two more identical keys arrive while the first capture is in flight.
|
||||
for range 2 {
|
||||
if err := worker.MoveDocument(DocumentHeading, 0, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
initial.result <- captureResult{tree: tree}
|
||||
first := <-navigator.calls
|
||||
if first.position.ID() != webID("/second") {
|
||||
t.Fatalf("first placement = %+v", first)
|
||||
}
|
||||
if result := waitResult(t, worker); result.Err != nil || result.DocumentPosition.Object.ID() != webID("/second") {
|
||||
t.Fatalf("first result = %+v", result)
|
||||
}
|
||||
second := <-navigator.calls
|
||||
if second.position.ID() != webID("/second") {
|
||||
t.Fatalf("batched placement = %+v, want final /second after wrap", second)
|
||||
}
|
||||
result := waitResult(t, worker)
|
||||
if result.Err != nil || !result.Moved || !result.DocumentWrapped || result.DocumentPosition.Object.ID() != webID("/second") {
|
||||
t.Fatalf("batched result = %+v", result)
|
||||
}
|
||||
select {
|
||||
case placement := <-navigator.calls:
|
||||
t.Fatalf("unexpected extra placement %+v; queued repeats were not coalesced", placement)
|
||||
case extra := <-worker.Results():
|
||||
t.Fatalf("unexpected extra result %+v; queued repeats were not coalesced", extra)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
type retainedDocumentCapturer struct {
|
||||
trees map[atspi.ObjectID]*atspi.AccessibleTree
|
||||
captures []atspi.ObjectID
|
||||
placements []atspi.ObjectID
|
||||
}
|
||||
|
||||
func (capturer *retainedDocumentCapturer) CaptureDocument(_ context.Context, focus atspi.ObjectID) (*atspi.AccessibleTree, error) {
|
||||
capturer.captures = append(capturer.captures, focus)
|
||||
if tree := capturer.trees[focus]; tree != nil {
|
||||
return tree, nil
|
||||
}
|
||||
return nil, atspi.ErrObjectNotFound
|
||||
}
|
||||
|
||||
func (capturer *retainedDocumentCapturer) CaptureDocumentText(ctx context.Context, focus atspi.ObjectID) (*atspi.AccessibleTree, error) {
|
||||
return capturer.CaptureDocument(ctx, focus)
|
||||
}
|
||||
|
||||
func (capturer *retainedDocumentCapturer) CaptureReviewWindow(ctx context.Context, focus atspi.ObjectID) (*atspi.AccessibleTree, error) {
|
||||
return capturer.CaptureDocument(ctx, focus)
|
||||
}
|
||||
|
||||
func (capturer *retainedDocumentCapturer) CaptureDesktop(context.Context) (*atspi.AccessibleTree, error) {
|
||||
return nil, atspi.ErrObjectNotFound
|
||||
}
|
||||
|
||||
func (capturer *retainedDocumentCapturer) SetDocumentPosition(_ context.Context, position atspi.ObjectReference, _ atspi.ObjectID) error {
|
||||
capturer.placements = append(capturer.placements, position.ID())
|
||||
return nil
|
||||
}
|
||||
|
||||
func retainedDocumentTree(t *testing.T, root string, headings ...string) *atspi.AccessibleTree {
|
||||
t.Helper()
|
||||
node := func(path, role string, children ...string) atspi.TreeNode {
|
||||
snapshot := atspi.NewSnapshot(path, path, role, "", "")
|
||||
snapshot.BusName = webID(path).BusName
|
||||
if role == "heading" {
|
||||
snapshot.HeadingLevel = 2
|
||||
}
|
||||
result := atspi.TreeNode{Snapshot: snapshot}
|
||||
for _, child := range children {
|
||||
result.Children = append(result.Children, webID(child))
|
||||
}
|
||||
return result
|
||||
}
|
||||
nodes := []atspi.TreeNode{node(root, "document web", headings...)}
|
||||
for _, heading := range headings {
|
||||
nodes = append(nodes, node(heading, "heading"))
|
||||
}
|
||||
tree, err := atspi.NewAccessibleTree(webID(root), nodes)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tree
|
||||
}
|
||||
|
||||
// Returning to a tab (or browser workspace) must resume that document's
|
||||
// browse place from its retained cursor instead of recapturing cold and
|
||||
// restarting at the top, even across document epoch resets.
|
||||
func TestWorkerRetainsDocumentCursorsAcrossSwitches(t *testing.T) {
|
||||
capturer := &retainedDocumentCapturer{trees: map[atspi.ObjectID]*atspi.AccessibleTree{
|
||||
webID("/docA"): retainedDocumentTree(t, "/docA", "/a1", "/a2", "/a3"),
|
||||
webID("/docB"): retainedDocumentTree(t, "/docB", "/b1", "/b2"),
|
||||
}}
|
||||
worker, cancel := startWorker(t, capturer, time.Second)
|
||||
defer worker.Stop(context.Background())
|
||||
defer cancel()
|
||||
move := func(want string) {
|
||||
t.Helper()
|
||||
if err := worker.MoveDocument(DocumentHeading, 0, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := waitResult(t, worker)
|
||||
if result.Err != nil || !result.Moved || result.DocumentPosition.Object.ID() != webID(want) {
|
||||
t.Fatalf("heading move = %+v, want %s", result, want)
|
||||
}
|
||||
}
|
||||
worker.SyncFocus(webID("/docA"))
|
||||
move("/a1")
|
||||
move("/a2")
|
||||
worker.SuspendDocument(webID("/docB"))
|
||||
move("/b1")
|
||||
capturesA := 0
|
||||
for _, focus := range capturer.captures {
|
||||
if focus == webID("/docA") {
|
||||
capturesA++
|
||||
}
|
||||
}
|
||||
worker.SuspendDocument(webID("/docA"))
|
||||
move("/a3")
|
||||
for _, focus := range capturer.captures {
|
||||
if focus == webID("/docA") {
|
||||
capturesA--
|
||||
}
|
||||
}
|
||||
if capturesA != 0 {
|
||||
t.Fatalf("returning to first document recaptured it; captures=%v", capturer.captures)
|
||||
}
|
||||
}
|
||||
|
||||
// Warming runs the first capture and focus sync in the background, so the
|
||||
// first navigation key afterwards needs no capture of its own.
|
||||
func TestWorkerWarmDocumentPreparesFirstMove(t *testing.T) {
|
||||
capturer := &retainedDocumentCapturer{trees: map[atspi.ObjectID]*atspi.AccessibleTree{
|
||||
webID("/docA"): retainedDocumentTree(t, "/docA", "/a1", "/a2"),
|
||||
}}
|
||||
worker, cancel := startWorker(t, capturer, time.Second)
|
||||
defer worker.Stop(context.Background())
|
||||
defer cancel()
|
||||
worker.SyncFocus(webID("/docA"))
|
||||
worker.WarmDocument()
|
||||
if err := worker.MoveDocument(DocumentHeading, 0, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := waitResult(t, worker)
|
||||
if result.Err != nil || !result.Moved || result.DocumentPosition.Object.ID() != webID("/a1") {
|
||||
t.Fatalf("warmed first move = %+v", result)
|
||||
}
|
||||
if len(capturer.captures) != 1 {
|
||||
t.Fatalf("captures = %v, want only the warm capture", capturer.captures)
|
||||
}
|
||||
}
|
||||
|
||||
// A ticking page can destroy the focused anchor itself. Capture keyed on
|
||||
// that dead focus fails with ErrObjectNotFound forever; the move must
|
||||
// recapture through the saved document instead of losing the location on
|
||||
// every following keypress.
|
||||
func TestDocumentCaretRecapturesThroughDocumentWhenFocusAnchorDies(t *testing.T) {
|
||||
text := caretTextReader{webID("/text"): "Alpha", webID("/next"): "Next"}
|
||||
oldCursor := newCaretTextCursor(t, text)
|
||||
oldCursor.tree.Invalidate(webID("/text"))
|
||||
fresh := newCaretTextCursor(t, text).tree
|
||||
reader := &refreshingCaretReader{fakeCapturer: &fakeCapturer{calls: make(chan captureCall, 1)}, text: text}
|
||||
worker, err := NewWorker(reader, time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
worker.SyncFocus(webID("/text"))
|
||||
done := make(chan Result, 1)
|
||||
go func() {
|
||||
result, _ := worker.moveDocumentCaret(context.Background(), oldCursor, CaretNextCharacter, 0, 0)
|
||||
done <- result
|
||||
}()
|
||||
initial := <-reader.calls
|
||||
if initial.reviewFocus != webID("/text") {
|
||||
t.Fatalf("initial capture focus = %v", initial.reviewFocus)
|
||||
}
|
||||
initial.result <- captureResult{err: fmt.Errorf("%w: document focus", atspi.ErrObjectNotFound)}
|
||||
fallback := <-reader.calls
|
||||
if fallback.reviewFocus != webID("/doc") {
|
||||
t.Fatalf("fallback capture focus = %v, want saved document /doc", fallback.reviewFocus)
|
||||
}
|
||||
fallback.result <- captureResult{tree: fresh}
|
||||
result := <-done
|
||||
if result.Err != nil || !result.Moved || result.DocumentCaretText != "l" || result.DocumentPosition.Offset != 1 {
|
||||
t.Fatalf("dead anchor recapture: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func (reader *waitingCaretReader) ReadDocumentCaret(ctx context.Context, _ atspi.ObjectReference, _ int, _ atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) {
|
||||
reader.deadline, _ = ctx.Deadline()
|
||||
<-ctx.Done()
|
||||
@@ -338,7 +543,7 @@ func TestDocumentCaretNavigationDeadlinePreservesPosition(t *testing.T) {
|
||||
worker.SyncFocus(webID("/text"))
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
result, _ := worker.moveDocumentCaret(ctx, cursor, CaretNextWord, 0)
|
||||
result, _ := worker.moveDocumentCaret(ctx, cursor, CaretNextWord, 0, 0)
|
||||
parentDeadline, _ := ctx.Deadline()
|
||||
if !errors.Is(result.Err, context.DeadlineExceeded) || result.Moved || !reader.deadline.Before(parentDeadline) {
|
||||
t.Fatalf("navigation deadline: result %+v, deadline %v", result, reader.deadline)
|
||||
@@ -415,7 +620,7 @@ func TestDocumentMoveRecoversAfterDynamicObjectRemoval(t *testing.T) {
|
||||
}
|
||||
done := make(chan moveResult, 1)
|
||||
go func() {
|
||||
result, _ := worker.moveDocument(context.Background(), cursor, DocumentHeading, 0, false, 0)
|
||||
result, _ := worker.moveDocument(context.Background(), cursor, DocumentHeading, 0, false, 0, 0)
|
||||
done <- moveResult{result: result}
|
||||
}()
|
||||
call := <-capturer.calls
|
||||
@@ -451,7 +656,7 @@ func TestDocumentMoveRecoversFocusRemovedDuringTextRecapture(t *testing.T) {
|
||||
worker.SyncFocus(webID("/first"))
|
||||
done := make(chan Result, 1)
|
||||
go func() {
|
||||
result, _ := worker.moveDocument(context.Background(), cursor, DocumentHeading, 0, false, 0)
|
||||
result, _ := worker.moveDocument(context.Background(), cursor, DocumentHeading, 0, false, 0, 0)
|
||||
done <- result
|
||||
}()
|
||||
deadline := time.After(3 * time.Second)
|
||||
@@ -487,7 +692,7 @@ func TestDocumentMoveRetriesStaleRecaptureWithoutSkipping(t *testing.T) {
|
||||
worker.SyncFocus(webID("/first"))
|
||||
done := make(chan Result, 1)
|
||||
go func() {
|
||||
result, _ := worker.moveDocument(context.Background(), cursor, DocumentHeading, 0, false, 0)
|
||||
result, _ := worker.moveDocument(context.Background(), cursor, DocumentHeading, 0, false, 0, 0)
|
||||
done <- result
|
||||
}()
|
||||
initial := <-capturer.calls
|
||||
@@ -520,7 +725,7 @@ func TestDocumentMoveRetriesStaleSnapshotWithoutSkipping(t *testing.T) {
|
||||
worker.SyncFocus(webID("/first"))
|
||||
done := make(chan Result, 1)
|
||||
go func() {
|
||||
result, _ := worker.moveDocument(context.Background(), cursor, DocumentHeading, 0, false, 0)
|
||||
result, _ := worker.moveDocument(context.Background(), cursor, DocumentHeading, 0, false, 0, 0)
|
||||
done <- result
|
||||
}()
|
||||
initial := <-capturer.calls
|
||||
@@ -551,7 +756,7 @@ func TestDocumentCaretRetriesStaleSnapshotWithoutSkipping(t *testing.T) {
|
||||
worker.SyncFocus(webID("/text"))
|
||||
done := make(chan Result, 1)
|
||||
go func() {
|
||||
result, _ := worker.moveDocumentCaret(context.Background(), oldCursor, CaretNextCharacter, 0)
|
||||
result, _ := worker.moveDocumentCaret(context.Background(), oldCursor, CaretNextCharacter, 0, 0)
|
||||
done <- result
|
||||
}()
|
||||
initial := <-reader.calls
|
||||
@@ -609,7 +814,7 @@ func TestWorkerJumpsToLoadedDocumentFragment(t *testing.T) {
|
||||
worker.SyncFocus(webID("/doc"))
|
||||
results := make(chan Result, 1)
|
||||
go func() {
|
||||
result, _ := worker.jumpDocumentFragment(context.Background(), nil, "p71", worker.documentEpoch.Load())
|
||||
result, _ := worker.jumpDocumentFragment(context.Background(), nil, "p71", worker.documentEpoch.Load(), worker.documentLoadEpoch.Load())
|
||||
results <- result
|
||||
}()
|
||||
capture := <-navigator.fakeCapturer.calls
|
||||
@@ -753,7 +958,7 @@ func TestWorkerCaretRefusalKeepsVirtualMove(t *testing.T) {
|
||||
worker.SyncFocus(webID("/first"))
|
||||
failure := errors.New("browser refused document caret")
|
||||
navigator.placementErr <- failure
|
||||
result, _ := worker.moveDocument(context.Background(), cursor, DocumentHeading, 0, false, worker.DocumentEpoch())
|
||||
result, _ := worker.moveDocument(context.Background(), cursor, DocumentHeading, 0, false, worker.DocumentEpoch(), worker.documentLoadEpoch.Load())
|
||||
if result.Err != nil || result.CaretErr != failure || !result.Moved || result.DocumentPosition.Object.ID() != webID("/second") {
|
||||
t.Fatalf("ordinary caret refusal lost virtual reading position: %+v", result)
|
||||
}
|
||||
@@ -1003,7 +1208,7 @@ func TestDocumentCaretKeepsOffsetAfterDocumentRefresh(t *testing.T) {
|
||||
worker.SyncFocus(webID("/doc"))
|
||||
ctx := context.Background()
|
||||
for _, want := range []string{"First\n", "Second\n"} {
|
||||
result, next := worker.moveDocumentCaret(ctx, cursor, CaretNextLine, worker.documentEpoch.Load())
|
||||
result, next := worker.moveDocumentCaret(ctx, cursor, CaretNextLine, worker.documentEpoch.Load(), worker.documentLoadEpoch.Load())
|
||||
cursor = next
|
||||
if result.Err != nil || result.DocumentCaretText != want {
|
||||
t.Fatalf("initial navigation = %+v, want %q", result, want)
|
||||
@@ -1033,7 +1238,7 @@ func TestDocumentCaretKeepsOffsetAfterDocumentRefresh(t *testing.T) {
|
||||
if kind != "children" {
|
||||
go refresh()
|
||||
}
|
||||
result, _ := worker.moveDocumentCaret(ctx, cursor, CaretNextLine, worker.documentEpoch.Load())
|
||||
result, _ := worker.moveDocumentCaret(ctx, cursor, CaretNextLine, worker.documentEpoch.Load(), worker.documentLoadEpoch.Load())
|
||||
if result.Err != nil || !errors.Is(result.CaptureReason, wantReason) || result.DocumentCaretText != "Third" {
|
||||
t.Fatalf("semantic refresh restarted or skipped reading: %+v", result)
|
||||
}
|
||||
@@ -1041,10 +1246,11 @@ func TestDocumentCaretKeepsOffsetAfterDocumentRefresh(t *testing.T) {
|
||||
if result.DocumentDiagnostics.CaptureAttempts != 0 {
|
||||
t.Fatal("local text movement recaptured unrelated child order")
|
||||
}
|
||||
go refresh()
|
||||
result, _ = worker.moveDocumentCaret(ctx, cursor, CaretNextLine, worker.documentEpoch.Load())
|
||||
if result.Err != nil || !errors.Is(result.CaptureReason, atspi.ErrIncompleteTree) || result.DocumentCaretText != "End" {
|
||||
t.Fatalf("crossing refreshed children lost position: %+v", result)
|
||||
// Crossing follows captured child order with live reads and
|
||||
// generation-checked endpoints; it no longer recaptures.
|
||||
result, _ = worker.moveDocumentCaret(ctx, cursor, CaretNextLine, worker.documentEpoch.Load(), worker.documentLoadEpoch.Load())
|
||||
if result.Err != nil || result.CaptureReason != nil || result.DocumentDiagnostics.CaptureAttempts != 0 || result.DocumentCaretText != "End" {
|
||||
t.Fatalf("crossing changed children lost position: %+v", result)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
+80
-18
@@ -54,12 +54,23 @@ type Worker struct {
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
|
||||
wakeFocus chan struct{}
|
||||
requests chan request
|
||||
results chan Result
|
||||
lineSequence atomic.Uint64
|
||||
documentEpoch atomic.Uint64
|
||||
documentBraille atomic.Bool
|
||||
wakeFocus chan struct{}
|
||||
requests chan request
|
||||
results chan Result
|
||||
lineSequence atomic.Uint64
|
||||
documentEpoch atomic.Uint64
|
||||
// documentLoadEpoch bumps only on hard document resets (page loads).
|
||||
// A retained cursor stamped with an older load epoch must never be
|
||||
// reused: its tree may be the previous page at reused paths.
|
||||
documentLoadEpoch atomic.Uint64
|
||||
documentBraille atomic.Bool
|
||||
|
||||
// Retained browse cursors per captured document tree, so returning to
|
||||
// a tab resumes its place instead of restarting cold. Owned by the
|
||||
// run goroutine (document moves run there); no lock.
|
||||
documentCursors map[*atspi.AccessibleTree]*DocumentCursor
|
||||
documentCursorEpochs map[*atspi.AccessibleTree]uint64
|
||||
documentCursorOrder []*atspi.AccessibleTree
|
||||
}
|
||||
|
||||
// SetDocumentBrailleEnabled avoids extra native line reads while braille is off.
|
||||
@@ -68,13 +79,30 @@ func (worker *Worker) SetDocumentBrailleEnabled(enabled bool) {
|
||||
}
|
||||
|
||||
// ResetDocument retires queued work and cursors for the previous page, even
|
||||
// when the provider reuses accessible paths after navigation.
|
||||
// when the provider reuses accessible paths after navigation. It is the
|
||||
// hard reset for page loads: retained cursors from before it must not be
|
||||
// reused, because a new page can reuse the same accessible paths.
|
||||
func (worker *Worker) ResetDocument(focus atspi.ObjectID) {
|
||||
worker.resetDocument(focus, true)
|
||||
}
|
||||
|
||||
// SuspendDocument retires queued work for the previously selected tab
|
||||
// without retiring its browse cursors, so returning to the tab (or its
|
||||
// workspace) resumes each document's place. Only for focus/tab switches
|
||||
// where no page (re)loaded.
|
||||
func (worker *Worker) SuspendDocument(focus atspi.ObjectID) {
|
||||
worker.resetDocument(focus, false)
|
||||
}
|
||||
|
||||
func (worker *Worker) resetDocument(focus atspi.ObjectID, hard bool) {
|
||||
worker.stateMutex.Lock()
|
||||
worker.latestFocus = focus
|
||||
worker.focusRevision++
|
||||
worker.flatOrigin = atspi.ObjectID{}
|
||||
worker.documentEpoch.Add(1)
|
||||
if hard {
|
||||
worker.documentLoadEpoch.Add(1)
|
||||
}
|
||||
worker.stateMutex.Unlock()
|
||||
select {
|
||||
case worker.wakeFocus <- struct{}{}:
|
||||
@@ -251,20 +279,47 @@ func (worker *Worker) run(ctx context.Context, done chan<- struct{}) {
|
||||
explicitInterest := implicitInterest
|
||||
flat := &FlatCursor{}
|
||||
var flatTree *atspi.AccessibleTree
|
||||
// held is the first non-identical request found while coalescing
|
||||
// queued identical document moves; it runs next, in order.
|
||||
var held *request
|
||||
takeRepeat := func(first request) int {
|
||||
repeat := 1
|
||||
for {
|
||||
select {
|
||||
case next := <-worker.requests:
|
||||
if sameDocumentRepeat(first, next) {
|
||||
repeat++
|
||||
continue
|
||||
}
|
||||
held = &next
|
||||
return repeat
|
||||
default:
|
||||
return repeat
|
||||
}
|
||||
}
|
||||
}
|
||||
for {
|
||||
// A ready request/result channel can win a select even after Stop.
|
||||
// Do not drain queued work or start further captures during shutdown.
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-worker.wakeFocus:
|
||||
if next, err := worker.syncLatestFocus(ctx, cursor, false); err == nil {
|
||||
cursor = next
|
||||
var request request
|
||||
if held != nil {
|
||||
request, held = *held, nil
|
||||
} else {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-worker.wakeFocus:
|
||||
if next, err := worker.syncLatestFocus(ctx, cursor, false); err == nil {
|
||||
cursor = next
|
||||
}
|
||||
continue
|
||||
case request = <-worker.requests:
|
||||
}
|
||||
case request := <-worker.requests:
|
||||
}
|
||||
{
|
||||
worker.stateMutex.Lock()
|
||||
focus, revision := worker.latestFocus, worker.focusRevision
|
||||
worker.stateMutex.Unlock()
|
||||
@@ -283,6 +338,12 @@ func (worker *Worker) run(ctx context.Context, done chan<- struct{}) {
|
||||
request.documentList.result <- documentListReply{list, err}
|
||||
continue
|
||||
}
|
||||
if request.warmDocument {
|
||||
if request.documentEpoch == documentEpoch {
|
||||
document = worker.warmDocument(ctx, document, request.documentLoadEpoch)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if request.interest != nil {
|
||||
interest, err := resolve_interest(request.interest.ctx, interestFocus, explicitInterest, cursor, flat, flatTree, document)
|
||||
worker.stateMutex.Lock()
|
||||
@@ -338,16 +399,16 @@ func (worker *Worker) run(ctx context.Context, done chan<- struct{}) {
|
||||
result, documentTextTree = worker.readDocumentLine(ctx, documentTextTree, request.lineRequest, request.lineFocus, request.lineOffset)
|
||||
result.Elapsed = time.Since(request.queuedAt)
|
||||
} else if request.target != 0 {
|
||||
result, document = worker.moveDocument(ctx, document, request.target, request.level, request.backwards, request.documentEpoch)
|
||||
result, document = worker.moveDocumentRepeated(ctx, document, request.target, request.level, request.backwards, request.documentEpoch, request.documentLoadEpoch, takeRepeat(request))
|
||||
result.Elapsed = time.Since(request.queuedAt)
|
||||
} else if request.caretCommand != 0 {
|
||||
result, document = worker.moveDocumentCaret(ctx, document, request.caretCommand, request.documentEpoch)
|
||||
result, document = worker.moveDocumentCaret(ctx, document, request.caretCommand, request.documentEpoch, request.documentLoadEpoch)
|
||||
result.Elapsed = time.Since(request.queuedAt)
|
||||
} else if request.activate {
|
||||
result, document = worker.activateDocumentCaret(ctx, document, request.documentEpoch)
|
||||
result.Elapsed = time.Since(request.queuedAt)
|
||||
} else if request.fragment != "" {
|
||||
result, document = worker.jumpDocumentFragment(ctx, document, request.fragment, request.documentEpoch)
|
||||
result, document = worker.jumpDocumentFragment(ctx, document, request.fragment, request.documentEpoch, request.documentLoadEpoch)
|
||||
result.Elapsed = time.Since(request.queuedAt)
|
||||
} else if request.unit != 0 {
|
||||
result, flatTree = worker.readFlat(ctx, flat, flatTree, request.unit, request.step)
|
||||
@@ -373,7 +434,8 @@ func (worker *Worker) run(ctx context.Context, done chan<- struct{}) {
|
||||
worker.stateMutex.Unlock()
|
||||
}
|
||||
result.DocumentEpoch = request.documentEpoch
|
||||
if documentCommand && worker.CurrentDocumentResult(result) && errors.Is(result.Err, context.DeadlineExceeded) {
|
||||
if documentCommand && worker.CurrentDocumentResult(result) &&
|
||||
(errors.Is(result.Err, context.DeadlineExceeded) || errors.Is(result.Err, atspi.ErrProviderHung)) {
|
||||
// Keys pressed while the provider was unavailable must not each
|
||||
// start another full timeout. A command issued after this failure
|
||||
// can try again; successful navigation keeps its ordered steps.
|
||||
|
||||
@@ -112,6 +112,7 @@ type Result struct {
|
||||
// activation, fragment, flat unit, then object direction. Flags are deliberately
|
||||
// retained; epoch and timeout retirement depend on their distinct combinations.
|
||||
type request struct {
|
||||
warmDocument bool
|
||||
currentLine bool
|
||||
currentLineBrowse bool
|
||||
documentList *documentListRequest
|
||||
@@ -126,6 +127,7 @@ type request struct {
|
||||
readTree *atspi.AccessibleTree
|
||||
readFocus atspi.ObjectID
|
||||
documentEpoch uint64
|
||||
documentLoadEpoch uint64
|
||||
queuedAt time.Time
|
||||
target DocumentTarget
|
||||
caretCommand DocumentCaretCommand
|
||||
|
||||
Reference in new issue
Block a user