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.
272 lines
11 KiB
Go
272 lines
11 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package review
|
|
|
|
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 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
|
|
}
|
|
}
|
|
if childIndex+1 < len(parent.Children) {
|
|
if sibling, ok := doc.Node(parent.Children[childIndex+1]); ok {
|
|
place.textNext = sibling.Reference
|
|
}
|
|
}
|
|
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
|
|
}
|
|
}
|
|
if parent.Reference == place.document || len(place.recoveryParents) == 32 {
|
|
break
|
|
}
|
|
place.recoveryParents = append(place.recoveryParents, parent.Reference)
|
|
parentID = parent.Parent
|
|
}
|
|
}
|
|
|
|
// Navigation may restart within a surviving container when a page replaces
|
|
// several text children at once. Never transfer offsets or use this fallback
|
|
// for actions, and never cross a document generation, hidden or editable area.
|
|
func survivingDocumentParent(doc atspi.DocumentSnapshot, saved documentPlace) (atspi.DocumentNode, bool) {
|
|
if saved.document != doc.Context.TopLevel {
|
|
return atspi.DocumentNode{}, false
|
|
}
|
|
for _, parent := range saved.recoveryParents {
|
|
for _, node := range doc.Nodes {
|
|
if node.Reference == parent && !node.Hidden && !node.ParentEditable &&
|
|
!node.Snapshot.Editable && !node.Snapshot.Focusable && !node.Snapshot.SupportsClick &&
|
|
!atspi.IsWebDocumentRole(node.Snapshot.Role) {
|
|
return node, true
|
|
}
|
|
}
|
|
}
|
|
return atspi.DocumentNode{}, false
|
|
}
|
|
|
|
func replacement_text_node(doc atspi.DocumentSnapshot, saved documentPlace) (atspi.DocumentNode, string, bool) {
|
|
if saved.document != doc.Context.TopLevel || !saved.textParent.Valid() || saved.origin != CaretNavigation {
|
|
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
|
|
}
|
|
for _, parent := range doc.Nodes {
|
|
if parent.Reference != saved.textParent || !replacement_text_parent(parent) {
|
|
continue
|
|
}
|
|
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
|
|
}
|
|
child := byID[id]
|
|
if previous == saved.textPrevious && next == saved.textNext && child != nil && replacement_text_leaf(*child) {
|
|
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
|
|
}
|
|
|
|
// 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 {
|
|
if len(node.Children) == 0 || node.Hidden || node.ParentEditable || node.Snapshot.Editable {
|
|
return false
|
|
}
|
|
// Ordinary links retain their native text children. CSS-generated text
|
|
// (including ::before) can be recreated when its native caret is placed.
|
|
// Recover only navigation within the same surviving link and sibling
|
|
// boundaries. Activation still checks the saved target before SyncFocus.
|
|
if node.Snapshot.Role == "link" {
|
|
return !node.Snapshot.ExplicitName
|
|
}
|
|
return !node.Snapshot.Focusable && !node.Snapshot.SupportsClick &&
|
|
(node.Snapshot.Role == "paragraph" || node.Snapshot.Role == "heading" || node.Snapshot.Role == "section")
|
|
}
|
|
|
|
func replacement_text_leaf(node atspi.DocumentNode) bool {
|
|
return len(node.Children) == 0 && !node.Hidden && !node.ParentEditable &&
|
|
!node.Snapshot.Editable && !node.Snapshot.Focusable && !node.Snapshot.SupportsClick &&
|
|
!node.Snapshot.ExplicitName && node.Snapshot.CharacterCount > 0 &&
|
|
(node.Snapshot.Role == "static" || node.Snapshot.Role == "text")
|
|
}
|