Read Thunderbird message text and preserve link announcements

Thunderbird HTML messages expose readable text on containers around embedded
objects. The browse cursor previously considered only text leaves, and native
document capture omitted heading/link character counts. This skipped message
content, headings and link text. A caret event following Tab could also replace
a complete link announcement with its first character.

Capture owned hypertext child identities and native placeholder offsets on the
GLib thread, releasing native interfaces before returning. Require every child
to occupy one embedded-object character so providers exposing duplicate text
through static descendants retain their ordinary traversal.

Traverse native text ranges before and after embedded children without changing
their offset domains. Preserve character, word, line and document-boundary
navigation; normalize native caret and Braille routing positions at adjacent
embedded links. Keep the Braille cursor on the correct fragment when a parent
contributes text more than once to an assembled line.

Ignore same-input link caret echoes after focus while retaining subsequent
arrow and selection feedback. Add regression tests, extend the private Firefox
fixture, and record behavior and remaining acceptance in the parity ledger.

Validation:
- Full Go test suite with count=1; focused app/atspi/review race checks.
- go vet ./...; reader and brlapi helper builds.
- Private Firefox baseline, mixed-parent offsets and tab navigation.
- Private Chromium fragments, named links, document navigation and selection.
- Parity ledger validation and whitespace checks.

The existing Firefox tab test intermittently timed out on both an untouched
HEAD copy and the changed tree; the final run passed. The unchanged
TestRunReachesSoundStopAfterEarlierServiceStopError also failed once during
startup cancellation and passed in subsequent full-suite and race runs.

Both installed binaries were verified and the reader reloaded in its original
desktop session. The maintainer confirmed readable message text and later
working links after an earlier intermittent omission. That omission remains
untraced; repeat live acceptance and physical Braille verification remain open.
This commit is contained in:
deedra committed 2026-10-20 10:41:38 -07:00
1 parent 8ef838e16e
commit edd6c560b8
19 files changed
+538 -73

No files matched your search

+1 -1
View File
@@ -33,7 +33,7 @@ func brailleDocumentFrame(result review.Result, options config.BrailleOptions, c
start := utf8.RuneCountInString(frame.Text)
frame.Text += raw
length := utf8.RuneCountInString(raw)
if source.ID() == snapshot.ID() {
if source.ID() == snapshot.ID() && result.DocumentPosition.Offset >= sourceStart && result.DocumentPosition.Offset <= sourceStart+length {
frame.Cursor = start + result.DocumentPosition.Offset - sourceStart
}
if source.ID().Valid() && source.Role != "password text" && length > 0 {
+16
View File
@@ -395,3 +395,19 @@ func TestTableNavigationUpdatesBrailleWithoutNativeCaretEvent(t *testing.T) {
}
wantFrame(t, frames, brailleFocusFrame(cell))
}
func TestBrowseBrailleRepeatedParentKeepsNativeCursor(t *testing.T) {
parent := atspi.NewSnapshot("/body", "", "paragraph", "", "")
parent.BusName = ":1.1"
child := atspi.NewSnapshot("/span", "", "section", "", "")
child.BusName = ":1.1"
result := review.Result{DocumentPosition: review.DocumentPosition{Snapshot: parent, Offset: 0}, DocumentCaretLine: &atspi.DocumentLine{Contents: []atspi.DocumentContent{
{Snapshot: parent, Start: 0, End: 7, RawText: "Before "},
{Snapshot: child, Start: 0, End: 9, RawText: "Read more"},
{Snapshot: parent, Start: 8, End: 15, RawText: " after."},
}}}
frame := brailleDocumentFrame(result, config.DefaultSettings().Braille)
if frame.Cursor != 0 {
t.Fatalf("text=%q cursor=%d; expected cursor=0", frame.Text, frame.Cursor)
}
}
+1 -1
View File
@@ -164,7 +164,7 @@ func TestSettingsTreeSelectionSpeaksOnceAndIgnoresUnfocusedEdits(t *testing.T) {
}
func TestControlFocusCaretDoesNotInterruptButArrowCaretSpeaks(t *testing.T) {
for _, role := range []string{"spin button", "button"} {
for _, role := range []string{"spin button", "button", "link"} {
t.Run(role, func(t *testing.T) {
queue := &fakeSpeechQueue{spoken: make(chan string, 8)}
adapter := &fakeAdapter{events: make(chan atspi.Event, 8)}
+2 -2
View File
@@ -29,11 +29,11 @@ func (handler *accessibilityEventHandler) handleCaret(event atspi.Event, activit
}
}
if (event.Source.Autocomplete || activity.Sequence != 0 &&
(event.Source.Role == "spin button" || event.Source.Role == "button")) &&
(event.Source.Role == "spin button" || event.Source.Role == "button" || event.Source.Role == "link")) &&
event.Source.ID() == state.focusCaretID && activity.Sequence == state.focusCaretSequence &&
!activity.Selecting &&
(intent == input.NoIntent || intent == input.OtherIntent) {
// Address bars, spin buttons, and Gecko buttons move their
// Address bars, spin buttons, and Gecko buttons/links move their
// text caret on focus. With the same input sequence and no
// caret-navigation intent, this must not interrupt the label.
logger.Debug("skipped control caret caused by focus", "role", event.Source.Role)
+5 -1
View File
@@ -890,7 +890,9 @@ gchar *cthulhu_capture_caret_unit(AtspiAccessible *source, AtspiTextGranularity
static void cthulhu_capture_document_metrics(AtspiAccessible *source, cthulhu_snapshot *snapshot) {
const gchar *role = snapshot->role;
if (g_strcmp0(role, "paragraph") != 0 &&
if (g_strcmp0(role, "heading") != 0 &&
g_strcmp0(role, "link") != 0 &&
g_strcmp0(role, "paragraph") != 0 &&
g_strcmp0(role, "list item") != 0 &&
g_strcmp0(role, "math") != 0 &&
g_strcmp0(role, "static") != 0 &&
@@ -932,6 +934,7 @@ static void cthulhu_capture_document_metrics(AtspiAccessible *source, cthulhu_sn
gint characterCount = atspi_text_get_character_count(text, &error);
if (error != NULL) {
g_clear_error(&error);
g_object_unref(text);
return;
}
snapshot->characterCount = characterCount;
@@ -949,4 +952,5 @@ static void cthulhu_capture_document_metrics(AtspiAccessible *source, cthulhu_sn
g_free(contents);
}
}
g_object_unref(text);
}
+3
View File
@@ -313,6 +313,9 @@ func captureNativeTree(ctx context.Context, root *C.AtspiAccessible, reviewBusNa
pending = append(pending, pendingNode{source: child, clip: childClip, clipped: childClipped,
clipChanged: item.clipChanged || !known || cached.geometryInvalid, parentIndex: len(nodes)})
}
if document && refresh {
snapshot.TextChildren = nativeTextChildren(ctx, source, snapshot.CharacterCount, children)
}
nodes = append(nodes, TreeNode{Snapshot: snapshot, Children: children, Geometry: geometry, Text: text})
C.g_object_unref(C.gpointer(source))
}
@@ -0,0 +1,76 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package atspi
/*
#include <atspi/atspi.h>
*/
import "C"
import "context"
// Capture only single-character embedded children. Chromium's ordinary text
// descendants share their parent's offset domain and need no extra ranges.
// Require every child to occupy one placeholder, avoiding duplicate text from
// containers which also expose their plain text through static children.
// All handles stay on the GLib owner and are released before returning.
func nativeTextChildren(ctx context.Context, source *C.AtspiAccessible, count int, children []ObjectID) []TextChild {
if count <= 0 || len(children) == 0 {
return nil
}
hypertext := C.atspi_accessible_get_hypertext_iface(source)
if hypertext == nil {
return nil
}
defer C.g_object_unref(C.gpointer(hypertext))
links := int(C.atspi_hypertext_get_n_links(hypertext, nil))
if links <= 0 || links != len(children) {
return nil
}
text := C.atspi_accessible_get_text_iface(source)
if text == nil {
return nil
}
defer C.g_object_unref(C.gpointer(text))
known := make(map[ObjectID]bool, len(children))
for _, child := range children {
known[child] = true
}
var result []TextChild
previous := -1
for index := 0; index < links; index++ {
if ctx.Err() != nil {
return nil
}
link := C.atspi_hypertext_get_link(hypertext, C.gint(index), nil)
if link == nil {
return nil
}
start := int(C.atspi_hyperlink_get_start_index(link, nil))
end := int(C.atspi_hyperlink_get_end_index(link, nil))
child := C.atspi_hyperlink_get_object(link, 0, nil)
var id ObjectID
if child != nil {
id = nativeObjectID(child)
C.g_object_unref(C.gpointer(child))
}
C.g_object_unref(C.gpointer(link))
if start <= previous || end != start+1 || start < 0 || end > count || !known[id] {
return nil
}
value := C.atspi_text_get_text(text, C.gint(start), C.gint(end), nil)
placeholder := value != nil && C.GoString(value) == "\ufffc"
if value != nil {
C.g_free(C.gpointer(value))
}
if !placeholder {
return nil
}
delete(known, id)
result = append(result, TextChild{Offset: start, Child: id})
previous = start
}
return result
}
+20
View File
@@ -144,6 +144,26 @@ chromeFocus:
if !ref.Valid() {
t.Fatal("Firefox heading missing from focused document capture")
}
for _, name := range []string{"Firefox baseline heading", "Firefox baseline link"} {
object := findNamedReference(t, page, name)
snapshot, err := page.Resolve(object)
if err != nil || snapshot.CharacterCount == 0 {
t.Fatalf("browse text metrics missing for %q: count=%d err=%v", name, snapshot.CharacterCount, err)
}
}
inline := findNamedReference(t, page, "Inline message link")
parent, ok, err := page.Parent(inline)
if err != nil || !ok {
t.Fatalf("inline parent: %v", err)
}
mixed, err := page.Resolve(parent)
if err != nil || len(mixed.TextChildren) != 1 || mixed.TextChildren[0].Offset != 13 || mixed.TextChildren[0].Child != inline.ID() {
t.Fatalf("mixed native text offsets: children=%v err=%v", mixed.TextChildren, err)
}
mixedLine, err := adapter.ReadDocumentCaret(ctx, parent, 0, DocumentCaretBrailleLine)
if err != nil || mixedLine.RawText != "Mixed before \ufffc after." {
t.Fatalf("mixed native line=%q err=%v", mixedLine.RawText, err)
}
var paragraph ObjectReference
captured, err := page.Document(ctx, ref)
if err != nil {
+10
View File
@@ -68,6 +68,9 @@ type Snapshot struct {
GUITable bool
CharacterCount int
EmbeddedObjectCount int
// TextChildren maps embedded-object characters to native child offsets.
// Captured for hypertext containers; nil leaves ordinary leaf traversal.
TextChildren []TextChild
// Selection offsets are captured for text-bearing focused events.
SelectionStart, SelectionEnd int
SelectionText string
@@ -139,6 +142,13 @@ type Snapshot struct {
SoundGeometry SoundGeometry
}
// TextChild is a single embedded-object character in an owned text container.
// Its offset belongs to the parent; Child retains no native handle.
type TextChild struct {
Offset int
Child ObjectID
}
// TextAttributeRun is the native attribute range at the caret, copied without
// retaining an AT-SPI object. Offsets are Unicode character offsets.
type TextAttributeRun struct {
+6 -6
View File
@@ -77,7 +77,7 @@ func (worker *Worker) route_document_braille(ctx context.Context, cursor *Docume
result.Err = err
return
}
node, err := native_caret_node(ctx, reader, doc, route.target, route.offset)
node, caretOffset, err := native_caret_node(ctx, reader, doc, route.target, route.offset)
if err != nil {
result.Err = err
return
@@ -101,10 +101,10 @@ func (worker *Worker) route_document_braille(ctx context.Context, cursor *Docume
return
}
place := &cursor.places[len(cursor.places)-1]
place.object, place.offset, place.origin = node.Reference, route.offset, CaretNavigation
place.object, place.offset, place.origin = node.Reference, caretOffset, CaretNavigation
place.tableCell = atspi.ObjectID{}
snapshot, err := cursor.tree.Resolve(node.Reference)
result.DocumentPosition = DocumentPosition{Document: owner.TopLevel, Object: node.Reference, Snapshot: snapshot, Offset: route.offset, Origin: CaretNavigation}
result.DocumentPosition = DocumentPosition{Document: owner.TopLevel, Object: node.Reference, Snapshot: snapshot, Offset: caretOffset, Origin: CaretNavigation}
cursor.mu.Unlock()
result.Err, result.Moved = err, err == nil
result.DocumentCaretContext = owner.Ancestors
@@ -113,16 +113,16 @@ func (worker *Worker) route_document_braille(ctx context.Context, cursor *Docume
// retaining the exact routed offset. It never repeats caret placement.
deadline, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
defer cancel()
indices, _ := caret_node_indices(doc.Nodes, node.Reference)
indices := caretNodeRanges(doc.Nodes)
position := -1
for index, candidate := range indices {
if doc.Nodes[candidate].Reference == node.Reference {
if doc.Nodes[candidate.index].Reference == node.Reference && caretOffset >= candidate.start && caretOffset < candidate.end {
position = index
break
}
}
if position >= 0 {
parts, err := collect_visual_line(deadline, reader, doc, indices, visual_line_owners(doc.Nodes), position, route.offset, true)
parts, err := collect_visual_line(deadline, reader, doc, indices, visual_line_owners(doc.Nodes), position, caretOffset, true)
if err == nil && len(parts) > 1 {
line, _, _ := assemble_visual_line(cursor.tree, doc, parts)
result.DocumentCaretLine = &line
+2 -1
View File
@@ -93,7 +93,8 @@ func (cursor *DocumentCursor) MoveCaret(ctx context.Context, reader DocumentCare
return cursor.moveTable(ctx, doc, place, command)
}
place.tableCell = atspi.ObjectID{}
indices, current := caret_node_indices(doc.Nodes, place.object)
_, current := caret_node_indices(doc.Nodes, place.object)
indices := caretNodeRanges(doc.Nodes)
if current < 0 {
return DocumentCaretMove{}, atspi.ErrStaleObject
}
+23 -26
View File
@@ -15,15 +15,12 @@ import (
// move_caret_line handles line and document boundaries under the cursor lock
// held by MoveCaret. Text-unit movement has its separate traversal helper.
func move_caret_object_line(ctx context.Context, reader DocumentCaretReader, doc atspi.DocumentSnapshot, place *documentPlace, indices []int, current int, command DocumentCaretCommand) (DocumentCaretMove, error) {
func move_caret_object_line(ctx context.Context, reader DocumentCaretReader, doc atspi.DocumentSnapshot, place *documentPlace, indices []caretRange, current int, command DocumentCaretCommand) (DocumentCaretMove, error) {
unit := atspi.DocumentCaretLine
if command == CaretReadLine {
unit = atspi.DocumentCaretLineRemainder
}
position := 0
for position < len(indices) && indices[position] < current {
position++
}
position, same := caretRangePosition(indices, current, place.offset)
backwards := command == CaretPreviousLine || command == CaretDocumentEnd
if command == CaretDocumentStart {
position = 0
@@ -31,31 +28,31 @@ func move_caret_object_line(ctx context.Context, reader DocumentCaretReader, doc
position = len(indices) - 1
}
offset := place.offset
if position == len(indices) || indices[position] != current || command == CaretDocumentStart {
if !same || command == CaretDocumentStart {
offset = 0
}
if command == CaretDocumentEnd {
offset = doc.Nodes[indices[position]].Snapshot.CharacterCount - 1
offset = indices[position].end - 1
}
if backwards && command != CaretDocumentEnd && (position == len(indices) || indices[position] != current) {
if backwards && command != CaretDocumentEnd && !same {
position--
if position < 0 {
return DocumentCaretMove{Position: documentPosition(doc, *place)}, nil
}
offset = doc.Nodes[indices[position]].Snapshot.CharacterCount - 1
offset = indices[position].end - 1
}
initialPosition := position
for attempts := 0; attempts < len(indices)+1; attempts++ {
if position < 0 || position >= len(indices) {
return DocumentCaretMove{Position: documentPosition(doc, *place)}, nil
}
node := doc.Nodes[indices[position]]
node := doc.Nodes[indices[position].index]
if node.Snapshot.CharacterCount == 0 || wholeCaretNode(node.Snapshot) {
if (command == CaretNextLine || command == CaretPreviousLine) && node.Reference == place.object {
if backwards {
position--
if position >= 0 {
offset = doc.Nodes[indices[position]].Snapshot.CharacterCount - 1
offset = indices[position].end - 1
}
} else {
position++
@@ -66,31 +63,31 @@ func move_caret_object_line(ctx context.Context, reader DocumentCaretReader, doc
place.object, place.offset, place.origin = node.Reference, 0, CaretNavigation
return DocumentCaretMove{Position: documentPosition(doc, *place), Moved: true}, nil
}
if offset < 0 {
offset = 0
if offset < indices[position].start {
offset = indices[position].start
}
if command == CaretReadLine && offset >= node.Snapshot.CharacterCount {
if command == CaretReadLine && offset >= indices[position].end {
position++
offset = 0
continue
}
if offset >= node.Snapshot.CharacterCount {
offset = node.Snapshot.CharacterCount - 1
if offset >= indices[position].end {
offset = indices[position].end - 1
}
piece, readErr := reader.ReadDocumentCaret(ctx, node.Reference, offset, unit)
piece, readErr := readCaretRange(ctx, reader, doc, indices[position], offset, unit)
if readErr != nil && !errors.Is(readErr, atspi.ErrNoDocumentCaret) {
return DocumentCaretMove{}, readErr
}
if readErr == nil && piece.End > piece.Start && strings.TrimSpace(piece.Text) != "\ufffc" {
nextOffset := offset
sameCurrent := node.Reference == place.object ||
place.origin == StructuralNavigation && position == initialPosition && indices[position] >= current
sameCurrent := position == initialPosition && same ||
place.origin == StructuralNavigation && position == initialPosition && indices[position].index >= current
switch command {
case CaretNextLine:
if sameCurrent {
nextOffset = piece.End
if nextOffset < piece.CharacterCount {
piece, readErr = reader.ReadDocumentCaret(ctx, node.Reference, nextOffset, unit)
piece, readErr = readCaretRange(ctx, reader, doc, indices[position], nextOffset, unit)
if readErr != nil {
return DocumentCaretMove{}, readErr
}
@@ -106,8 +103,8 @@ func move_caret_object_line(ctx context.Context, reader DocumentCaretReader, doc
nextOffset = piece.Start
case CaretPreviousLine:
if sameCurrent {
if piece.Start > 0 {
piece, readErr = reader.ReadDocumentCaret(ctx, node.Reference, piece.Start-1, unit)
if piece.Start > indices[position].start {
piece, readErr = readCaretRange(ctx, reader, doc, indices[position], piece.Start-1, unit)
if readErr != nil {
return DocumentCaretMove{}, readErr
}
@@ -116,7 +113,7 @@ func move_caret_object_line(ctx context.Context, reader DocumentCaretReader, doc
if position < 0 {
return DocumentCaretMove{Position: documentPosition(doc, *place)}, nil
}
offset = doc.Nodes[indices[position]].Snapshot.CharacterCount - 1
offset = indices[position].end - 1
continue
}
}
@@ -133,7 +130,7 @@ func move_caret_object_line(ctx context.Context, reader DocumentCaretReader, doc
if backwards {
position--
if position >= 0 {
offset = doc.Nodes[indices[position]].Snapshot.CharacterCount - 1
offset = indices[position].end - 1
}
} else {
position++
@@ -143,7 +140,7 @@ func move_caret_object_line(ctx context.Context, reader DocumentCaretReader, doc
}
spoken := piece.Text
if command == CaretLineStart || command == CaretLineEnd {
character, charErr := reader.ReadDocumentCaret(ctx, node.Reference, nextOffset, atspi.DocumentCaretCharacter)
character, charErr := readCaretRange(ctx, reader, doc, indices[position], nextOffset, atspi.DocumentCaretCharacter)
if charErr != nil {
return DocumentCaretMove{}, charErr
}
@@ -157,7 +154,7 @@ func move_caret_object_line(ctx context.Context, reader DocumentCaretReader, doc
if backwards {
position--
if position >= 0 {
offset = doc.Nodes[indices[position]].Snapshot.CharacterCount - 1
offset = indices[position].end - 1
}
} else {
position++
+158
View File
@@ -0,0 +1,158 @@
// 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
}
+14 -15
View File
@@ -17,32 +17,28 @@ import (
// Python caret_navigation starts a word move at the adjacent non-space
// character, rather than the next/previous word boundary. In particular,
// reversing at the end of a word must read that word, not skip over it.
func moveCaretText(ctx context.Context, reader DocumentCaretReader, doc atspi.DocumentSnapshot, place *documentPlace, indices []int, current int, backwards, byWord bool) (DocumentCaretMove, error) {
func moveCaretText(ctx context.Context, reader DocumentCaretReader, doc atspi.DocumentSnapshot, place *documentPlace, indices []caretRange, current int, backwards, byWord bool) (DocumentCaretMove, error) {
step := 1
if backwards {
step = -1
}
position := 0
for position < len(indices) && indices[position] < current {
position++
}
position, same := caretRangePosition(indices, current, place.offset)
offset := 0
if position < len(indices) && indices[position] == current {
if same {
offset = place.offset + step
} else if backwards {
position--
if position >= 0 {
offset = doc.Nodes[indices[position]].Snapshot.CharacterCount - 1
offset = indices[position].end - 1
}
} else if place.origin == StructuralNavigation {
// Structural jumps speak the first descendant at offset zero.
offset = 1
}
for position >= 0 && position < len(indices) {
if err := ctx.Err(); err != nil {
return DocumentCaretMove{}, err
}
node := doc.Nodes[indices[position]]
node := doc.Nodes[indices[position].index]
if node.Snapshot.CharacterCount == 0 || wholeCaretNode(node.Snapshot) {
if node.Reference != place.object {
place.object, place.offset, place.origin = node.Reference, 0, CaretNavigation
@@ -51,12 +47,15 @@ func moveCaretText(ctx context.Context, reader DocumentCaretReader, doc atspi.Do
position += step
offset = 0
if backwards && position >= 0 {
offset = doc.Nodes[indices[position]].Snapshot.CharacterCount - 1
offset = indices[position].end - 1
}
continue
}
if offset >= 0 && offset < node.Snapshot.CharacterCount {
character, err := reader.ReadDocumentCaret(ctx, node.Reference, offset, atspi.DocumentCaretCharacter)
if !backwards {
offset = max(offset, indices[position].start)
}
if offset >= indices[position].start && offset < indices[position].end {
character, err := readCaretRange(ctx, reader, doc, indices[position], offset, atspi.DocumentCaretCharacter)
if err != nil && !errors.Is(err, atspi.ErrNoDocumentCaret) {
return DocumentCaretMove{}, err
}
@@ -70,7 +69,7 @@ func moveCaretText(ctx context.Context, reader DocumentCaretReader, doc atspi.Do
place.object, place.offset, place.origin = node.Reference, character.Start, CaretNavigation
return DocumentCaretMove{Position: documentPosition(doc, *place), Text: character.Text, Misspelled: character.Misspelled, Moved: true, Start: character.Start, End: character.End}, nil
}
word, err := reader.ReadDocumentCaret(ctx, node.Reference, offset, atspi.DocumentCaretWord)
word, err := readCaretRange(ctx, reader, doc, indices[position], offset, atspi.DocumentCaretWord)
if err != nil {
return DocumentCaretMove{}, err
}
@@ -89,7 +88,7 @@ func moveCaretText(ctx context.Context, reader DocumentCaretReader, doc atspi.Do
nextOffset = word.End
// WORD ranges may contain a trailing delimiter. Query its
// actual character; spoken word text can be normalized.
last, err := reader.ReadDocumentCaret(ctx, node.Reference, word.End-1, atspi.DocumentCaretCharacter)
last, err := readCaretRange(ctx, reader, doc, indices[position], word.End-1, atspi.DocumentCaretCharacter)
if err != nil {
return DocumentCaretMove{}, err
}
@@ -105,7 +104,7 @@ func moveCaretText(ctx context.Context, reader DocumentCaretReader, doc atspi.Do
position += step
offset = 0
if backwards && position >= 0 {
offset = doc.Nodes[indices[position]].Snapshot.CharacterCount - 1
offset = indices[position].end - 1
}
}
return DocumentCaretMove{Position: documentPosition(doc, *place)}, nil
+160
View File
@@ -0,0 +1,160 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package review
import (
"context"
"testing"
"unicode/utf8"
"git.stormux.org/storm/cthulhu/internal/atspi"
)
func hypertextCursor(t *testing.T) (*DocumentCursor, caretTextReader) {
t.Helper()
reader := caretTextReader{webID("/body"): "Before \ufffc after.", webID("/link"): "Read more", webID("/tail"): "Next paragraph"}
node := func(path, role string, children ...string) atspi.TreeNode {
snapshot := atspi.NewSnapshot(path, "", role, "", "")
snapshot.BusName = webID(path).BusName
snapshot.CharacterCount = utf8.RuneCountInString(reader[webID(path)])
result := atspi.TreeNode{Snapshot: snapshot}
for _, child := range children {
result.Children = append(result.Children, webID(child))
}
return result
}
body := node("/body", "section", "/link")
body.Snapshot.TextChildren = []atspi.TextChild{{Offset: 7, Child: webID("/link")}}
tree, err := atspi.NewAccessibleTree(webID("/doc"), []atspi.TreeNode{
node("/doc", "document web", "/body", "/tail"), body, node("/link", "link"), node("/tail", "paragraph"),
})
if err != nil {
t.Fatal(err)
}
cursor, err := NewDocumentCursor(tree)
if err != nil {
t.Fatal(err)
}
if _, err := cursor.SyncFocus(context.Background(), webID("/doc")); err != nil {
t.Fatal(err)
}
return cursor, reader
}
func TestDocumentCaretReadsTextAroundEmbeddedLink(t *testing.T) {
cursor, reader := hypertextCursor(t)
for _, step := range []struct {
command DocumentCaretCommand
text, path string
offset int
}{
{CaretDocumentStart, "Before ", "/body", 0},
{CaretNextLine, "Read more", "/link", 0},
{CaretNextLine, " after.", "/body", 8},
{CaretNextLine, "Next paragraph", "/tail", 0},
{CaretPreviousLine, " after.", "/body", 8},
{CaretPreviousLine, "Read more", "/link", 0},
{CaretPreviousLine, "Before ", "/body", 0},
} {
move, err := cursor.MoveCaret(context.Background(), reader, step.command)
if err != nil || !move.Moved || move.Text != step.text || move.Position.Object.ID() != webID(step.path) || move.Position.Offset != step.offset {
t.Fatalf("%s: move=%+v err=%v; want %q at %s:%d", step.command, move, err, step.text, step.path, step.offset)
}
}
}
func TestDocumentCaretCharactersCrossEmbeddedLink(t *testing.T) {
cursor, reader := hypertextCursor(t)
if _, err := cursor.MoveCaret(context.Background(), reader, CaretDocumentStart); err != nil {
t.Fatal(err)
}
var text string
for i := 0; i < 64; i++ {
move, err := cursor.MoveCaret(context.Background(), reader, CaretNextCharacter)
if err != nil {
t.Fatal(err)
}
if move.Position.Object.ID() == webID("/tail") {
break
}
text += move.Text
}
if text != "efore Read more after." {
t.Fatalf("characters=%q", text)
}
}
func TestNativeCaretKeepsHypertextParentOffsets(t *testing.T) {
cursor, reader := hypertextCursor(t)
if err := cursor.syncNativeCaret(context.Background(), reader, webID("/body"), 9); err != nil {
t.Fatal(err)
}
move, err := cursor.MoveCaret(context.Background(), reader, CaretPreviousCharacter)
if err != nil || move.Text != " " || move.Position.Offset != 8 || move.Position.Object.ID() != webID("/body") {
t.Fatalf("native hypertext caret: text=%q offset=%d err=%v", move.Text, move.Position.Offset, err)
}
}
func TestDocumentCaretWordsCrossEmbeddedLink(t *testing.T) {
cursor, reader := hypertextCursor(t)
if _, err := cursor.MoveCaret(context.Background(), reader, CaretDocumentStart); err != nil {
t.Fatal(err)
}
for _, step := range []struct {
command DocumentCaretCommand
text, path string
offset int
}{
{CaretNextWord, "Before ", "/body", 6},
{CaretNextWord, "Read ", "/link", 4},
{CaretNextWord, "more", "/link", 9},
{CaretNextWord, "after.", "/body", 15},
{CaretPreviousWord, "after.", "/body", 9},
{CaretPreviousWord, "more", "/link", 5},
} {
move, err := cursor.MoveCaret(context.Background(), reader, step.command)
if err != nil || move.Text != step.text || move.Position.Object.ID() != webID(step.path) || move.Position.Offset != step.offset {
t.Fatalf("%s: text=%q offset=%d err=%v; want %q at %s:%d", step.command, move.Text, move.Position.Offset, err, step.text, step.path, step.offset)
}
}
}
func TestNativeCaretOnAdjacentEmbeddedLinks(t *testing.T) {
reader := caretTextReader{webID("/body"): "A\ufffc\ufffcZ", webID("/link1"): "One", webID("/link2"): "Two"}
node := func(path, role string, children ...string) atspi.TreeNode {
s := atspi.NewSnapshot(path, "", role, "", "")
s.BusName = webID(path).BusName
s.CharacterCount = utf8.RuneCountInString(reader[webID(path)])
n := atspi.TreeNode{Snapshot: s}
for _, child := range children {
n.Children = append(n.Children, webID(child))
}
return n
}
body := node("/body", "paragraph", "/link1", "/link2")
body.Snapshot.TextChildren = []atspi.TextChild{{Offset: 1, Child: webID("/link1")}, {Offset: 2, Child: webID("/link2")}}
tree, err := atspi.NewAccessibleTree(webID("/doc"), []atspi.TreeNode{node("/doc", "document web", "/body"), body, node("/link1", "link"), node("/link2", "link")})
if err != nil {
t.Fatal(err)
}
for _, cmd := range []DocumentCaretCommand{CaretNextCharacter, CaretPreviousCharacter} {
cursor, err := NewDocumentCursor(tree)
if err != nil {
t.Fatal(err)
}
if err = cursor.syncNativeCaret(context.Background(), reader, webID("/body"), 2); err != nil {
t.Fatal(err)
}
move, err := cursor.MoveCaret(context.Background(), reader, cmd)
t.Logf("command=%s text=%q object=%s offset=%d err=%v", cmd, move.Text, move.Position.Object.ID().Path, move.Position.Offset, err)
want := "w"
if cmd == CaretPreviousCharacter {
want = "e"
}
if move.Text != want {
t.Errorf("want %q", want)
}
}
}
+29 -10
View File
@@ -12,7 +12,8 @@ import (
// 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; embedded or ambiguous child offsets must never be guessed.
// 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
@@ -27,7 +28,7 @@ func (cursor *DocumentCursor) syncNativeCaret(ctx context.Context, reader Docume
if err != nil {
return err
}
node, err := native_caret_node(ctx, reader, doc, focus, offset)
node, offset, err := native_caret_node(ctx, reader, doc, focus, offset)
if err != nil {
return err
}
@@ -37,38 +38,56 @@ func (cursor *DocumentCursor) syncNativeCaret(ctx context.Context, reader Docume
return nil
}
func native_caret_node(ctx context.Context, reader DocumentCaretReader, doc atspi.DocumentSnapshot, focus atspi.ObjectID, offset int) (atspi.DocumentNode, error) {
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{}, atspi.ErrNoDocumentCaret
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{}, atspi.ErrNoDocumentCaret
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{}, atspi.ErrNoDocumentCaret
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{}, err
return atspi.DocumentNode{}, 0, err
}
childText, err := reader.ReadDocumentCaret(ctx, child.Reference, probeOffset, atspi.DocumentCaretBrailleLine)
if err != nil {
return atspi.DocumentNode{}, err
return atspi.DocumentNode{}, 0, err
}
if parentText.Start != childText.Start || parentText.End != childText.End || parentText.Text == "" || parentText.Text != childText.Text {
return atspi.DocumentNode{}, atspi.ErrNoDocumentCaret
return atspi.DocumentNode{}, 0, atspi.ErrNoDocumentCaret
}
node = child
}
return node, nil
return node, offset, nil
}
// Use the navigation FIFO so a subsequent intercepted arrow sees the native
+10 -10
View File
@@ -48,7 +48,7 @@ func (reader *visualLineReader) ReadDocumentCaret(ctx context.Context, object at
return text, err
}
func move_caret_line(ctx context.Context, source DocumentCaretReader, tree *atspi.AccessibleTree, doc atspi.DocumentSnapshot, place *documentPlace, indices []int, current int, command DocumentCaretCommand) (DocumentCaretMove, error) {
func move_caret_line(ctx context.Context, source DocumentCaretReader, tree *atspi.AccessibleTree, doc atspi.DocumentSnapshot, place *documentPlace, indices []caretRange, current int, command DocumentCaretCommand) (DocumentCaretMove, error) {
reader := &visualLineReader{DocumentCaretReader: source, cache: make(map[visualLineKey]atspi.DocumentCaretText)}
owners := visual_line_owners(doc.Nodes)
next := *place
@@ -58,7 +58,7 @@ func move_caret_line(ctx context.Context, source DocumentCaretReader, tree *atsp
if command == CaretNextLine || command == CaretPreviousLine {
position := -1
for i, index := range indices {
if index == current || place.origin == StructuralNavigation && index > current {
if index.index == current && place.offset >= index.start && place.offset < max(index.start+1, index.end) || place.origin == StructuralNavigation && index.index > current {
position = i
break
}
@@ -87,7 +87,7 @@ func move_caret_line(ctx context.Context, source DocumentCaretReader, tree *atsp
}
position := -1
for i, index := range indices {
if doc.Nodes[index].Reference == next.object {
if doc.Nodes[index.index].Reference == next.object && next.offset >= index.start && next.offset < max(index.start+1, index.end) {
position = i
break
}
@@ -159,8 +159,8 @@ func visual_line_owners(nodes []atspi.DocumentNode) map[int]atspi.ObjectID {
return owners
}
func collect_visual_line(ctx context.Context, reader DocumentCaretReader, doc atspi.DocumentSnapshot, indices []int, owners map[int]atspi.ObjectID, position, offset int, left bool) ([]visualLinePart, error) {
index := indices[position]
func collect_visual_line(ctx context.Context, reader DocumentCaretReader, doc atspi.DocumentSnapshot, indices []caretRange, owners map[int]atspi.ObjectID, position, offset int, left bool) ([]visualLinePart, error) {
index := indices[position].index
node := doc.Nodes[index]
if !owners[index].Valid() || node.Snapshot.Editable || wholeCaretNode(node.Snapshot) || node.Snapshot.CharacterCount <= 0 {
return nil, nil
@@ -169,7 +169,7 @@ func collect_visual_line(ctx context.Context, reader DocumentCaretReader, doc at
if !left {
unit = atspi.DocumentCaretLineRemainder
}
text, err := reader.ReadDocumentCaret(ctx, node.Reference, min(offset, node.Snapshot.CharacterCount-1), unit)
text, err := readCaretRange(ctx, reader, doc, indices[position], min(offset, indices[position].end-1), unit)
if errors.Is(err, atspi.ErrNoDocumentCaret) {
return nil, nil
}
@@ -194,15 +194,15 @@ func collect_visual_line(ctx context.Context, reader DocumentCaretReader, doc at
continue
}
for next := position + direction; next >= 0 && next < len(indices); next += direction {
candidate := indices[next]
candidate := indices[next].index
if owners[candidate] != owners[index] {
break
}
probe := 0
probe := indices[next].start
if direction < 0 {
probe = doc.Nodes[candidate].Snapshot.CharacterCount - 1
probe = indices[next].end - 1
}
piece, err := reader.ReadDocumentCaret(ctx, doc.Nodes[candidate].Reference, probe, atspi.DocumentCaretLine)
piece, err := readCaretRange(ctx, reader, doc, indices[next], probe, atspi.DocumentCaretLine)
if errors.Is(err, atspi.ErrNoDocumentCaret) {
break
}