187 lines
5.8 KiB
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
|
|
}
|