Read Thunderbird message text and preserve link announcements
Thunderbird HTML messages expose readable text on containers around embedded objects. The browse cursor previously considered only text leaves, and native document capture omitted heading/link character counts. This skipped message content, headings and link text. A caret event following Tab could also replace a complete link announcement with its first character. Capture owned hypertext child identities and native placeholder offsets on the GLib thread, releasing native interfaces before returning. Require every child to occupy one embedded-object character so providers exposing duplicate text through static descendants retain their ordinary traversal. Traverse native text ranges before and after embedded children without changing their offset domains. Preserve character, word, line and document-boundary navigation; normalize native caret and Braille routing positions at adjacent embedded links. Keep the Braille cursor on the correct fragment when a parent contributes text more than once to an assembled line. Ignore same-input link caret echoes after focus while retaining subsequent arrow and selection feedback. Add regression tests, extend the private Firefox fixture, and record behavior and remaining acceptance in the parity ledger. Validation: - Full Go test suite with count=1; focused app/atspi/review race checks. - go vet ./...; reader and brlapi helper builds. - Private Firefox baseline, mixed-parent offsets and tab navigation. - Private Chromium fragments, named links, document navigation and selection. - Parity ledger validation and whitespace checks. The existing Firefox tab test intermittently timed out on both an untouched HEAD copy and the changed tree; the final run passed. The unchanged TestRunReachesSoundStopAfterEarlierServiceStopError also failed once during startup cancellation and passed in subsequent full-suite and race runs. Both installed binaries were verified and the reader reloaded in its original desktop session. The maintainer confirmed readable message text and later working links after an earlier intermittent omission. That omission remains untraced; repeat live acceptance and physical Braille verification remain open.
This commit is contained in:
1 parent
8ef838e16e
commit
edd6c560b8
19 files changed
+538
-73
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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++
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -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."
|
||||
|
@@ -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>
|
||||
Reference in new issue
Block a user