811 lines
31 KiB
Go
811 lines
31 KiB
Go
// Cthulhu screen reader.
|
|
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify it
|
|
// under the terms of the GNU General Public License as published by the Free
|
|
// Software Foundation, either version 3 of the License, or (at your option)
|
|
// any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful, but WITHOUT
|
|
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
|
|
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
|
|
// more details.
|
|
//
|
|
// You should have received a copy of the GNU General Public License along with
|
|
// this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
|
|
|
|
package review
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"time"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
|
)
|
|
|
|
// MoveDocument shares the bounded review FIFO, but owns an independent cursor.
|
|
func (worker *Worker) MoveDocument(target DocumentTarget, level int, backwards bool) error {
|
|
if !validDocumentTarget(target) || level < 0 || level > 6 || target != DocumentHeading && level != 0 {
|
|
return ErrInvalidDirection
|
|
}
|
|
select {
|
|
case worker.requests <- request{documentEpoch: worker.documentEpoch.Load(), documentLoadEpoch: worker.documentLoadEpoch.Load(), target: target, backwards: backwards, level: level, queuedAt: time.Now()}:
|
|
return nil
|
|
default:
|
|
return ErrRequestQueueFull
|
|
}
|
|
}
|
|
|
|
// sameDocumentRepeat reports whether two queued requests are the same
|
|
// structural move in the same document generation, so they can execute as
|
|
// one batch in order. Caret steps are excluded: every queued character,
|
|
// word and line step keeps its own ordered result.
|
|
func sameDocumentRepeat(first, next request) bool {
|
|
if first.documentEpoch != next.documentEpoch {
|
|
return false
|
|
}
|
|
return first.target != 0 && first.target == next.target &&
|
|
first.level == next.level && first.backwards == next.backwards
|
|
}
|
|
|
|
const maxRetainedDocumentCursors = 4
|
|
|
|
// cursorForTree returns the retained browse cursor for a captured tree,
|
|
// creating and retaining one on first use. The adapter retains one tree
|
|
// object per document root, so the pointer identifies the document.
|
|
func (worker *Worker) cursorForTree(tree *atspi.AccessibleTree, loadEpoch uint64) (*DocumentCursor, bool, error) {
|
|
if tree == nil {
|
|
return nil, false, errors.New("document cursor: nil tree")
|
|
}
|
|
if cursor := worker.documentCursors[tree]; cursor != nil && worker.documentCursorEpochs[tree] == loadEpoch {
|
|
worker.touchDocumentCursor(tree)
|
|
return cursor, false, nil
|
|
}
|
|
cursor, err := NewDocumentCursor(tree)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
worker.rememberDocumentCursor(cursor, loadEpoch)
|
|
return cursor, true, nil
|
|
}
|
|
|
|
func (worker *Worker) rememberDocumentCursor(cursor *DocumentCursor, loadEpoch uint64) {
|
|
if cursor == nil || cursor.tree == nil {
|
|
return
|
|
}
|
|
if worker.documentCursors == nil {
|
|
worker.documentCursors = make(map[*atspi.AccessibleTree]*DocumentCursor)
|
|
worker.documentCursorEpochs = make(map[*atspi.AccessibleTree]uint64)
|
|
}
|
|
worker.documentCursors[cursor.tree] = cursor
|
|
worker.documentCursorEpochs[cursor.tree] = loadEpoch
|
|
worker.touchDocumentCursor(cursor.tree)
|
|
for len(worker.documentCursorOrder) > maxRetainedDocumentCursors {
|
|
oldest := worker.documentCursorOrder[0]
|
|
delete(worker.documentCursors, oldest)
|
|
delete(worker.documentCursorEpochs, oldest)
|
|
worker.documentCursorOrder = worker.documentCursorOrder[1:]
|
|
}
|
|
}
|
|
|
|
func (worker *Worker) touchDocumentCursor(tree *atspi.AccessibleTree) {
|
|
for index, retained := range worker.documentCursorOrder {
|
|
if retained == tree {
|
|
worker.documentCursorOrder = append(worker.documentCursorOrder[:index], worker.documentCursorOrder[index+1:]...)
|
|
break
|
|
}
|
|
}
|
|
worker.documentCursorOrder = append(worker.documentCursorOrder, tree)
|
|
}
|
|
|
|
// retainedCursorsExcept lists retained cursors most recently used first.
|
|
func (worker *Worker) retainedCursorsExcept(current *DocumentCursor, loadEpoch uint64) []*DocumentCursor {
|
|
cursors := make([]*DocumentCursor, 0, len(worker.documentCursorOrder))
|
|
for index := len(worker.documentCursorOrder) - 1; index >= 0; index-- {
|
|
tree := worker.documentCursorOrder[index]
|
|
if cursor := worker.documentCursors[tree]; cursor != nil && cursor != current && worker.documentCursorEpochs[tree] == loadEpoch {
|
|
cursors = append(cursors, cursor)
|
|
}
|
|
}
|
|
return cursors
|
|
}
|
|
|
|
// trialRetainedCursors retries move on retained cursors for other
|
|
// documents before paying for a capture. Returning to a tab or workspace
|
|
// usually validates against its retained tree with no provider work at
|
|
// all; a cursor whose tree changed meanwhile simply falls through to the
|
|
// caller's capture path.
|
|
func (worker *Worker) trialRetainedCursors(cursor **DocumentCursor, loadEpoch uint64, result *Result, move func() error) {
|
|
if !documentNeedsCapture(result.Err) {
|
|
return
|
|
}
|
|
originalCursor, originalErr := *cursor, result.Err
|
|
for _, retained := range worker.retainedCursorsExcept(*cursor, loadEpoch) {
|
|
*cursor = retained
|
|
result.Err = move()
|
|
if !documentNeedsCapture(result.Err) {
|
|
return
|
|
}
|
|
}
|
|
// Failed trials do not own the focus. Keep recapture anchored to the
|
|
// original document if its focused child disappeared in the meantime.
|
|
*cursor, result.Err = originalCursor, originalErr
|
|
}
|
|
|
|
// warmDocument captures and syncs the focused document so the first
|
|
// navigation key afterwards is warm. It returns the cursor to keep as the
|
|
// active document; failures (including focus in browser chrome) keep the
|
|
// previous cursor and stay silent.
|
|
func (worker *Worker) warmDocument(ctx context.Context, cursor *DocumentCursor, loadEpoch uint64) *DocumentCursor {
|
|
focus := worker.focus()
|
|
if !focus.Valid() {
|
|
return cursor
|
|
}
|
|
candidates := worker.retainedCursorsExcept(cursor, loadEpoch)
|
|
if cursor != nil {
|
|
candidates = append([]*DocumentCursor{cursor}, candidates...)
|
|
}
|
|
for _, candidate := range candidates {
|
|
if _, err := candidate.SyncFocus(ctx, focus); err == nil {
|
|
worker.touchDocumentCursor(candidate.tree)
|
|
return candidate
|
|
}
|
|
}
|
|
// Warm-up is speculative: never let it occupy the provider thread for
|
|
// the full interactive capture budget on an unresponsive document.
|
|
warmContext, cancel := context.WithTimeout(ctx, 3*time.Second)
|
|
defer cancel()
|
|
tree, _, err := worker.captureDocumentForMove(warmContext, cursor, focus)
|
|
if err != nil || tree == nil {
|
|
return cursor
|
|
}
|
|
warm, _, err := worker.cursorForTree(tree, loadEpoch)
|
|
if err != nil {
|
|
return cursor
|
|
}
|
|
if _, err := warm.SyncFocus(ctx, focus); err != nil {
|
|
return cursor
|
|
}
|
|
return warm
|
|
}
|
|
|
|
// captureDocumentForMove captures through the focus anchor. When the page
|
|
// destroyed that anchor, capture keyed on it fails with ErrObjectNotFound on
|
|
// every keypress; fall back to the cursor's saved document so the existing
|
|
// anchor-recovery path can rebind focus within the same generation.
|
|
func (worker *Worker) captureDocumentForMove(ctx context.Context, cursor *DocumentCursor, focus atspi.ObjectID) (*atspi.AccessibleTree, time.Duration, error) {
|
|
capture := func(anchor atspi.ObjectID) (*atspi.AccessibleTree, time.Duration, error) {
|
|
captureContext, cancel := context.WithTimeout(ctx, worker.documentCaptureTimeout)
|
|
defer cancel()
|
|
started := time.Now()
|
|
tree, err := worker.capturer.CaptureDocument(captureContext, anchor)
|
|
return tree, time.Since(started), err
|
|
}
|
|
tree, duration, err := capture(focus)
|
|
if err == nil || !errors.Is(err, atspi.ErrObjectNotFound) {
|
|
return tree, duration, err
|
|
}
|
|
anchor, ok := cursor.capture_anchor(focus)
|
|
if !ok {
|
|
return tree, duration, err
|
|
}
|
|
fallbackTree, fallbackDuration, fallbackErr := capture(anchor)
|
|
if fallbackErr != nil {
|
|
return tree, duration + fallbackDuration, err
|
|
}
|
|
return fallbackTree, duration + fallbackDuration, nil
|
|
}
|
|
|
|
func (worker *Worker) moveDocument(ctx context.Context, cursor *DocumentCursor, target DocumentTarget, level int, backwards bool, epoch, loadEpoch uint64) (Result, *DocumentCursor) {
|
|
return worker.moveDocumentRepeated(ctx, cursor, target, level, backwards, epoch, loadEpoch, 1)
|
|
}
|
|
|
|
// moveDocumentRepeated executes coalesced identical keys as one batch: one
|
|
// focus sync, one capture cycle and one native placement for the final
|
|
// position, landing the full step count from the original position.
|
|
func (worker *Worker) moveDocumentRepeated(ctx context.Context, cursor *DocumentCursor, target DocumentTarget, level int, backwards bool, epoch, loadEpoch uint64, repeat int) (Result, *DocumentCursor) {
|
|
result := Result{DocumentEpoch: epoch, DocumentTarget: target, DocumentBackwards: backwards, DocumentLevel: level, DocumentFocus: worker.focus()}
|
|
if !result.DocumentFocus.Valid() {
|
|
result.Err = ErrNoCurrent
|
|
return result, cursor
|
|
}
|
|
move := func() (moveErr error) {
|
|
attempt := DocumentAttempt{Focus: result.DocumentFocus, Before: cursor.diagnostic_state(), Stage: "focus"}
|
|
defer func() { result.record_document_attempt(cursor, attempt, moveErr) }()
|
|
if cursor == nil {
|
|
attempt.Recovery = "no-cursor"
|
|
return ErrNoCurrent
|
|
}
|
|
_, recovery, err := cursor.sync_focus(ctx, result.DocumentFocus)
|
|
attempt.Recovery, attempt.Synced = recovery, cursor.diagnostic_state()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
attempt.Stage = "move"
|
|
moved := false
|
|
for range repeat {
|
|
move, err := cursor.Move(ctx, target, level, backwards)
|
|
if err != nil {
|
|
if moved {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
result.DocumentPosition = move.Position
|
|
result.Moved = move.Moved
|
|
result.DocumentWrapped = result.DocumentWrapped || move.Wrapped
|
|
if !move.Moved {
|
|
break
|
|
}
|
|
moved = true
|
|
}
|
|
return nil
|
|
}
|
|
result.Err = move()
|
|
worker.trialRetainedCursors(&cursor, loadEpoch, &result, move)
|
|
if !documentNeedsCapture(result.Err) {
|
|
return worker.placeDocumentWithRetry(ctx, cursor, result), cursor
|
|
}
|
|
// A bounded refresh plus one retry handles a second replacement without
|
|
// allowing a constantly mutating page to loop indefinitely.
|
|
result.CaptureReason = result.Err
|
|
result.DocumentDiagnostics.CaptureAttempts++
|
|
tree, captureDuration, err := worker.captureDocumentForMove(ctx, cursor, result.DocumentFocus)
|
|
result.CaptureDuration = captureDuration
|
|
if err != nil {
|
|
result.Err = err
|
|
return result, cursor
|
|
}
|
|
if tree == nil {
|
|
result.Err = errors.New("capture document tree: nil tree")
|
|
return result, cursor
|
|
}
|
|
recovered := worker.recoverCapturedDocumentFocus(cursor, tree, &result)
|
|
if cursor == nil || cursor.tree != tree {
|
|
retained, created, _ := worker.cursorForTree(tree, loadEpoch)
|
|
result.DocumentDiagnostics.CursorRecreated = created
|
|
cursor = retained
|
|
}
|
|
if !recovered {
|
|
result.DocumentFocus = worker.focus()
|
|
}
|
|
result.Err = move()
|
|
if !result.Moved && documentNeedsCapture(result.Err) &&
|
|
worker.CurrentDocumentResult(result) && worker.focus() == result.DocumentFocus {
|
|
// A second replacement can race the first capture. Retry once while
|
|
// the navigation has not moved, so the heading is not skipped.
|
|
result.DocumentDiagnostics.CaptureAttempts++
|
|
var retryDuration time.Duration
|
|
tree, retryDuration, err = worker.captureDocumentForMove(ctx, cursor, result.DocumentFocus)
|
|
result.CaptureDuration += retryDuration
|
|
if err == nil && tree != nil && worker.CurrentDocumentResult(result) && worker.focus() == result.DocumentFocus {
|
|
if cursor == nil || cursor.tree != tree {
|
|
retained, created, _ := worker.cursorForTree(tree, loadEpoch)
|
|
result.DocumentDiagnostics.CursorRecreated = created
|
|
cursor = retained
|
|
}
|
|
result.Err = move()
|
|
}
|
|
}
|
|
return worker.placeDocumentWithRetry(ctx, cursor, result), cursor
|
|
}
|
|
|
|
func (worker *Worker) jumpDocumentFragment(ctx context.Context, cursor *DocumentCursor, fragment string, epoch, loadEpoch uint64) (Result, *DocumentCursor) {
|
|
result := Result{DocumentEpoch: epoch, DocumentFragment: true, DocumentFocus: worker.focus()}
|
|
if !result.DocumentFocus.Valid() {
|
|
result.Err = ErrNoCurrent
|
|
return result, cursor
|
|
}
|
|
captureContext, cancel := context.WithTimeout(ctx, worker.documentCaptureTimeout)
|
|
captureStarted := time.Now()
|
|
tree, err := worker.capturer.CaptureDocument(captureContext, result.DocumentFocus)
|
|
result.CaptureDuration = time.Since(captureStarted)
|
|
cancel()
|
|
if err != nil {
|
|
result.Err = err
|
|
return result, cursor
|
|
}
|
|
if tree == nil {
|
|
result.Err = errors.New("capture document tree: nil tree")
|
|
return result, cursor
|
|
}
|
|
cursor, _, err = worker.cursorForTree(tree, loadEpoch)
|
|
if err != nil {
|
|
result.Err = err
|
|
return result, cursor
|
|
}
|
|
if _, err = cursor.SyncFocus(ctx, result.DocumentFocus); err != nil {
|
|
result.Err = err
|
|
return result, cursor
|
|
}
|
|
result.DocumentPosition, result.Moved, result.Err = cursor.JumpToFragment(ctx, fragment)
|
|
if result.Err == nil && result.Moved && worker.CurrentDocumentResult(result) && worker.focus() == result.DocumentFocus {
|
|
result = worker.placeDocumentWithRetry(ctx, cursor, result)
|
|
}
|
|
return result, cursor
|
|
}
|
|
|
|
func (worker *Worker) placeDocumentWithRetry(ctx context.Context, cursor *DocumentCursor, result Result) Result {
|
|
place := func(result Result) Result {
|
|
if result.DocumentCaretCommand != 0 {
|
|
return worker.placeDocumentCaret(ctx, cursor, result)
|
|
}
|
|
return worker.placeDocument(ctx, cursor, result)
|
|
}
|
|
result = place(result)
|
|
if errors.Is(result.CaretErr, atspi.ErrDeadObject) {
|
|
// A native caret refusal can leave a valid virtual position. A provider
|
|
// confirming that the target no longer exists cannot: do not announce
|
|
// its cached label as a successful move, even if recapture fails.
|
|
result.Err, result.Moved = result.CaretErr, false
|
|
}
|
|
if !documentCaretNeedsRecapture(result.CaretErr) || !worker.CurrentDocumentResult(result) {
|
|
return result
|
|
}
|
|
// A semantic event can retire the captured tree between selecting a target
|
|
// and placing its native caret. Retry only the same target after one fresh
|
|
// capture; repeating the navigation command could skip to another target.
|
|
oldPosition := result.DocumentPosition
|
|
result.DocumentDiagnostics.CaptureAttempts++
|
|
tree, captureDuration, err := worker.captureDocumentForMove(ctx, cursor, result.DocumentFocus)
|
|
result.CaptureDuration += captureDuration
|
|
if err != nil || tree == nil || !worker.CurrentDocumentResult(result) {
|
|
return result
|
|
}
|
|
target, err := tree.Reference(oldPosition.Object.ID())
|
|
if err != nil {
|
|
return result
|
|
}
|
|
owner, err := tree.DocumentContext(target)
|
|
if err != nil || owner.TopLevel.ID() != oldPosition.Document.ID() {
|
|
return result
|
|
}
|
|
snapshot, err := tree.Resolve(target)
|
|
if err != nil || snapshot.Role != oldPosition.Snapshot.Role || snapshot.HeadingLevel != oldPosition.Snapshot.HeadingLevel {
|
|
return result
|
|
}
|
|
result.DocumentPosition.Object = target
|
|
result.DocumentPosition.Document = owner.TopLevel
|
|
result.DocumentPosition.Snapshot = snapshot
|
|
result.Err, result.Moved = nil, true
|
|
result.CaretErr = nil
|
|
result = place(result)
|
|
if errors.Is(result.CaretErr, atspi.ErrDeadObject) {
|
|
result.Err, result.Moved = result.CaretErr, false
|
|
}
|
|
if result.Err == nil && result.CaretErr == nil && cursor != nil {
|
|
cursor.rebindPlace(tree, oldPosition, result.DocumentPosition)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func documentCaretNeedsRecapture(err error) bool {
|
|
return errors.Is(err, atspi.ErrObjectNotFound) || errors.Is(err, atspi.ErrStaleObject) ||
|
|
errors.Is(err, atspi.ErrStaleSnapshot) || errors.Is(err, atspi.ErrDeadObject)
|
|
}
|
|
|
|
func (worker *Worker) moveDocumentCaret(ctx context.Context, cursor *DocumentCursor, command DocumentCaretCommand, epoch, loadEpoch uint64) (Result, *DocumentCursor) {
|
|
result := Result{DocumentEpoch: epoch, DocumentCaretCommand: command, DocumentFocus: worker.focus()}
|
|
if !result.DocumentFocus.Valid() {
|
|
result.Err = ErrNoCurrent
|
|
return result, cursor
|
|
}
|
|
reader, ok := worker.nativeCapturerFor(result.DocumentFocus).(DocumentCaretReader)
|
|
if !ok {
|
|
result.Err = errors.New("document caret reader unavailable")
|
|
return result, cursor
|
|
}
|
|
move := func() (moveErr error) {
|
|
attempt := DocumentAttempt{Focus: result.DocumentFocus, Before: cursor.diagnostic_state(), Stage: "focus"}
|
|
defer func() { result.record_document_attempt(cursor, attempt, moveErr) }()
|
|
if cursor == nil {
|
|
attempt.Recovery = "no-cursor"
|
|
return ErrNoCurrent
|
|
}
|
|
_, recovery, err := cursor.sync_focus(ctx, result.DocumentFocus)
|
|
attempt.Recovery, attempt.Synced = recovery, cursor.diagnostic_state()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
attempt.Stage = "move"
|
|
// A word move can cross whitespace and several objects. Bound the
|
|
// whole navigation, not just each individual native text request.
|
|
navigationContext, cancel := context.WithTimeout(ctx, worker.captureTimeout)
|
|
defer cancel()
|
|
movement, err := cursor.MoveCaret(navigationContext, reader, command)
|
|
result.DocumentPosition = movement.Position
|
|
result.DocumentCaretText = movement.Text
|
|
result.DocumentCaretRawText = movement.RawText
|
|
result.DocumentCaretLine = movement.Line
|
|
result.DocumentTablePrefix, result.DocumentTableSuffix = movement.TablePrefix, movement.TableSuffix
|
|
result.DocumentTableRowHeader, result.DocumentTableColumnHeader = movement.TableRowHeader, movement.TableColumnHeader
|
|
result.DocumentPosition.Snapshot.Misspelled = movement.Misspelled
|
|
result.DocumentCaretStart, result.DocumentCaretEnd = movement.Start, movement.End
|
|
result.Moved = movement.Moved
|
|
if err == nil && movement.Moved {
|
|
context, contextErr := cursor.tree.DocumentContext(movement.Position.Object)
|
|
if contextErr != nil {
|
|
// ChatGPT can replace a text object between the successful
|
|
// caret read and this optional ancestor lookup. The captured
|
|
// position and text remain usable; retrying the command here
|
|
// would advance past the line the user requested.
|
|
if !errors.Is(contextErr, atspi.ErrStaleObject) && !errors.Is(contextErr, atspi.ErrObjectNotFound) && !errors.Is(contextErr, atspi.ErrStaleSnapshot) {
|
|
return contextErr
|
|
}
|
|
return nil
|
|
}
|
|
result.DocumentCaretContext = context.Ancestors
|
|
}
|
|
return err
|
|
}
|
|
result.Err = move()
|
|
worker.trialRetainedCursors(&cursor, loadEpoch, &result, move)
|
|
if !documentNeedsCapture(result.Err) {
|
|
return worker.placeDocumentWithRetry(ctx, cursor, result), cursor
|
|
}
|
|
result.CaptureReason = result.Err
|
|
result.DocumentDiagnostics.CaptureAttempts++
|
|
tree, captureDuration, err := worker.captureDocumentForMove(ctx, cursor, result.DocumentFocus)
|
|
result.CaptureDuration = captureDuration
|
|
if err != nil {
|
|
result.Err = err
|
|
return result, cursor
|
|
}
|
|
if tree == nil {
|
|
result.Err = errors.New("capture document tree: nil tree")
|
|
return result, cursor
|
|
}
|
|
recovered := worker.recoverCapturedDocumentFocus(cursor, tree, &result)
|
|
if cursor == nil || cursor.tree != tree {
|
|
retained, created, _ := worker.cursorForTree(tree, loadEpoch)
|
|
result.DocumentDiagnostics.CursorRecreated = created
|
|
cursor = retained
|
|
}
|
|
if !recovered {
|
|
result.DocumentFocus = worker.focus()
|
|
}
|
|
result.Err = move()
|
|
if result.Moved || !documentNeedsCapture(result.Err) ||
|
|
!worker.CurrentDocumentResult(result) || worker.focus() != result.DocumentFocus {
|
|
return worker.placeDocumentWithRetry(ctx, cursor, result), cursor
|
|
}
|
|
// A dynamic page can replace the captured target between recapture and
|
|
// SyncFocus. Retry once only if the first attempt did not move the caret.
|
|
result.DocumentDiagnostics.CaptureAttempts++
|
|
var retryDuration time.Duration
|
|
tree, retryDuration, err = worker.captureDocumentForMove(ctx, cursor, result.DocumentFocus)
|
|
result.CaptureDuration += retryDuration
|
|
if err != nil || tree == nil || !worker.CurrentDocumentResult(result) || worker.focus() != result.DocumentFocus {
|
|
return result, cursor
|
|
}
|
|
if cursor == nil || cursor.tree != tree {
|
|
retained, created, _ := worker.cursorForTree(tree, loadEpoch)
|
|
result.DocumentDiagnostics.CursorRecreated = created
|
|
cursor = retained
|
|
}
|
|
result.Err = move()
|
|
return worker.placeDocumentWithRetry(ctx, cursor, result), cursor
|
|
}
|
|
|
|
func (worker *Worker) placeDocumentCaret(ctx context.Context, cursor *DocumentCursor, result Result) Result {
|
|
if result.Err != nil || !result.Moved ||
|
|
!worker.CurrentDocumentResult(result) || worker.focus() != result.DocumentFocus {
|
|
return result
|
|
}
|
|
if result.DocumentCaretCommand == CaretReadLine {
|
|
return result
|
|
}
|
|
navigator, ok := worker.nativeCapturerFor(result.DocumentFocus).(interface {
|
|
SetDocumentCaretPosition(context.Context, atspi.ObjectReference, atspi.ObjectID, int) error
|
|
})
|
|
if !ok {
|
|
return result
|
|
}
|
|
deadline, cancel := context.WithTimeout(ctx, worker.captureTimeout)
|
|
defer cancel()
|
|
nativeOffset := result.DocumentPosition.Offset
|
|
if named_link_label(result.DocumentPosition.Snapshot) != "" {
|
|
// The label's review offset has no relationship to the native badge
|
|
// or embedded-object text. Place only the link's native anchor.
|
|
nativeOffset = 0
|
|
}
|
|
result.CaretErr = navigator.SetDocumentCaretPosition(deadline, result.DocumentPosition.Object, result.DocumentFocus, nativeOffset)
|
|
if result.CaretErr == nil && worker.CurrentDocumentResult(result) {
|
|
switch result.DocumentCaretCommand {
|
|
case CaretNextLine, CaretPreviousLine, CaretNextWord, CaretPreviousWord, CaretDocumentStart, CaretDocumentEnd:
|
|
result = worker.refreshDocumentSoundGeometry(deadline, cursor, result)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (worker *Worker) activateDocumentCaret(ctx context.Context, cursor *DocumentCursor, epoch uint64) (Result, *DocumentCursor) {
|
|
result := Result{DocumentEpoch: epoch, DocumentActivate: true, DocumentFocus: worker.focus()}
|
|
if !result.DocumentFocus.Valid() {
|
|
result.Err = ErrNoCurrent
|
|
return result, cursor
|
|
}
|
|
target := atspi.ObjectReference{}
|
|
resolve := func() error {
|
|
if cursor == nil {
|
|
return ErrNoCurrent
|
|
}
|
|
if _, err := cursor.syncActionFocus(ctx, result.DocumentFocus); err != nil {
|
|
return err
|
|
}
|
|
var err error
|
|
target, err = cursor.ActivationTarget()
|
|
return err
|
|
}
|
|
result.Err = resolve()
|
|
if documentNeedsCapture(result.Err) {
|
|
cursor, result.Err = worker.refreshDocumentCursor(ctx, cursor, result.DocumentFocus)
|
|
if result.Err == nil {
|
|
result.Err = resolve()
|
|
}
|
|
}
|
|
if result.Err != nil {
|
|
return result, cursor
|
|
}
|
|
activator, ok := worker.nativeCapturerFor(result.DocumentFocus).(interface {
|
|
ActivateDocumentObject(context.Context, atspi.ObjectReference, atspi.ObjectID) error
|
|
})
|
|
if !ok {
|
|
result.Err = errors.New("document activation unavailable")
|
|
return result, cursor
|
|
}
|
|
actionContext, cancel := context.WithTimeout(ctx, worker.captureTimeout)
|
|
defer cancel()
|
|
if !worker.CurrentDocumentResult(result) || worker.focus() != result.DocumentFocus {
|
|
result.Err = atspi.ErrStaleObject
|
|
return result, cursor
|
|
}
|
|
result.Err = activator.ActivateDocumentObject(actionContext, target, result.DocumentFocus)
|
|
if errors.Is(result.Err, atspi.ErrStaleObject) && worker.CurrentDocumentResult(result) && worker.focus() == result.DocumentFocus {
|
|
// ErrStaleObject is a failed preflight, before any native action ran.
|
|
// Refresh once, and retry only the identical target, never a new link.
|
|
originalTarget := target.ID()
|
|
cursor, result.Err = worker.refreshDocumentCursor(ctx, cursor, result.DocumentFocus)
|
|
if result.Err == nil {
|
|
result.Err = resolve()
|
|
}
|
|
if result.Err == nil && (target.ID() != originalTarget || !worker.CurrentDocumentResult(result) || worker.focus() != result.DocumentFocus) {
|
|
result.Err = atspi.ErrStaleObject
|
|
}
|
|
if result.Err == nil {
|
|
retryContext, retryCancel := context.WithTimeout(ctx, worker.captureTimeout)
|
|
defer retryCancel()
|
|
result.Err = activator.ActivateDocumentObject(retryContext, target, result.DocumentFocus)
|
|
}
|
|
}
|
|
return result, cursor
|
|
}
|
|
|
|
// Navigation can recover by returning to native focus; an action cannot. Check
|
|
// the saved target first, before SyncFocus has a chance to replace a stale place.
|
|
func (cursor *DocumentCursor) syncActionFocus(ctx context.Context, focus atspi.ObjectID) (DocumentPosition, error) {
|
|
if len(cursor.places) > 0 {
|
|
saved := cursor.places[len(cursor.places)-1]
|
|
if saved.focus.ID() == focus || saved.object.ID() == focus {
|
|
if _, err := cursor.tree.Resolve(saved.object); err != nil {
|
|
return DocumentPosition{}, err
|
|
}
|
|
}
|
|
}
|
|
return cursor.SyncFocus(ctx, focus)
|
|
}
|
|
|
|
// Refresh before an action, retaining the virtual position only within the same
|
|
// document and focus. Never substitute another link if the selected one vanished.
|
|
func (worker *Worker) refreshDocumentCursor(ctx context.Context, cursor *DocumentCursor, focus atspi.ObjectID) (*DocumentCursor, error) {
|
|
return worker.refresh_document_cursor(ctx, cursor, focus, false)
|
|
}
|
|
|
|
// Read All is navigation: a missing text child can recover within the same
|
|
// captured tree using its generation-checked parent and sibling boundaries.
|
|
// Activation keeps the stricter refreshDocumentCursor path above.
|
|
func (worker *Worker) refresh_read_all_cursor(ctx context.Context, cursor *DocumentCursor, focus atspi.ObjectID) (*DocumentCursor, error) {
|
|
return worker.refresh_document_cursor(ctx, cursor, focus, true)
|
|
}
|
|
|
|
func (worker *Worker) refresh_document_cursor(ctx context.Context, cursor *DocumentCursor, focus atspi.ObjectID, recoverNavigation bool) (*DocumentCursor, error) {
|
|
var saved documentPlace
|
|
if cursor != nil && len(cursor.places) > 0 {
|
|
saved = cursor.places[len(cursor.places)-1]
|
|
}
|
|
loadEpoch := worker.documentLoadEpoch.Load()
|
|
captureContext, cancel := context.WithTimeout(ctx, worker.documentCaptureTimeout)
|
|
defer cancel()
|
|
tree, err := worker.capturer.CaptureDocument(captureContext, focus)
|
|
if err != nil {
|
|
return cursor, err
|
|
}
|
|
if recoverNavigation && cursor != nil && cursor.tree == tree {
|
|
position, err := cursor.SyncFocus(ctx, focus)
|
|
if err == nil && saved.document.Valid() && (saved.focus.ID() == focus || saved.object.ID() == focus) && saved.document != position.Document {
|
|
return cursor, errors.New("read all: document changed")
|
|
}
|
|
return cursor, err
|
|
}
|
|
fresh, err := NewDocumentCursor(tree)
|
|
if err != nil {
|
|
return cursor, err
|
|
}
|
|
position, err := fresh.SyncFocus(ctx, focus)
|
|
if err != nil {
|
|
return cursor, err
|
|
}
|
|
if saved.document.Valid() && (saved.focus.ID() == focus || saved.object.ID() == focus) {
|
|
if saved.document.ID() != position.Document.ID() ||
|
|
cursor.tree == tree && saved.document != position.Document {
|
|
return cursor, atspi.ErrStaleObject
|
|
}
|
|
target, err := tree.Reference(saved.object.ID())
|
|
if err != nil {
|
|
return cursor, err
|
|
}
|
|
owner, err := tree.DocumentContext(target)
|
|
if err != nil || owner.TopLevel != position.Document {
|
|
return cursor, atspi.ErrStaleObject
|
|
}
|
|
place := &fresh.places[len(fresh.places)-1]
|
|
place.object, place.offset, place.origin = target, saved.offset, saved.origin
|
|
}
|
|
worker.rememberDocumentCursor(fresh, loadEpoch)
|
|
return fresh, nil
|
|
}
|
|
|
|
func (worker *Worker) recoverCapturedDocumentFocus(cursor *DocumentCursor, tree *atspi.AccessibleTree, result *Result) bool {
|
|
if cursor == nil || len(cursor.places) == 0 {
|
|
return false
|
|
}
|
|
// The capture reason describes the old tree. Even a text or geometry
|
|
// refresh can discover a focus object removed while capture was pending.
|
|
// The fresh tree and unchanged document/focus ownership decide recovery.
|
|
if _, err := tree.Reference(result.DocumentFocus); !errors.Is(err, atspi.ErrObjectNotFound) {
|
|
return false
|
|
}
|
|
root, err := tree.Root()
|
|
if err != nil || root.ID() != cursor.places[len(cursor.places)-1].document.ID() || root.ID().BusName != result.DocumentFocus.BusName {
|
|
return false
|
|
}
|
|
if !worker.recoverDocumentFocus(result.DocumentFocus, root.ID(), result.DocumentEpoch) {
|
|
return false
|
|
}
|
|
// Losing the native anchor does not invalidate a virtual position elsewhere
|
|
// in the same generation. Rebind only the anchor so SyncFocus can retain
|
|
// the selected text or recover its surviving parent after this capture.
|
|
if cursor.tree == tree {
|
|
cursor.mu.Lock()
|
|
place := &cursor.places[len(cursor.places)-1]
|
|
if place.document == root && place.focus.ID() == result.DocumentFocus {
|
|
place.focus = root
|
|
}
|
|
cursor.mu.Unlock()
|
|
}
|
|
result.DocumentFocus = root.ID()
|
|
result.DocumentDiagnostics.FocusRecovered = true
|
|
return true
|
|
}
|
|
|
|
func documentNeedsCapture(err error) bool {
|
|
return errors.Is(err, ErrNoCurrent) || errors.Is(err, atspi.ErrObjectNotFound) ||
|
|
errors.Is(err, atspi.ErrStaleObject) || errors.Is(err, atspi.ErrDeadObject) || errors.Is(err, atspi.ErrIncompleteTree) ||
|
|
errors.Is(err, atspi.ErrStaleText) || errors.Is(err, atspi.ErrStaleGeometry) || errors.Is(err, atspi.ErrStaleSnapshot)
|
|
}
|
|
|
|
func (worker *Worker) placeDocument(ctx context.Context, cursor *DocumentCursor, result Result) Result {
|
|
if cursor != nil && result.Err == nil && result.Moved {
|
|
if owner, err := cursor.tree.DocumentContext(result.DocumentPosition.Object); err == nil {
|
|
result.DocumentCaretContext = owner.Ancestors
|
|
}
|
|
}
|
|
if !worker.CurrentDocumentResult(result) {
|
|
result.Err = atspi.ErrStaleObject
|
|
return result
|
|
}
|
|
navigator, ok := worker.nativeCapturerFor(result.DocumentFocus).(interface {
|
|
SetDocumentPosition(context.Context, atspi.ObjectReference, atspi.ObjectID) error
|
|
})
|
|
if !ok || result.Err != nil || !result.Moved {
|
|
return result
|
|
}
|
|
if !result.DocumentPosition.Document.Valid() {
|
|
result.Err = atspi.ErrStaleObject
|
|
return result
|
|
}
|
|
focus := worker.focus()
|
|
if focus != result.DocumentFocus {
|
|
if cursor == nil {
|
|
result.Err = errors.New("document focus changed")
|
|
return result
|
|
}
|
|
reference, err := cursor.tree.Reference(focus)
|
|
if err != nil {
|
|
result.Err = err
|
|
return result
|
|
}
|
|
document, err := cursor.tree.DocumentContext(reference)
|
|
if err != nil {
|
|
result.Err = err
|
|
return result
|
|
}
|
|
if document.TopLevel != result.DocumentPosition.Document {
|
|
result.Err = errors.New("document focus changed")
|
|
return result
|
|
}
|
|
result.DocumentFocus = focus
|
|
}
|
|
if !focus.Valid() {
|
|
result.Err = atspi.ErrStaleObject
|
|
return result
|
|
}
|
|
deadline, cancel := context.WithTimeout(ctx, worker.captureTimeout)
|
|
defer cancel()
|
|
result.CaretErr = navigator.SetDocumentPosition(deadline, result.DocumentPosition.Object, focus)
|
|
if result.CaretErr == nil && worker.CurrentDocumentResult(result) {
|
|
result = worker.refreshDocumentSoundGeometry(deadline, cursor, result)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (worker *Worker) refreshDocumentSoundGeometry(ctx context.Context, cursor *DocumentCursor, result Result) Result {
|
|
reader, ok := worker.nativeCapturerFor(result.DocumentFocus).(interface {
|
|
CaptureDocumentSoundGeometry(context.Context, atspi.ObjectReference, atspi.ObjectReference) ([2]atspi.SoundGeometry, error)
|
|
})
|
|
if !ok {
|
|
return result
|
|
}
|
|
// Geometry belongs to this presentation, not the document's initial viewport.
|
|
// Failed refreshes must not reuse an old position after scrolling or resizing.
|
|
result.DocumentPosition.Snapshot.SoundGeometry = atspi.SoundGeometry{}
|
|
result.DocumentCaretContext = append([]atspi.Snapshot(nil), result.DocumentCaretContext...)
|
|
var linkID atspi.ObjectID
|
|
for index := range result.DocumentCaretContext {
|
|
ancestor := &result.DocumentCaretContext[index]
|
|
ancestor.SoundGeometry = atspi.SoundGeometry{}
|
|
if !linkID.Valid() && ancestor.Role == "link" && ancestor.ID() != result.DocumentPosition.Object.ID() {
|
|
linkID = ancestor.ID()
|
|
}
|
|
}
|
|
if previous := result.DocumentPosition.LinkedHeading; previous != nil {
|
|
copy := *previous
|
|
copy.SoundGeometry = atspi.SoundGeometry{}
|
|
result.DocumentPosition.LinkedHeading = ©
|
|
linkID = copy.ID()
|
|
}
|
|
link := atspi.ObjectReference{}
|
|
if linkID.Valid() {
|
|
if cursor == nil {
|
|
result.DocumentSoundErr = atspi.ErrStaleObject
|
|
return result
|
|
}
|
|
var err error
|
|
link, err = cursor.tree.Reference(linkID)
|
|
if err != nil {
|
|
result.DocumentSoundErr = err
|
|
return result
|
|
}
|
|
}
|
|
geometry, err := reader.CaptureDocumentSoundGeometry(ctx, result.DocumentPosition.Object, link)
|
|
result.DocumentSoundErr = err
|
|
if err == nil {
|
|
result.DocumentPosition.Snapshot.SoundGeometry = geometry[0]
|
|
if result.DocumentPosition.LinkedHeading != nil {
|
|
result.DocumentPosition.LinkedHeading.SoundGeometry = geometry[1]
|
|
}
|
|
for index := range result.DocumentCaretContext {
|
|
ancestor := &result.DocumentCaretContext[index]
|
|
switch ancestor.ID() {
|
|
case result.DocumentPosition.Object.ID():
|
|
ancestor.SoundGeometry = geometry[0]
|
|
case linkID:
|
|
ancestor.SoundGeometry = geometry[1]
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|