Browser changes.
This commit is contained in:
1 parent
133953e972
commit
bf4b9d7b54
20 files changed
+920
-168
No files matched your search
@@ -4,7 +4,7 @@
|
|||||||
pkgbase=cthulhu-git
|
pkgbase=cthulhu-git
|
||||||
pkgname=cthulhu-git
|
pkgname=cthulhu-git
|
||||||
_pkgname=cthulhu
|
_pkgname=cthulhu
|
||||||
pkgver=2026.10.24.r75.g8a560a2
|
pkgver=2026.10.24.r77.g133953e
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Desktop-agnostic screen reader with speech, Braille, and Tentacle support"
|
pkgdesc="Desktop-agnostic screen reader with speech, Braille, and Tentacle support"
|
||||||
url="https://git.stormux.org/storm/cthulhu"
|
url="https://git.stormux.org/storm/cthulhu"
|
||||||
|
|||||||
@@ -31,6 +31,20 @@ text, with two objects on each visual row. Each benchmark iteration performs:
|
|||||||
construction and navigation bookkeeping.
|
construction and navigation bookkeeping.
|
||||||
- `BenchmarkDocumentLineAssembly`: one line lookup in the middle of a document,
|
- `BenchmarkDocumentLineAssembly`: one line lookup in the middle of a document,
|
||||||
combining the two text objects on that row. This includes snapshot construction.
|
combining the two text objects on that row. This includes snapshot construction.
|
||||||
|
- `BenchmarkDocumentCaretNavigation`: a character forward/backward round trip
|
||||||
|
within the same text object. Reusable caret ranges are warmed before timing.
|
||||||
|
- `BenchmarkDocumentWorkerHeadingNavigation`: a heading forward/backward round
|
||||||
|
trip through the review worker, including focus synchronization, diagnostics
|
||||||
|
and placement dispatch, with an in-memory provider. It performs no browser IPC.
|
||||||
|
|
||||||
|
Include the additional cases with
|
||||||
|
`-bench '^BenchmarkDocument(Snapshot|HeadingNavigation|LineAssembly|CaretNavigation|WorkerHeadingNavigation)$'`.
|
||||||
|
Navigation shares an immutable indexed view; `BenchmarkDocumentSnapshot`
|
||||||
|
continues to measure the independently owned node copy returned by `Document`.
|
||||||
|
The first view construction is included in snapshot and line measurements and
|
||||||
|
amortized over subsequent iterations. A short run can therefore overstate the
|
||||||
|
steady allocation rate. See [document navigation validation](browser-navigation-performance.md)
|
||||||
|
for cache ownership, invalidation and real-browser checks.
|
||||||
|
|
||||||
Fixture construction and initial focus synchronization are outside the timed
|
Fixture construction and initial focus synchronization are outside the timed
|
||||||
loops. The benchmarks fail on errors, missing work or invalid result shapes.
|
loops. The benchmarks fail on errors, missing work or invalid result shapes.
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# Document navigation performance and validation
|
||||||
|
|
||||||
|
Browse navigation reuses an immutable document view and an object-to-index
|
||||||
|
lookup owned by `AccessibleTree`. `DocumentCursor` reuses the view's caret ranges
|
||||||
|
and visual-line ownership map. Focus synchronization, heading/link searches,
|
||||||
|
position lookup, linked-heading lookup and replacement-parent bookkeeping use
|
||||||
|
that index instead of rebuilding maps or scanning the entire document per key.
|
||||||
|
|
||||||
|
`Replace`, object removal and destructive child invalidation retire the cached
|
||||||
|
view. Pending semantic, text, geometry and child-order changes keep the view's
|
||||||
|
immutable data but mark the live tree entries dirty. Before navigation reads or
|
||||||
|
selects content, it validates the objects it traverses and their relevant
|
||||||
|
ancestors. Dirty skipped candidates are validated too, so navigation cannot
|
||||||
|
silently omit newly readable content. A local move within one live text object
|
||||||
|
can proceed through a parent's unrelated insertion; crossing objects requires
|
||||||
|
fresh child order. Table-row summaries also validate their contributing sibling
|
||||||
|
cells using the same bounded dependency list used to derive the row text.
|
||||||
|
|
||||||
|
Failed validation preserves the reading position and uses the existing bounded
|
||||||
|
recapture/retry path. Successful native text remains valid for the requested
|
||||||
|
announcement if the provider removes its object immediately afterwards; retrying
|
||||||
|
that completed move would skip text. Activation, element lists, native selection
|
||||||
|
resolution and captured-line assembly retain complete-document validation.
|
||||||
|
`Document` still returns an independently owned node slice; callers of
|
||||||
|
`DocumentView` and `NavigationDocument` must treat the shared data as read-only.
|
||||||
|
Tree access and cache publication use the tree mutex, and native object lifetime
|
||||||
|
and GLib ownership stay with the adapter.
|
||||||
|
|
||||||
|
On 2026-10-24, three sequential 200 ms runs on an AMD Ryzen 9 PRO 5945 with
|
||||||
|
Go 1.27.3 compared the unchanged `133953e` source through a Go overlay with
|
||||||
|
these changes. The same benchmark source ran against both versions. Median
|
||||||
|
results for the 10,000-content-object fixture were:
|
||||||
|
|
||||||
|
| Operation | Before | After | Bytes allocated before | Bytes allocated after |
|
||||||
|
| --- | ---: | ---: | ---: | ---: |
|
||||||
|
| Worker heading forward/back pair | 87.476 ms | 0.053 ms | 276,775,952 | 51,650 |
|
||||||
|
| Character forward/back pair | 53.536 ms | 0.014 ms | 124,261,980 | 16,072 |
|
||||||
|
| Heading forward/back pair | 43.129 ms | 0.035 ms | 142,691,897 | 18,080 |
|
||||||
|
| Heading wrapping pair | 44.895 ms | 0.032 ms | 142,687,798 | 18,080 |
|
||||||
|
| Owned document snapshot | 14.747 ms | 5.421 ms | 54,801,125 | 11,723,115 |
|
||||||
|
| Captured line assembly | 15.297 ms | 3.095 ms | 54,810,445 | 182,019 |
|
||||||
|
|
||||||
|
These measure owned Go data and fake-provider calls, excluding native browser
|
||||||
|
IPC, initial capture, speech delivery and audible latency. Allocation figures
|
||||||
|
are traffic per operation, not retained process memory. Snapshot/line results
|
||||||
|
include an amortized first index build; navigation setup precedes timing.
|
||||||
|
Raw outputs are `/tmp/cthulhu-web-performance-before.txt` and
|
||||||
|
`/tmp/cthulhu-web-performance-after.txt`; medians are retained in
|
||||||
|
`/tmp/cthulhu-web-performance-medians.json`. Performance remains informational,
|
||||||
|
with no timing gate added to tests.
|
||||||
|
|
||||||
|
Run the affected correctness and race checks in a private session:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tests/native_test_session.py env GOCACHE=/tmp/cthulhu-cache go test ./internal/atspi ./internal/review ./internal/app -count=1
|
||||||
|
python3 tests/native_test_session.py env GOCACHE=/tmp/cthulhu-cache go test -race ./internal/atspi ./internal/review ./internal/app -count=1
|
||||||
|
go vet ./internal/atspi ./internal/review ./internal/app
|
||||||
|
```
|
||||||
|
|
||||||
|
The tests cover cached-view ownership, concurrent replacement, retired views,
|
||||||
|
cancellation, unrelated dirty branches, stale traversal and unchanged reading
|
||||||
|
positions after failures. Existing regressions also cover replacement recovery,
|
||||||
|
generation-checked activation, table navigation, native selections, passwords,
|
||||||
|
inline text, named links and browser-chrome round trips. Two existing recapture
|
||||||
|
tests now distinguish continued reading within a live leaf from crossing dirty
|
||||||
|
child order, rather than requiring a recapture for every document mutation.
|
||||||
|
|
||||||
|
Run the existing browser fixtures and actual-reader navigation fixture:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
CTHULHU_NATIVE_BROWSER_TESTS=replaced-feed,generatedlinks,replacement,fragments,tabs,namedlinks,linkedheadings,tables,dynamic,document,editing,selection,modes python3 tests/native_browser.py
|
||||||
|
go build -o /tmp/cthulhu-browser-performance ./cmd/cthulhu
|
||||||
|
CTHULHU_BINARY=/tmp/cthulhu-browser-performance python3 tests/native_browser_navigation.py
|
||||||
|
```
|
||||||
|
|
||||||
|
The actual-reader fixture checks 76 navigation commands while page text changes,
|
||||||
|
including forward/reverse headings, line/character/word movement and custom
|
||||||
|
activation. It records speech protocol requests in a private X11/browser session.
|
||||||
|
It does not establish audible output or physical keyboard/Braille behavior.
|
||||||
|
An initial optimized run passed the entire fixture. A final-binary run completed
|
||||||
|
all 76 navigation commands and both custom activations, then failed its final
|
||||||
|
pointer-handoff assertion: the click at screen coordinates 0,0 produced no
|
||||||
|
observed shared AT-SPI pointer press. The focused native pointer test passed.
|
||||||
|
Keep this separate from the verified navigation sequence; it remains an open
|
||||||
|
harness/delivery check. The unchanged original binary reproduced the same final
|
||||||
|
pointer failure after completing its navigation sequence, so this failure
|
||||||
|
predates the performance changes. Evidence is retained in
|
||||||
|
`/tmp/cthulhu-browser-navigation-56h2cw3n` (final binary),
|
||||||
|
`/tmp/cthulhu-browser-navigation-i2jxxobv` (original binary), and
|
||||||
|
`/tmp/cthulhu-browser-navigation-mduikajr` (initial complete optimized pass).
|
||||||
|
|
||||||
|
The browser `modes` fixture remains an intermittent gate. During this change it
|
||||||
|
passed in an isolated optimized run, but mixed runs failed once on page return
|
||||||
|
and once while expecting typed chrome text. The original source passed isolated
|
||||||
|
and tables/selection/modes comparison runs; these comparisons do not conclusively
|
||||||
|
attribute the intermittent failures. Preserve their logs and report the open
|
||||||
|
gate rather than describing the whole browser suite as consistently passing.
|
||||||
|
|
||||||
|
For human acceptance, use the actual reported Reddit view and a long updating
|
||||||
|
page. Navigate headings in both directions through wrapping, read post/comment
|
||||||
|
text with arrows and word/character commands, expand/collapse comments, load
|
||||||
|
more content, type and select in an editor, activate links, and return from the
|
||||||
|
address bar and another tab. Check that stale/replaced targets never activate a
|
||||||
|
different control. On a table, change a sibling cell and verify full-row speech
|
||||||
|
and Braille refresh. Check Steam browse navigation and search/Tab behavior after
|
||||||
|
an application/workspace round trip. Listen for skipped/repeated text and verify
|
||||||
|
physical Braille where available. Preserve the current log and obtain approval
|
||||||
|
before replacing the active reader.
|
||||||
|
|
||||||
|
The first native document capture still enumerates the captured tree, the
|
||||||
|
30,000-object safety limit still applies, and generic named-clickable filtering
|
||||||
|
is unchanged. These performance changes do not establish a fix for Reddit's
|
||||||
|
specific reported content problem. Review the diff and complete the live checks
|
||||||
|
before submitting under the project's AI-assisted contribution policy.
|
||||||
+51
-14
@@ -93,14 +93,48 @@ type DocumentNode struct {
|
|||||||
type DocumentSnapshot struct {
|
type DocumentSnapshot struct {
|
||||||
Context DocumentContext
|
Context DocumentContext
|
||||||
Nodes []DocumentNode
|
Nodes []DocumentNode
|
||||||
|
data *documentData
|
||||||
}
|
}
|
||||||
|
|
||||||
// Document copies the top-level document, including embedded documents but
|
// Document copies the top-level document, including embedded documents but
|
||||||
// excluding browser chrome and other tabs. A stale/incomplete capture fails
|
// excluding browser chrome and other tabs. A stale/incomplete capture fails
|
||||||
// atomically. It does not use flat review's viewport-limited text ranges.
|
// atomically. It does not use flat review's viewport-limited text ranges.
|
||||||
func (tree *AccessibleTree) Document(ctx context.Context, reference ObjectReference) (DocumentSnapshot, error) {
|
func (tree *AccessibleTree) Document(ctx context.Context, reference ObjectReference) (DocumentSnapshot, error) {
|
||||||
tree.mu.RLock()
|
view, err := tree.DocumentView(ctx, reference)
|
||||||
defer tree.mu.RUnlock()
|
if err != nil {
|
||||||
|
return DocumentSnapshot{}, err
|
||||||
|
}
|
||||||
|
view.Nodes = append([]DocumentNode(nil), view.Nodes...)
|
||||||
|
view.data = nil
|
||||||
|
return view, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DocumentView validates the complete document atomically and shares its
|
||||||
|
// immutable data. Like NavigationDocument, the returned Nodes are read-only.
|
||||||
|
func (tree *AccessibleTree) DocumentView(ctx context.Context, reference ObjectReference) (DocumentSnapshot, error) {
|
||||||
|
tree.mu.Lock()
|
||||||
|
defer tree.mu.Unlock()
|
||||||
|
view, err := tree.navigation_document_locked(ctx, reference)
|
||||||
|
if err != nil {
|
||||||
|
return DocumentSnapshot{}, err
|
||||||
|
}
|
||||||
|
if err := tree.validate_document_range_locked(ctx, view, 0, len(view.Nodes)-1); err != nil {
|
||||||
|
return DocumentSnapshot{}, err
|
||||||
|
}
|
||||||
|
return view, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NavigationDocument shares immutable document data until Replace or removal
|
||||||
|
// changes the tree. Callers must not modify Nodes or their nested slices. Dirty
|
||||||
|
// entries remain in the view: navigation validates the path it actually uses
|
||||||
|
// with ValidateDocumentRange rather than recapturing unrelated page updates.
|
||||||
|
func (tree *AccessibleTree) NavigationDocument(ctx context.Context, reference ObjectReference) (DocumentSnapshot, error) {
|
||||||
|
tree.mu.Lock()
|
||||||
|
defer tree.mu.Unlock()
|
||||||
|
return tree.navigation_document_locked(ctx, reference)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tree *AccessibleTree) navigation_document_locked(ctx context.Context, reference ObjectReference) (DocumentSnapshot, error) {
|
||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return DocumentSnapshot{}, err
|
return DocumentSnapshot{}, err
|
||||||
}
|
}
|
||||||
@@ -108,7 +142,14 @@ func (tree *AccessibleTree) Document(ctx context.Context, reference ObjectRefere
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return DocumentSnapshot{}, err
|
return DocumentSnapshot{}, err
|
||||||
}
|
}
|
||||||
result := DocumentSnapshot{Context: owner}
|
if data := tree.documents[owner.TopLevel.ID()]; data != nil {
|
||||||
|
return DocumentSnapshot{Context: owner, Nodes: data.nodes, data: data}, nil
|
||||||
|
}
|
||||||
|
capacity := 128
|
||||||
|
if owner.TopLevel.ID() == tree.root {
|
||||||
|
capacity = len(tree.nodes)
|
||||||
|
}
|
||||||
|
result := DocumentSnapshot{Context: owner, Nodes: make([]DocumentNode, 0, capacity)}
|
||||||
type pendingNode struct {
|
type pendingNode struct {
|
||||||
id ObjectID
|
id ObjectID
|
||||||
hidden bool
|
hidden bool
|
||||||
@@ -121,19 +162,10 @@ func (tree *AccessibleTree) Document(ctx context.Context, reference ObjectRefere
|
|||||||
last := len(pending) - 1
|
last := len(pending) - 1
|
||||||
next := pending[last]
|
next := pending[last]
|
||||||
pending = pending[:last]
|
pending = pending[:last]
|
||||||
entry := tree.nodes[next.id]
|
entry, present := tree.nodes[next.id]
|
||||||
if entry.snapshotInvalid {
|
if !present {
|
||||||
return DocumentSnapshot{}, ErrStaleSnapshot
|
|
||||||
}
|
|
||||||
if entry.childrenInvalid {
|
|
||||||
return DocumentSnapshot{}, ErrIncompleteTree
|
return DocumentSnapshot{}, ErrIncompleteTree
|
||||||
}
|
}
|
||||||
if entry.textInvalid {
|
|
||||||
return DocumentSnapshot{}, ErrStaleText
|
|
||||||
}
|
|
||||||
if entry.geometryInvalid {
|
|
||||||
return DocumentSnapshot{}, ErrStaleGeometry
|
|
||||||
}
|
|
||||||
hidden := next.hidden || entry.geometry.VisibilityKnown && !entry.geometry.Visible
|
hidden := next.hidden || entry.geometry.VisibilityKnown && !entry.geometry.Visible
|
||||||
parentEditable := false
|
parentEditable := false
|
||||||
if entry.hasParent {
|
if entry.hasParent {
|
||||||
@@ -153,6 +185,11 @@ func (tree *AccessibleTree) Document(ctx context.Context, reference ObjectRefere
|
|||||||
pending = append(pending, pendingNode{entry.children[i], hidden})
|
pending = append(pending, pendingNode{entry.children[i], hidden})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
result.data = new_document_data(result.Nodes)
|
||||||
populate_document_rows(&result)
|
populate_document_rows(&result)
|
||||||
|
if tree.documents == nil {
|
||||||
|
tree.documents = make(map[ObjectID]*documentData)
|
||||||
|
}
|
||||||
|
tree.documents[owner.TopLevel.ID()] = result.data
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
@@ -27,7 +27,7 @@ type DocumentLine struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (tree *AccessibleTree) DocumentLineAtOffset(ctx context.Context, reference ObjectReference, offset int) (DocumentLine, error) {
|
func (tree *AccessibleTree) DocumentLineAtOffset(ctx context.Context, reference ObjectReference, offset int) (DocumentLine, error) {
|
||||||
document, err := tree.Document(ctx, reference)
|
document, err := tree.DocumentView(ctx, reference)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return DocumentLine{}, err
|
return DocumentLine{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,10 +12,7 @@ import (
|
|||||||
// Derive rows from the already captured document. This performs no native
|
// Derive rows from the already captured document. This performs no native
|
||||||
// queries and keeps large tables bounded to 32 cells and 4096 bytes per row.
|
// queries and keeps large tables bounded to 32 cells and 4096 bytes per row.
|
||||||
func populate_document_rows(document *DocumentSnapshot) {
|
func populate_document_rows(document *DocumentSnapshot) {
|
||||||
nodes := make(map[ObjectID]int, len(document.Nodes))
|
nodes := document.data.indices
|
||||||
for index, node := range document.Nodes {
|
|
||||||
nodes[node.Reference.ID()] = index
|
|
||||||
}
|
|
||||||
type rowKey struct {
|
type rowKey struct {
|
||||||
table ObjectID
|
table ObjectID
|
||||||
row int
|
row int
|
||||||
@@ -55,10 +52,19 @@ func populate_document_rows(document *DocumentSnapshot) {
|
|||||||
owners[index] = key
|
owners[index] = key
|
||||||
}
|
}
|
||||||
texts := make(map[rowKey]string, len(rows))
|
texts := make(map[rowKey]string, len(rows))
|
||||||
|
dependencies := make(map[rowKey][]ObjectReference, len(rows))
|
||||||
for key, indices := range rows {
|
for key, indices := range rows {
|
||||||
sort.SliceStable(indices, func(a, b int) bool {
|
sort.SliceStable(indices, func(a, b int) bool {
|
||||||
return document.Nodes[indices[a]].Snapshot.TableColumnIndex < document.Nodes[indices[b]].Snapshot.TableColumnIndex
|
return document.Nodes[indices[a]].Snapshot.TableColumnIndex < document.Nodes[indices[b]].Snapshot.TableColumnIndex
|
||||||
})
|
})
|
||||||
|
// Validation follows the same bounded set used to derive row text.
|
||||||
|
// Include cells past the byte limit too: shorter updated labels could
|
||||||
|
// make those cells newly contribute to the next row summary.
|
||||||
|
references := make([]ObjectReference, len(indices))
|
||||||
|
for index, member := range indices {
|
||||||
|
references[index] = document.Nodes[member].Reference
|
||||||
|
}
|
||||||
|
dependencies[key] = references
|
||||||
var parts []string
|
var parts []string
|
||||||
length := 0
|
length := 0
|
||||||
for _, index := range indices {
|
for _, index := range indices {
|
||||||
@@ -78,7 +84,11 @@ func populate_document_rows(document *DocumentSnapshot) {
|
|||||||
}
|
}
|
||||||
texts[key] = strings.Join(parts, ", ")
|
texts[key] = strings.Join(parts, ", ")
|
||||||
}
|
}
|
||||||
|
if len(owners) > 0 {
|
||||||
|
document.data.rows = make(map[ObjectID][]ObjectReference, len(owners))
|
||||||
|
}
|
||||||
for index, key := range owners {
|
for index, key := range owners {
|
||||||
document.Nodes[index].Snapshot.TableRowText = texts[key]
|
document.Nodes[index].Snapshot.TableRowText = texts[key]
|
||||||
|
document.data.rows[document.Nodes[index].Reference.ID()] = dependencies[key]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
// Copyright (c) 2026 Storm Dragon
|
||||||
|
// Authors: Storm Dragon, Angus Kola
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package atspi
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// Owned by AccessibleTree under its mutex. Published data is immutable and can
|
||||||
|
// outlive a replacement; ValidateDocumentRange rejects retired views. Dirty
|
||||||
|
// flags stay in treeEntry, so an unrelated invalidation need not rebuild this.
|
||||||
|
type documentData struct {
|
||||||
|
nodes []DocumentNode
|
||||||
|
indices map[ObjectID]int
|
||||||
|
ends []int
|
||||||
|
rows map[ObjectID][]ObjectReference
|
||||||
|
}
|
||||||
|
|
||||||
|
func new_document_data(nodes []DocumentNode) *documentData {
|
||||||
|
data := &documentData{nodes: nodes, indices: make(map[ObjectID]int, len(nodes)), ends: make([]int, len(nodes))}
|
||||||
|
for index, node := range nodes {
|
||||||
|
data.indices[node.Reference.ID()] = index
|
||||||
|
data.ends[index] = index
|
||||||
|
}
|
||||||
|
for index := len(nodes) - 1; index > 0; index-- {
|
||||||
|
parent := data.indices[nodes[index].Parent]
|
||||||
|
data.ends[parent] = max(data.ends[parent], data.ends[index])
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// SameContent identifies a shared immutable view, not merely matching object
|
||||||
|
// paths. A replacement with surviving identities still has different content.
|
||||||
|
func (document DocumentSnapshot) SameContent(other DocumentSnapshot) bool {
|
||||||
|
return document.data != nil && document.data == other.data
|
||||||
|
}
|
||||||
|
|
||||||
|
func (document DocumentSnapshot) Index(id ObjectID) (int, bool) {
|
||||||
|
if document.data != nil {
|
||||||
|
index, ok := document.data.indices[id]
|
||||||
|
return index, ok
|
||||||
|
}
|
||||||
|
for index, node := range document.Nodes {
|
||||||
|
if node.Reference.ID() == id {
|
||||||
|
return index, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (document DocumentSnapshot) Node(id ObjectID) (DocumentNode, bool) {
|
||||||
|
index, ok := document.Index(id)
|
||||||
|
if !ok {
|
||||||
|
return DocumentNode{}, false
|
||||||
|
}
|
||||||
|
return document.Nodes[index], true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (document DocumentSnapshot) SubtreeEnd(index int) int {
|
||||||
|
if document.data != nil {
|
||||||
|
return document.data.ends[index]
|
||||||
|
}
|
||||||
|
end := index
|
||||||
|
for next := index + 1; next < len(document.Nodes); next++ {
|
||||||
|
parent, ok := document.Index(document.Nodes[next].Parent)
|
||||||
|
if !ok || parent < index {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
end = next
|
||||||
|
}
|
||||||
|
return end
|
||||||
|
}
|
||||||
|
|
||||||
|
func document_entry_error(entry treeEntry) error {
|
||||||
|
switch {
|
||||||
|
case entry.snapshotInvalid:
|
||||||
|
return ErrStaleSnapshot
|
||||||
|
case entry.childrenInvalid:
|
||||||
|
return ErrIncompleteTree
|
||||||
|
case entry.textInvalid:
|
||||||
|
return ErrStaleText
|
||||||
|
case entry.geometryInvalid:
|
||||||
|
return ErrStaleGeometry
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateDocumentRange checks traversed objects, including skipped/hidden
|
||||||
|
// candidates, before committing a move. Ancestor metadata always matters;
|
||||||
|
// ancestor child order matters when crossing objects. A character move within
|
||||||
|
// one live leaf therefore survives insertions elsewhere in its parent.
|
||||||
|
func (tree *AccessibleTree) ValidateDocumentRange(ctx context.Context, document DocumentSnapshot, from, to int) error {
|
||||||
|
tree.mu.RLock()
|
||||||
|
defer tree.mu.RUnlock()
|
||||||
|
return tree.validate_document_range_locked(ctx, document, from, to)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateDocumentOrder also checks ancestor child order when a structural
|
||||||
|
// search or a document-boundary jump will leave its current object.
|
||||||
|
func (tree *AccessibleTree) ValidateDocumentOrder(ctx context.Context, document DocumentSnapshot, index int) error {
|
||||||
|
tree.mu.RLock()
|
||||||
|
defer tree.mu.RUnlock()
|
||||||
|
return tree.validate_document_path_locked(ctx, document, index, index, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tree *AccessibleTree) validate_document_range_locked(ctx context.Context, document DocumentSnapshot, from, to int) error {
|
||||||
|
return tree.validate_document_path_locked(ctx, document, from, to, from != to)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tree *AccessibleTree) validate_document_path_locked(ctx context.Context, document DocumentSnapshot, from, to int, requireOrder bool) error {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if document.data == nil || tree.documents[document.Context.TopLevel.ID()] != document.data {
|
||||||
|
return ErrStaleObject
|
||||||
|
}
|
||||||
|
if from < 0 || to < 0 || from >= len(document.Nodes) || to >= len(document.Nodes) {
|
||||||
|
return ErrStaleObject
|
||||||
|
}
|
||||||
|
for index := min(from, to); index <= max(from, to); index++ {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
node := document.Nodes[index]
|
||||||
|
entry, err := tree.resolveLocked(node.Reference)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := document_entry_error(entry); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tree.validate_document_row_locked(document, node.Reference.ID()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for entry.hasParent {
|
||||||
|
entry = tree.nodes[entry.parent]
|
||||||
|
if entry.snapshotInvalid {
|
||||||
|
return ErrStaleSnapshot
|
||||||
|
}
|
||||||
|
if entry.geometryInvalid {
|
||||||
|
return ErrStaleGeometry
|
||||||
|
}
|
||||||
|
if requireOrder && entry.childrenInvalid {
|
||||||
|
return ErrIncompleteTree
|
||||||
|
}
|
||||||
|
if err := tree.validate_document_row_locked(document, entry.snapshot.ID()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tree *AccessibleTree) validate_document_row_locked(document DocumentSnapshot, id ObjectID) error {
|
||||||
|
for _, reference := range document.data.rows[id] {
|
||||||
|
entry, err := tree.resolveLocked(reference)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := document_entry_error(entry); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
// Copyright (c) 2026 Storm Dragon
|
||||||
|
// Authors: Storm Dragon, Angus Kola
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package atspi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNavigationDocumentConcurrentReplacementKeepsViewsImmutable(t *testing.T) {
|
||||||
|
tree, _ := NewAccessibleTree(testID("/root"), documentTestNodes())
|
||||||
|
ref, _ := tree.Reference(testID("/heading"))
|
||||||
|
var workers sync.WaitGroup
|
||||||
|
workers.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer workers.Done()
|
||||||
|
for range 100 {
|
||||||
|
nodes := documentTestNodes()
|
||||||
|
for index := range nodes {
|
||||||
|
nodes[index].Snapshot.Name = "Replacement"
|
||||||
|
}
|
||||||
|
if err := tree.Replace(testID("/root"), nodes); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
defer workers.Wait()
|
||||||
|
for range 100 {
|
||||||
|
view, err := tree.NavigationDocument(context.Background(), ref)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
replaced := view.Context.Ancestors[0].Name == "Replacement"
|
||||||
|
for _, node := range view.Nodes {
|
||||||
|
if (node.Snapshot.Name == "Replacement") != replaced {
|
||||||
|
t.Fatal("mixed replacement data in shared view")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tree.ValidateDocumentRange(context.Background(), view, 0, len(view.Nodes)-1); err != nil && !errors.Is(err, ErrStaleObject) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNavigationDocumentSharesOnlyImmutableData(t *testing.T) {
|
||||||
|
tree, _ := NewAccessibleTree(testID("/root"), documentTestNodes())
|
||||||
|
ref, _ := tree.Reference(testID("/heading"))
|
||||||
|
first, err := tree.NavigationDocument(context.Background(), ref)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second, err := tree.NavigationDocument(context.Background(), ref)
|
||||||
|
if err != nil || !first.SameContent(second) || &first.Nodes[0] != &second.Nodes[0] {
|
||||||
|
t.Fatal("unchanged document data was rebuilt", err)
|
||||||
|
}
|
||||||
|
copy, err := tree.Document(context.Background(), ref)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
copy.Nodes[0].Snapshot.Name = "Changed copy"
|
||||||
|
if first.Nodes[0].Snapshot.Name == "Changed copy" {
|
||||||
|
t.Fatal("owned snapshot modified shared navigation data")
|
||||||
|
}
|
||||||
|
for index, node := range first.Nodes {
|
||||||
|
if got, ok := first.Index(node.Reference.ID()); !ok || got != index {
|
||||||
|
t.Fatalf("index for %v = %d, %v", node.Reference.ID(), got, ok)
|
||||||
|
}
|
||||||
|
if end := first.SubtreeEnd(index); end < index || end >= len(first.Nodes) {
|
||||||
|
t.Fatalf("invalid subtree end %d", end)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNavigationDocumentValidatesOnlyTraversedBranches(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
invalidate func(*AccessibleTree)
|
||||||
|
want error
|
||||||
|
}{
|
||||||
|
{"text", func(tree *AccessibleTree) { tree.InvalidateText(testID("/link")) }, ErrStaleText},
|
||||||
|
{"semantics", func(tree *AccessibleTree) { tree.InvalidateSnapshot(testID("/link")) }, ErrStaleSnapshot},
|
||||||
|
{"geometry", func(tree *AccessibleTree) { tree.InvalidateGeometry(testID("/inner")) }, ErrStaleGeometry},
|
||||||
|
{"children", func(tree *AccessibleTree) { tree.MarkChildrenChanged(testID("/inner")) }, ErrIncompleteTree},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
tree, _ := NewAccessibleTree(testID("/root"), documentTestNodes())
|
||||||
|
ref, _ := tree.Reference(testID("/heading"))
|
||||||
|
view, err := tree.NavigationDocument(context.Background(), ref)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
test.invalidate(tree)
|
||||||
|
current, err := tree.NavigationDocument(context.Background(), ref)
|
||||||
|
if err != nil || !current.SameContent(view) {
|
||||||
|
t.Fatal("unrelated dirty branch rebuilt document", err)
|
||||||
|
}
|
||||||
|
heading, _ := view.Index(ref.ID())
|
||||||
|
if err := tree.ValidateDocumentRange(context.Background(), view, heading, heading); err != nil {
|
||||||
|
t.Fatal("unrelated update blocked current heading", err)
|
||||||
|
}
|
||||||
|
if err := tree.ValidateDocumentRange(context.Background(), view, heading, len(view.Nodes)-1); !errors.Is(err, test.want) {
|
||||||
|
t.Fatalf("traversed stale branch: %v, want %v", err, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNavigationDocumentRejectsRetiredViewsAndCanceledMoves(t *testing.T) {
|
||||||
|
tree, _ := NewAccessibleTree(testID("/root"), documentTestNodes())
|
||||||
|
ref, _ := tree.Reference(testID("/heading"))
|
||||||
|
view, _ := tree.NavigationDocument(context.Background(), ref)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
if err := tree.ValidateDocumentRange(ctx, view, 0, 0); !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := tree.Replace(testID("/root"), documentTestNodes()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := tree.ValidateDocumentRange(context.Background(), view, 0, 0); !errors.Is(err, ErrStaleObject) {
|
||||||
|
t.Fatal("replacement retained old view", err)
|
||||||
|
}
|
||||||
|
fresh, _ := tree.NavigationDocument(context.Background(), ref)
|
||||||
|
if fresh.SameContent(view) {
|
||||||
|
t.Fatal("replacement reused navigation data")
|
||||||
|
}
|
||||||
|
tree.Invalidate(testID("/link"))
|
||||||
|
if err := tree.ValidateDocumentRange(context.Background(), fresh, 0, 0); !errors.Is(err, ErrStaleObject) {
|
||||||
|
t.Fatal("removal retained old topology", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNavigationDocumentValidatesDerivedRowDependencies(t *testing.T) {
|
||||||
|
node := func(path, role string, children ...ObjectID) TreeNode {
|
||||||
|
n := testNode(path, path, children...)
|
||||||
|
n.Snapshot.Role = role
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
nodes := []TreeNode{node("/doc", "document web", testID("/table")), node("/table", "table", testID("/first"), testID("/second")), node("/first", "table cell", testID("/text")), node("/text", "static"), node("/second", "table cell")}
|
||||||
|
for _, index := range []int{2, 4} {
|
||||||
|
nodes[index].Snapshot.TablePositionKnown = true
|
||||||
|
nodes[index].Snapshot.TableColumnIndex = index/2 - 1
|
||||||
|
}
|
||||||
|
tree, err := NewAccessibleTree(testID("/doc"), nodes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ref, _ := tree.Reference(testID("/text"))
|
||||||
|
view, err := tree.NavigationDocument(context.Background(), ref)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
index, _ := view.Index(ref.ID())
|
||||||
|
tree.InvalidateText(testID("/second"))
|
||||||
|
if err := tree.ValidateDocumentRange(context.Background(), view, index, index); !errors.Is(err, ErrStaleText) {
|
||||||
|
t.Fatal("cached full row ignored a changed sibling cell", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,6 +88,7 @@ type AccessibleTree struct {
|
|||||||
root ObjectID
|
root ObjectID
|
||||||
nodes map[ObjectID]treeEntry
|
nodes map[ObjectID]treeEntry
|
||||||
nextGeneration uint64
|
nextGeneration uint64
|
||||||
|
documents map[ObjectID]*documentData
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAccessibleTree(root ObjectID, nodes []TreeNode) (*AccessibleTree, error) {
|
func NewAccessibleTree(root ObjectID, nodes []TreeNode) (*AccessibleTree, error) {
|
||||||
@@ -126,6 +127,7 @@ func (tree *AccessibleTree) Replace(root ObjectID, nodes []TreeNode) error {
|
|||||||
}
|
}
|
||||||
tree.root = root
|
tree.root = root
|
||||||
tree.nodes = next
|
tree.nodes = next
|
||||||
|
tree.documents = nil
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,6 +354,7 @@ func (tree *AccessibleTree) Invalidate(id ObjectID) int {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
tree.documents = nil
|
||||||
if entry.hasParent {
|
if entry.hasParent {
|
||||||
parent := tree.nodes[entry.parent]
|
parent := tree.nodes[entry.parent]
|
||||||
for index, childID := range parent.children {
|
for index, childID := range parent.children {
|
||||||
@@ -392,6 +395,7 @@ func (tree *AccessibleTree) InvalidateChildren(parentID ObjectID) int {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
tree.documents = nil
|
||||||
pending := append([]ObjectID(nil), parent.children...)
|
pending := append([]ObjectID(nil), parent.children...)
|
||||||
parent.children = nil
|
parent.children = nil
|
||||||
parent.childrenInvalid = true
|
parent.childrenInvalid = true
|
||||||
|
|||||||
+79
-91
@@ -79,10 +79,13 @@ type documentPlace struct {
|
|||||||
// capture/recapture lifecycle. Methods serialize cursor state and do no native
|
// capture/recapture lifecycle. Methods serialize cursor state and do no native
|
||||||
// work, key handling, focus changes, or speech.
|
// work, key handling, focus changes, or speech.
|
||||||
type DocumentCursor struct {
|
type DocumentCursor struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
tree *atspi.AccessibleTree
|
tree *atspi.AccessibleTree
|
||||||
places []documentPlace
|
places []documentPlace
|
||||||
active atspi.ObjectReference
|
active atspi.ObjectReference
|
||||||
|
caretDocument atspi.DocumentSnapshot
|
||||||
|
caretRanges []caretRange
|
||||||
|
lineOwners map[int]atspi.ObjectID
|
||||||
}
|
}
|
||||||
|
|
||||||
// rebindPlace keeps the selected target after a semantic refresh retires its
|
// rebindPlace keeps the selected target after a semantic refresh retires its
|
||||||
@@ -155,7 +158,7 @@ func (cursor *DocumentCursor) ActivationTarget() (atspi.ObjectReference, error)
|
|||||||
current = parent
|
current = parent
|
||||||
}
|
}
|
||||||
if place.origin == StructuralNavigation && selectedHeading {
|
if place.origin == StructuralNavigation && selectedHeading {
|
||||||
doc, err := cursor.tree.Document(context.Background(), place.object)
|
doc, err := cursor.tree.DocumentView(context.Background(), place.object)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return atspi.ObjectReference{}, err
|
return atspi.ObjectReference{}, err
|
||||||
}
|
}
|
||||||
@@ -190,12 +193,13 @@ func (cursor *DocumentCursor) sync_focus(ctx context.Context, focus atspi.Object
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return DocumentPosition{}, "focus-unresolved", err
|
return DocumentPosition{}, "focus-unresolved", err
|
||||||
}
|
}
|
||||||
doc, err := cursor.tree.Document(ctx, ref)
|
doc, err := cursor.tree.NavigationDocument(ctx, ref)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return DocumentPosition{}, "document-unresolved", err
|
return DocumentPosition{}, "document-unresolved", err
|
||||||
}
|
}
|
||||||
place := documentPlace{document: doc.Context.TopLevel, object: ref, focus: ref}
|
place := documentPlace{document: doc.Context.TopLevel, object: ref, focus: ref}
|
||||||
recovery := "new-document"
|
recovery := "new-document"
|
||||||
|
removeIndex := -1
|
||||||
for index, saved := range cursor.places {
|
for index, saved := range cursor.places {
|
||||||
if saved.document != place.document {
|
if saved.document != place.document {
|
||||||
if saved.document.ID() == place.document.ID() {
|
if saved.document.ID() == place.document.ID() {
|
||||||
@@ -220,9 +224,19 @@ func (cursor *DocumentCursor) sync_focus(ctx context.Context, focus atspi.Object
|
|||||||
place.object = parent.Reference
|
place.object = parent.Reference
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
cursor.places = append(cursor.places[:index], cursor.places[index+1:]...)
|
removeIndex = index
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
placeIndex, ok := doc.Index(place.object.ID())
|
||||||
|
if !ok {
|
||||||
|
return DocumentPosition{}, "saved-object-missing", atspi.ErrStaleObject
|
||||||
|
}
|
||||||
|
if err := cursor.tree.ValidateDocumentRange(ctx, doc, placeIndex, placeIndex); err != nil {
|
||||||
|
return DocumentPosition{}, "document-unresolved", err
|
||||||
|
}
|
||||||
|
if removeIndex >= 0 {
|
||||||
|
cursor.places = append(cursor.places[:removeIndex], cursor.places[removeIndex+1:]...)
|
||||||
|
}
|
||||||
remember_text_parent(doc, &place)
|
remember_text_parent(doc, &place)
|
||||||
if len(cursor.places) == 16 {
|
if len(cursor.places) == 16 {
|
||||||
cursor.places = cursor.places[1:]
|
cursor.places = cursor.places[1:]
|
||||||
@@ -242,7 +256,7 @@ func (cursor *DocumentCursor) JumpToFragment(ctx context.Context, fragment strin
|
|||||||
return DocumentPosition{}, false, ErrNoCurrent
|
return DocumentPosition{}, false, ErrNoCurrent
|
||||||
}
|
}
|
||||||
place := &cursor.places[len(cursor.places)-1]
|
place := &cursor.places[len(cursor.places)-1]
|
||||||
doc, err := cursor.tree.Document(ctx, place.object)
|
doc, err := cursor.tree.DocumentView(ctx, place.object)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return DocumentPosition{}, false, err
|
return DocumentPosition{}, false, err
|
||||||
}
|
}
|
||||||
@@ -334,7 +348,7 @@ func (cursor *DocumentCursor) Move(ctx context.Context, target DocumentTarget, l
|
|||||||
return DocumentMoveResult{}, ErrNoCurrent
|
return DocumentMoveResult{}, ErrNoCurrent
|
||||||
}
|
}
|
||||||
place := &cursor.places[len(cursor.places)-1]
|
place := &cursor.places[len(cursor.places)-1]
|
||||||
doc, err := cursor.tree.Document(ctx, place.object)
|
doc, err := cursor.tree.NavigationDocument(ctx, place.object)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return DocumentMoveResult{}, err
|
return DocumentMoveResult{}, err
|
||||||
}
|
}
|
||||||
@@ -343,86 +357,64 @@ func (cursor *DocumentCursor) Move(ctx context.Context, target DocumentTarget, l
|
|||||||
if doc.Context.TopLevel != place.document {
|
if doc.Context.TopLevel != place.document {
|
||||||
return DocumentMoveResult{}, atspi.ErrStaleObject
|
return DocumentMoveResult{}, atspi.ErrStaleObject
|
||||||
}
|
}
|
||||||
index := -1
|
index, found := doc.Index(place.object.ID())
|
||||||
for i, node := range doc.Nodes {
|
if !found || doc.Nodes[index].Reference != place.object {
|
||||||
if node.Reference == place.object {
|
|
||||||
index = i
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if index < 0 {
|
|
||||||
return DocumentMoveResult{}, atspi.ErrStaleObject
|
return DocumentMoveResult{}, atspi.ErrStaleObject
|
||||||
}
|
}
|
||||||
// Python treats a focused descendant as being within its nearest matching
|
if err := cursor.tree.ValidateDocumentOrder(ctx, doc, index); err != nil {
|
||||||
// ancestor. Anchor the search there so previous navigation does not select
|
return DocumentMoveResult{}, err
|
||||||
// and reannounce the containing link, heading, or other target.
|
|
||||||
indexByID := make(map[atspi.ObjectID]int, len(doc.Nodes))
|
|
||||||
parentByID := make(map[atspi.ObjectID]atspi.ObjectID, len(doc.Nodes))
|
|
||||||
for candidateIndex, node := range doc.Nodes {
|
|
||||||
indexByID[node.Reference.ID()] = candidateIndex
|
|
||||||
for _, child := range node.Children {
|
|
||||||
parentByID[child] = node.Reference.ID()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
for parent := place.object.ID(); parent.Valid(); parent = parentByID[parent] {
|
// Anchor at the nearest matching ancestor without rebuilding parent maps.
|
||||||
candidateIndex, ok := indexByID[parent]
|
for parent := place.object.ID(); parent.Valid(); {
|
||||||
|
candidate, ok := doc.Node(parent)
|
||||||
if !ok {
|
if !ok {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if documentTargetMatches(target, doc.Nodes[candidateIndex], level) {
|
if documentTargetMatches(target, candidate, level) {
|
||||||
index = candidateIndex
|
index, _ = doc.Index(parent)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
parent = candidate.Parent
|
||||||
|
}
|
||||||
|
commit := func(targetIndex int, wrapped bool) (DocumentMoveResult, error) {
|
||||||
|
if err := cursor.tree.ValidateDocumentRange(ctx, doc, targetIndex, doc.SubtreeEnd(targetIndex)); err != nil {
|
||||||
|
return DocumentMoveResult{}, err
|
||||||
|
}
|
||||||
|
place.tableCell = atspi.ObjectID{}
|
||||||
|
place.object, place.origin, place.offset = doc.Nodes[targetIndex].Reference, StructuralNavigation, 0
|
||||||
|
remember_text_parent(doc, place)
|
||||||
|
return DocumentMoveResult{Position: documentPosition(doc, *place), Moved: true, Wrapped: wrapped}, nil
|
||||||
}
|
}
|
||||||
step := 1
|
step := 1
|
||||||
if backwards {
|
if backwards {
|
||||||
step = -1
|
step = -1
|
||||||
}
|
}
|
||||||
|
// Validate every traversed node, including nonmatches and hidden nodes: a
|
||||||
|
// dirty candidate must be refreshed before it can be skipped safely.
|
||||||
for i := index + step; i >= 0 && i < len(doc.Nodes); i += step {
|
for i := index + step; i >= 0 && i < len(doc.Nodes); i += step {
|
||||||
node := doc.Nodes[i]
|
if err := cursor.tree.ValidateDocumentRange(ctx, doc, i-step, i); err != nil {
|
||||||
if node.Hidden {
|
return DocumentMoveResult{}, err
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
match := documentTargetMatches(target, node, level)
|
node := doc.Nodes[i]
|
||||||
if match {
|
if !node.Hidden && documentTargetMatches(target, node, level) {
|
||||||
place.tableCell = atspi.ObjectID{}
|
return commit(i, false)
|
||||||
place.object = node.Reference
|
|
||||||
place.origin = StructuralNavigation
|
|
||||||
place.offset = 0
|
|
||||||
remember_text_parent(doc, place)
|
|
||||||
return DocumentMoveResult{Position: documentPosition(doc, *place), Moved: true}, nil
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
firstMatch, lastMatch := -1, -1
|
start, end := 0, index
|
||||||
for i := 0; i < len(doc.Nodes); i++ {
|
|
||||||
node := doc.Nodes[i]
|
|
||||||
if node.Hidden {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !documentTargetMatches(target, node, level) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if firstMatch < 0 {
|
|
||||||
firstMatch = i
|
|
||||||
}
|
|
||||||
lastMatch = i
|
|
||||||
}
|
|
||||||
wrappedIndex := firstMatch
|
|
||||||
if backwards {
|
if backwards {
|
||||||
wrappedIndex = lastMatch
|
start, end = len(doc.Nodes)-1, index
|
||||||
}
|
}
|
||||||
if wrappedIndex >= 0 {
|
for i := start; ; i += step {
|
||||||
node := doc.Nodes[wrappedIndex]
|
if err := cursor.tree.ValidateDocumentRange(ctx, doc, i, i); err != nil {
|
||||||
place.tableCell = atspi.ObjectID{}
|
return DocumentMoveResult{}, err
|
||||||
place.object = node.Reference
|
}
|
||||||
place.origin = StructuralNavigation
|
node := doc.Nodes[i]
|
||||||
place.offset = 0
|
if !node.Hidden && documentTargetMatches(target, node, level) {
|
||||||
remember_text_parent(doc, place)
|
return commit(i, true)
|
||||||
return DocumentMoveResult{
|
}
|
||||||
Position: documentPosition(doc, *place),
|
if i == end {
|
||||||
Moved: true,
|
break
|
||||||
Wrapped: true,
|
}
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
return DocumentMoveResult{Position: documentPosition(doc, *place)}, nil
|
return DocumentMoveResult{Position: documentPosition(doc, *place)}, nil
|
||||||
}
|
}
|
||||||
@@ -536,12 +528,11 @@ func documentChunkRole(snapshot atspi.Snapshot) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func documentObject(doc atspi.DocumentSnapshot, ref atspi.ObjectReference) (atspi.Snapshot, bool) {
|
func documentObject(doc atspi.DocumentSnapshot, ref atspi.ObjectReference) (atspi.Snapshot, bool) {
|
||||||
for _, node := range doc.Nodes {
|
node, ok := doc.Node(ref.ID())
|
||||||
if node.Reference == ref {
|
if !ok || node.Reference != ref {
|
||||||
return node.Snapshot, true
|
return atspi.Snapshot{}, false
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return atspi.Snapshot{}, false
|
return node.Snapshot, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func documentPosition(doc atspi.DocumentSnapshot, place documentPlace) DocumentPosition {
|
func documentPosition(doc atspi.DocumentSnapshot, place documentPlace) DocumentPosition {
|
||||||
@@ -560,26 +551,19 @@ func documentPosition(doc atspi.DocumentSnapshot, place documentPlace) DocumentP
|
|||||||
// readable caret position, matching Python's heading navigation. A heading
|
// readable caret position, matching Python's heading navigation. A heading
|
||||||
// starting with plain text has no unambiguous link target.
|
// starting with plain text has no unambiguous link target.
|
||||||
func linkedHeadingNode(doc atspi.DocumentSnapshot, heading atspi.ObjectReference) (atspi.DocumentNode, bool, bool) {
|
func linkedHeadingNode(doc atspi.DocumentSnapshot, heading atspi.ObjectReference) (atspi.DocumentNode, bool, bool) {
|
||||||
byID := make(map[atspi.ObjectID]atspi.DocumentNode, len(doc.Nodes))
|
root, ok := doc.Node(heading.ID())
|
||||||
parents := make(map[atspi.ObjectID]atspi.ObjectID, len(doc.Nodes))
|
if !ok || root.Reference != heading || root.Snapshot.Role != "heading" {
|
||||||
for _, node := range doc.Nodes {
|
|
||||||
byID[node.Reference.ID()] = node
|
|
||||||
for _, child := range node.Children {
|
|
||||||
parents[child] = node.Reference.ID()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
root, ok := byID[heading.ID()]
|
|
||||||
if !ok || root.Snapshot.Role != "heading" {
|
|
||||||
return atspi.DocumentNode{}, false, false
|
return atspi.DocumentNode{}, false, false
|
||||||
}
|
}
|
||||||
for parent := parents[heading.ID()]; parent.Valid(); parent = parents[parent] {
|
for parent := root.Parent; parent.Valid(); {
|
||||||
node, found := byID[parent]
|
node, found := doc.Node(parent)
|
||||||
if !found || atspi.IsWebDocumentRole(node.Snapshot.Role) {
|
if !found || atspi.IsWebDocumentRole(node.Snapshot.Role) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if node.Snapshot.Role == "link" {
|
if node.Snapshot.Role == "link" {
|
||||||
return node, true, false
|
return node, true, false
|
||||||
}
|
}
|
||||||
|
parent = node.Parent
|
||||||
}
|
}
|
||||||
var match atspi.DocumentNode
|
var match atspi.DocumentNode
|
||||||
count := 0
|
count := 0
|
||||||
@@ -587,7 +571,7 @@ func linkedHeadingNode(doc atspi.DocumentSnapshot, heading atspi.ObjectReference
|
|||||||
for len(pending) != 0 && count < 2 {
|
for len(pending) != 0 && count < 2 {
|
||||||
id := pending[len(pending)-1]
|
id := pending[len(pending)-1]
|
||||||
pending = pending[:len(pending)-1]
|
pending = pending[:len(pending)-1]
|
||||||
node, found := byID[id]
|
node, found := doc.Node(id)
|
||||||
if !found || node.Hidden || node.Snapshot.Role == "heading" {
|
if !found || node.Hidden || node.Snapshot.Role == "heading" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -607,7 +591,7 @@ func linkedHeadingNode(doc atspi.DocumentSnapshot, heading atspi.ObjectReference
|
|||||||
for len(pending) != 0 {
|
for len(pending) != 0 {
|
||||||
id := pending[len(pending)-1]
|
id := pending[len(pending)-1]
|
||||||
pending = pending[:len(pending)-1]
|
pending = pending[:len(pending)-1]
|
||||||
node, found := byID[id]
|
node, found := doc.Node(id)
|
||||||
if !found || node.Hidden {
|
if !found || node.Hidden {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -622,7 +606,7 @@ func linkedHeadingNode(doc atspi.DocumentSnapshot, heading atspi.ObjectReference
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
var firstLink atspi.DocumentNode
|
var firstLink atspi.DocumentNode
|
||||||
for ; id.Valid(); id = parents[id] {
|
for id.Valid() {
|
||||||
if id == heading.ID() {
|
if id == heading.ID() {
|
||||||
if firstLink.Reference.Valid() {
|
if firstLink.Reference.Valid() {
|
||||||
// Gecko may omit plain text between child accessibles.
|
// Gecko may omit plain text between child accessibles.
|
||||||
@@ -636,13 +620,17 @@ func linkedHeadingNode(doc atspi.DocumentSnapshot, heading atspi.ObjectReference
|
|||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
node := byID[id]
|
node, found := doc.Node(id)
|
||||||
|
if !found {
|
||||||
|
break
|
||||||
|
}
|
||||||
if node.Snapshot.Role == "heading" || atspi.IsWebDocumentRole(node.Snapshot.Role) {
|
if node.Snapshot.Role == "heading" || atspi.IsWebDocumentRole(node.Snapshot.Role) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if node.Snapshot.Role == "link" && !firstLink.Reference.Valid() {
|
if node.Snapshot.Role == "link" && !firstLink.Reference.Valid() {
|
||||||
firstLink = node
|
firstLink = node
|
||||||
}
|
}
|
||||||
|
id = node.Parent
|
||||||
}
|
}
|
||||||
// Only the heading's starting caret position can select a link.
|
// Only the heading's starting caret position can select a link.
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -8,11 +8,87 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Include focus synchronization and worker bookkeeping, which the primitive
|
||||||
|
// heading benchmark intentionally excludes. Native provider work is separate.
|
||||||
|
func BenchmarkDocumentWorkerHeadingNavigation(b *testing.B) {
|
||||||
|
for _, contentNodes := range []int{100, 1000, 10000} {
|
||||||
|
b.Run(fmt.Sprintf("nodes-%d", contentNodes), func(b *testing.B) {
|
||||||
|
tree, ids := benchmark_document_tree(b, contentNodes)
|
||||||
|
cursor, err := NewDocumentCursor(tree)
|
||||||
|
if err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
worker, err := NewWorker(&fakeCapturer{}, time.Second)
|
||||||
|
if err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
middle := contentNodes / 40 * 20
|
||||||
|
worker.SyncFocus(ids[middle])
|
||||||
|
ctx := context.Background()
|
||||||
|
if _, err := cursor.SyncFocus(ctx, ids[middle]); err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
b.ReportAllocs()
|
||||||
|
for b.Loop() {
|
||||||
|
for _, backwards := range []bool{false, true} {
|
||||||
|
result, next := worker.moveDocument(ctx, cursor, DocumentHeading, 2, backwards, 0)
|
||||||
|
cursor = next
|
||||||
|
want := ids[middle+20]
|
||||||
|
if backwards {
|
||||||
|
want = ids[middle]
|
||||||
|
}
|
||||||
|
if result.Err != nil || !result.Moved || result.DocumentPosition.Object.ID() != want || result.DocumentDiagnostics.CaptureAttempts != 0 {
|
||||||
|
b.Fatalf("worker move: moved=%v error=%v", result.Moved, result.Err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkDocumentCaretNavigation(b *testing.B) {
|
||||||
|
for _, contentNodes := range []int{100, 1000, 10000} {
|
||||||
|
b.Run(fmt.Sprintf("nodes-%d", contentNodes), func(b *testing.B) {
|
||||||
|
tree, ids := benchmark_document_tree(b, contentNodes)
|
||||||
|
cursor, err := NewDocumentCursor(tree)
|
||||||
|
if err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
middle := contentNodes/2 + 2
|
||||||
|
ctx := context.Background()
|
||||||
|
if _, err := cursor.SyncFocus(ctx, ids[middle]); err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
reader := caretTextReader{ids[middle]: fmt.Sprintf("Café sample %d", middle)}
|
||||||
|
// Build the view's reusable ranges before measuring repeated keys.
|
||||||
|
for _, command := range []DocumentCaretCommand{CaretNextCharacter, CaretPreviousCharacter} {
|
||||||
|
if _, err := cursor.MoveCaret(ctx, reader, command); err != nil {
|
||||||
|
b.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.ReportAllocs()
|
||||||
|
for b.Loop() {
|
||||||
|
for _, command := range []DocumentCaretCommand{CaretNextCharacter, CaretPreviousCharacter} {
|
||||||
|
move, err := cursor.MoveCaret(ctx, reader, command)
|
||||||
|
want := 1
|
||||||
|
if command == CaretPreviousCharacter {
|
||||||
|
want = 0
|
||||||
|
}
|
||||||
|
if err != nil || !move.Moved || move.Position.Object.ID() != ids[middle] || move.Position.Offset != want {
|
||||||
|
b.Fatalf("caret move: moved=%v error=%v", move.Moved, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// benchmark_document_tree models an already captured page, not native AT-SPI
|
// benchmark_document_tree models an already captured page, not native AT-SPI
|
||||||
// capture. Each section has twenty content objects: a heading, a link, and
|
// capture. Each section has twenty content objects: a heading, a link, and
|
||||||
// ordinary text. Pairs of objects share a visual row, including Unicode text.
|
// ordinary text. Pairs of objects share a visual row, including Unicode text.
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ func (worker *Worker) route_document_braille(ctx context.Context, cursor *Docume
|
|||||||
result.Err = atspi.ErrNoDocumentCaret
|
result.Err = atspi.ErrNoDocumentCaret
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
doc, err := cursor.tree.Document(ctx, target)
|
doc, err := cursor.tree.DocumentView(ctx, target)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.Err = err
|
result.Err = err
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -83,37 +83,85 @@ func (cursor *DocumentCursor) MoveCaret(ctx context.Context, reader DocumentCare
|
|||||||
if !cursor.active.Valid() || len(cursor.places) == 0 {
|
if !cursor.active.Valid() || len(cursor.places) == 0 {
|
||||||
return DocumentCaretMove{}, ErrNoCurrent
|
return DocumentCaretMove{}, ErrNoCurrent
|
||||||
}
|
}
|
||||||
place := &cursor.places[len(cursor.places)-1]
|
saved := &cursor.places[len(cursor.places)-1]
|
||||||
doc, err := cursor.tree.Document(ctx, place.object)
|
next := *saved
|
||||||
|
place := &next
|
||||||
|
doc, err := cursor.tree.NavigationDocument(ctx, place.object)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return DocumentCaretMove{}, err
|
return DocumentCaretMove{}, err
|
||||||
}
|
}
|
||||||
if doc.Context.TopLevel != place.document {
|
if doc.Context.TopLevel != place.document {
|
||||||
return DocumentCaretMove{}, atspi.ErrStaleObject
|
return DocumentCaretMove{}, atspi.ErrStaleObject
|
||||||
}
|
}
|
||||||
if command >= TableLeft {
|
current, found := doc.Index(place.object.ID())
|
||||||
return cursor.moveTable(ctx, doc, place, command)
|
if !found || doc.Nodes[current].Reference != place.object {
|
||||||
}
|
|
||||||
place.tableCell = atspi.ObjectID{}
|
|
||||||
_, current := caret_node_indices(doc.Nodes, place.object)
|
|
||||||
indices := caretNodeRanges(doc.Nodes)
|
|
||||||
if current < 0 {
|
|
||||||
return DocumentCaretMove{}, atspi.ErrStaleObject
|
return DocumentCaretMove{}, atspi.ErrStaleObject
|
||||||
}
|
}
|
||||||
|
if err := cursor.tree.ValidateDocumentRange(ctx, doc, current, current); err != nil {
|
||||||
|
return DocumentCaretMove{}, err
|
||||||
|
}
|
||||||
|
if command >= TableLeft {
|
||||||
|
// Table coordinates and row/header presentation consume the complete
|
||||||
|
// table, unlike ordinary caret movement's local text path.
|
||||||
|
if err := cursor.tree.ValidateDocumentRange(ctx, doc, 0, len(doc.Nodes)-1); err != nil {
|
||||||
|
return DocumentCaretMove{}, err
|
||||||
|
}
|
||||||
|
movement, err := cursor.moveTable(ctx, doc, place, command)
|
||||||
|
if err == nil && movement.Moved {
|
||||||
|
*saved = next
|
||||||
|
}
|
||||||
|
return movement, err
|
||||||
|
}
|
||||||
|
place.tableCell = atspi.ObjectID{}
|
||||||
|
if !doc.SameContent(cursor.caretDocument) {
|
||||||
|
cursor.caretDocument = doc
|
||||||
|
cursor.caretRanges = caretNodeRanges(doc.Nodes)
|
||||||
|
cursor.lineOwners = visual_line_owners(doc.Nodes)
|
||||||
|
}
|
||||||
|
indices := cursor.caretRanges
|
||||||
if len(indices) == 0 {
|
if len(indices) == 0 {
|
||||||
|
if err := cursor.tree.ValidateDocumentRange(ctx, doc, 0, len(doc.Nodes)-1); err != nil {
|
||||||
|
return DocumentCaretMove{}, err
|
||||||
|
}
|
||||||
return DocumentCaretMove{Position: documentPosition(doc, *place)}, nil
|
return DocumentCaretMove{Position: documentPosition(doc, *place)}, nil
|
||||||
}
|
}
|
||||||
var movement DocumentCaretMove
|
var movement DocumentCaretMove
|
||||||
|
navigationReader := &navigationTextReader{DocumentCaretReader: reader, tree: cursor.tree, document: doc, anchor: current}
|
||||||
|
reader = navigationReader
|
||||||
switch command {
|
switch command {
|
||||||
case CaretNextWord, CaretPreviousWord, CaretNextCharacter, CaretPreviousCharacter:
|
case CaretNextWord, CaretPreviousWord, CaretNextCharacter, CaretPreviousCharacter:
|
||||||
movement, err = moveCaretText(ctx, reader, doc, place, indices, current,
|
movement, err = moveCaretText(ctx, reader, doc, place, indices, current,
|
||||||
command == CaretPreviousWord || command == CaretPreviousCharacter,
|
command == CaretPreviousWord || command == CaretPreviousCharacter,
|
||||||
command == CaretNextWord || command == CaretPreviousWord)
|
command == CaretNextWord || command == CaretPreviousWord)
|
||||||
default:
|
default:
|
||||||
movement, err = move_caret_line(ctx, reader, cursor.tree, doc, place, indices, current, command)
|
movement, err = move_caret_line(ctx, reader, cursor.tree, doc, place, indices, current, command, cursor.lineOwners)
|
||||||
}
|
}
|
||||||
if err == nil && movement.Moved {
|
if err == nil && movement.Moved {
|
||||||
|
target, found := doc.Index(place.object.ID())
|
||||||
|
if !found {
|
||||||
|
return DocumentCaretMove{}, atspi.ErrStaleObject
|
||||||
|
}
|
||||||
|
// Successful text already belongs to this move even if the provider
|
||||||
|
// removes its ancestor immediately afterwards. Repeating that move
|
||||||
|
// 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 {
|
||||||
|
return DocumentCaretMove{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
remember_text_parent(doc, place)
|
remember_text_parent(doc, place)
|
||||||
|
*saved = next
|
||||||
|
} else if err == nil {
|
||||||
|
// Reaching a boundary must not silently skip newly readable text in a
|
||||||
|
// dirty object which had no caret range in the captured view.
|
||||||
|
boundary := len(doc.Nodes) - 1
|
||||||
|
if command == CaretPreviousCharacter || command == CaretPreviousWord || command == CaretPreviousLine || command == CaretDocumentStart {
|
||||||
|
boundary = 0
|
||||||
|
}
|
||||||
|
if err := cursor.tree.ValidateDocumentRange(ctx, doc, current, boundary); err != nil {
|
||||||
|
return DocumentCaretMove{}, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return movement, err
|
return movement, err
|
||||||
}
|
}
|
||||||
@@ -115,7 +115,7 @@ func (worker *Worker) listDocument(ctx context.Context, cursor *DocumentCursor,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return list, err
|
return list, err
|
||||||
}
|
}
|
||||||
doc, err := tree.Document(captureContext, ref)
|
doc, err := tree.DocumentView(captureContext, ref)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return list, err
|
return list, err
|
||||||
}
|
}
|
||||||
@@ -206,7 +206,7 @@ func (worker *Worker) selectDocumentList(ctx context.Context, cursor *DocumentCu
|
|||||||
result.Err = err
|
result.Err = err
|
||||||
return result, cursor
|
return result, cursor
|
||||||
}
|
}
|
||||||
doc, err := list.tree.Document(ctx, position.Object)
|
doc, err := list.tree.DocumentView(ctx, position.Object)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result.Err = err
|
result.Err = err
|
||||||
return result, cursor
|
return result, cursor
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ func (cursor *DocumentCursor) syncNativeCaret(ctx context.Context, reader Docume
|
|||||||
cursor.mu.Lock()
|
cursor.mu.Lock()
|
||||||
defer cursor.mu.Unlock()
|
defer cursor.mu.Unlock()
|
||||||
place := &cursor.places[len(cursor.places)-1]
|
place := &cursor.places[len(cursor.places)-1]
|
||||||
doc, err := cursor.tree.Document(ctx, place.focus)
|
doc, err := cursor.tree.DocumentView(ctx, place.focus)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -40,11 +40,7 @@ func (cursor *DocumentCursor) syncNativeCaret(ctx context.Context, reader Docume
|
|||||||
}
|
}
|
||||||
|
|
||||||
func native_caret_node(ctx context.Context, reader DocumentCaretReader, doc atspi.DocumentSnapshot, focus atspi.ObjectID, offset int) (atspi.DocumentNode, int, error) {
|
func native_caret_node(ctx context.Context, reader DocumentCaretReader, doc atspi.DocumentSnapshot, focus atspi.ObjectID, offset int) (atspi.DocumentNode, int, error) {
|
||||||
nodes := make(map[atspi.ObjectID]atspi.DocumentNode, len(doc.Nodes))
|
node, _ := doc.Node(focus)
|
||||||
for _, node := range doc.Nodes {
|
|
||||||
nodes[node.Reference.ID()] = node
|
|
||||||
}
|
|
||||||
node := nodes[focus]
|
|
||||||
if !node.Reference.Valid() || offset < 0 || offset > node.Snapshot.CharacterCount || node.Snapshot.Editable || node.Snapshot.Role == "password text" {
|
if !node.Reference.Valid() || offset < 0 || offset > node.Snapshot.CharacterCount || node.Snapshot.Editable || node.Snapshot.Role == "password text" {
|
||||||
return atspi.DocumentNode{}, 0, atspi.ErrNoDocumentCaret
|
return atspi.DocumentNode{}, 0, atspi.ErrNoDocumentCaret
|
||||||
}
|
}
|
||||||
@@ -60,7 +56,7 @@ func native_caret_node(ctx context.Context, reader DocumentCaretReader, doc atsp
|
|||||||
if !embedded.Valid() {
|
if !embedded.Valid() {
|
||||||
return node, offset, nil
|
return node, offset, nil
|
||||||
}
|
}
|
||||||
child, ok := nodes[embedded]
|
child, ok := doc.Node(embedded)
|
||||||
if !ok || child.Hidden || child.Snapshot.Editable || child.Snapshot.Role == "password text" {
|
if !ok || child.Hidden || child.Snapshot.Editable || child.Snapshot.Role == "password text" {
|
||||||
return atspi.DocumentNode{}, 0, atspi.ErrNoDocumentCaret
|
return atspi.DocumentNode{}, 0, atspi.ErrNoDocumentCaret
|
||||||
}
|
}
|
||||||
@@ -70,7 +66,7 @@ func native_caret_node(ctx context.Context, reader DocumentCaretReader, doc atsp
|
|||||||
if len(node.Children) != 1 {
|
if len(node.Children) != 1 {
|
||||||
return atspi.DocumentNode{}, 0, atspi.ErrNoDocumentCaret
|
return atspi.DocumentNode{}, 0, atspi.ErrNoDocumentCaret
|
||||||
}
|
}
|
||||||
child, ok := nodes[node.Children[0]]
|
child, ok := doc.Node(node.Children[0])
|
||||||
if !ok || child.Hidden || child.Snapshot.Editable || child.Snapshot.CharacterCount != node.Snapshot.CharacterCount {
|
if !ok || child.Hidden || child.Snapshot.Editable || child.Snapshot.CharacterCount != node.Snapshot.CharacterCount {
|
||||||
return atspi.DocumentNode{}, 0, atspi.ErrNoDocumentCaret
|
return atspi.DocumentNode{}, 0, atspi.ErrNoDocumentCaret
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
// Copyright (c) 2026 Storm Dragon
|
||||||
|
// Authors: Storm Dragon, Angus Kola
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package review
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||||
|
)
|
||||||
|
|
||||||
|
// One move owns this wrapper. Validate the path before native reads so stale
|
||||||
|
// range lengths and omitted candidates trigger recovery without consuming a
|
||||||
|
// different object's text or advancing the retained cursor.
|
||||||
|
type navigationTextReader struct {
|
||||||
|
DocumentCaretReader
|
||||||
|
tree *atspi.AccessibleTree
|
||||||
|
document atspi.DocumentSnapshot
|
||||||
|
anchor int
|
||||||
|
readObjects []atspi.ObjectReference
|
||||||
|
}
|
||||||
|
|
||||||
|
func (reader *navigationTextReader) ReadDocumentCaret(ctx context.Context, object atspi.ObjectReference, offset int, unit atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) {
|
||||||
|
index, found := reader.document.Index(object.ID())
|
||||||
|
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 {
|
||||||
|
return atspi.DocumentCaretText{}, err
|
||||||
|
}
|
||||||
|
text, err := reader.DocumentCaretReader.ReadDocumentCaret(ctx, object, offset, unit)
|
||||||
|
if err == nil {
|
||||||
|
reader.readObjects = append(reader.readObjects, object)
|
||||||
|
}
|
||||||
|
return text, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (reader *navigationTextReader) did_read(object atspi.ObjectReference) bool {
|
||||||
|
for _, read := range reader.readObjects {
|
||||||
|
if read == object {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// Copyright (c) 2026 Storm Dragon
|
||||||
|
// Authors: Storm Dragon, Angus Kola
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package review
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||||
|
)
|
||||||
|
|
||||||
|
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},
|
||||||
|
{"semantics", func(tree *atspi.AccessibleTree) { tree.InvalidateSnapshot(webID("/next")) }, atspi.ErrStaleSnapshot},
|
||||||
|
{"geometry", func(tree *atspi.AccessibleTree) { tree.InvalidateGeometry(webID("/next")) }, atspi.ErrStaleGeometry},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
reader := caretTextReader{webID("/text"): "Alpha", webID("/next"): "Next"}
|
||||||
|
cursor := newCaretTextCursor(t, reader)
|
||||||
|
test.invalidate(cursor.tree)
|
||||||
|
if _, err := cursor.SyncFocus(context.Background(), webID("/text")); err != nil {
|
||||||
|
t.Fatal("unrelated update blocked focus synchronization", err)
|
||||||
|
}
|
||||||
|
move, err := cursor.MoveCaret(context.Background(), reader, CaretNextCharacter)
|
||||||
|
if err != nil || !move.Moved || move.Text != "l" {
|
||||||
|
t.Fatalf("local character move: %+v %v", move, err)
|
||||||
|
}
|
||||||
|
_, err = cursor.MoveCaret(context.Background(), reader, CaretNextLine)
|
||||||
|
if !errors.Is(err, test.want) {
|
||||||
|
t.Fatalf("dirty target read: %v, want %v", err, test.want)
|
||||||
|
}
|
||||||
|
if place := cursor.places[0]; place.object.ID() != webID("/text") || place.offset != 1 {
|
||||||
|
t.Fatalf("failed move changed position: %+v", place)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDocumentCaretKeepsLocalTextThroughParentInsertion(t *testing.T) {
|
||||||
|
reader := caretTextReader{webID("/text"): "Alpha", webID("/next"): "Next"}
|
||||||
|
cursor := newCaretTextCursor(t, reader)
|
||||||
|
cursor.tree.MarkChildrenChanged(webID("/doc"))
|
||||||
|
move, err := cursor.MoveCaret(context.Background(), reader, CaretNextCharacter)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDocumentStructuralMoveDefersUnrelatedBranch(t *testing.T) {
|
||||||
|
nodes := []atspi.TreeNode{
|
||||||
|
{Snapshot: atspi.Snapshot{BusName: webID("/doc").BusName, Path: "/doc", Role: "document web"}, Children: []atspi.ObjectID{webID("/one"), webID("/two"), webID("/later")}},
|
||||||
|
{Snapshot: atspi.Snapshot{BusName: webID("/one").BusName, Path: "/one", Role: "heading", Name: "First"}},
|
||||||
|
{Snapshot: atspi.Snapshot{BusName: webID("/two").BusName, Path: "/two", Role: "heading", Name: "Second"}},
|
||||||
|
{Snapshot: atspi.Snapshot{BusName: webID("/later").BusName, Path: "/later", Role: "static", Name: "Later"}},
|
||||||
|
}
|
||||||
|
tree, err := atspi.NewAccessibleTree(webID("/doc"), nodes)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cursor, _ := NewDocumentCursor(tree)
|
||||||
|
if _, err := cursor.SyncFocus(context.Background(), webID("/one")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tree.InvalidateText(webID("/later"))
|
||||||
|
move, err := cursor.Move(context.Background(), DocumentHeading, 0, false)
|
||||||
|
if err != nil || !move.Moved || move.Position.Object.ID() != webID("/two") {
|
||||||
|
t.Fatalf("unrelated update blocked adjacent heading: %+v %v", move, err)
|
||||||
|
}
|
||||||
|
if _, err := cursor.Move(context.Background(), DocumentHeading, 0, false); !errors.Is(err, atspi.ErrStaleText) {
|
||||||
|
t.Fatal("wrapped across dirty content", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,50 +14,43 @@ func remember_text_parent(doc atspi.DocumentSnapshot, place *documentPlace) {
|
|||||||
place.textParent = atspi.ObjectReference{}
|
place.textParent = atspi.ObjectReference{}
|
||||||
place.textPrevious, place.textNext = atspi.ObjectReference{}, atspi.ObjectReference{}
|
place.textPrevious, place.textNext = atspi.ObjectReference{}, atspi.ObjectReference{}
|
||||||
place.recoveryParents = nil
|
place.recoveryParents = nil
|
||||||
// Nodes are in provider preorder. Walking backwards follows the parent
|
node, ok := doc.Node(place.object.ID())
|
||||||
// chain in one scan, without rescanning all nodes for each ancestor.
|
if !ok || node.Reference != place.object {
|
||||||
var parentID atspi.ObjectID
|
return
|
||||||
plainText := false
|
}
|
||||||
for index := len(doc.Nodes) - 1; index >= 0; index-- {
|
plainText := replacement_text_leaf(node)
|
||||||
node := doc.Nodes[index]
|
for parentID := node.Parent; parentID.Valid(); {
|
||||||
if node.Reference == place.object {
|
parent, ok := doc.Node(parentID)
|
||||||
parentID, plainText = node.Parent, replacement_text_leaf(node)
|
if !ok {
|
||||||
continue
|
break
|
||||||
}
|
}
|
||||||
if !parentID.Valid() || node.Reference.ID() != parentID {
|
if len(place.recoveryParents) == 0 && plainText && replacement_text_parent(parent) {
|
||||||
continue
|
place.textParent = parent.Reference
|
||||||
}
|
for childIndex, id := range parent.Children {
|
||||||
if len(place.recoveryParents) == 0 && plainText && replacement_text_parent(node) {
|
|
||||||
place.textParent = node.Reference
|
|
||||||
for childIndex, id := range node.Children {
|
|
||||||
if id != place.object.ID() {
|
if id != place.object.ID() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if len(node.Children) == 1 {
|
if childIndex > 0 {
|
||||||
break
|
if sibling, ok := doc.Node(parent.Children[childIndex-1]); ok {
|
||||||
}
|
|
||||||
for _, sibling := range doc.Nodes {
|
|
||||||
if childIndex > 0 && sibling.Reference.ID() == node.Children[childIndex-1] {
|
|
||||||
place.textPrevious = sibling.Reference
|
place.textPrevious = sibling.Reference
|
||||||
}
|
}
|
||||||
if childIndex+1 < len(node.Children) && sibling.Reference.ID() == node.Children[childIndex+1] {
|
}
|
||||||
|
if childIndex+1 < len(parent.Children) {
|
||||||
|
if sibling, ok := doc.Node(parent.Children[childIndex+1]); ok {
|
||||||
place.textNext = sibling.Reference
|
place.textNext = sibling.Reference
|
||||||
}
|
}
|
||||||
if (childIndex == 0 || place.textPrevious.Valid()) && (childIndex+1 == len(node.Children) || place.textNext.Valid()) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if childIndex > 0 && !place.textPrevious.Valid() || childIndex+1 < len(node.Children) && !place.textNext.Valid() {
|
if childIndex > 0 && !place.textPrevious.Valid() || childIndex+1 < len(parent.Children) && !place.textNext.Valid() {
|
||||||
place.textParent = atspi.ObjectReference{}
|
place.textParent = atspi.ObjectReference{}
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if node.Reference == place.document || len(place.recoveryParents) == 32 {
|
if parent.Reference == place.document || len(place.recoveryParents) == 32 {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
place.recoveryParents = append(place.recoveryParents, node.Reference)
|
place.recoveryParents = append(place.recoveryParents, parent.Reference)
|
||||||
parentID = node.Parent
|
parentID = parent.Parent
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,9 +48,8 @@ func (reader *visualLineReader) ReadDocumentCaret(ctx context.Context, object at
|
|||||||
return text, err
|
return text, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func move_caret_line(ctx context.Context, source DocumentCaretReader, tree *atspi.AccessibleTree, doc atspi.DocumentSnapshot, place *documentPlace, indices []caretRange, current int, command DocumentCaretCommand) (DocumentCaretMove, error) {
|
func move_caret_line(ctx context.Context, source DocumentCaretReader, tree *atspi.AccessibleTree, doc atspi.DocumentSnapshot, place *documentPlace, indices []caretRange, current int, command DocumentCaretCommand, owners map[int]atspi.ObjectID) (DocumentCaretMove, error) {
|
||||||
reader := &visualLineReader{DocumentCaretReader: source, cache: make(map[visualLineKey]atspi.DocumentCaretText)}
|
reader := &visualLineReader{DocumentCaretReader: source, cache: make(map[visualLineKey]atspi.DocumentCaretText)}
|
||||||
owners := visual_line_owners(doc.Nodes)
|
|
||||||
next := *place
|
next := *place
|
||||||
// Move beyond the entire current line, even when character/word movement
|
// Move beyond the entire current line, even when character/word movement
|
||||||
// left the cursor in a middle fragment or structural navigation chose its
|
// left the cursor in a middle fragment or structural navigation chose its
|
||||||
|
|||||||
@@ -870,7 +870,10 @@ func TestWorkerDocumentRecapturePreservesPosition(t *testing.T) {
|
|||||||
call := <-capturer.calls
|
call := <-capturer.calls
|
||||||
call.result <- captureResult{tree: tree}
|
call.result <- captureResult{tree: tree}
|
||||||
waitResult(t, worker)
|
waitResult(t, worker)
|
||||||
tree.InvalidateText(webID("/link"))
|
// Only a traversed branch requires recapture now. The hidden heading is
|
||||||
|
// between the current and requested positions; it cannot be skipped until
|
||||||
|
// its invalidated data has been refreshed.
|
||||||
|
tree.InvalidateText(webID("/hidden"))
|
||||||
worker.MoveDocument(DocumentHeading, 0, true)
|
worker.MoveDocument(DocumentHeading, 0, true)
|
||||||
call = <-capturer.calls
|
call = <-capturer.calls
|
||||||
nodes := webNodes()
|
nodes := webNodes()
|
||||||
@@ -889,7 +892,7 @@ func TestWorkerDocumentRecapturePreservesPosition(t *testing.T) {
|
|||||||
t.Fatalf("recapture diagnostics = %+v", diagnostics)
|
t.Fatalf("recapture diagnostics = %+v", diagnostics)
|
||||||
}
|
}
|
||||||
failed, recovered := diagnostics.Attempts[0], diagnostics.Attempts[1]
|
failed, recovered := diagnostics.Attempts[0], diagnostics.Attempts[1]
|
||||||
if failed.Stage != "focus" || failed.Recovery != "document-unresolved" || failed.Error == "" ||
|
if failed.Stage != "move" || failed.Recovery != "preserved" || failed.Error == "" ||
|
||||||
recovered.Recovery != "preserved" || recovered.Error != "" || recovered.Before.Object != webID("/second") ||
|
recovered.Recovery != "preserved" || recovered.Error != "" || recovered.Before.Object != webID("/second") ||
|
||||||
recovered.Synced.Object != webID("/second") || recovered.After.Object != webID("/first") {
|
recovered.Synced.Object != webID("/second") || recovered.After.Object != webID("/first") {
|
||||||
t.Fatalf("recapture lost diagnostic history: %+v", diagnostics)
|
t.Fatalf("recapture lost diagnostic history: %+v", diagnostics)
|
||||||
@@ -1015,22 +1018,35 @@ func TestDocumentCaretKeepsOffsetAfterDocumentRefresh(t *testing.T) {
|
|||||||
for _, node := range doc.Nodes {
|
for _, node := range doc.Nodes {
|
||||||
nodes = append(nodes, atspi.TreeNode{Snapshot: node.Snapshot, Children: node.Children, Geometry: node.Geometry, Text: node.Text})
|
nodes = append(nodes, atspi.TreeNode{Snapshot: node.Snapshot, Children: node.Children, Geometry: node.Geometry, Text: node.Text})
|
||||||
}
|
}
|
||||||
wantReason := atspi.ErrStaleSnapshot
|
var wantReason error = atspi.ErrStaleSnapshot
|
||||||
if kind == "children" {
|
if kind == "children" {
|
||||||
cursor.tree.MarkChildrenChanged(webID("/doc"))
|
cursor.tree.MarkChildrenChanged(webID("/doc"))
|
||||||
wantReason = atspi.ErrIncompleteTree
|
wantReason = nil
|
||||||
} else {
|
} else {
|
||||||
cursor.tree.InvalidateSnapshot(webID("/text"))
|
cursor.tree.InvalidateSnapshot(webID("/text"))
|
||||||
}
|
}
|
||||||
go func() {
|
refresh := func() {
|
||||||
call := <-navigator.calls
|
call := <-navigator.calls
|
||||||
err := cursor.tree.Replace(webID("/doc"), nodes)
|
err := cursor.tree.Replace(webID("/doc"), nodes)
|
||||||
call.result <- captureResult{tree: cursor.tree, err: err}
|
call.result <- captureResult{tree: cursor.tree, err: err}
|
||||||
}()
|
}
|
||||||
|
if kind != "children" {
|
||||||
|
go refresh()
|
||||||
|
}
|
||||||
result, _ := worker.moveDocumentCaret(ctx, cursor, CaretNextLine, worker.documentEpoch.Load())
|
result, _ := worker.moveDocumentCaret(ctx, cursor, CaretNextLine, worker.documentEpoch.Load())
|
||||||
if result.Err != nil || !errors.Is(result.CaptureReason, wantReason) || result.DocumentCaretText != "Third" {
|
if result.Err != nil || !errors.Is(result.CaptureReason, wantReason) || result.DocumentCaretText != "Third" {
|
||||||
t.Fatalf("semantic refresh restarted or skipped reading: %+v", result)
|
t.Fatalf("semantic refresh restarted or skipped reading: %+v", result)
|
||||||
}
|
}
|
||||||
|
if kind == "children" {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user