Expanding embedded-object placeholders changes the displayed braille text
without changing native AT-SPI offsets. Several consumers still validated
native positions against the expanded string, which could reject a valid
line end after contraction, accept an offset beyond the native line after
expansion, or drop selection and text-attribute indicators on a plain-text
fragment following an embedded object.
Use RawText for native-offset validation and fragment comparisons whenever
a braille line is expanded. Apply the same rule to parent/child native-caret
handoff: identical displayed text must not authorize an offset transfer
when the underlying native strings differ. Expanded spans retain their
existing conservative presentation without native cell routes.
Restrict the assembled-line displayed-text fallback to lines from which
embedded-object placeholders were actually removed. Ordinary whitespace-only
lines now retain their spaces, cursor position, and native routing instead
of being replaced with the object's text or accessible name.
Add regressions for contracted and expanded native offset bounds, suffix
selection and attribute preservation, immutable line enrichment, ambiguous
caret transfer, and whitespace-only line routing. The regressions reproduced
the review findings before the fixes and pass afterward.
Validation:
- Full Go suite before fixes; full race and BrlAPI suites after fixes.
- Focused Braille and native-caret regressions, including race checks.
- go vet ./...; reader and BrlAPI helper builds; parity-ledger validation.
- Private Chromium linked-heading, selection, and browse-mode checks.
- Private Firefox baseline and linked-heading checks.
- git diff --check.
The Chromium fragment harness still fails its embedded-editor navigation
step, and the Firefox harness still times out in its New Tab test. Both
failures reproduce on the parent of the reviewed commit, 8ce5e5c, as well.
These failures are not claimed as passing checks.
The active reader was not replaced. Physical keyboard, audible speech, and
Braille acceptance remain for maintainer testing: check linked headings,
mixed inline-link text, selection indicators after links, and ordinary or
blank-line cursor routing in both browser families before submission.
139 lines
5.1 KiB
Go
139 lines
5.1 KiB
Go
// 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.DocumentView(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{}
|
|
remember_text_parent(doc, place)
|
|
return nil
|
|
}
|
|
|
|
func native_caret_node(ctx context.Context, reader DocumentCaretReader, doc atspi.DocumentSnapshot, focus atspi.ObjectID, offset int) (atspi.DocumentNode, int, error) {
|
|
node, _ := doc.Node(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 := doc.Node(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 := doc.Node(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
|
|
}
|
|
parentNative, childNative := documentBrailleNativeText(parentText), documentBrailleNativeText(childText)
|
|
if parentText.Start != childText.Start || parentText.End != childText.End || parentNative == "" || parentNative != childNative {
|
|
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
|
|
}
|