Recreated-node caret recovery, tolerant caret validation, per-root retained document trees and cursors across tab switches, document warm-up, coalesced structural moves, and hung-provider fast-fail with an 'Application is not responding' announcement.
195 lines
6.5 KiB
Go
195 lines
6.5 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"
|
|
"strings"
|
|
|
|
"git.stormux.org/storm/cthulhu/internal/atspi"
|
|
)
|
|
|
|
// move_caret_line handles line and document boundaries under the cursor lock
|
|
// held by MoveCaret. Text-unit movement has its separate traversal helper.
|
|
func move_caret_object_line(ctx context.Context, reader DocumentCaretReader, doc atspi.DocumentSnapshot, place *documentPlace, indices []caretRange, lookup *caretRangeLookup, current int, command DocumentCaretCommand) (DocumentCaretMove, error) {
|
|
unit := atspi.DocumentCaretLine
|
|
if command == CaretReadLine {
|
|
unit = atspi.DocumentCaretLineRemainder
|
|
}
|
|
position, same := lookup.position(indices, current, place.offset)
|
|
backwards := command == CaretPreviousLine || command == CaretDocumentEnd
|
|
if command == CaretDocumentStart {
|
|
position = 0
|
|
} else if command == CaretDocumentEnd {
|
|
position = len(indices) - 1
|
|
}
|
|
offset := place.offset
|
|
if !same || command == CaretDocumentStart {
|
|
offset = 0
|
|
}
|
|
if command == CaretDocumentEnd {
|
|
offset = indices[position].end - 1
|
|
}
|
|
if backwards && command != CaretDocumentEnd && !same {
|
|
position--
|
|
if position < 0 {
|
|
return DocumentCaretMove{Position: documentPosition(doc, *place)}, nil
|
|
}
|
|
offset = indices[position].end - 1
|
|
}
|
|
initialPosition := position
|
|
// Structural navigation already announced its target's whole line,
|
|
// including embedded children such as a heading's link. A following
|
|
// Down must leave that subtree, not descend into it and repeat the
|
|
// announcement as a new line. The target's own first range still uses
|
|
// the same-current logic below, so a long structural paragraph keeps
|
|
// advancing line by line within its own text.
|
|
structuralEnd := -1
|
|
if place.origin == StructuralNavigation {
|
|
structuralEnd = doc.SubtreeEnd(current)
|
|
}
|
|
for attempts := 0; attempts < len(indices)+1; attempts++ {
|
|
if position < 0 || position >= len(indices) {
|
|
return DocumentCaretMove{Position: documentPosition(doc, *place)}, nil
|
|
}
|
|
if command == CaretNextLine && structuralEnd >= current &&
|
|
indices[position].index >= current && indices[position].index <= structuralEnd &&
|
|
!(position == initialPosition && indices[position].index == current) {
|
|
position++
|
|
offset = 0
|
|
continue
|
|
}
|
|
node := doc.Nodes[indices[position].index]
|
|
if named_link_label(node.Snapshot) != "" && (command == CaretLineStart || command == CaretLineEnd) {
|
|
offset = 0
|
|
if command == CaretLineEnd {
|
|
offset = caret_text_count(node.Snapshot) - 1
|
|
}
|
|
character, err := read_caret_text(ctx, reader, node, offset, atspi.DocumentCaretCharacter)
|
|
if err != nil {
|
|
return DocumentCaretMove{}, err
|
|
}
|
|
place.object, place.offset, place.origin = node.Reference, offset, CaretNavigation
|
|
return DocumentCaretMove{Position: documentPosition(doc, *place), Text: character.Text, Moved: true, Start: character.Start, End: character.End}, nil
|
|
}
|
|
if node.Snapshot.CharacterCount == 0 || wholeCaretNode(node.Snapshot) {
|
|
if (command == CaretNextLine || command == CaretPreviousLine) && node.Reference == place.object {
|
|
if backwards {
|
|
position--
|
|
if position >= 0 {
|
|
offset = indices[position].end - 1
|
|
}
|
|
} else {
|
|
position++
|
|
offset = 0
|
|
}
|
|
continue
|
|
}
|
|
place.object, place.offset, place.origin = node.Reference, 0, CaretNavigation
|
|
return DocumentCaretMove{Position: documentPosition(doc, *place), Moved: true}, nil
|
|
}
|
|
if offset < indices[position].start {
|
|
offset = indices[position].start
|
|
}
|
|
if command == CaretReadLine && offset >= indices[position].end {
|
|
position++
|
|
offset = 0
|
|
continue
|
|
}
|
|
if offset >= indices[position].end {
|
|
offset = indices[position].end - 1
|
|
}
|
|
piece, readErr := readCaretRange(ctx, reader, doc, indices[position], offset, unit)
|
|
if readErr != nil && !errors.Is(readErr, atspi.ErrNoDocumentCaret) {
|
|
return DocumentCaretMove{}, readErr
|
|
}
|
|
if readErr == nil && piece.End > piece.Start && strings.TrimSpace(piece.Text) != "\ufffc" {
|
|
nextOffset := offset
|
|
sameCurrent := position == initialPosition && same ||
|
|
place.origin == StructuralNavigation && position == initialPosition && indices[position].index >= current
|
|
switch command {
|
|
case CaretNextLine:
|
|
if sameCurrent {
|
|
nextOffset = piece.End
|
|
if nextOffset < piece.CharacterCount {
|
|
piece, readErr = readCaretRange(ctx, reader, doc, indices[position], nextOffset, unit)
|
|
if readErr != nil {
|
|
return DocumentCaretMove{}, readErr
|
|
}
|
|
} else {
|
|
position++
|
|
if position >= len(indices) {
|
|
return DocumentCaretMove{Position: documentPosition(doc, *place)}, nil
|
|
}
|
|
offset = 0
|
|
continue
|
|
}
|
|
}
|
|
nextOffset = piece.Start
|
|
case CaretPreviousLine:
|
|
if sameCurrent {
|
|
if piece.Start > indices[position].start {
|
|
piece, readErr = readCaretRange(ctx, reader, doc, indices[position], piece.Start-1, unit)
|
|
if readErr != nil {
|
|
return DocumentCaretMove{}, readErr
|
|
}
|
|
} else {
|
|
position--
|
|
if position < 0 {
|
|
return DocumentCaretMove{Position: documentPosition(doc, *place)}, nil
|
|
}
|
|
offset = indices[position].end - 1
|
|
continue
|
|
}
|
|
}
|
|
nextOffset = piece.Start
|
|
case CaretLineStart:
|
|
nextOffset = piece.Start
|
|
case CaretLineEnd, CaretDocumentEnd:
|
|
nextOffset = max(piece.Start, piece.End-1)
|
|
case CaretDocumentStart:
|
|
nextOffset = piece.Start
|
|
}
|
|
if piece.End <= piece.Start || strings.TrimSpace(piece.Text) == "\ufffc" {
|
|
// An embedded-object placeholder is a container, not spoken text.
|
|
if backwards {
|
|
position--
|
|
if position >= 0 {
|
|
offset = indices[position].end - 1
|
|
}
|
|
} else {
|
|
position++
|
|
offset = 0
|
|
}
|
|
continue
|
|
}
|
|
spoken := piece.Text
|
|
if command == CaretLineStart || command == CaretLineEnd {
|
|
character, charErr := readCaretRange(ctx, reader, doc, indices[position], nextOffset, atspi.DocumentCaretCharacter)
|
|
if charErr != nil {
|
|
return DocumentCaretMove{}, charErr
|
|
}
|
|
spoken = character.Text
|
|
}
|
|
place.object = node.Reference
|
|
place.offset = nextOffset
|
|
place.origin = CaretNavigation
|
|
return DocumentCaretMove{Position: documentPosition(doc, *place), Text: spoken, RawText: piece.RawText, Misspelled: piece.Misspelled, Moved: true, Start: piece.Start, End: piece.End}, nil
|
|
}
|
|
if backwards {
|
|
position--
|
|
if position >= 0 {
|
|
offset = indices[position].end - 1
|
|
}
|
|
} else {
|
|
position++
|
|
offset = 0
|
|
}
|
|
}
|
|
return DocumentCaretMove{Position: documentPosition(doc, *place)}, nil
|
|
}
|