// 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 . 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 }