// 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" "errors" "unicode" "unicode/utf8" "git.stormux.org/storm/cthulhu/internal/atspi" ) // Python caret_navigation starts a word move at the adjacent non-space // character, rather than the next/previous word boundary. In particular, // reversing at the end of a word must read that word, not skip over it. func moveCaretText(ctx context.Context, reader DocumentCaretReader, doc atspi.DocumentSnapshot, place *documentPlace, indices []caretRange, current int, backwards, byWord bool) (DocumentCaretMove, error) { step := 1 if backwards { step = -1 } position, same := caretRangePosition(indices, current, place.offset) offset := 0 if same { offset = place.offset + step } else if backwards { position-- if position >= 0 { offset = indices[position].end - 1 } } else if place.origin == StructuralNavigation { offset = 1 } for position >= 0 && position < len(indices) { if err := ctx.Err(); err != nil { return DocumentCaretMove{}, err } node := doc.Nodes[indices[position].index] if node.Snapshot.CharacterCount == 0 || wholeCaretNode(node.Snapshot) { if node.Reference != place.object { place.object, place.offset, place.origin = node.Reference, 0, CaretNavigation return DocumentCaretMove{Position: documentPosition(doc, *place), Moved: true}, nil } position += step offset = 0 if backwards && position >= 0 { offset = indices[position].end - 1 } continue } if !backwards { offset = max(offset, indices[position].start) } if offset >= indices[position].start && offset < indices[position].end { character, err := readCaretRange(ctx, reader, doc, indices[position], offset, atspi.DocumentCaretCharacter) if err != nil && !errors.Is(err, atspi.ErrNoDocumentCaret) { return DocumentCaretMove{}, err } if err == nil && character.End > character.Start { value, _ := utf8.DecodeRuneInString(character.Text) if character.Text == "" || byWord && unicode.IsSpace(value) || value == '\ufffc' || value == '\ufeff' { offset += step continue } if !byWord { place.object, place.offset, place.origin = node.Reference, character.Start, CaretNavigation return DocumentCaretMove{Position: documentPosition(doc, *place), Text: character.Text, Misspelled: character.Misspelled, Moved: true, Start: character.Start, End: character.End}, nil } word, err := readCaretRange(ctx, reader, doc, indices[position], offset, atspi.DocumentCaretWord) if err != nil { return DocumentCaretMove{}, err } if word.Text == "" || word.Start < 0 || word.Start > offset || word.End <= offset || word.End > word.CharacterCount { // Chromium can expose an emoji as a valid character but // return a broken WORD range (for example, end = -1). // Read that character as a unit rather than trapping word // navigation at the same position on every key press. if character.Start < 0 || character.Start > offset || character.End <= offset || character.End > character.CharacterCount { return DocumentCaretMove{}, atspi.ErrNoDocumentCaret } word = character } nextOffset := word.Start if !backwards { nextOffset = word.End // WORD ranges may contain a trailing delimiter. Query its // actual character; spoken word text can be normalized. last, err := readCaretRange(ctx, reader, doc, indices[position], word.End-1, atspi.DocumentCaretCharacter) if err != nil { return DocumentCaretMove{}, err } value, _ := utf8.DecodeRuneInString(last.Text) if unicode.IsSpace(value) { nextOffset-- } } place.object, place.offset, place.origin = node.Reference, nextOffset, CaretNavigation return DocumentCaretMove{Position: documentPosition(doc, *place), Text: word.Text, Misspelled: word.Misspelled, Moved: true, Start: word.Start, End: word.End}, nil } } position += step offset = 0 if backwards && position >= 0 { offset = indices[position].end - 1 } } return DocumentCaretMove{Position: documentPosition(doc, *place)}, nil }