// 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 }