Merged changes from Deedra.

This commit is contained in:
Storm Dragon committed 2026-10-20 14:03:44 -04:00
commit 1f8420ab4b
19 files changed
+584 -74

No files matched your search

+1 -1
View File
@@ -37,7 +37,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
@@ -407,3 +407,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
@@ -180,7 +180,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)
+2
View File
@@ -955,6 +955,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;
@@ -972,4 +973,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
@@ -155,6 +158,13 @@ func (snapshot Snapshot) ControlRole() string {
return snapshot.Role
}
// 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,11 +101,11 @@ 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{}
remember_text_parent(doc, place)
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
@@ -114,16 +114,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
@@ -95,7 +95,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,25 +28,25 @@ 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 named_link_label(node.Snapshot) != "" && (command == CaretLineStart || command == CaretLineEnd) {
offset = 0
if command == CaretLineEnd {
@@ -67,7 +64,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++
@@ -78,31 +75,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
}
@@ -118,8 +115,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
}
@@ -128,7 +125,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
}
}
@@ -145,7 +142,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++
@@ -155,7 +152,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
}
@@ -169,7 +166,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++
+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"
"strings"
"unicode/utf8"
"git.stormux.org/storm/cthulhu/internal/atspi"
)
// A range uses native text offsets, except for explicitly named links whose
// label offsets stay virtual. 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 || node.Snapshot.Role == "password text" {
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, caret_text_count(node.Snapshot)})
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))
node := doc.Nodes[item.index]
text, err := read_caret_text(ctx, reader, node, offset, unit)
if err != nil || text.End <= text.Start {
return text, err
}
if item.start == 0 && item.end == caret_text_count(node.Snapshot) {
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
}
+15 -17
View File
@@ -17,34 +17,29 @@ 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 = caret_text_count(doc.Nodes[indices[position]].Snapshot) - 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]]
count := caret_text_count(node.Snapshot)
if count == 0 || wholeCaretNode(node.Snapshot) && named_link_label(node.Snapshot) == "" {
node := doc.Nodes[indices[position].index]
if caret_text_count(node.Snapshot) == 0 || wholeCaretNode(node.Snapshot) && named_link_label(node.Snapshot) == "" {
if node.Reference != place.object {
place.object, place.offset, place.origin = node.Reference, 0, CaretNavigation
return DocumentCaretMove{Position: documentPosition(doc, *place), Moved: true}, nil
@@ -52,12 +47,15 @@ func moveCaretText(ctx context.Context, reader DocumentCaretReader, doc atspi.Do
position += step
offset = 0
if backwards && position >= 0 {
offset = caret_text_count(doc.Nodes[indices[position]].Snapshot) - 1
offset = indices[position].end - 1
}
continue
}
if offset >= 0 && offset < count {
character, err := read_caret_text(ctx, reader, node, 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
}
@@ -71,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 := read_caret_text(ctx, reader, node, offset, atspi.DocumentCaretWord)
word, err := readCaretRange(ctx, reader, doc, indices[position], offset, atspi.DocumentCaretWord)
if err != nil {
return DocumentCaretMove{}, err
}
@@ -90,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 := read_caret_text(ctx, reader, node, word.End-1, atspi.DocumentCaretCharacter)
last, err := readCaretRange(ctx, reader, doc, indices[position], word.End-1, atspi.DocumentCaretCharacter)
if err != nil {
return DocumentCaretMove{}, err
}
@@ -106,7 +104,7 @@ func moveCaretText(ctx context.Context, reader DocumentCaretReader, doc atspi.Do
position += step
offset = 0
if backwards && position >= 0 {
offset = caret_text_count(doc.Nodes[indices[position]].Snapshot) - 1
offset = indices[position].end - 1
}
}
return DocumentCaretMove{Position: documentPosition(doc, *place)}, nil
+206
View File
@@ -0,0 +1,206 @@
// 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()
return hypertextCursorWithLabel(t, "")
}
func hypertextCursorWithLabel(t *testing.T, label string) (*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")}}
link := node("/link", "link")
if label != "" {
link.Snapshot.Name, link.Snapshot.ExplicitName, link.Snapshot.CharacterCount = label, true, 0
}
tree, err := atspi.NewAccessibleTree(webID("/doc"), []atspi.TreeNode{
node("/doc", "document web", "/body", "/tail"), body, 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)
}
}
}
func TestDocumentCaretEmbeddedNamedLinkKeepsLabelOffsets(t *testing.T) {
cursor, reader := hypertextCursorWithLabel(t, "É猫 Info")
for _, step := range []struct {
command DocumentCaretCommand
text, path string
offset int
}{
{CaretDocumentStart, "Before ", "/body", 0},
{CaretNextLine, "", "/link", 0},
{CaretNextCharacter, "猫", "/link", 1},
{CaretNextWord, "Info", "/link", 7},
{CaretNextCharacter, " ", "/body", 8},
{CaretPreviousCharacter, "o", "/link", 6},
{CaretLineStart, "É", "/link", 0},
{CaretPreviousCharacter, " ", "/body", 6},
} {
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: text=%q at %s:%d err=%v; want %q at %s:%d", step.command, move.Text, move.Position.Object.ID().Path, move.Position.Offset, err, step.text, step.path, step.offset)
}
}
}
func TestDocumentCaretHypertextCannotEscapePasswordSubtree(t *testing.T) {
cursor, _ := hypertextCursor(t)
doc, err := cursor.tree.Document(context.Background(), cursor.places[0].object)
if err != nil {
t.Fatal(err)
}
// Even if editable state is missing, no nested hypertext may reintroduce
// the text suppressed by the password ancestor.
doc.Nodes[0].Snapshot.Role = "password text"
if ranges := caretNodeRanges(doc.Nodes); len(ranges) != 0 {
t.Fatalf("password subtree exposed caret ranges: %+v", ranges)
}
}
+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
}
@@ -38,38 +39,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
}
+1
View File
@@ -64,3 +64,4 @@ thunderbird-local-mail-composition,Orca src/orca/scripts/apps/Thunderbird/script
flatpak-foreground-identity,src/cthulhu/script_utilities.py:canBeActiveWindow,focus,Correlate Flatpak accessibility providers with the X11 foreground across PID namespaces and D-Bus proxies,,internal/platform/xorg internal/atspi,in-progress,go test -race ./internal/platform/xorg ./internal/atspi ./internal/app -count=1; CTHULHU_FOREGROUND_PID=1 python3 tests/native_atspi.py,Private XRes lookup and fallback; real proxy with matching and different synthetic instance metadata; GTK Qt and Brave ownership checks,,Actual Flatpak Steam speech pending,Physical Braille pending; original report also has helper geometry errors,docs/testing/flatpak-foreground.md; preserves active state geometry and sibling ownership checks; no application name exception
present-window-title,Orca src/orca/where_am_i_presenter.py;src/orca/where_am_i_presenter_command_definitions.py,command,Read the foreground window title with Cthulhu+Numpad Enter on desktop or Cthulhu+Slash on laptop,,internal/input internal/app internal/platform/xorg,in-progress,go test ./internal/input ./internal/app ./internal/platform/xorg -count=1; focused title command race checks; python3 tests/check_speech.py,Private Xvfb command dispatch verifies both layout bindings and both release orders. Private X11 and AT-SPI event loop repeats the current title with automatic changes disabled.,,Speech queue assertions passed; physical keyboard and audible acceptance pending.,Braille flash queue assertion passed; physical display acceptance pending.,Reuses copied foreground title metadata and the owned accessible title. Explicit requests bypass automatic-title policy and duplicate suppression without moving focus. Stale native epochs and unrelated native windows cannot supply a title. Lock and terminal handoff remain enforced. Double-press status-bar reading is outside this change. See tests/window_titles.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",Merge review on 2026-10-20: four parent-text regressions fail against testing before the merge and pass after resolution. Private Firefox baseline and Chromium fragments document selection named-link and editing checks pass.,,Recorded control-event assertions preserve full link focus output and subsequent arrow feedback. Original Thunderbird message and audible acceptance remain pending.,Repeated parent-fragment cursor and native routing offsets tested; physical Braille pending.,Hypertext traversal retains parent offsets around single-character embedded children; named-link labels retain virtual character and word offsets. Password subtrees remain excluded. Read and Tab through an HTML email; reverse direction; verify Firefox and Chromium links selection and address-bar editing before submission. Human diff review and original-message live acceptance remain required.
1 id python_source subsystem behavior owner go_destination status automated_evidence x11_evidence wayland_evidence speech_evidence braille_evidence notes
64 flatpak-foreground-identity src/cthulhu/script_utilities.py:canBeActiveWindow focus Correlate Flatpak accessibility providers with the X11 foreground across PID namespaces and D-Bus proxies internal/platform/xorg internal/atspi in-progress go test -race ./internal/platform/xorg ./internal/atspi ./internal/app -count=1; CTHULHU_FOREGROUND_PID=1 python3 tests/native_atspi.py Private XRes lookup and fallback; real proxy with matching and different synthetic instance metadata; GTK Qt and Brave ownership checks Actual Flatpak Steam speech pending Physical Braille pending; original report also has helper geometry errors docs/testing/flatpak-foreground.md; preserves active state geometry and sibling ownership checks; no application name exception
65 present-window-title Orca src/orca/where_am_i_presenter.py;src/orca/where_am_i_presenter_command_definitions.py command Read the foreground window title with Cthulhu+Numpad Enter on desktop or Cthulhu+Slash on laptop internal/input internal/app internal/platform/xorg in-progress go test ./internal/input ./internal/app ./internal/platform/xorg -count=1; focused title command race checks; python3 tests/check_speech.py Private Xvfb command dispatch verifies both layout bindings and both release orders. Private X11 and AT-SPI event loop repeats the current title with automatic changes disabled. Speech queue assertions passed; physical keyboard and audible acceptance pending. Braille flash queue assertion passed; physical display acceptance pending. Reuses copied foreground title metadata and the owned accessible title. Explicit requests bypass automatic-title policy and duplicate suppression without moving focus. Stale native epochs and unrelated native windows cannot supply a title. Lock and terminal handoff remain enforced. Double-press status-bar reading is outside this change. See tests/window_titles.md.
66 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.
67 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 Merge review on 2026-10-20: four parent-text regressions fail against testing before the merge and pass after resolution. Private Firefox baseline and Chromium fragments document selection named-link and editing checks pass. Recorded control-event assertions preserve full link focus output and subsequent arrow feedback. Original Thunderbird message and audible acceptance remain pending. Repeated parent-fragment cursor and native routing offsets tested; physical Braille pending. Hypertext traversal retains parent offsets around single-character embedded children; named-link labels retain virtual character and word offsets. Password subtrees remain excluded. Read and Tab through an HTML email; reverse direction; verify Firefox and Chromium links selection and address-bar editing before submission. Human diff review and original-message live acceptance remain required.
+1
View File
@@ -7,4 +7,5 @@
<a href="#destination">Firefox baseline link</a>
<label>Firefox baseline entry <input></label>
<p id="destination">Firefox baseline destination.</p>
<p>Mixed before <a href="#destination">Inline message link</a> after.</p>
</html>