Recreated-node caret recovery, tolerant caret validation, per-root retained document trees and cursors across tab switches, document warm-up, coalesced structural moves, and hung-provider fast-fail with an 'Application is not responding' announcement.
171 lines
5.9 KiB
Go
171 lines
5.9 KiB
Go
// Cthulhu screen reader.
|
|
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package review
|
|
|
|
import (
|
|
"context"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
|
)
|
|
|
|
// DocumentCaretCommand follows Python Cthulhu's browse caret key families.
|
|
type DocumentCaretCommand uint8
|
|
|
|
const (
|
|
CaretNextCharacter DocumentCaretCommand = iota + 1
|
|
CaretPreviousCharacter
|
|
CaretNextWord
|
|
CaretPreviousWord
|
|
CaretNextLine
|
|
CaretPreviousLine
|
|
CaretLineStart
|
|
CaretLineEnd
|
|
CaretDocumentStart
|
|
CaretDocumentEnd
|
|
CaretReadLine
|
|
TableLeft
|
|
TableRight
|
|
TableUp
|
|
TableDown
|
|
TableFirst
|
|
TableLast
|
|
)
|
|
|
|
func (command DocumentCaretCommand) valid() bool {
|
|
return command >= CaretNextCharacter && command <= TableLast
|
|
}
|
|
|
|
func (command DocumentCaretCommand) String() string {
|
|
names := [...]string{"invalid", "next-character", "previous-character",
|
|
"next-word", "previous-word", "next-line", "previous-line", "line-start",
|
|
"line-end", "document-start", "document-end", "read-line", "table-left",
|
|
"table-right", "table-up", "table-down", "table-first", "table-last"}
|
|
if !command.valid() {
|
|
return names[0]
|
|
}
|
|
return names[command]
|
|
}
|
|
|
|
type DocumentCaretReader interface {
|
|
ReadDocumentCaret(context.Context, atspi.ObjectReference, int, atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error)
|
|
}
|
|
|
|
type DocumentCharacterReader interface {
|
|
ReadDocumentCharacters(context.Context, atspi.ObjectReference, int) (atspi.DocumentCaretText, error)
|
|
}
|
|
|
|
type DocumentCaretMove struct {
|
|
Position DocumentPosition
|
|
Text string
|
|
RawText string
|
|
Misspelled bool
|
|
// Line retains each object's native range for an assembled visual line.
|
|
Line *atspi.DocumentLine
|
|
TablePrefix, TableSuffix string
|
|
|
|
TableRowHeader, TableColumnHeader string
|
|
|
|
Moved bool
|
|
// Text range used for this move, in Unicode character offsets. Named
|
|
// links use accessible-label offsets rather than native text offsets.
|
|
Start, End int
|
|
}
|
|
|
|
// MoveCaret advances the independent browse position using AT-SPI text units
|
|
// or accessible-label units for explicitly named links.
|
|
// It does not depend on Chromium's retained native caret. A stale captured
|
|
// document is reported to the worker for one bounded refresh.
|
|
func (cursor *DocumentCursor) MoveCaret(ctx context.Context, reader DocumentCaretReader, command DocumentCaretCommand) (DocumentCaretMove, error) {
|
|
if !command.valid() || reader == nil {
|
|
return DocumentCaretMove{}, ErrInvalidDirection
|
|
}
|
|
cursor.mu.Lock()
|
|
defer cursor.mu.Unlock()
|
|
if !cursor.active.Valid() || len(cursor.places) == 0 {
|
|
return DocumentCaretMove{}, ErrNoCurrent
|
|
}
|
|
saved := &cursor.places[len(cursor.places)-1]
|
|
next := *saved
|
|
place := &next
|
|
doc, err := cursor.tree.NavigationDocument(ctx, place.object)
|
|
if err != nil {
|
|
return DocumentCaretMove{}, err
|
|
}
|
|
if doc.Context.TopLevel != place.document {
|
|
return DocumentCaretMove{}, atspi.ErrStaleObject
|
|
}
|
|
current, found := doc.Index(place.object.ID())
|
|
if !found || doc.Nodes[current].Reference != place.object {
|
|
return DocumentCaretMove{}, atspi.ErrStaleObject
|
|
}
|
|
if err := cursor.tree.ValidateDocumentCaretRange(ctx, doc, current, current); err != nil {
|
|
return DocumentCaretMove{}, err
|
|
}
|
|
if command >= TableLeft {
|
|
movement, err := cursor.moveTable(ctx, doc, place, command)
|
|
if err == nil && movement.Moved {
|
|
*saved = next
|
|
}
|
|
return movement, err
|
|
}
|
|
place.tableCell = atspi.ObjectID{}
|
|
if !doc.SameContent(cursor.caretDocument) {
|
|
cursor.caretDocument = doc
|
|
cursor.caretRanges = caretNodeRanges(doc.Nodes)
|
|
cursor.caretLookup = new_caret_range_lookup(cursor.caretRanges, len(doc.Nodes))
|
|
cursor.lineOwners = visual_line_owners(doc.Nodes)
|
|
}
|
|
indices := cursor.caretRanges
|
|
if len(indices) == 0 {
|
|
if err := cursor.tree.ValidateDocumentCaretRange(ctx, doc, 0, len(doc.Nodes)-1); err != nil {
|
|
return DocumentCaretMove{}, err
|
|
}
|
|
return DocumentCaretMove{Position: documentPosition(doc, *place)}, nil
|
|
}
|
|
var movement DocumentCaretMove
|
|
navigationReader := &navigationTextReader{DocumentCaretReader: reader, tree: cursor.tree, document: doc, anchor: current}
|
|
reader = navigationReader
|
|
if characters, ok := navigationReader.DocumentCaretReader.(DocumentCharacterReader); ok {
|
|
reader = &navigationCharacterReader{navigationTextReader: navigationReader, source: characters}
|
|
}
|
|
switch command {
|
|
case CaretNextWord, CaretPreviousWord, CaretNextCharacter, CaretPreviousCharacter:
|
|
movement, err = moveCaretText(ctx, reader, doc, place, indices, cursor.caretLookup, current,
|
|
command == CaretPreviousWord || command == CaretPreviousCharacter,
|
|
command == CaretNextWord || command == CaretPreviousWord)
|
|
default:
|
|
movement, err = move_caret_line(ctx, reader, cursor.tree, doc, place, indices, cursor.caretLookup, current, command, cursor.lineOwners)
|
|
}
|
|
if err == nil && movement.Moved {
|
|
target, found := doc.Index(place.object.ID())
|
|
if !found {
|
|
return DocumentCaretMove{}, atspi.ErrStaleObject
|
|
}
|
|
// Successful text already belongs to this move even if the provider
|
|
// removes its ancestor immediately afterwards. Repeating that move
|
|
// during recovery would skip the line. Synthetic whole-object stops
|
|
// still need validation because they bypass the native text reader.
|
|
if !navigationReader.did_read(place.object) {
|
|
if err := cursor.tree.ValidateDocumentCaretRange(ctx, doc, current, target); err != nil {
|
|
return DocumentCaretMove{}, err
|
|
}
|
|
}
|
|
remember_text_parent(doc, place)
|
|
*saved = next
|
|
} else if err == nil {
|
|
// Reaching a boundary must not silently skip newly readable text in a
|
|
// dirty object which had no caret range in the captured view.
|
|
boundary := len(doc.Nodes) - 1
|
|
if command == CaretPreviousCharacter || command == CaretPreviousWord || command == CaretPreviousLine || command == CaretDocumentStart {
|
|
boundary = 0
|
|
}
|
|
if err := cursor.tree.ValidateDocumentCaretRange(ctx, doc, current, boundary); err != nil {
|
|
return DocumentCaretMove{}, err
|
|
}
|
|
}
|
|
return movement, err
|
|
}
|