291 lines
11 KiB
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
|
|
}
|