// 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 always uses an actual object's native offsets. 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, _ := 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 { 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, node.Snapshot.CharacterCount}) if wholeCaretNode(node.Snapshot) { return } } for _, id := range node.Children { if child, ok := byID[id]; ok { visit(child) } } } if len(nodes) > 0 { visit(0) } return result } func caretRangePosition(ranges []caretRange, current, offset int) (int, bool) { for position, item := range ranges { if item.index == current && offset >= item.start && offset <= max(item.start, item.end-1) { return position, true } } // Word and read-all cursors may be just after the final character. for position, item := range ranges { if item.index == current && offset == item.end { return position, true } } for position, item := range ranges { if item.index > current { return position, false } } return len(ranges), 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)) text, err := reader.ReadDocumentCaret(ctx, doc.Nodes[item.index].Reference, offset, unit) if err != nil || text.End <= text.Start { return text, err } if item.start == 0 && item.end == doc.Nodes[item.index].Snapshot.CharacterCount { 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 }