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.
199 lines
7.0 KiB
Go
199 lines
7.0 KiB
Go
// 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 {
|
|
return document_entry_error_tolerating(entry, false)
|
|
}
|
|
|
|
// document_entry_error_tolerating optionally ignores text, geometry, and
|
|
// child-order staleness. Caret movement reads text and line bounds live
|
|
// from the provider and generation-checks every endpoint, so captured
|
|
// staleness of those cannot make its result wrong: at worst a just-inserted
|
|
// sibling is skipped until the next capture. Identity and semantics are
|
|
// checked either way.
|
|
func document_entry_error_tolerating(entry treeEntry, tolerateText bool) error {
|
|
switch {
|
|
case entry.snapshotInvalid:
|
|
return ErrStaleSnapshot
|
|
case entry.childrenInvalid && !tolerateText:
|
|
return ErrIncompleteTree
|
|
case entry.textInvalid && !tolerateText:
|
|
return ErrStaleText
|
|
case entry.geometryInvalid && !tolerateText:
|
|
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)
|
|
}
|
|
|
|
// ValidateDocumentCaretRange is ValidateDocumentRange for caret movement,
|
|
// whose text and line bounds are read live from the provider during the
|
|
// move. Text, geometry, and child-order staleness along the path therefore
|
|
// does not force a recapture first; on constantly ticking pages some
|
|
// traversed counter is always mid-update, and strict validation could
|
|
// never commit a move no matter how often it recaptured. Endpoints remain
|
|
// generation-checked, and structural navigation keeps strict validation.
|
|
func (tree *AccessibleTree) ValidateDocumentCaretRange(ctx context.Context, document DocumentSnapshot, from, to int) error {
|
|
tree.mu.RLock()
|
|
defer tree.mu.RUnlock()
|
|
return tree.validate_document_path_locked(ctx, document, from, to, from != to, true)
|
|
}
|
|
|
|
// 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, false)
|
|
}
|
|
|
|
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, false)
|
|
}
|
|
|
|
func (tree *AccessibleTree) validate_document_path_locked(ctx context.Context, document DocumentSnapshot, from, to int, requireOrder, tolerateText 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_tolerating(entry, tolerateText); err != nil {
|
|
return err
|
|
}
|
|
if err := tree.validate_document_row_locked(document, node.Reference.ID(), tolerateText); err != nil {
|
|
return err
|
|
}
|
|
for entry.hasParent {
|
|
entry = tree.nodes[entry.parent]
|
|
if entry.snapshotInvalid {
|
|
return ErrStaleSnapshot
|
|
}
|
|
if entry.geometryInvalid && !tolerateText {
|
|
return ErrStaleGeometry
|
|
}
|
|
if requireOrder && !tolerateText && entry.childrenInvalid {
|
|
return ErrIncompleteTree
|
|
}
|
|
// Hypertext ranges interleave parent-owned text with embedded
|
|
// children. An insertion can change those ranges without changing
|
|
// the accessible child list. Refresh before crossing objects, while
|
|
// allowing movement within an unchanged child to remain local.
|
|
// Even caret movement keeps this check: captured embedded-object
|
|
// offsets decide which ranges exist, so tolerating a hypertext
|
|
// insertion would silently skip the inserted text.
|
|
if requireOrder && entry.textInvalid && len(entry.snapshot.TextChildren) != 0 {
|
|
return ErrStaleText
|
|
}
|
|
if err := tree.validate_document_row_locked(document, entry.snapshot.ID(), tolerateText); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (tree *AccessibleTree) validate_document_row_locked(document DocumentSnapshot, id ObjectID, tolerateText bool) error {
|
|
for _, reference := range document.data.rows[id] {
|
|
entry, err := tree.resolveLocked(reference)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := document_entry_error_tolerating(entry, tolerateText); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|