Files
cthulhu/internal/review/document_visual_line.go

291 lines
11 KiB
Go

// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package review
import (
"context"
"errors"
"math"
"strings"
"unicode"
"unicode/utf8"
"git.stormux.org/storm/cthulhu/internal/atspi"
)
// A visual line is temporary move-owned data. The retained cursor always uses
// an actual object's native offset, never an offset in the joined string.
type visualLinePart struct {
index int
text atspi.DocumentCaretText
}
type visualLineReader struct {
DocumentCaretReader
cache map[visualLineKey]atspi.DocumentCaretText
}
type visualLineKey struct {
object atspi.ObjectReference
offset int
unit atspi.DocumentCaretUnit
}
func (reader *visualLineReader) ReadDocumentCaret(ctx context.Context, object atspi.ObjectReference, offset int, unit atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) {
if err := ctx.Err(); err != nil {
return atspi.DocumentCaretText{}, err
}
key := visualLineKey{object, offset, unit}
if text, ok := reader.cache[key]; ok {
return text, nil
}
text, err := reader.DocumentCaretReader.ReadDocumentCaret(ctx, object, offset, unit)
if err == nil {
reader.cache[key] = text
}
return text, err
}
func move_caret_line(ctx context.Context, source DocumentCaretReader, tree *atspi.AccessibleTree, doc atspi.DocumentSnapshot, place *documentPlace, indices []caretRange, lookup *caretRangeLookup, current int, command DocumentCaretCommand, owners map[int]atspi.ObjectID) (DocumentCaretMove, error) {
reader := &visualLineReader{DocumentCaretReader: source, cache: make(map[visualLineKey]atspi.DocumentCaretText)}
next := *place
// Move beyond the entire current line, even when character/word movement
// left the cursor in a middle fragment or structural navigation chose its
// heading container. The object-local mover still handles empty controls.
if command == CaretNextLine || command == CaretPreviousLine {
position := lookup.containing(indices, current, place.offset)
if place.origin == StructuralNavigation && lookup.after[current] < len(indices) && (position < 0 || lookup.after[current] < position) {
position = lookup.after[current]
}
if position >= 0 {
parts, err := collect_visual_line(ctx, reader, doc, indices, owners, position, place.offset, true)
if err != nil {
return DocumentCaretMove{}, err
}
if len(parts) > 1 {
part := parts[0]
if command == CaretNextLine {
part = parts[len(parts)-1]
}
next.object, next.offset, next.origin = doc.Nodes[part.index].Reference, part.text.Start, CaretNavigation
current = part.index
}
}
}
movement, err := move_caret_object_line(ctx, reader, doc, &next, indices, lookup, current, command)
if err != nil || !movement.Moved {
if err == nil {
movement.Position = documentPosition(doc, *place)
}
return movement, err
}
nextIndex, _ := doc.Index(next.object.ID())
position := lookup.containing(indices, nextIndex, next.offset)
if position >= 0 {
parts, err := collect_visual_line(ctx, reader, doc, indices, owners, position, next.offset, command != CaretReadLine)
if err != nil {
return DocumentCaretMove{}, err
}
if len(parts) > 1 {
part := parts[0]
if command == CaretLineEnd || command == CaretDocumentEnd {
part = parts[len(parts)-1]
}
next.object, next.offset = doc.Nodes[part.index].Reference, part.text.Start
if command == CaretLineEnd || command == CaretDocumentEnd {
next.offset = part.text.End - 1
}
movement.Start, movement.End = part.text.Start, part.text.End
line, raw, misspelled := assemble_visual_line(tree, doc, parts)
movement.Line, movement.RawText, movement.Misspelled = &line, raw, movement.Misspelled || misspelled
movement.Text = strings.Join(strings.Fields(movement.RawText), " ")
if command == CaretLineStart || command == CaretLineEnd {
character, err := reader.ReadDocumentCaret(ctx, next.object, next.offset, atspi.DocumentCaretCharacter)
if err != nil {
return DocumentCaretMove{}, err
}
movement.Text = character.Text
}
movement.Position = documentPosition(doc, next)
}
}
*place = next
return movement, nil
}
// Retain semantic boundaries even if unrelated blocks have matching y values.
// Links and same-row list items inherit their surrounding block. Headings,
// paragraphs and table cells remain separate; grid items retain their boundaries.
func visual_line_owners(nodes []atspi.DocumentNode) map[int]atspi.ObjectID {
owners := make(map[int]atspi.ObjectID, len(nodes))
byID := make(map[atspi.ObjectID]atspi.ObjectID, len(nodes))
displays := make(map[atspi.ObjectID]string, len(nodes))
for index, node := range nodes {
owner := byID[node.Parent]
snapshot := node.Snapshot
displays[node.Reference.ID()] = snapshot.Display
switch snapshot.Role {
case "paragraph", "heading", "list", "table cell", "row header", "column header", "document web", "document frame", "embedded":
owner = node.Reference.ID()
case "list item":
if strings.Contains(displays[node.Parent], "grid") {
owner = node.Reference.ID()
}
case "section":
if snapshot.HTMLTag != "span" {
owner = node.Reference.ID()
}
}
if snapshot.Editable || wholeCaretNode(snapshot) || snapshot.Role == "password text" ||
snapshot.HTMLTag == "pre" || snapshot.HTMLTag == "code" || strings.Contains(" "+snapshot.XMLRoles+" ", " code ") {
// Exclude the whole subtree, including static code descendants.
owner = atspi.ObjectID{}
byID[node.Reference.ID()] = owner
owners[index] = owner
continue
}
// An excluded ancestor must stay excluded through nested blocks.
if parent, known := byID[node.Parent]; known && !parent.Valid() {
owner = atspi.ObjectID{}
}
byID[node.Reference.ID()] = owner
owners[index] = owner
}
return owners
}
func collect_visual_line(ctx context.Context, reader DocumentCaretReader, doc atspi.DocumentSnapshot, indices []caretRange, owners map[int]atspi.ObjectID, position, offset int, left bool) ([]visualLinePart, error) {
index := indices[position].index
node := doc.Nodes[index]
if !owners[index].Valid() || node.Snapshot.Editable || wholeCaretNode(node.Snapshot) || node.Snapshot.CharacterCount <= 0 {
return nil, nil
}
unit := atspi.DocumentCaretLine
if !left {
unit = atspi.DocumentCaretLineRemainder
}
text, err := readCaretRange(ctx, reader, doc, indices[position], min(offset, indices[position].end-1), unit)
if errors.Is(err, atspi.ErrNoDocumentCaret) {
return nil, nil
}
if err != nil {
return nil, err
}
if !visual_line_text(text) {
return nil, nil
}
if !left && text.RawText != "" {
// Native remainder speech keeps the full raw line for indentation;
// assembly needs only the characters after the starting caret.
runes := []rune(text.RawText)
if length := text.End - text.Start; length < len(runes) {
text.RawText = string(runes[len(runes)-length:])
}
}
parts := []visualLinePart{{index, text}}
characters := utf8.RuneCountInString(text.Text)
for _, direction := range []int{-1, 1} {
if direction < 0 && !left {
continue
}
for next := position + direction; next >= 0 && next < len(indices); next += direction {
candidate := indices[next].index
if owners[candidate] != owners[index] {
break
}
probe := indices[next].start
if direction < 0 {
probe = indices[next].end - 1
}
piece, err := readCaretRange(ctx, reader, doc, indices[next], probe, atspi.DocumentCaretLine)
if errors.Is(err, atspi.ErrNoDocumentCaret) {
break
}
if err != nil {
return nil, err
}
if !visual_line_text(piece) || !visual_line_bounds(text.Bounds, piece.Bounds) {
break
}
// A fragment containing a hard newline ends the line, even when
// a provider reports identical/overlapping bounds for both rows.
previous, following := parts[len(parts)-1].text, piece
if direction < 0 {
previous, following = piece, parts[0].text
}
if strings.ContainsAny(previous.RawText, "\r\n") || strings.ContainsAny(previous.Text, "\r\n") || strings.HasPrefix(following.RawText, "\n") || strings.HasPrefix(following.Text, "\n") {
break
}
characters += utf8.RuneCountInString(piece.Text)
if len(parts) >= 256 || characters > 65536 {
return nil, errors.New("document visual line exceeds capture limit")
}
part := visualLinePart{candidate, piece}
if direction < 0 {
parts = append([]visualLinePart{part}, parts...)
} else {
parts = append(parts, part)
}
}
}
return parts, nil
}
func visual_line_text(text atspi.DocumentCaretText) bool {
return text.Bounds.Valid() && text.Start >= 0 && text.End > text.Start && text.End <= text.CharacterCount &&
!strings.ContainsRune(text.Text, '\ufffc') && (text.Text != "" || text.RawText != "")
}
func visual_line_bounds(first, second atspi.Rectangle) bool {
if !first.Valid() || !second.Valid() {
return false
}
top := max(int64(first.Y), int64(second.Y))
bottom := min(int64(first.Y)+int64(first.Height), int64(second.Y)+int64(second.Height))
return bottom > top && math.Abs(float64(first.Y)+float64(first.Height)/2-float64(second.Y)-float64(second.Height)/2) <= 5
}
func visual_line_space(first, second atspi.DocumentCaretText) bool {
left, right := first.RawText, second.RawText
if left == "" {
left = first.Text
}
if right == "" {
right = second.Text
}
last, _ := utf8.DecodeLastRuneInString(left)
start, _ := utf8.DecodeRuneInString(right)
gap := max(int64(second.Bounds.X)-int64(first.Bounds.X)-int64(first.Bounds.Width), int64(first.Bounds.X)-int64(second.Bounds.X)-int64(second.Bounds.Width))
return gap > 1 && !unicode.IsSpace(last) && !unicode.IsSpace(start)
}
func assemble_visual_line(tree *atspi.AccessibleTree, doc atspi.DocumentSnapshot, parts []visualLinePart) (atspi.DocumentLine, string, bool) {
misspelled := false
line := atspi.DocumentLine{Document: doc.Context.TopLevel}
var raw strings.Builder
for i, part := range parts {
value := part.text.RawText
if value == "" {
value = part.text.Text
}
// Preserve native spacing and punctuation across span boundaries.
separator := ""
if i > 0 && visual_line_space(parts[i-1].text, part.text) {
separator = " "
}
raw.WriteString(separator)
raw.WriteString(value)
snapshot := doc.Nodes[part.index].Snapshot
owner, _ := tree.DocumentContext(doc.Nodes[part.index].Reference)
snapshot.BrailleContext = owner.Ancestors
line.SourceContexts = append(line.SourceContexts, owner.Ancestors)
snapshot.Misspelled = part.text.Misspelled
misspelled = misspelled || part.text.Misspelled
line.Contents = append(line.Contents, atspi.DocumentContent{Reference: doc.Nodes[part.index].Reference, Snapshot: snapshot,
Start: part.text.Start, End: part.text.End, Text: part.text.Text, RawText: value, Separator: separator, Bounds: part.text.Bounds})
}
return line, raw.String(), misspelled
}