// 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, current int, command DocumentCaretCommand) (DocumentCaretMove, error) { unit := atspi.DocumentCaretLine if command == CaretReadLine { unit = atspi.DocumentCaretLineRemainder } position, same := caretRangePosition(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 for attempts := 0; attempts < len(indices)+1; attempts++ { if position < 0 || position >= len(indices) { return DocumentCaretMove{Position: documentPosition(doc, *place)}, nil } node := doc.Nodes[indices[position].index] 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 }