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
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user