// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package review import ( "context" "git.stormux.org/storm/cthulhu/internal/atspi" ) // Native selections can report their caret on a label/paragraph rather than // its static text child. Transfer offsets only when both expose the identical // text range. Captured hypertext parents own their direct-text offsets; // embedded or ambiguous child offsets must never be guessed. func (cursor *DocumentCursor) syncNativeCaret(ctx context.Context, reader DocumentCaretReader, focus atspi.ObjectID, offset int) error { if offset < 0 { return atspi.ErrNoDocumentCaret } if _, err := cursor.SyncFocus(ctx, focus); err != nil { return err } cursor.mu.Lock() defer cursor.mu.Unlock() place := &cursor.places[len(cursor.places)-1] doc, err := cursor.tree.Document(ctx, place.focus) if err != nil { return err } node, offset, err := native_caret_node(ctx, reader, doc, focus, offset) if err != nil { return err } place.object, place.offset, place.origin = node.Reference, offset, CaretNavigation place.tableCell = atspi.ObjectID{} return nil } func native_caret_node(ctx context.Context, reader DocumentCaretReader, doc atspi.DocumentSnapshot, focus atspi.ObjectID, offset int) (atspi.DocumentNode, int, error) { nodes := make(map[atspi.ObjectID]atspi.DocumentNode, len(doc.Nodes)) for _, node := range doc.Nodes { nodes[node.Reference.ID()] = node } node := nodes[focus] if !node.Reference.Valid() || offset < 0 || offset > node.Snapshot.CharacterCount || node.Snapshot.Editable || node.Snapshot.Role == "password text" { return atspi.DocumentNode{}, 0, atspi.ErrNoDocumentCaret } for len(node.Children) != 0 && !wholeCaretNode(node.Snapshot) { if len(node.Snapshot.TextChildren) != 0 { embedded := atspi.ObjectID{} for _, child := range node.Snapshot.TextChildren { if child.Offset == offset { embedded = child.Child break } } if !embedded.Valid() { return node, offset, nil } child, ok := nodes[embedded] if !ok || child.Hidden || child.Snapshot.Editable || child.Snapshot.Role == "password text" { return atspi.DocumentNode{}, 0, atspi.ErrNoDocumentCaret } node, offset = child, 0 continue } if len(node.Children) != 1 { return atspi.DocumentNode{}, 0, atspi.ErrNoDocumentCaret } child, ok := nodes[node.Children[0]] if !ok || child.Hidden || child.Snapshot.Editable || child.Snapshot.CharacterCount != node.Snapshot.CharacterCount { return atspi.DocumentNode{}, 0, atspi.ErrNoDocumentCaret } probeOffset := max(0, min(offset, node.Snapshot.CharacterCount-1)) parentText, err := reader.ReadDocumentCaret(ctx, node.Reference, probeOffset, atspi.DocumentCaretBrailleLine) if err != nil { return atspi.DocumentNode{}, 0, err } childText, err := reader.ReadDocumentCaret(ctx, child.Reference, probeOffset, atspi.DocumentCaretBrailleLine) if err != nil { return atspi.DocumentNode{}, 0, err } if parentText.Start != childText.Start || parentText.End != childText.End || parentText.Text == "" || parentText.Text != childText.Text { return atspi.DocumentNode{}, 0, atspi.ErrNoDocumentCaret } node = child } return node, offset, nil } // Use the navigation FIFO so a subsequent intercepted arrow sees the native // selection endpoint, even if capturing the document is still in progress. func (worker *Worker) SyncDocumentCaret(focus atspi.ObjectID, offset int) error { if !focus.Valid() || offset < 0 { return atspi.ErrNoDocumentCaret } select { case worker.requests <- request{documentEpoch: worker.documentEpoch.Load(), nativeCaret: true, nativeCaretFocus: focus, nativeCaretOffset: offset}: return nil default: return ErrRequestQueueFull } } func (worker *Worker) syncDocumentCaret(ctx context.Context, cursor *DocumentCursor, request request) (Result, *DocumentCursor) { result := Result{DocumentNativeCaret: true, DocumentEpoch: request.documentEpoch, DocumentFocus: request.nativeCaretFocus} if worker.focus() != request.nativeCaretFocus { result.Err = atspi.ErrStaleObject return result, cursor } reader, ok := worker.nativeCapturerFor(request.nativeCaretFocus).(DocumentCaretReader) if !ok { result.Err = atspi.ErrNoDocumentCaret return result, cursor } ctx, cancel := context.WithTimeout(ctx, worker.captureTimeout) defer cancel() if cursor == nil { cursor, result.Err = worker.refreshDocumentCursor(ctx, nil, request.nativeCaretFocus) if result.Err != nil { return result, cursor } } result.Err = cursor.syncNativeCaret(ctx, reader, request.nativeCaretFocus, request.nativeCaretOffset) if documentNeedsCapture(result.Err) { fresh, captureErr := worker.refreshDocumentCursor(ctx, cursor, request.nativeCaretFocus) if captureErr == nil && worker.focus() == request.nativeCaretFocus { cursor = fresh result.Err = cursor.syncNativeCaret(ctx, reader, request.nativeCaretFocus, request.nativeCaretOffset) } else if captureErr != nil { result.Err = captureErr } } if result.Err == nil { result.DocumentPosition, result.Err = cursor.SyncFocus(ctx, request.nativeCaretFocus) } return result, cursor }