Files
cthulhu/internal/review/document_caret_text.go

140 lines
5.4 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"
"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, lookup *caretRangeLookup, current int, backwards, byWord bool) (DocumentCaretMove, error) {
step := 1
if backwards {
step = -1
}
position, same := lookup.position(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 caret_text_count(node.Snapshot) == 0 || wholeCaretNode(node.Snapshot) && named_link_label(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' {
if blocks, ok := reader.(DocumentCharacterReader); byWord && unicode.IsSpace(value) && ok && named_link_label(node.Snapshot) == "" {
nextOffset, err := skip_word_spaces(ctx, blocks, node.Reference, indices[position], offset, step)
if err != nil {
return DocumentCaretMove{}, err
}
if nextOffset != offset {
offset = nextOffset
continue
}
}
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
}
// Keep native Unicode offsets and stop at embedded-object range boundaries.
// A broken optional block cannot advance the cursor; ordinary unit reads then
// retain their existing provider-error handling.
func skip_word_spaces(ctx context.Context, reader DocumentCharacterReader, object atspi.ObjectReference, item caretRange, offset, step int) (int, error) {
block, err := reader.ReadDocumentCharacters(ctx, object, offset)
if err != nil {
return offset, err
}
runes := []rune(block.RawText)
if block.Start < 0 || block.Start > offset || block.End <= offset || block.End > block.CharacterCount || len(runes) != block.End-block.Start {
return offset, nil
}
for offset >= item.start && offset < item.end && offset >= block.Start && offset < block.End && unicode.IsSpace(runes[offset-block.Start]) {
offset += step
}
return offset, nil
}