From edd6c560b8eafcce44478dc8079fa3086470770c Mon Sep 17 00:00:00 2001 From: deedra Date: Tue, 20 Oct 2026 10:41:38 -0700 Subject: [PATCH] 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. --- internal/app/braille_presentation.go | 2 +- internal/app/braille_presentation_test.go | 16 ++ internal/app/control_events_test.go | 2 +- internal/app/event_caret.go | 4 +- internal/atspi/adapter_capture.c | 6 +- internal/atspi/adapter_tree_native.go | 3 + internal/atspi/document_hypertext_native.go | 76 ++++++++++ internal/atspi/firefox_native_test.go | 20 +++ internal/atspi/model.go | 10 ++ internal/review/document_braille_routing.go | 12 +- internal/review/document_caret.go | 3 +- internal/review/document_caret_line.go | 49 +++--- internal/review/document_caret_ranges.go | 158 +++++++++++++++++++ internal/review/document_caret_text.go | 29 ++-- internal/review/document_hypertext_test.go | 160 ++++++++++++++++++++ internal/review/document_native_caret.go | 39 +++-- internal/review/document_visual_line.go | 20 +-- testdata/parity-ledger.csv | 1 + tests/firefox-baseline.html | 1 + 19 files changed, 538 insertions(+), 73 deletions(-) create mode 100644 internal/atspi/document_hypertext_native.go create mode 100644 internal/review/document_caret_ranges.go create mode 100644 internal/review/document_hypertext_test.go diff --git a/internal/app/braille_presentation.go b/internal/app/braille_presentation.go index 195feee..a95bfd2 100644 --- a/internal/app/braille_presentation.go +++ b/internal/app/braille_presentation.go @@ -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 { diff --git a/internal/app/braille_presentation_test.go b/internal/app/braille_presentation_test.go index 127e2f3..993fc10 100644 --- a/internal/app/braille_presentation_test.go +++ b/internal/app/braille_presentation_test.go @@ -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) + } +} diff --git a/internal/app/control_events_test.go b/internal/app/control_events_test.go index 009ccf9..a817b02 100644 --- a/internal/app/control_events_test.go +++ b/internal/app/control_events_test.go @@ -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)} diff --git a/internal/app/event_caret.go b/internal/app/event_caret.go index f7b9e7d..85e0c41 100644 --- a/internal/app/event_caret.go +++ b/internal/app/event_caret.go @@ -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) diff --git a/internal/atspi/adapter_capture.c b/internal/atspi/adapter_capture.c index fe16c64..9d5508b 100644 --- a/internal/atspi/adapter_capture.c +++ b/internal/atspi/adapter_capture.c @@ -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); } diff --git a/internal/atspi/adapter_tree_native.go b/internal/atspi/adapter_tree_native.go index dbffce8..2a80520 100644 --- a/internal/atspi/adapter_tree_native.go +++ b/internal/atspi/adapter_tree_native.go @@ -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)) } diff --git a/internal/atspi/document_hypertext_native.go b/internal/atspi/document_hypertext_native.go new file mode 100644 index 0000000..cf543d3 --- /dev/null +++ b/internal/atspi/document_hypertext_native.go @@ -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 +*/ +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 +} diff --git a/internal/atspi/firefox_native_test.go b/internal/atspi/firefox_native_test.go index d20992a..916c91c 100644 --- a/internal/atspi/firefox_native_test.go +++ b/internal/atspi/firefox_native_test.go @@ -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 { diff --git a/internal/atspi/model.go b/internal/atspi/model.go index cab012c..97ef1eb 100644 --- a/internal/atspi/model.go +++ b/internal/atspi/model.go @@ -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 { diff --git a/internal/review/document_braille_routing.go b/internal/review/document_braille_routing.go index 8e8143c..7abed0a 100644 --- a/internal/review/document_braille_routing.go +++ b/internal/review/document_braille_routing.go @@ -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 diff --git a/internal/review/document_caret.go b/internal/review/document_caret.go index 4fee4ec..f706b7b 100644 --- a/internal/review/document_caret.go +++ b/internal/review/document_caret.go @@ -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 } diff --git a/internal/review/document_caret_line.go b/internal/review/document_caret_line.go index eee3986..49df8c8 100644 --- a/internal/review/document_caret_line.go +++ b/internal/review/document_caret_line.go @@ -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++ diff --git a/internal/review/document_caret_ranges.go b/internal/review/document_caret_ranges.go new file mode 100644 index 0000000..6c55442 --- /dev/null +++ b/internal/review/document_caret_ranges.go @@ -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 +} diff --git a/internal/review/document_caret_text.go b/internal/review/document_caret_text.go index f93ec67..048eae8 100644 --- a/internal/review/document_caret_text.go +++ b/internal/review/document_caret_text.go @@ -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 diff --git a/internal/review/document_hypertext_test.go b/internal/review/document_hypertext_test.go new file mode 100644 index 0000000..2d99450 --- /dev/null +++ b/internal/review/document_hypertext_test.go @@ -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) + } + } +} diff --git a/internal/review/document_native_caret.go b/internal/review/document_native_caret.go index aa0c63e..d260183 100644 --- a/internal/review/document_native_caret.go +++ b/internal/review/document_native_caret.go @@ -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 diff --git a/internal/review/document_visual_line.go b/internal/review/document_visual_line.go index 545db49..64efaf5 100644 --- a/internal/review/document_visual_line.go +++ b/internal/review/document_visual_line.go @@ -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 } diff --git a/testdata/parity-ledger.csv b/testdata/parity-ledger.csv index 096c0a0..8c53fce 100644 --- a/testdata/parity-ledger.csv +++ b/testdata/parity-ledger.csv @@ -56,3 +56,4 @@ web-explicit-link-names,src/cthulhu/scripts/web/script_utilities.py:_treatObject steam-notifications,src/cthulhu/scripts/apps/steamwebhelper/script.py;tests/test_steam_notification_regressions.py,web,Coalesce verified Steam background toasts and present once through speech braille and notification history,Storm Dragon,internal/webscripts internal/atspi internal/app,in-progress,go test -race ./...; python3 tests/native_webscripts.py,Private GTK/AT-SPI process matching and alert-root capture passed,,Event-loop background delivery and Chromium isolation passed; real Steam toast pending,Event-loop flash verified; physical Steam toast display pending,docs/testing/steam-browser.md; independent root and bus queues deliberately prevent unrelated toast timestamps from merging libreoffice-calc-basic,Orca src/orca/scripts/apps/soffice/script.py; Orca src/orca/ax_utilities_table.py; Orca src/orca/speech_generator.py,libreoffice,Present Calc cells and recover logical focus around editing and sheet changes,Storm Dragon,internal/app internal/atspi internal/presentation,in-progress,GOCACHE=/tmp/cthulhu-go-cache go test -race ./internal/atspi ./internal/app ./internal/presentation -count=1; /usr/bin/python3 tests/native_libreoffice.py,Private real LibreOffice Calc 26.2.6.3 on Xvfb and a private AT-SPI bus validates cell movement blanks F2 typing Escape Enter and sheet changes.,,Recorded speech assertions pass; audible and physical-input acceptance pending.,Recorded value and coordinate frames pass; physical Braille and editor routing acceptance pending.,Current Orca is the primary Office reference. Dedicated application policy follows verified process identity after foreground/security gates; native capture remains bounded. Formulas range announcements dynamic headers full-row reading and Writer workflows remain open. See LIBREOFFICE_SUPPORT.md. edit-field-relation-labels,src/cthulhu/generator.py:_generateLabel;src/cthulhu/script_utilities.py:displayedLabel,speech,Announce relation-only labels for unnamed edit fields,,internal/atspi internal/presentation,implemented,CTHULHU_NATIVE_EDIT_LABELS=1 python3 tests/native_atspi.py; go test ./... -count=1; go vet ./...,Private GTK/Xvfb verifies single and multiple labels plus existing names and password exclusion. Read-only live irctest capture verifies all three settings labels. Maintainer confirmed installed-reader label speech on physical Xorg on 2026-10-20.,,Formatted speech includes each label. Maintainer confirmed the installed reader speaks irctest settings field labels on 2026-10-20.,Physical Braille check pending.,Direct LABELLED_BY targets supply an empty text or entry or password-field name. Queries and output are bounded; explicit names are preserved. Human diff review remains required before submission. +mozilla-hypertext-reading,src/cthulhu/scripts/web/script.py;src/cthulhu/scripts/web/script_utilities.py,web,Read native heading and link text plus parent text around embedded children without Tab caret echoes interrupting link labels,,internal/atspi internal/review internal/app,in-progress,"go test ./... -count=1; go test -race ./internal/review ./internal/atspi ./internal/app -count=1; python3 tests/native_firefox.py; CTHULHU_NATIVE_BROWSER_TESTS=fragments,document,selection,namedlinks python3 tests/native_browser.py",Private Firefox and Chromium X11 fixtures pass; installed reader reloaded. Maintainer reports message and links working; repeat live navigation acceptance remains pending.,,Maintainer confirmed message readability and later working links after an earlier intermittent omission; the cause of that omission remains untraced.,Repeated parent-fragment cursor and native routing offsets tested; physical Braille pending.,"Orca web reference ignores link caret events caused by Tab. Hypertext traversal retains parent offsets around single-character embedded children; ordinary static-child traversal remains available. Read and Tab through an HTML email; reverse direction; verify Firefox and Chromium links, selection and address-bar editing before submission." diff --git a/tests/firefox-baseline.html b/tests/firefox-baseline.html index eebfef7..a3425ba 100644 --- a/tests/firefox-baseline.html +++ b/tests/firefox-baseline.html @@ -7,4 +7,5 @@ Firefox baseline link

Firefox baseline destination.

+

Mixed before Inline message link after.