671 lines
27 KiB
Go
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
|
|
}
|