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

+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++