140 lines
5.4 KiB
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
|
|
}
|