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