Files
cthulhu/internal/atspi/adapter_tree_native.go
2026-10-24 12:07:02 -07:00

671 lines
27 KiB
Go

// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package atspi
/*
#include "adapter_native.h"
*/
import "C"
import (
"context"
"errors"
"fmt"
"runtime/cgo"
"strings"
"time"
"unsafe"
)
func geometryFromNative(ctx context.Context, source *C.AtspiAccessible) (Geometry, error) {
return geometryFromNativeWithBounds(ctx, source, true)
}
func geometryFromNativeWithBounds(ctx context.Context, source *C.AtspiAccessible, captureBounds bool) (Geometry, error) {
geometry := Geometry{}
if err := ctx.Err(); err != nil {
return geometry, err
}
states := C.atspi_accessible_get_state_set(source)
if states == nil {
return geometry, ctx.Err()
}
geometry.VisibilityKnown = true
geometry.Visible = C.atspi_state_set_contains(states, C.ATSPI_STATE_VISIBLE) != 0
geometry.Showing = C.atspi_state_set_contains(states, C.ATSPI_STATE_SHOWING) != 0
C.g_object_unref(C.gpointer(states))
// libatspi specifies that component bounds are meaningful only when both
// states are present. Avoid querying hidden/offscreen components.
if !captureBounds || !geometry.Visible || !geometry.Showing {
return geometry, ctx.Err()
}
if err := ctx.Err(); err != nil {
return geometry, err
}
component := C.atspi_accessible_get_component_iface(source)
if component == nil {
return geometry, ctx.Err()
}
defer C.g_object_unref(C.gpointer(component))
if err := ctx.Err(); err != nil {
return geometry, err
}
var nativeErr *C.GError
bounds := C.atspi_component_get_extents(component, C.ATSPI_COORD_TYPE_SCREEN, &nativeErr)
if bounds != nil {
defer C.g_free(C.gpointer(bounds))
}
if nativeErr != nil {
C.g_error_free(nativeErr)
return geometry, ctx.Err()
}
if bounds != nil {
rectangle := Rectangle{X: int32(bounds.x), Y: int32(bounds.y), Width: int32(bounds.width), Height: int32(bounds.height)}
if rectangle.Valid() {
geometry.Bounds = rectangle
geometry.BoundsKnown = true
}
}
return geometry, ctx.Err()
}
func admitNativeChild(scheduled map[ObjectID]struct{}, childID ObjectID) bool {
if _, duplicate := scheduled[childID]; duplicate {
return false
}
scheduled[childID] = struct{}{}
return true
}
type nativeTreeCache struct {
nodes map[ObjectID]treeEntry
sources map[ObjectID]*C.AtspiAccessible
}
// captureNativeTree copies the desktop or, when reviewBusName is set, just the
// focused application's subtree. The latter keeps flat review independent of
// unrelated AT-SPI providers that can be slow or transiently unavailable.
func captureNativeTree(ctx context.Context, root *C.AtspiAccessible, reviewBusName string, document, documentText bool, retained map[ObjectID]*C.AtspiAccessible, cache nativeTreeCache) (ObjectID, []TreeNode, error) {
trace := capture_diagnostics(ctx)
trace.begin_stage("tree-setup", ObjectID{}, "")
reviewOnly := !document && reviewBusName != ""
rootID := nativeObjectID(root)
if !rootID.Valid() {
C.g_object_unref(C.gpointer(root))
return ObjectID{}, nil, fmt.Errorf("%w: native root identity is invalid", ErrInvalidTree)
}
type pendingNode struct {
source *C.AtspiAccessible
clip Rectangle
clipped bool
clipChanged bool
parentIndex int
}
pending := []pendingNode{{source: root, parentIndex: -1}}
textBytes := 0
scheduled := map[ObjectID]struct{}{rootID: {}}
nodes := make([]TreeNode, 0, 128)
// Embedded frames can belong to another AT-SPI process. Application names
// are stable within one bus, including during flat review of a large page.
applicationNames := make(map[string]string)
releasePending := func() {
for _, item := range pending {
C.g_object_unref(C.gpointer(item.source))
}
}
for len(pending) != 0 {
if err := ctx.Err(); err != nil {
releasePending()
return ObjectID{}, nil, err
}
last := len(pending) - 1
item := pending[last]
source := item.source
pending = pending[:last]
sourceID := nativeObjectID(source)
cached, known := cache.nodes[sourceID]
refresh := !known || cached.snapshotInvalid || cached.textInvalid || cached.geometryInvalid || cached.childrenInvalid || cached.childrenUnavailable
snapshot := cached.snapshot
trace.begin_stage("snapshot", sourceID, snapshot.Role)
if refresh {
// Only changed/new objects require synchronous provider queries.
// Reusing live siblings matches Python's event-driven web cache and
// prevents a small insertion from repeatedly timing out on a big page.
// New document and flat-review nodes already have libatspi's
// cached child lists. Clearing them forces another child-count
// request even for a cached leaf, which can fail on a busy provider.
// Known dirty entries still need a fresh provider snapshot. Preserve
// the existing full refresh for desktop/object capture.
if known || (!document && !reviewOnly) {
C.atspi_accessible_clear_cache_single(source)
} else if reviewOnly {
// A browse scroll can leave SHOWING cached from the previous
// viewport without a state-change event for each descendant.
// Refresh states on entry to visual capture, while preserving
// cached children and interfaces for slow native providers.
source.cached_properties &^= C.ATSPI_CACHE_STATES
}
// Navigation refreshes sound geometry only for the announced target,
// after scrolling. Capturing it for every descendant repeats native
// ancestor walks and can exhaust the document capture deadline.
applicationName, knownApplication := applicationNames[nativeObjectID(source).BusName]
if knownApplication {
snapshot = snapshotFromNativeWithOptions(source, !document, false, document, false, false)
snapshot.ApplicationName = applicationName
} else {
snapshot = snapshotFromNativeWithCaret(source, !document, false, document, false)
}
}
trace.progress(snapshot.Role, len(nodes), len(pending)+1, textBytes)
if _, knownApplication := applicationNames[snapshot.BusName]; !knownApplication {
applicationNames[snapshot.BusName] = snapshot.ApplicationName
}
if snapshot.Defunct {
if (document || reviewOnly) && item.parentIndex >= 0 {
// Providers can retire a descendant after exposing it in its
// parent's child list. Drop that edge and keep the live window.
children := nodes[item.parentIndex].Children
for index, child := range children {
if child == snapshot.ID() {
nodes[item.parentIndex].Children = append(children[:index], children[index+1:]...)
break
}
}
C.g_object_unref(C.gpointer(source))
continue
}
C.g_object_unref(C.gpointer(source))
releasePending()
return ObjectID{}, nil, fmt.Errorf("%w: bus %q path %q", ErrDeadObject, snapshot.BusName, snapshot.Path)
}
if retained != nil {
retained[snapshot.ID()] = (*C.AtspiAccessible)(C.g_object_ref(C.gpointer(source)))
}
trace.begin_stage("geometry", snapshot.ID(), snapshot.Role)
geometry := cached.geometry
var geometryErr error
if refresh {
geometry, geometryErr = geometryFromNativeWithBounds(ctx, source, !document || documentText)
}
if geometryErr != nil {
C.g_object_unref(C.gpointer(source))
releasePending()
return ObjectID{}, nil, geometryErr
}
clip, clipped := item.clip, item.clipped
if geometry.BoundsKnown {
if clipped {
clip, _ = clip.Intersection(geometry.Bounds)
} else {
clip = geometry.Bounds
clipped = true
}
} else if geometry.VisibilityKnown && (!geometry.Visible || !geometry.Showing) && snapshot.Role != "application" && snapshot.Role != "desktop frame" {
clip, clipped = Rectangle{}, true
}
childClip, childClipped := clip, clipped
if snapshot.Role == "page tab" && geometry.Visible && geometry.Showing {
// A tab's bounds describe its header, while its accessible children
// can be the displayed page below it. Preserve the parent viewport.
childClip, childClipped = item.clip, item.clipped
}
trace.begin_stage("visible-text", snapshot.ID(), snapshot.Role)
text := VisibleText{Limited: true, Issue: TextTreeLimit}
if (!document || documentText) && textBytes < maxTreeTextBytes {
if known && !cached.textInvalid && !cached.geometryInvalid && !item.clipChanged {
text = cached.text
} else {
var textErr error
text, textErr = textFromNative(ctx, source, geometry, snapshot, clip)
if textErr != nil {
C.g_object_unref(C.gpointer(source))
releasePending()
return ObjectID{}, nil, textErr
}
}
size := 0
for _, r := range text.Ranges {
size += len(r.Text)
}
if textBytes+size > maxTreeTextBytes {
text = VisibleText{Limited: true, Issue: TextTreeLimit}
trace.text_issue(TextTreeLimit)
} else {
textBytes += size
}
}
trace.begin_stage("table-viewport", snapshot.ID(), snapshot.Role)
// Visual review cannot use descendants of hidden controls. Inactive
// notebook pages and closed GTK combo menus can expose thousands of
// children; traversing them can exhaust the entire capture deadline.
// Keep semantic document and desktop/object capture complete.
captureChildren := !reviewOnly || !geometry.VisibilityKnown ||
(geometry.Visible && geometry.Showing) || snapshot.Role == "application" || snapshot.Role == "desktop frame"
// A visibility change can reveal children omitted by an earlier review
// capture, even when the provider emits no children-changed event.
cachedChildren := known && !cached.childrenInvalid && !cached.childrenUnavailable && (!reviewOnly || !cached.geometryInvalid)
childrenUnavailable := false
for _, id := range cached.children {
if cache.sources[id] == nil {
cachedChildren = false
break
}
}
var childCountError *C.GError
childCount := len(cached.children)
var tableChildren []*C.AtspiAccessible
tableViewport := false
if reviewOnly && captureChildren {
var tableErr error
tableChildren, tableViewport, tableErr = native_review_table_children(ctx, source, clip)
if tableErr != nil {
C.g_object_unref(C.gpointer(source))
releasePending()
return ObjectID{}, nil, fmt.Errorf("%w (bus %q path %q role %q)", tableErr, snapshot.BusName, snapshot.Path, snapshot.Role)
}
// The pending stack takes its own reference to each admitted cell.
defer func(children []*C.AtspiAccessible) {
for _, child := range children {
C.g_object_unref(C.gpointer(child))
}
}(tableChildren)
}
trace.begin_stage("child-count", snapshot.ID(), snapshot.Role)
if !captureChildren {
childCount = 0
} else if tableViewport {
childCount = len(tableChildren)
} else if !cachedChildren {
// Snapshot refresh above already cleared dirty entries. Clearing
// again discards the state and interface data just fetched.
childCount = int(C.atspi_accessible_get_child_count(source, &childCountError))
}
if childCountError != nil {
err := fmt.Errorf("%w (bus %q path %q role %q captured_nodes %d)",
nativeError("query AT-SPI child count", childCountError), snapshot.BusName, snapshot.Path, snapshot.Role, len(nodes))
C.g_error_free(childCountError)
if !reviewOnly || item.parentIndex < 0 || ctx.Err() != nil {
C.g_object_unref(C.gpointer(source))
releasePending()
return ObjectID{}, nil, err
}
childCount, childrenUnavailable = 0, true
}
if childCount < 0 {
if reviewOnly && item.parentIndex >= 0 && ctx.Err() == nil {
childCount, childrenUnavailable = 0, true
} else {
C.g_object_unref(C.gpointer(source))
releasePending()
return ObjectID{}, nil, fmt.Errorf("query AT-SPI child count: negative result (bus %q path %q role %q captured_nodes %d)",
snapshot.BusName, snapshot.Path, snapshot.Role, len(nodes))
}
}
if reviewBusName == "" && len(scheduled)+childCount > maxTreeNodes {
C.g_object_unref(C.gpointer(source))
releasePending()
return ObjectID{}, nil, fmt.Errorf("%w: native tree exceeds limit %d", ErrInvalidTree, maxTreeNodes)
}
trace.begin_stage("child-enumeration", snapshot.ID(), snapshot.Role)
children := make([]ObjectID, 0, childCount)
for index := 0; index < childCount; index++ {
if err := ctx.Err(); err != nil {
C.g_object_unref(C.gpointer(source))
releasePending()
return ObjectID{}, nil, err
}
var childError *C.GError
var child *C.AtspiAccessible
if tableViewport {
child = (*C.AtspiAccessible)(C.g_object_ref(C.gpointer(tableChildren[index])))
} else if cachedChildren {
child = (*C.AtspiAccessible)(C.g_object_ref(C.gpointer(cache.sources[cached.children[index]])))
} else {
child = C.atspi_accessible_get_child_at_index(source, C.gint(index), &childError)
}
if childError != nil {
err := nativeError("query AT-SPI child", childError)
C.g_error_free(childError)
if child != nil {
C.g_object_unref(C.gpointer(child))
}
if reviewOnly && ctx.Err() == nil {
childrenUnavailable = true
continue
}
C.g_object_unref(C.gpointer(source))
releasePending()
return ObjectID{}, nil, err
}
if child == nil {
// Child lists can change between the count and lookup calls. Match
// Python Cthulhu's traversal policy: omit an unavailable child while
// retaining the rest of the current parent snapshot. A missing child
// must not prevent flat review of an otherwise live focused window.
continue
}
childID := nativeObjectID(child)
if !childID.Valid() {
C.g_object_unref(C.gpointer(child))
C.g_object_unref(C.gpointer(source))
releasePending()
return ObjectID{}, nil, fmt.Errorf("%w: native child identity is invalid", ErrInvalidTree)
}
if reviewBusName != "" && childID.BusName != reviewBusName {
C.g_object_unref(C.gpointer(child))
continue
}
if len(scheduled) >= maxTreeNodes {
C.g_object_unref(C.gpointer(child))
C.g_object_unref(C.gpointer(source))
releasePending()
return ObjectID{}, nil, fmt.Errorf("%w: native tree exceeds limit %d", ErrInvalidTree, maxTreeNodes)
}
if !admitNativeChild(scheduled, childID) {
// Chromium can transiently expose the same accessible through more
// than one child edge while replacing document content. Python's
// flat-review traversal tolerates that provider defect. Keep the
// first edge so the owned snapshot remains a proper tree rather than
// rejecting the entire active window.
C.g_object_unref(C.gpointer(child))
continue
}
children = append(children, childID)
pending = append(pending, pendingNode{source: child, clip: childClip, clipped: childClipped,
clipChanged: item.clipChanged || !known || cached.geometryInvalid, parentIndex: len(nodes)})
}
if document && refresh {
snapshot.TextChildren = nativeTextChildren(ctx, source, snapshot.CharacterCount, children)
}
nodes = append(nodes, TreeNode{Snapshot: snapshot, Children: children, Geometry: geometry, Text: text, ChildrenUnavailable: childrenUnavailable})
trace.progress(snapshot.Role, len(nodes), len(pending), textBytes)
C.g_object_unref(C.gpointer(source))
}
return rootID, nodes, nil
}
//export cthulhuAtspiTreeCaptureCallback
func cthulhuAtspiTreeCaptureCallback(userData unsafe.Pointer) {
if userData == nil {
return
}
callbackHandle := cgo.Handle(C.cthulhu_callback_value((*C.cthulhu_callback_data)(userData)))
adapter, ok := callbackHandle.Value().(*Adapter)
if !ok {
return
}
request := <-adapter.treeRequests
capture_diagnostics(request.ctx).begin_stage("callback-setup", request.reviewFocus, "")
adapter.applySecureReset()
if request.ctx.Err() != nil {
request.result <- treeCaptureResult{err: request.ctx.Err()}
return
}
if adapter.secure.Load() {
request.result <- treeCaptureResult{err: errors.New("screen lock active")}
return
}
if request.transferItem {
item, err := adapter.nativeTransferItem(request.ctx, request.reviewFocus, request.positionOffset)
request.result <- treeCaptureResult{item: item, err: err}
} else if request.listActions || request.actionTarget != nil {
object := request.reviewFocus
if request.actionTarget != nil {
object = request.actionTarget.Object
}
actions, err := adapter.native_actions(request.ctx, object, request.actionTarget, request.actionName)
request.result <- treeCaptureResult{actions: actions, err: err}
} else if request.ocrGeometry {
geometry, err := adapter.nativeOCRGeometry(request.ctx, request.reviewFocus, request.ocrZone)
request.result <- treeCaptureResult{ocr: geometry, err: err}
} else if request.pointReview {
point, err := adapter.nativeReviewAtPoint(request.ctx, request.pointX, request.pointY, request.pointPID)
request.result <- treeCaptureResult{point: point, err: err}
} else if request.brailleRoute != nil {
err := adapter.routeNativeBraille(request.ctx, *request.brailleRoute)
request.result <- treeCaptureResult{err: err}
} else if request.setFocusedCaret {
err := adapter.setNativeFocusedTextCaret(request.ctx, request.reviewFocus, request.positionOffset)
request.result <- treeCaptureResult{err: err}
} else if request.readFocusedLine {
content, err := adapter.read_native_focused_line(request.ctx, request.reviewFocus)
request.result <- treeCaptureResult{content: content, err: err}
} else if request.readFocusedText {
content, err := adapter.readNativeFocusedText(request.ctx, request.reviewFocus)
request.result <- treeCaptureResult{content: content, err: err}
} else if request.readAttributes {
attributes, err := adapter.readNativeCharacterAttributes(request.ctx, request.reviewFocus)
request.result <- treeCaptureResult{attributes: attributes, err: err}
} else if request.contextOnly {
owner, err := adapter.nativeDocumentContext(request.ctx, request.reviewFocus)
request.result <- treeCaptureResult{owner: owner, err: err}
} else if request.caretObject.Valid() {
caret, err := adapter.readNativeDocumentCaret(request.ctx, request.caretObject, request.caretOffset, request.caretUnit)
request.result <- treeCaptureResult{caret: caret, err: err}
} else if request.activate {
err := adapter.activateNativeDocumentObject(request.ctx, request.position, request.reviewFocus)
request.result <- treeCaptureResult{err: err}
} else if request.soundObjects[0].Valid() {
geometry, err := adapter.captureNativeDocumentSoundGeometry(request.ctx, request.soundObjects)
request.result <- treeCaptureResult{soundGeometry: geometry, err: err}
} else if request.position.Valid() {
err := adapter.setNativeDocumentPosition(request.ctx, request.position, request.reviewFocus, request.positionOffset, request.usePositionOffset)
request.result <- treeCaptureResult{err: err}
} else {
tree, err := adapter.captureDesktop(request.ctx, request.reviewFocus, request.document, request.documentText)
request.result <- treeCaptureResult{tree: tree, err: err}
}
}
func (adapter *Adapter) applyTreeInvalidation(eventType string, event *C.AtspiEvent) bool {
if isDocumentLifecycleEvent(eventType) {
clear(adapter.navigationEcho)
sourceID := nativeObjectID(event.source)
adapter.treeMutex.Lock()
documentTree := adapter.documentTree
adapter.treeMutex.Unlock()
replacement := false
if documentTree != nil {
root, err := documentTree.Root()
if err == nil && root.ID().BusName == sourceID.BusName {
_, err = documentTree.Reference(sourceID)
replacement = errors.Is(err, ErrObjectNotFound)
}
}
adapter.invalidateTreeEvent(eventType, 0, sourceID, ObjectID{})
// A replacement page can have a different accessible path from the
// captured document. In that case invalidating the event source is a
// no-op. Retire the old tree only when this is the focused document;
// background tabs must not displace the active browse position.
if replacement && len(adapter.nativeFocus) != 0 {
focus := adapter.nativeFocus[len(adapter.nativeFocus)-1].id
ctx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond)
active, err := adapter.nativeDocumentRoot(ctx, focus)
cancel()
if err == nil {
activeID := nativeObjectID(active)
C.g_object_unref(C.gpointer(active))
adapter.invalidateReplacedDocument(sourceID, activeID)
}
}
return false
}
childrenAdded := strings.HasPrefix(eventType, ChildrenAddedEvent)
childrenRemoved := strings.HasPrefix(eventType, ChildrenRemovedEvent)
textChanged := strings.HasPrefix(eventType, TextInsertedEvent) || strings.HasPrefix(eventType, TextDeletedEvent) || eventType == TextBoundsChangedEvent || eventType == TextAttributesChangedEvent || eventType == CaretEvent
if textChanged {
adapter.invalidateTreeEvent(eventType, int(C.cthulhu_event_detail1(event)), nativeObjectID(event.source), ObjectID{})
return eventType == TextBoundsChangedEvent || eventType == TextAttributesChangedEvent
}
geometryChanged := eventType == BoundsChangedEvent || eventType == VisibleStateEvent || eventType == ShowingStateEvent
semanticChanged := semanticSnapshotChanged(eventType)
if !childrenAdded && !childrenRemoved && eventType != DefunctStateEvent && !geometryChanged && !semanticChanged {
return false
}
sourceID := nativeObjectID(event.source)
if semanticChanged {
adapter.invalidateTreeEvent(eventType, int(C.cthulhu_event_detail1(event)), sourceID, ObjectID{})
return false
}
childID := ObjectID{}
if childrenRemoved {
child := C.cthulhu_event_active_descendant(event)
childID = nativeObjectID(child)
}
detail1 := int(C.cthulhu_event_detail1(event))
// Setting Chromium's caret can retire the target or one of its descendants
// and immediately replace it with a new provider object. That defunct event
// is part of the navigation echo, but its native object is still dead.
// Retain the document and live siblings while refreshing the affected
// parent's children, so navigation can recover the replacement.
navigationDefunct := eventType == DefunctStateEvent && detail1 != 0 && adapter.navigationEcho[sourceID] != 0
adapter.invalidateTreeEventWithPolicy(eventType, detail1, sourceID, childID, navigationDefunct)
if navigationDefunct {
delete(adapter.navigationEcho, sourceID)
}
return true
}
func (adapter *Adapter) invalidateReplacedDocument(sourceID, activeID ObjectID) {
if !sourceID.Valid() || sourceID != activeID {
return
}
adapter.treeMutex.Lock()
trees := []*AccessibleTree{adapter.tree, adapter.documentTree, adapter.documentTextTree}
adapter.treeMutex.Unlock()
for _, tree := range trees {
if tree == nil {
continue
}
root, err := tree.Root()
if err != nil || root.ID().BusName != sourceID.BusName {
continue
}
if _, err := tree.Reference(sourceID); err == nil {
continue
}
adapter.treeInvalidations.Add(uint64(tree.Invalidate(root.ID())))
}
}
func (adapter *Adapter) invalidateTreeEvent(eventType string, detail1 int, sourceID, childID ObjectID) {
adapter.invalidateTreeEventWithPolicy(eventType, detail1, sourceID, childID, false)
}
func (adapter *Adapter) invalidateTreeEventWithPolicy(eventType string, detail1 int, sourceID, childID ObjectID, navigationDefunct bool) {
if eventType == DefunctStateEvent && detail1 == 0 {
return
}
if eventType != CaretEvent {
// Background providers cannot change the owned document's text lines.
for id := range adapter.documentLineCache {
if id.BusName == sourceID.BusName {
delete(adapter.documentLineCache, id)
}
}
}
adapter.treeMutex.Lock()
trees := []*AccessibleTree{adapter.tree, adapter.documentTree, adapter.documentTextTree}
adapter.treeMutex.Unlock()
for index, tree := range trees {
if navigationDefunct && index == 1 && tree != nil {
tree.mu.RLock()
entry := tree.nodes[sourceID]
tree.mu.RUnlock()
adapter.treeInvalidations.Add(uint64(tree.Invalidate(sourceID)))
if entry.hasParent {
adapter.treeInvalidations.Add(uint64(tree.MarkChildrenChanged(entry.parent)))
}
continue
}
// Structural matching consumes the semantic document snapshot. Other
// owned trees remain usable for object/flat review until their own
// established text, geometry, child, or lifecycle invalidation.
if semanticSnapshotChanged(eventType) && index != 1 {
continue
}
// Caret position and viewport bounds do not change semantic order,
// names or hidden state. Flat review still needs these invalidations.
if index == 1 && (eventType == CaretEvent || eventType == BoundsChangedEvent || eventType == TextBoundsChangedEvent || eventType == ShowingStateEvent) {
continue
}
// The visual-line tree receives layout, visibility, and text
// invalidations. A caret event supplies its current offset separately,
// so it does not make otherwise-current line geometry stale.
if index == 2 && eventType == CaretEvent {
continue
}
if index == 1 && tree != nil && (strings.HasPrefix(eventType, ChildrenAddedEvent) ||
strings.HasPrefix(eventType, ChildrenRemovedEvent) && !childID.Valid()) {
adapter.treeInvalidations.Add(uint64(tree.MarkChildrenChanged(sourceID)))
continue
}
adapter.invalidateCapturedTree(tree, eventType, detail1, sourceID, childID)
}
}
func (adapter *Adapter) invalidateCapturedTree(tree *AccessibleTree, eventType string, detail1 int, sourceID, childID ObjectID) {
if tree == nil {
return
}
removed := 0
switch {
case isDocumentLifecycleEvent(eventType):
// Browsers can reuse an accessible path when replacing a document.
// Retire its generation, including all descendants, before recapture.
removed = tree.Invalidate(sourceID)
case strings.HasPrefix(eventType, TextInsertedEvent) || strings.HasPrefix(eventType, TextDeletedEvent) || eventType == TextBoundsChangedEvent || eventType == TextAttributesChangedEvent || eventType == CaretEvent:
removed = tree.InvalidateText(sourceID)
case eventType == BoundsChangedEvent || eventType == VisibleStateEvent || eventType == ShowingStateEvent:
removed = tree.InvalidateGeometry(sourceID)
case eventType == DefunctStateEvent:
removed = tree.Invalidate(sourceID)
case semanticSnapshotChanged(eventType):
// Refresh changed semantics without discarding document/caret identities.
// Events outside this tree remain a no-op.
removed = tree.InvalidateSnapshot(sourceID)
case strings.HasPrefix(eventType, ChildrenAddedEvent):
removed = tree.InvalidateChildren(sourceID)
case strings.HasPrefix(eventType, ChildrenRemovedEvent):
if childID.Valid() {
removed = tree.Invalidate(childID)
} else {
removed = tree.InvalidateChildren(sourceID)
}
}
adapter.treeInvalidations.Add(uint64(removed))
}
func semanticSnapshotChanged(eventType string) bool {
if eventType == AccessibleNameEvent || eventType == ValueChangedEvent || eventType == AccessibleValueEvent {
return true
}
switch eventType {
case "object:state-changed:checked",
"object:state-changed:expanded",
"object:state-changed:indeterminate",
"object:state-changed:invalid-entry",
"object:state-changed:pressed",
"object:state-changed:read-only",
"object:state-changed:required",
"object:state-changed:selected",
"object:state-changed:sensitive",
"object:state-changed:visited":
return true
default:
return false
}
}
func isDocumentLifecycleEvent(eventType string) bool {
return eventType == DocumentReloadEvent || eventType == DocumentLoadCompleteEvent || eventType == DocumentLoadStoppedEvent
}