// 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 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 // Native text range used for this move, in Unicode character offsets. Start, End int } // MoveCaret advances the independent browse position using AT-SPI text units. // 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 } place := &cursor.places[len(cursor.places)-1] doc, err := cursor.tree.Document(ctx, place.object) if err != nil { return DocumentCaretMove{}, err } if doc.Context.TopLevel != place.document { return DocumentCaretMove{}, atspi.ErrStaleObject } if command >= TableLeft { return cursor.moveTable(ctx, doc, place, command) } place.tableCell = atspi.ObjectID{} _, current := caret_node_indices(doc.Nodes, place.object) indices := caretNodeRanges(doc.Nodes) if current < 0 { return DocumentCaretMove{}, atspi.ErrStaleObject } if len(indices) == 0 { return DocumentCaretMove{Position: documentPosition(doc, *place)}, nil } switch command { case CaretNextWord, CaretPreviousWord, CaretNextCharacter, CaretPreviousCharacter: return moveCaretText(ctx, reader, doc, place, indices, current, command == CaretPreviousWord || command == CaretPreviousCharacter, command == CaretNextWord || command == CaretPreviousWord) } return move_caret_line(ctx, reader, cursor.tree, doc, place, indices, current, command) }