Merged changes from Deedra.
This commit is contained in:
commit
1f8420ab4b
19 files changed
+584
-74
No files matched your search
@@ -37,7 +37,7 @@ func brailleDocumentFrame(result review.Result, options config.BrailleOptions, c
|
||||
start := utf8.RuneCountInString(frame.Text)
|
||||
frame.Text += raw
|
||||
length := utf8.RuneCountInString(raw)
|
||||
if source.ID() == snapshot.ID() {
|
||||
if source.ID() == snapshot.ID() && result.DocumentPosition.Offset >= sourceStart && result.DocumentPosition.Offset <= sourceStart+length {
|
||||
frame.Cursor = start + result.DocumentPosition.Offset - sourceStart
|
||||
}
|
||||
if source.ID().Valid() && source.Role != "password text" && length > 0 {
|
||||
|
||||
@@ -407,3 +407,19 @@ func TestTableNavigationUpdatesBrailleWithoutNativeCaretEvent(t *testing.T) {
|
||||
}
|
||||
wantFrame(t, frames, brailleFocusFrame(cell))
|
||||
}
|
||||
|
||||
func TestBrowseBrailleRepeatedParentKeepsNativeCursor(t *testing.T) {
|
||||
parent := atspi.NewSnapshot("/body", "", "paragraph", "", "")
|
||||
parent.BusName = ":1.1"
|
||||
child := atspi.NewSnapshot("/span", "", "section", "", "")
|
||||
child.BusName = ":1.1"
|
||||
result := review.Result{DocumentPosition: review.DocumentPosition{Snapshot: parent, Offset: 0}, DocumentCaretLine: &atspi.DocumentLine{Contents: []atspi.DocumentContent{
|
||||
{Snapshot: parent, Start: 0, End: 7, RawText: "Before "},
|
||||
{Snapshot: child, Start: 0, End: 9, RawText: "Read more"},
|
||||
{Snapshot: parent, Start: 8, End: 15, RawText: " after."},
|
||||
}}}
|
||||
frame := brailleDocumentFrame(result, config.DefaultSettings().Braille)
|
||||
if frame.Cursor != 0 {
|
||||
t.Fatalf("text=%q cursor=%d; expected cursor=0", frame.Text, frame.Cursor)
|
||||
}
|
||||
}
|
||||
@@ -180,7 +180,7 @@ func TestSettingsTreeSelectionSpeaksOnceAndIgnoresUnfocusedEdits(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestControlFocusCaretDoesNotInterruptButArrowCaretSpeaks(t *testing.T) {
|
||||
for _, role := range []string{"spin button", "button"} {
|
||||
for _, role := range []string{"spin button", "button", "link"} {
|
||||
t.Run(role, func(t *testing.T) {
|
||||
queue := &fakeSpeechQueue{spoken: make(chan string, 8)}
|
||||
adapter := &fakeAdapter{events: make(chan atspi.Event, 8)}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -955,6 +955,7 @@ static void cthulhu_capture_document_metrics(AtspiAccessible *source, cthulhu_sn
|
||||
gint characterCount = atspi_text_get_character_count(text, &error);
|
||||
if (error != NULL) {
|
||||
g_clear_error(&error);
|
||||
g_object_unref(text);
|
||||
return;
|
||||
}
|
||||
snapshot->characterCount = characterCount;
|
||||
@@ -972,4 +973,5 @@ static void cthulhu_capture_document_metrics(AtspiAccessible *source, cthulhu_sn
|
||||
g_free(contents);
|
||||
}
|
||||
}
|
||||
g_object_unref(text);
|
||||
}
|
||||
@@ -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
|
||||
@@ -155,6 +158,13 @@ func (snapshot Snapshot) ControlRole() string {
|
||||
return snapshot.Role
|
||||
}
|
||||
|
||||
// TextChild is a single embedded-object character in an owned text container.
|
||||
// Its offset belongs to the parent; Child retains no native handle.
|
||||
type TextChild struct {
|
||||
Offset int
|
||||
Child ObjectID
|
||||
}
|
||||
|
||||
// TextAttributeRun is the native attribute range at the caret, copied without
|
||||
// retaining an AT-SPI object. Offsets are Unicode character offsets.
|
||||
type TextAttributeRun struct {
|
||||
|
||||
@@ -77,7 +77,7 @@ func (worker *Worker) route_document_braille(ctx context.Context, cursor *Docume
|
||||
result.Err = err
|
||||
return
|
||||
}
|
||||
node, err := native_caret_node(ctx, reader, doc, route.target, route.offset)
|
||||
node, caretOffset, err := native_caret_node(ctx, reader, doc, route.target, route.offset)
|
||||
if err != nil {
|
||||
result.Err = err
|
||||
return
|
||||
@@ -101,11 +101,11 @@ func (worker *Worker) route_document_braille(ctx context.Context, cursor *Docume
|
||||
return
|
||||
}
|
||||
place := &cursor.places[len(cursor.places)-1]
|
||||
place.object, place.offset, place.origin = node.Reference, route.offset, CaretNavigation
|
||||
place.object, place.offset, place.origin = node.Reference, caretOffset, CaretNavigation
|
||||
place.tableCell = atspi.ObjectID{}
|
||||
remember_text_parent(doc, place)
|
||||
snapshot, err := cursor.tree.Resolve(node.Reference)
|
||||
result.DocumentPosition = DocumentPosition{Document: owner.TopLevel, Object: node.Reference, Snapshot: snapshot, Offset: route.offset, Origin: CaretNavigation}
|
||||
result.DocumentPosition = DocumentPosition{Document: owner.TopLevel, Object: node.Reference, Snapshot: snapshot, Offset: caretOffset, Origin: CaretNavigation}
|
||||
cursor.mu.Unlock()
|
||||
result.Err, result.Moved = err, err == nil
|
||||
result.DocumentCaretContext = owner.Ancestors
|
||||
@@ -114,16 +114,16 @@ func (worker *Worker) route_document_braille(ctx context.Context, cursor *Docume
|
||||
// retaining the exact routed offset. It never repeats caret placement.
|
||||
deadline, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
|
||||
defer cancel()
|
||||
indices, _ := caret_node_indices(doc.Nodes, node.Reference)
|
||||
indices := caretNodeRanges(doc.Nodes)
|
||||
position := -1
|
||||
for index, candidate := range indices {
|
||||
if doc.Nodes[candidate].Reference == node.Reference {
|
||||
if doc.Nodes[candidate.index].Reference == node.Reference && caretOffset >= candidate.start && caretOffset < candidate.end {
|
||||
position = index
|
||||
break
|
||||
}
|
||||
}
|
||||
if position >= 0 {
|
||||
parts, err := collect_visual_line(deadline, reader, doc, indices, visual_line_owners(doc.Nodes), position, route.offset, true)
|
||||
parts, err := collect_visual_line(deadline, reader, doc, indices, visual_line_owners(doc.Nodes), position, caretOffset, true)
|
||||
if err == nil && len(parts) > 1 {
|
||||
line, _, _ := assemble_visual_line(cursor.tree, doc, parts)
|
||||
result.DocumentCaretLine = &line
|
||||
|
||||
@@ -95,7 +95,8 @@ func (cursor *DocumentCursor) MoveCaret(ctx context.Context, reader DocumentCare
|
||||
return cursor.moveTable(ctx, doc, place, command)
|
||||
}
|
||||
place.tableCell = atspi.ObjectID{}
|
||||
indices, current := caret_node_indices(doc.Nodes, place.object)
|
||||
_, current := caret_node_indices(doc.Nodes, place.object)
|
||||
indices := caretNodeRanges(doc.Nodes)
|
||||
if current < 0 {
|
||||
return DocumentCaretMove{}, atspi.ErrStaleObject
|
||||
}
|
||||
|
||||
@@ -15,15 +15,12 @@ import (
|
||||
|
||||
// move_caret_line handles line and document boundaries under the cursor lock
|
||||
// held by MoveCaret. Text-unit movement has its separate traversal helper.
|
||||
func move_caret_object_line(ctx context.Context, reader DocumentCaretReader, doc atspi.DocumentSnapshot, place *documentPlace, indices []int, current int, command DocumentCaretCommand) (DocumentCaretMove, error) {
|
||||
func move_caret_object_line(ctx context.Context, reader DocumentCaretReader, doc atspi.DocumentSnapshot, place *documentPlace, indices []caretRange, current int, command DocumentCaretCommand) (DocumentCaretMove, error) {
|
||||
unit := atspi.DocumentCaretLine
|
||||
if command == CaretReadLine {
|
||||
unit = atspi.DocumentCaretLineRemainder
|
||||
}
|
||||
position := 0
|
||||
for position < len(indices) && indices[position] < current {
|
||||
position++
|
||||
}
|
||||
position, same := caretRangePosition(indices, current, place.offset)
|
||||
backwards := command == CaretPreviousLine || command == CaretDocumentEnd
|
||||
if command == CaretDocumentStart {
|
||||
position = 0
|
||||
@@ -31,25 +28,25 @@ func move_caret_object_line(ctx context.Context, reader DocumentCaretReader, doc
|
||||
position = len(indices) - 1
|
||||
}
|
||||
offset := place.offset
|
||||
if position == len(indices) || indices[position] != current || command == CaretDocumentStart {
|
||||
if !same || command == CaretDocumentStart {
|
||||
offset = 0
|
||||
}
|
||||
if command == CaretDocumentEnd {
|
||||
offset = doc.Nodes[indices[position]].Snapshot.CharacterCount - 1
|
||||
offset = indices[position].end - 1
|
||||
}
|
||||
if backwards && command != CaretDocumentEnd && (position == len(indices) || indices[position] != current) {
|
||||
if backwards && command != CaretDocumentEnd && !same {
|
||||
position--
|
||||
if position < 0 {
|
||||
return DocumentCaretMove{Position: documentPosition(doc, *place)}, nil
|
||||
}
|
||||
offset = doc.Nodes[indices[position]].Snapshot.CharacterCount - 1
|
||||
offset = indices[position].end - 1
|
||||
}
|
||||
initialPosition := position
|
||||
for attempts := 0; attempts < len(indices)+1; attempts++ {
|
||||
if position < 0 || position >= len(indices) {
|
||||
return DocumentCaretMove{Position: documentPosition(doc, *place)}, nil
|
||||
}
|
||||
node := doc.Nodes[indices[position]]
|
||||
node := doc.Nodes[indices[position].index]
|
||||
if named_link_label(node.Snapshot) != "" && (command == CaretLineStart || command == CaretLineEnd) {
|
||||
offset = 0
|
||||
if command == CaretLineEnd {
|
||||
@@ -67,7 +64,7 @@ func move_caret_object_line(ctx context.Context, reader DocumentCaretReader, doc
|
||||
if backwards {
|
||||
position--
|
||||
if position >= 0 {
|
||||
offset = doc.Nodes[indices[position]].Snapshot.CharacterCount - 1
|
||||
offset = indices[position].end - 1
|
||||
}
|
||||
} else {
|
||||
position++
|
||||
@@ -78,31 +75,31 @@ func move_caret_object_line(ctx context.Context, reader DocumentCaretReader, doc
|
||||
place.object, place.offset, place.origin = node.Reference, 0, CaretNavigation
|
||||
return DocumentCaretMove{Position: documentPosition(doc, *place), Moved: true}, nil
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
if offset < indices[position].start {
|
||||
offset = indices[position].start
|
||||
}
|
||||
if command == CaretReadLine && offset >= node.Snapshot.CharacterCount {
|
||||
if command == CaretReadLine && offset >= indices[position].end {
|
||||
position++
|
||||
offset = 0
|
||||
continue
|
||||
}
|
||||
if offset >= node.Snapshot.CharacterCount {
|
||||
offset = node.Snapshot.CharacterCount - 1
|
||||
if offset >= indices[position].end {
|
||||
offset = indices[position].end - 1
|
||||
}
|
||||
piece, readErr := reader.ReadDocumentCaret(ctx, node.Reference, offset, unit)
|
||||
piece, readErr := readCaretRange(ctx, reader, doc, indices[position], offset, unit)
|
||||
if readErr != nil && !errors.Is(readErr, atspi.ErrNoDocumentCaret) {
|
||||
return DocumentCaretMove{}, readErr
|
||||
}
|
||||
if readErr == nil && piece.End > piece.Start && strings.TrimSpace(piece.Text) != "\ufffc" {
|
||||
nextOffset := offset
|
||||
sameCurrent := node.Reference == place.object ||
|
||||
place.origin == StructuralNavigation && position == initialPosition && indices[position] >= current
|
||||
sameCurrent := position == initialPosition && same ||
|
||||
place.origin == StructuralNavigation && position == initialPosition && indices[position].index >= current
|
||||
switch command {
|
||||
case CaretNextLine:
|
||||
if sameCurrent {
|
||||
nextOffset = piece.End
|
||||
if nextOffset < piece.CharacterCount {
|
||||
piece, readErr = reader.ReadDocumentCaret(ctx, node.Reference, nextOffset, unit)
|
||||
piece, readErr = readCaretRange(ctx, reader, doc, indices[position], nextOffset, unit)
|
||||
if readErr != nil {
|
||||
return DocumentCaretMove{}, readErr
|
||||
}
|
||||
@@ -118,8 +115,8 @@ func move_caret_object_line(ctx context.Context, reader DocumentCaretReader, doc
|
||||
nextOffset = piece.Start
|
||||
case CaretPreviousLine:
|
||||
if sameCurrent {
|
||||
if piece.Start > 0 {
|
||||
piece, readErr = reader.ReadDocumentCaret(ctx, node.Reference, piece.Start-1, unit)
|
||||
if piece.Start > indices[position].start {
|
||||
piece, readErr = readCaretRange(ctx, reader, doc, indices[position], piece.Start-1, unit)
|
||||
if readErr != nil {
|
||||
return DocumentCaretMove{}, readErr
|
||||
}
|
||||
@@ -128,7 +125,7 @@ func move_caret_object_line(ctx context.Context, reader DocumentCaretReader, doc
|
||||
if position < 0 {
|
||||
return DocumentCaretMove{Position: documentPosition(doc, *place)}, nil
|
||||
}
|
||||
offset = doc.Nodes[indices[position]].Snapshot.CharacterCount - 1
|
||||
offset = indices[position].end - 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -145,7 +142,7 @@ func move_caret_object_line(ctx context.Context, reader DocumentCaretReader, doc
|
||||
if backwards {
|
||||
position--
|
||||
if position >= 0 {
|
||||
offset = doc.Nodes[indices[position]].Snapshot.CharacterCount - 1
|
||||
offset = indices[position].end - 1
|
||||
}
|
||||
} else {
|
||||
position++
|
||||
@@ -155,7 +152,7 @@ func move_caret_object_line(ctx context.Context, reader DocumentCaretReader, doc
|
||||
}
|
||||
spoken := piece.Text
|
||||
if command == CaretLineStart || command == CaretLineEnd {
|
||||
character, charErr := reader.ReadDocumentCaret(ctx, node.Reference, nextOffset, atspi.DocumentCaretCharacter)
|
||||
character, charErr := readCaretRange(ctx, reader, doc, indices[position], nextOffset, atspi.DocumentCaretCharacter)
|
||||
if charErr != nil {
|
||||
return DocumentCaretMove{}, charErr
|
||||
}
|
||||
@@ -169,7 +166,7 @@ func move_caret_object_line(ctx context.Context, reader DocumentCaretReader, doc
|
||||
if backwards {
|
||||
position--
|
||||
if position >= 0 {
|
||||
offset = doc.Nodes[indices[position]].Snapshot.CharacterCount - 1
|
||||
offset = indices[position].end - 1
|
||||
}
|
||||
} else {
|
||||
position++
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package review
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
)
|
||||
|
||||
// A range uses native text offsets, except for explicitly named links whose
|
||||
// label offsets stay virtual. Hypertext containers can occur more than once,
|
||||
// before and after their embedded children.
|
||||
type caretRange struct{ index, start, end int }
|
||||
|
||||
func caretNodeRanges(nodes []atspi.DocumentNode) []caretRange {
|
||||
indices, _ := caret_node_indices(nodes, atspi.ObjectReference{})
|
||||
eligible := make(map[int]bool, len(indices))
|
||||
byID := make(map[atspi.ObjectID]int, len(nodes))
|
||||
for _, index := range indices {
|
||||
eligible[index] = true
|
||||
}
|
||||
for index, node := range nodes {
|
||||
byID[node.Reference.ID()] = index
|
||||
}
|
||||
var result []caretRange
|
||||
var visit func(int)
|
||||
visit = func(index int) {
|
||||
node := nodes[index]
|
||||
if node.Hidden || node.Snapshot.Role == "password text" {
|
||||
return
|
||||
}
|
||||
children := node.Snapshot.TextChildren
|
||||
valid := len(children) > 0 && !node.Snapshot.Editable && !wholeCaretNode(node.Snapshot)
|
||||
previous := -1
|
||||
direct := make(map[atspi.ObjectID]bool, len(node.Children))
|
||||
for _, id := range node.Children {
|
||||
direct[id] = true
|
||||
}
|
||||
for _, child := range children {
|
||||
if child.Offset <= previous || child.Offset >= node.Snapshot.CharacterCount || !direct[child.Child] {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
previous = child.Offset
|
||||
}
|
||||
if valid {
|
||||
offset := 0
|
||||
for _, child := range children {
|
||||
if offset < child.Offset {
|
||||
result = append(result, caretRange{index, offset, child.Offset})
|
||||
}
|
||||
if childIndex, ok := byID[child.Child]; ok {
|
||||
visit(childIndex)
|
||||
}
|
||||
delete(direct, child.Child)
|
||||
offset = child.Offset + 1
|
||||
}
|
||||
if offset < node.Snapshot.CharacterCount {
|
||||
result = append(result, caretRange{index, offset, node.Snapshot.CharacterCount})
|
||||
}
|
||||
for _, id := range node.Children {
|
||||
if direct[id] {
|
||||
if child, ok := byID[id]; ok {
|
||||
visit(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if eligible[index] {
|
||||
result = append(result, caretRange{index, 0, caret_text_count(node.Snapshot)})
|
||||
if wholeCaretNode(node.Snapshot) {
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, id := range node.Children {
|
||||
if child, ok := byID[id]; ok {
|
||||
visit(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(nodes) > 0 {
|
||||
visit(0)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func caretRangePosition(ranges []caretRange, current, offset int) (int, bool) {
|
||||
for position, item := range ranges {
|
||||
if item.index == current && offset >= item.start && offset <= max(item.start, item.end-1) {
|
||||
return position, true
|
||||
}
|
||||
}
|
||||
// Word and read-all cursors may be just after the final character.
|
||||
for position, item := range ranges {
|
||||
if item.index == current && offset == item.end {
|
||||
return position, true
|
||||
}
|
||||
}
|
||||
for position, item := range ranges {
|
||||
if item.index > current {
|
||||
return position, false
|
||||
}
|
||||
}
|
||||
return len(ranges), false
|
||||
}
|
||||
|
||||
func readCaretRange(ctx context.Context, reader DocumentCaretReader, doc atspi.DocumentSnapshot, item caretRange, offset int, unit atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) {
|
||||
offset = max(item.start, min(offset, item.end-1))
|
||||
node := doc.Nodes[item.index]
|
||||
text, err := read_caret_text(ctx, reader, node, offset, unit)
|
||||
if err != nil || text.End <= text.Start {
|
||||
return text, err
|
||||
}
|
||||
if item.start == 0 && item.end == caret_text_count(node.Snapshot) {
|
||||
return text, nil
|
||||
}
|
||||
if text.Start < 0 || text.Start > offset || text.End <= offset || text.End > text.CharacterCount {
|
||||
return text, nil
|
||||
}
|
||||
start, end := max(text.Start, item.start), min(text.End, item.end)
|
||||
if start >= end {
|
||||
return atspi.DocumentCaretText{CharacterCount: item.end}, nil
|
||||
}
|
||||
if start != text.Start || end != text.End {
|
||||
raw := text.RawText
|
||||
if raw == "" {
|
||||
raw = text.Text
|
||||
}
|
||||
// Speech may be whitespace-normalized. Clip using the raw native text;
|
||||
// never treat offsets in a normalized string as native character offsets.
|
||||
runes := []rune(raw)
|
||||
if unit == atspi.DocumentCaretLineRemainder && len(runes) > text.End-text.Start {
|
||||
runes = runes[len(runes)-(text.End-text.Start):]
|
||||
}
|
||||
if len(runes) != text.End-text.Start {
|
||||
return atspi.DocumentCaretText{}, atspi.ErrNoDocumentCaret
|
||||
}
|
||||
raw = string(runes[start-text.Start : end-text.Start])
|
||||
if text.RawText != "" {
|
||||
text.RawText = raw
|
||||
text.Text = strings.Join(strings.Fields(raw), " ")
|
||||
} else {
|
||||
text.Text = raw
|
||||
}
|
||||
text.Start, text.End = start, end
|
||||
}
|
||||
// Range-local traversal must stop at the embedded object. The retained
|
||||
// snapshot still has the full native character count for routing/placement.
|
||||
text.CharacterCount = item.end
|
||||
if unit == atspi.DocumentCaretCharacter && utf8.RuneCountInString(text.Text) > 1 {
|
||||
return atspi.DocumentCaretText{}, atspi.ErrNoDocumentCaret
|
||||
}
|
||||
return text, nil
|
||||
}
|
||||
@@ -17,34 +17,29 @@ import (
|
||||
// Python caret_navigation starts a word move at the adjacent non-space
|
||||
// character, rather than the next/previous word boundary. In particular,
|
||||
// reversing at the end of a word must read that word, not skip over it.
|
||||
func moveCaretText(ctx context.Context, reader DocumentCaretReader, doc atspi.DocumentSnapshot, place *documentPlace, indices []int, current int, backwards, byWord bool) (DocumentCaretMove, error) {
|
||||
func moveCaretText(ctx context.Context, reader DocumentCaretReader, doc atspi.DocumentSnapshot, place *documentPlace, indices []caretRange, current int, backwards, byWord bool) (DocumentCaretMove, error) {
|
||||
step := 1
|
||||
if backwards {
|
||||
step = -1
|
||||
}
|
||||
position := 0
|
||||
for position < len(indices) && indices[position] < current {
|
||||
position++
|
||||
}
|
||||
position, same := caretRangePosition(indices, current, place.offset)
|
||||
offset := 0
|
||||
if position < len(indices) && indices[position] == current {
|
||||
if same {
|
||||
offset = place.offset + step
|
||||
} else if backwards {
|
||||
position--
|
||||
if position >= 0 {
|
||||
offset = caret_text_count(doc.Nodes[indices[position]].Snapshot) - 1
|
||||
offset = indices[position].end - 1
|
||||
}
|
||||
} else if place.origin == StructuralNavigation {
|
||||
// Structural jumps speak the first descendant at offset zero.
|
||||
offset = 1
|
||||
}
|
||||
for position >= 0 && position < len(indices) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return DocumentCaretMove{}, err
|
||||
}
|
||||
node := doc.Nodes[indices[position]]
|
||||
count := caret_text_count(node.Snapshot)
|
||||
if count == 0 || wholeCaretNode(node.Snapshot) && named_link_label(node.Snapshot) == "" {
|
||||
node := doc.Nodes[indices[position].index]
|
||||
if caret_text_count(node.Snapshot) == 0 || wholeCaretNode(node.Snapshot) && named_link_label(node.Snapshot) == "" {
|
||||
if node.Reference != place.object {
|
||||
place.object, place.offset, place.origin = node.Reference, 0, CaretNavigation
|
||||
return DocumentCaretMove{Position: documentPosition(doc, *place), Moved: true}, nil
|
||||
@@ -52,12 +47,15 @@ func moveCaretText(ctx context.Context, reader DocumentCaretReader, doc atspi.Do
|
||||
position += step
|
||||
offset = 0
|
||||
if backwards && position >= 0 {
|
||||
offset = caret_text_count(doc.Nodes[indices[position]].Snapshot) - 1
|
||||
offset = indices[position].end - 1
|
||||
}
|
||||
continue
|
||||
}
|
||||
if offset >= 0 && offset < count {
|
||||
character, err := read_caret_text(ctx, reader, node, offset, atspi.DocumentCaretCharacter)
|
||||
if !backwards {
|
||||
offset = max(offset, indices[position].start)
|
||||
}
|
||||
if offset >= indices[position].start && offset < indices[position].end {
|
||||
character, err := readCaretRange(ctx, reader, doc, indices[position], offset, atspi.DocumentCaretCharacter)
|
||||
if err != nil && !errors.Is(err, atspi.ErrNoDocumentCaret) {
|
||||
return DocumentCaretMove{}, err
|
||||
}
|
||||
@@ -71,7 +69,7 @@ func moveCaretText(ctx context.Context, reader DocumentCaretReader, doc atspi.Do
|
||||
place.object, place.offset, place.origin = node.Reference, character.Start, CaretNavigation
|
||||
return DocumentCaretMove{Position: documentPosition(doc, *place), Text: character.Text, Misspelled: character.Misspelled, Moved: true, Start: character.Start, End: character.End}, nil
|
||||
}
|
||||
word, err := read_caret_text(ctx, reader, node, offset, atspi.DocumentCaretWord)
|
||||
word, err := readCaretRange(ctx, reader, doc, indices[position], offset, atspi.DocumentCaretWord)
|
||||
if err != nil {
|
||||
return DocumentCaretMove{}, err
|
||||
}
|
||||
@@ -90,7 +88,7 @@ func moveCaretText(ctx context.Context, reader DocumentCaretReader, doc atspi.Do
|
||||
nextOffset = word.End
|
||||
// WORD ranges may contain a trailing delimiter. Query its
|
||||
// actual character; spoken word text can be normalized.
|
||||
last, err := read_caret_text(ctx, reader, node, word.End-1, atspi.DocumentCaretCharacter)
|
||||
last, err := readCaretRange(ctx, reader, doc, indices[position], word.End-1, atspi.DocumentCaretCharacter)
|
||||
if err != nil {
|
||||
return DocumentCaretMove{}, err
|
||||
}
|
||||
@@ -106,7 +104,7 @@ func moveCaretText(ctx context.Context, reader DocumentCaretReader, doc atspi.Do
|
||||
position += step
|
||||
offset = 0
|
||||
if backwards && position >= 0 {
|
||||
offset = caret_text_count(doc.Nodes[indices[position]].Snapshot) - 1
|
||||
offset = indices[position].end - 1
|
||||
}
|
||||
}
|
||||
return DocumentCaretMove{Position: documentPosition(doc, *place)}, nil
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package review
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
)
|
||||
|
||||
func hypertextCursor(t *testing.T) (*DocumentCursor, caretTextReader) {
|
||||
t.Helper()
|
||||
return hypertextCursorWithLabel(t, "")
|
||||
}
|
||||
|
||||
func hypertextCursorWithLabel(t *testing.T, label string) (*DocumentCursor, caretTextReader) {
|
||||
t.Helper()
|
||||
reader := caretTextReader{webID("/body"): "Before \ufffc after.", webID("/link"): "Read more", webID("/tail"): "Next paragraph"}
|
||||
node := func(path, role string, children ...string) atspi.TreeNode {
|
||||
snapshot := atspi.NewSnapshot(path, "", role, "", "")
|
||||
snapshot.BusName = webID(path).BusName
|
||||
snapshot.CharacterCount = utf8.RuneCountInString(reader[webID(path)])
|
||||
result := atspi.TreeNode{Snapshot: snapshot}
|
||||
for _, child := range children {
|
||||
result.Children = append(result.Children, webID(child))
|
||||
}
|
||||
return result
|
||||
}
|
||||
body := node("/body", "section", "/link")
|
||||
body.Snapshot.TextChildren = []atspi.TextChild{{Offset: 7, Child: webID("/link")}}
|
||||
link := node("/link", "link")
|
||||
if label != "" {
|
||||
link.Snapshot.Name, link.Snapshot.ExplicitName, link.Snapshot.CharacterCount = label, true, 0
|
||||
}
|
||||
tree, err := atspi.NewAccessibleTree(webID("/doc"), []atspi.TreeNode{
|
||||
node("/doc", "document web", "/body", "/tail"), body, link, node("/tail", "paragraph"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cursor, err := NewDocumentCursor(tree)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := cursor.SyncFocus(context.Background(), webID("/doc")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cursor, reader
|
||||
}
|
||||
|
||||
func TestDocumentCaretReadsTextAroundEmbeddedLink(t *testing.T) {
|
||||
cursor, reader := hypertextCursor(t)
|
||||
for _, step := range []struct {
|
||||
command DocumentCaretCommand
|
||||
text, path string
|
||||
offset int
|
||||
}{
|
||||
{CaretDocumentStart, "Before ", "/body", 0},
|
||||
{CaretNextLine, "Read more", "/link", 0},
|
||||
{CaretNextLine, " after.", "/body", 8},
|
||||
{CaretNextLine, "Next paragraph", "/tail", 0},
|
||||
{CaretPreviousLine, " after.", "/body", 8},
|
||||
{CaretPreviousLine, "Read more", "/link", 0},
|
||||
{CaretPreviousLine, "Before ", "/body", 0},
|
||||
} {
|
||||
move, err := cursor.MoveCaret(context.Background(), reader, step.command)
|
||||
if err != nil || !move.Moved || move.Text != step.text || move.Position.Object.ID() != webID(step.path) || move.Position.Offset != step.offset {
|
||||
t.Fatalf("%s: move=%+v err=%v; want %q at %s:%d", step.command, move, err, step.text, step.path, step.offset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentCaretCharactersCrossEmbeddedLink(t *testing.T) {
|
||||
cursor, reader := hypertextCursor(t)
|
||||
if _, err := cursor.MoveCaret(context.Background(), reader, CaretDocumentStart); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var text string
|
||||
for i := 0; i < 64; i++ {
|
||||
move, err := cursor.MoveCaret(context.Background(), reader, CaretNextCharacter)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if move.Position.Object.ID() == webID("/tail") {
|
||||
break
|
||||
}
|
||||
text += move.Text
|
||||
}
|
||||
if text != "efore Read more after." {
|
||||
t.Fatalf("characters=%q", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeCaretKeepsHypertextParentOffsets(t *testing.T) {
|
||||
cursor, reader := hypertextCursor(t)
|
||||
if err := cursor.syncNativeCaret(context.Background(), reader, webID("/body"), 9); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
move, err := cursor.MoveCaret(context.Background(), reader, CaretPreviousCharacter)
|
||||
if err != nil || move.Text != " " || move.Position.Offset != 8 || move.Position.Object.ID() != webID("/body") {
|
||||
t.Fatalf("native hypertext caret: text=%q offset=%d err=%v", move.Text, move.Position.Offset, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentCaretWordsCrossEmbeddedLink(t *testing.T) {
|
||||
cursor, reader := hypertextCursor(t)
|
||||
if _, err := cursor.MoveCaret(context.Background(), reader, CaretDocumentStart); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, step := range []struct {
|
||||
command DocumentCaretCommand
|
||||
text, path string
|
||||
offset int
|
||||
}{
|
||||
{CaretNextWord, "Before ", "/body", 6},
|
||||
{CaretNextWord, "Read ", "/link", 4},
|
||||
{CaretNextWord, "more", "/link", 9},
|
||||
{CaretNextWord, "after.", "/body", 15},
|
||||
{CaretPreviousWord, "after.", "/body", 9},
|
||||
{CaretPreviousWord, "more", "/link", 5},
|
||||
} {
|
||||
move, err := cursor.MoveCaret(context.Background(), reader, step.command)
|
||||
if err != nil || move.Text != step.text || move.Position.Object.ID() != webID(step.path) || move.Position.Offset != step.offset {
|
||||
t.Fatalf("%s: text=%q offset=%d err=%v; want %q at %s:%d", step.command, move.Text, move.Position.Offset, err, step.text, step.path, step.offset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeCaretOnAdjacentEmbeddedLinks(t *testing.T) {
|
||||
reader := caretTextReader{webID("/body"): "A\ufffc\ufffcZ", webID("/link1"): "One", webID("/link2"): "Two"}
|
||||
node := func(path, role string, children ...string) atspi.TreeNode {
|
||||
s := atspi.NewSnapshot(path, "", role, "", "")
|
||||
s.BusName = webID(path).BusName
|
||||
s.CharacterCount = utf8.RuneCountInString(reader[webID(path)])
|
||||
n := atspi.TreeNode{Snapshot: s}
|
||||
for _, child := range children {
|
||||
n.Children = append(n.Children, webID(child))
|
||||
}
|
||||
return n
|
||||
}
|
||||
body := node("/body", "paragraph", "/link1", "/link2")
|
||||
body.Snapshot.TextChildren = []atspi.TextChild{{Offset: 1, Child: webID("/link1")}, {Offset: 2, Child: webID("/link2")}}
|
||||
tree, err := atspi.NewAccessibleTree(webID("/doc"), []atspi.TreeNode{node("/doc", "document web", "/body"), body, node("/link1", "link"), node("/link2", "link")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, cmd := range []DocumentCaretCommand{CaretNextCharacter, CaretPreviousCharacter} {
|
||||
cursor, err := NewDocumentCursor(tree)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = cursor.syncNativeCaret(context.Background(), reader, webID("/body"), 2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
move, err := cursor.MoveCaret(context.Background(), reader, cmd)
|
||||
t.Logf("command=%s text=%q object=%s offset=%d err=%v", cmd, move.Text, move.Position.Object.ID().Path, move.Position.Offset, err)
|
||||
want := "w"
|
||||
if cmd == CaretPreviousCharacter {
|
||||
want = "e"
|
||||
}
|
||||
if move.Text != want {
|
||||
t.Errorf("want %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentCaretEmbeddedNamedLinkKeepsLabelOffsets(t *testing.T) {
|
||||
cursor, reader := hypertextCursorWithLabel(t, "É猫 Info")
|
||||
for _, step := range []struct {
|
||||
command DocumentCaretCommand
|
||||
text, path string
|
||||
offset int
|
||||
}{
|
||||
{CaretDocumentStart, "Before ", "/body", 0},
|
||||
{CaretNextLine, "", "/link", 0},
|
||||
{CaretNextCharacter, "猫", "/link", 1},
|
||||
{CaretNextWord, "Info", "/link", 7},
|
||||
{CaretNextCharacter, " ", "/body", 8},
|
||||
{CaretPreviousCharacter, "o", "/link", 6},
|
||||
{CaretLineStart, "É", "/link", 0},
|
||||
{CaretPreviousCharacter, " ", "/body", 6},
|
||||
} {
|
||||
move, err := cursor.MoveCaret(context.Background(), reader, step.command)
|
||||
if err != nil || !move.Moved || move.Text != step.text || move.Position.Object.ID() != webID(step.path) || move.Position.Offset != step.offset {
|
||||
t.Fatalf("%s: text=%q at %s:%d err=%v; want %q at %s:%d", step.command, move.Text, move.Position.Object.ID().Path, move.Position.Offset, err, step.text, step.path, step.offset)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentCaretHypertextCannotEscapePasswordSubtree(t *testing.T) {
|
||||
cursor, _ := hypertextCursor(t)
|
||||
doc, err := cursor.tree.Document(context.Background(), cursor.places[0].object)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Even if editable state is missing, no nested hypertext may reintroduce
|
||||
// the text suppressed by the password ancestor.
|
||||
doc.Nodes[0].Snapshot.Role = "password text"
|
||||
if ranges := caretNodeRanges(doc.Nodes); len(ranges) != 0 {
|
||||
t.Fatalf("password subtree exposed caret ranges: %+v", ranges)
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,8 @@ import (
|
||||
|
||||
// Native selections can report their caret on a label/paragraph rather than
|
||||
// its static text child. Transfer offsets only when both expose the identical
|
||||
// text range; embedded or ambiguous child offsets must never be guessed.
|
||||
// text range. Captured hypertext parents own their direct-text offsets;
|
||||
// embedded or ambiguous child offsets must never be guessed.
|
||||
func (cursor *DocumentCursor) syncNativeCaret(ctx context.Context, reader DocumentCaretReader, focus atspi.ObjectID, offset int) error {
|
||||
if offset < 0 {
|
||||
return atspi.ErrNoDocumentCaret
|
||||
@@ -27,7 +28,7 @@ func (cursor *DocumentCursor) syncNativeCaret(ctx context.Context, reader Docume
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
node, err := native_caret_node(ctx, reader, doc, focus, offset)
|
||||
node, offset, err := native_caret_node(ctx, reader, doc, focus, offset)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -38,38 +39,56 @@ func (cursor *DocumentCursor) syncNativeCaret(ctx context.Context, reader Docume
|
||||
return nil
|
||||
}
|
||||
|
||||
func native_caret_node(ctx context.Context, reader DocumentCaretReader, doc atspi.DocumentSnapshot, focus atspi.ObjectID, offset int) (atspi.DocumentNode, error) {
|
||||
func native_caret_node(ctx context.Context, reader DocumentCaretReader, doc atspi.DocumentSnapshot, focus atspi.ObjectID, offset int) (atspi.DocumentNode, int, error) {
|
||||
nodes := make(map[atspi.ObjectID]atspi.DocumentNode, len(doc.Nodes))
|
||||
for _, node := range doc.Nodes {
|
||||
nodes[node.Reference.ID()] = node
|
||||
}
|
||||
node := nodes[focus]
|
||||
if !node.Reference.Valid() || offset < 0 || offset > node.Snapshot.CharacterCount || node.Snapshot.Editable || node.Snapshot.Role == "password text" {
|
||||
return atspi.DocumentNode{}, atspi.ErrNoDocumentCaret
|
||||
return atspi.DocumentNode{}, 0, atspi.ErrNoDocumentCaret
|
||||
}
|
||||
for len(node.Children) != 0 && !wholeCaretNode(node.Snapshot) {
|
||||
if len(node.Snapshot.TextChildren) != 0 {
|
||||
embedded := atspi.ObjectID{}
|
||||
for _, child := range node.Snapshot.TextChildren {
|
||||
if child.Offset == offset {
|
||||
embedded = child.Child
|
||||
break
|
||||
}
|
||||
}
|
||||
if !embedded.Valid() {
|
||||
return node, offset, nil
|
||||
}
|
||||
child, ok := nodes[embedded]
|
||||
if !ok || child.Hidden || child.Snapshot.Editable || child.Snapshot.Role == "password text" {
|
||||
return atspi.DocumentNode{}, 0, atspi.ErrNoDocumentCaret
|
||||
}
|
||||
node, offset = child, 0
|
||||
continue
|
||||
}
|
||||
if len(node.Children) != 1 {
|
||||
return atspi.DocumentNode{}, atspi.ErrNoDocumentCaret
|
||||
return atspi.DocumentNode{}, 0, atspi.ErrNoDocumentCaret
|
||||
}
|
||||
child, ok := nodes[node.Children[0]]
|
||||
if !ok || child.Hidden || child.Snapshot.Editable || child.Snapshot.CharacterCount != node.Snapshot.CharacterCount {
|
||||
return atspi.DocumentNode{}, atspi.ErrNoDocumentCaret
|
||||
return atspi.DocumentNode{}, 0, atspi.ErrNoDocumentCaret
|
||||
}
|
||||
probeOffset := max(0, min(offset, node.Snapshot.CharacterCount-1))
|
||||
parentText, err := reader.ReadDocumentCaret(ctx, node.Reference, probeOffset, atspi.DocumentCaretBrailleLine)
|
||||
if err != nil {
|
||||
return atspi.DocumentNode{}, err
|
||||
return atspi.DocumentNode{}, 0, err
|
||||
}
|
||||
childText, err := reader.ReadDocumentCaret(ctx, child.Reference, probeOffset, atspi.DocumentCaretBrailleLine)
|
||||
if err != nil {
|
||||
return atspi.DocumentNode{}, err
|
||||
return atspi.DocumentNode{}, 0, err
|
||||
}
|
||||
if parentText.Start != childText.Start || parentText.End != childText.End || parentText.Text == "" || parentText.Text != childText.Text {
|
||||
return atspi.DocumentNode{}, atspi.ErrNoDocumentCaret
|
||||
return atspi.DocumentNode{}, 0, atspi.ErrNoDocumentCaret
|
||||
}
|
||||
node = child
|
||||
}
|
||||
return node, nil
|
||||
return node, offset, nil
|
||||
}
|
||||
|
||||
// Use the navigation FIFO so a subsequent intercepted arrow sees the native
|
||||
|
||||
@@ -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
@@ -64,3 +64,4 @@ thunderbird-local-mail-composition,Orca src/orca/scripts/apps/Thunderbird/script
|
||||
flatpak-foreground-identity,src/cthulhu/script_utilities.py:canBeActiveWindow,focus,Correlate Flatpak accessibility providers with the X11 foreground across PID namespaces and D-Bus proxies,,internal/platform/xorg internal/atspi,in-progress,go test -race ./internal/platform/xorg ./internal/atspi ./internal/app -count=1; CTHULHU_FOREGROUND_PID=1 python3 tests/native_atspi.py,Private XRes lookup and fallback; real proxy with matching and different synthetic instance metadata; GTK Qt and Brave ownership checks,,Actual Flatpak Steam speech pending,Physical Braille pending; original report also has helper geometry errors,docs/testing/flatpak-foreground.md; preserves active state geometry and sibling ownership checks; no application name exception
|
||||
present-window-title,Orca src/orca/where_am_i_presenter.py;src/orca/where_am_i_presenter_command_definitions.py,command,Read the foreground window title with Cthulhu+Numpad Enter on desktop or Cthulhu+Slash on laptop,,internal/input internal/app internal/platform/xorg,in-progress,go test ./internal/input ./internal/app ./internal/platform/xorg -count=1; focused title command race checks; python3 tests/check_speech.py,Private Xvfb command dispatch verifies both layout bindings and both release orders. Private X11 and AT-SPI event loop repeats the current title with automatic changes disabled.,,Speech queue assertions passed; physical keyboard and audible acceptance pending.,Braille flash queue assertion passed; physical display acceptance pending.,Reuses copied foreground title metadata and the owned accessible title. Explicit requests bypass automatic-title policy and duplicate suppression without moving focus. Stale native epochs and unrelated native windows cannot supply a title. Lock and terminal handoff remain enforced. Double-press status-bar reading is outside this change. See tests/window_titles.md.
|
||||
edit-field-relation-labels,src/cthulhu/generator.py:_generateLabel;src/cthulhu/script_utilities.py:displayedLabel,speech,Announce relation-only labels for unnamed edit fields,,internal/atspi internal/presentation,implemented,CTHULHU_NATIVE_EDIT_LABELS=1 python3 tests/native_atspi.py; go test ./... -count=1; go vet ./...,Private GTK/Xvfb verifies single and multiple labels plus existing names and password exclusion. Read-only live irctest capture verifies all three settings labels. Maintainer confirmed installed-reader label speech on physical Xorg on 2026-10-20.,,Formatted speech includes each label. Maintainer confirmed the installed reader speaks irctest settings field labels on 2026-10-20.,Physical Braille check pending.,Direct LABELLED_BY targets supply an empty text or entry or password-field name. Queries and output are bounded; explicit names are preserved. Human diff review remains required before submission.
|
||||
mozilla-hypertext-reading,src/cthulhu/scripts/web/script.py;src/cthulhu/scripts/web/script_utilities.py,web,Read native heading and link text plus parent text around embedded children without Tab caret echoes interrupting link labels,,internal/atspi internal/review internal/app,in-progress,"go test ./... -count=1; go test -race ./internal/review ./internal/atspi ./internal/app -count=1; python3 tests/native_firefox.py; CTHULHU_NATIVE_BROWSER_TESTS=fragments,document,selection,namedlinks python3 tests/native_browser.py",Merge review on 2026-10-20: four parent-text regressions fail against testing before the merge and pass after resolution. Private Firefox baseline and Chromium fragments document selection named-link and editing checks pass.,,Recorded control-event assertions preserve full link focus output and subsequent arrow feedback. Original Thunderbird message and audible acceptance remain pending.,Repeated parent-fragment cursor and native routing offsets tested; physical Braille pending.,Hypertext traversal retains parent offsets around single-character embedded children; named-link labels retain virtual character and word offsets. Password subtrees remain excluded. Read and Tab through an HTML email; reverse direction; verify Firefox and Chromium links selection and address-bar editing before submission. Human diff review and original-message live acceptance remain required.
|
||||
|
@@ -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