// 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") }