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