Files
cthulhu/internal/review/document_caret.go
hjozwiak 7eef6e8be6 Make web document navigation survive live-updating pages
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.
2026-11-03 10:34:55 -05:00

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
}