Files
cthulhu/internal/review/document_caret_line.go
hjozwiak 7eef6e8be6 Make web document navigation survive live-updating pages
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.
2026-11-03 10:34:55 -05:00

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
}