Files
cthulhu/internal/review/document_worker.go

533 lines
20 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(), target: target, backwards: backwards, level: level, queuedAt: time.Now()}:
return nil
default:
return ErrRequestQueueFull
}
}
func (worker *Worker) moveDocument(ctx context.Context, cursor *DocumentCursor, target DocumentTarget, level int, backwards bool, epoch uint64) (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() error {
if cursor == nil {
return ErrNoCurrent
}
if _, err := cursor.SyncFocus(ctx, result.DocumentFocus); err != nil {
return err
}
move, err := cursor.Move(ctx, target, level, backwards)
result.DocumentPosition = move.Position
result.Moved = move.Moved
result.DocumentWrapped = move.Wrapped
return err
}
result.Err = 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
captureContext, cancel := context.WithTimeout(ctx, worker.captureTimeout)
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
}
recovered := worker.recoverCapturedDocumentFocus(cursor, tree, &result)
if cursor == nil || cursor.tree != tree {
cursor, _ = NewDocumentCursor(tree)
}
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.
retryContext, retryCancel := context.WithTimeout(ctx, worker.captureTimeout)
retryStarted := time.Now()
tree, err = worker.capturer.CaptureDocument(retryContext, result.DocumentFocus)
result.CaptureDuration += time.Since(retryStarted)
retryCancel()
if err == nil && tree != nil && worker.CurrentDocumentResult(result) && worker.focus() == result.DocumentFocus {
if cursor == nil || cursor.tree != tree {
cursor, _ = NewDocumentCursor(tree)
}
result.Err = move()
}
}
return worker.placeDocumentWithRetry(ctx, cursor, result), cursor
}
func (worker *Worker) jumpDocumentFragment(ctx context.Context, cursor *DocumentCursor, fragment string, epoch 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.captureTimeout)
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 = NewDocumentCursor(tree)
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 {
result = worker.placeDocument(ctx, cursor, result)
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
captureContext, cancel := context.WithTimeout(ctx, worker.captureTimeout)
tree, err := worker.capturer.CaptureDocument(captureContext, result.DocumentFocus)
cancel()
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.CaretErr = nil
result = worker.placeDocument(ctx, cursor, result)
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)
}
func (worker *Worker) moveDocumentCaret(ctx context.Context, cursor *DocumentCursor, command DocumentCaretCommand, epoch 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() error {
if cursor == nil {
return ErrNoCurrent
}
if _, err := cursor.SyncFocus(ctx, result.DocumentFocus); err != nil {
return err
}
// 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()
if !documentNeedsCapture(result.Err) {
return worker.placeDocumentCaret(ctx, result), cursor
}
result.CaptureReason = result.Err
captureContext, cancel := context.WithTimeout(ctx, worker.captureTimeout)
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
}
recovered := worker.recoverCapturedDocumentFocus(cursor, tree, &result)
if cursor == nil || cursor.tree != tree {
cursor, _ = NewDocumentCursor(tree)
}
if !recovered {
result.DocumentFocus = worker.focus()
}
result.Err = move()
if result.Moved || !documentNeedsCapture(result.Err) ||
!worker.CurrentDocumentResult(result) || worker.focus() != result.DocumentFocus {
return worker.placeDocumentCaret(ctx, 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.
retryContext, retryCancel := context.WithTimeout(ctx, worker.captureTimeout)
retryStarted := time.Now()
tree, err = worker.capturer.CaptureDocument(retryContext, result.DocumentFocus)
result.CaptureDuration += time.Since(retryStarted)
retryCancel()
if err != nil || tree == nil || !worker.CurrentDocumentResult(result) || worker.focus() != result.DocumentFocus {
return result, cursor
}
if cursor == nil || cursor.tree != tree {
cursor, _ = NewDocumentCursor(tree)
}
result.Err = move()
return worker.placeDocumentCaret(ctx, result), cursor
}
func (worker *Worker) placeDocumentCaret(ctx context.Context, 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()
result.CaretErr = navigator.SetDocumentCaretPosition(deadline, result.DocumentPosition.Object, result.DocumentFocus, result.DocumentPosition.Offset)
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) {
var saved documentPlace
if cursor != nil && len(cursor.places) > 0 {
saved = cursor.places[len(cursor.places)-1]
}
captureContext, cancel := context.WithTimeout(ctx, worker.captureTimeout)
defer cancel()
tree, err := worker.capturer.CaptureDocument(captureContext, focus)
if err != nil {
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() {
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
}
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
}
result.DocumentFocus = root.ID()
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.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{}
link := atspi.ObjectReference{}
if previous := result.DocumentPosition.LinkedHeading; previous != nil {
copy := *previous
copy.SoundGeometry = atspi.SoundGeometry{}
result.DocumentPosition.LinkedHeading = &copy
if cursor == nil {
result.DocumentSoundErr = atspi.ErrStaleObject
return result
}
var err error
link, err = cursor.tree.Reference(copy.ID())
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]
}
}
return result
}