Files
cthulhu/internal/review/document_caret_ranges.go

187 lines
5.8 KiB
Go

// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package review
import (
"context"
"strings"
"unicode/utf8"
"git.stormux.org/storm/cthulhu/internal/atspi"
)
// A range uses native text offsets, except for explicitly named links whose
// label offsets stay virtual. Hypertext containers can occur more than once,
// before and after their embedded children.
type caretRange struct{ index, start, end int }
func caretNodeRanges(nodes []atspi.DocumentNode) []caretRange {
indices, _, expanded := caret_node_indices(nodes, atspi.ObjectReference{})
eligible := make(map[int]bool, len(indices))
byID := make(map[atspi.ObjectID]int, len(nodes))
for _, index := range indices {
eligible[index] = true
}
for index, node := range nodes {
byID[node.Reference.ID()] = index
}
var result []caretRange
var visit func(int)
visit = func(index int) {
node := nodes[index]
if node.Hidden || node.Snapshot.Role == "password text" || passive_caret_tooltip(node.Snapshot) {
return
}
children := node.Snapshot.TextChildren
valid := len(children) > 0 && !node.Snapshot.Editable && !wholeCaretNode(node.Snapshot)
previous := -1
direct := make(map[atspi.ObjectID]bool, len(node.Children))
for _, id := range node.Children {
direct[id] = true
}
for _, child := range children {
if child.Offset <= previous || child.Offset >= node.Snapshot.CharacterCount || !direct[child.Child] {
valid = false
break
}
previous = child.Offset
}
if valid {
offset := 0
for _, child := range children {
if offset < child.Offset {
result = append(result, caretRange{index, offset, child.Offset})
}
if childIndex, ok := byID[child.Child]; ok {
visit(childIndex)
}
delete(direct, child.Child)
offset = child.Offset + 1
}
if offset < node.Snapshot.CharacterCount {
result = append(result, caretRange{index, offset, node.Snapshot.CharacterCount})
}
for _, id := range node.Children {
if direct[id] {
if child, ok := byID[id]; ok {
visit(child)
}
}
}
return
}
if eligible[index] {
result = append(result, caretRange{index, 0, caret_text_count(node.Snapshot)})
if wholeCaretNode(node.Snapshot) && !expanded[node.Reference.ID()] {
return
}
}
for _, id := range node.Children {
if child, ok := byID[id]; ok {
visit(child)
}
}
}
if len(nodes) > 0 {
visit(0)
}
return result
}
// Object-local range positions also handle a hypertext parent occurring on
// both sides of an embedded child. after preserves provider traversal order,
// which is not monotonically increasing in document node index.
type caretRangeLookup struct {
objects [][]int
after []int
}
func new_caret_range_lookup(ranges []caretRange, nodeCount int) *caretRangeLookup {
lookup := &caretRangeLookup{objects: make([][]int, nodeCount), after: make([]int, nodeCount)}
for position, item := range ranges {
lookup.objects[item.index] = append(lookup.objects[item.index], position)
}
following := len(ranges)
for index := nodeCount - 1; index >= 0; index-- {
lookup.after[index] = following
if positions := lookup.objects[index]; len(positions) > 0 {
following = min(following, positions[0])
}
}
return lookup
}
func (lookup *caretRangeLookup) containing(ranges []caretRange, current, offset int) int {
for _, position := range lookup.objects[current] {
item := ranges[position]
if offset >= item.start && offset < max(item.start+1, item.end) {
return position
}
}
return -1
}
func (lookup *caretRangeLookup) position(ranges []caretRange, current, offset int) (int, bool) {
if position := lookup.containing(ranges, current, offset); position >= 0 {
return position, true
}
// Word and Read All cursors may be just after the final character.
for _, position := range lookup.objects[current] {
if offset == ranges[position].end {
return position, true
}
}
return lookup.after[current], false
}
func readCaretRange(ctx context.Context, reader DocumentCaretReader, doc atspi.DocumentSnapshot, item caretRange, offset int, unit atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) {
offset = max(item.start, min(offset, item.end-1))
node := doc.Nodes[item.index]
text, err := read_caret_text(ctx, reader, node, offset, unit)
if err != nil || text.End <= text.Start {
return text, err
}
if item.start == 0 && item.end == caret_text_count(node.Snapshot) {
return text, nil
}
if text.Start < 0 || text.Start > offset || text.End <= offset || text.End > text.CharacterCount {
return text, nil
}
start, end := max(text.Start, item.start), min(text.End, item.end)
if start >= end {
return atspi.DocumentCaretText{CharacterCount: item.end}, nil
}
if start != text.Start || end != text.End {
raw := text.RawText
if raw == "" {
raw = text.Text
}
// Speech may be whitespace-normalized. Clip using the raw native text;
// never treat offsets in a normalized string as native character offsets.
runes := []rune(raw)
if unit == atspi.DocumentCaretLineRemainder && len(runes) > text.End-text.Start {
runes = runes[len(runes)-(text.End-text.Start):]
}
if len(runes) != text.End-text.Start {
return atspi.DocumentCaretText{}, atspi.ErrNoDocumentCaret
}
raw = string(runes[start-text.Start : end-text.Start])
if text.RawText != "" {
text.RawText = raw
text.Text = strings.Join(strings.Fields(raw), " ")
} else {
text.Text = raw
}
text.Start, text.End = start, end
}
// Range-local traversal must stop at the embedded object. The retained
// snapshot still has the full native character count for routing/placement.
text.CharacterCount = item.end
if unit == atspi.DocumentCaretCharacter && utf8.RuneCountInString(text.Text) > 1 {
return atspi.DocumentCaretText{}, atspi.ErrNoDocumentCaret
}
return text, nil
}