Name unnamed clickables from descendant text on structural landings

This commit is contained in:
hjozwiak committed 2026-11-11 07:42:50 -05:00
1 parent 469ed7c549
commit bebde8fd5e
3 files changed
+119 -24

No files matched your search

+13
View File
@@ -584,6 +584,19 @@ func documentPosition(doc atspi.DocumentSnapshot, place documentPlace) DocumentP
position.LinkedHeading = &link.Snapshot
}
}
// Unnamed clickables often expose their readable label only in static
// descendants. Name the returned snapshot copy from those descendants
// so a structural landing speaks more than the bare role; the captured
// tree and caret navigation into the descendants are unchanged.
if place.origin == StructuralNavigation && snapshot.SupportsClick && !snapshot.Editable &&
snapshot.Role != "password text" && !atspi.IsWebDocumentRole(snapshot.Role) &&
strings.TrimSpace(snapshot.Name) == "" && strings.TrimSpace(snapshot.Text) == "" {
if index, ok := doc.Index(place.object.ID()); ok {
if label := documentDescendantLabel(doc, index); label != "" {
position.Snapshot.Name = label
}
}
}
return position
}
+55 -24
View File
@@ -140,31 +140,11 @@ func (worker *Worker) listDocument(ctx context.Context, cursor *DocumentCursor,
if node.Hidden {
continue
}
label := node.Snapshot.Name
if node.Snapshot.Role != "password text" {
if label == "" {
label = node.Snapshot.Text
}
if label == "" {
var parts []string
size := 0
for _, child := range node.Children {
if part := labels[child]; part != "" {
parts = append(parts, part)
size += len(part)
}
if size >= 900 {
break
}
}
label = strings.Join(parts, " ")
}
var childLabels []string
for _, child := range node.Children {
childLabels = append(childLabels, labels[child])
}
runes := []rune(label)
if len(runes) > 225 {
label = string(runes[:225])
}
labels[node.Reference.ID()] = label
labels[node.Reference.ID()] = documentNodeLabel(node.Snapshot, childLabels)
}
parents := make(map[atspi.ObjectID]atspi.ObjectID, len(doc.Nodes))
indices := make(map[atspi.ObjectID]int)
@@ -188,6 +168,57 @@ func (worker *Worker) listDocument(ctx context.Context, cursor *DocumentCursor,
return list, captureContext.Err()
}
// documentNodeLabel is the bounded readable label for one captured node:
// its own name, else its text, else its children's labels in provider
// order. Password text never contributes its text or descendants.
func documentNodeLabel(snapshot atspi.Snapshot, childLabels []string) string {
label := snapshot.Name
if snapshot.Role != "password text" {
if label == "" {
label = snapshot.Text
}
if label == "" {
var parts []string
size := 0
for _, part := range childLabels {
if part != "" {
parts = append(parts, part)
size += len(part)
}
if size >= 900 {
break
}
}
label = strings.Join(parts, " ")
}
}
runes := []rune(label)
if len(runes) > 225 {
label = string(runes[:225])
}
return label
}
// documentDescendantLabel assembles the label for one node's subtree using
// the same policy as document lists, without labeling the whole document.
// Hidden subtrees contribute nothing.
func documentDescendantLabel(doc atspi.DocumentSnapshot, index int) string {
end := doc.SubtreeEnd(index)
labels := make(map[atspi.ObjectID]string, end-index+1)
for i := end; i >= index; i-- {
node := doc.Nodes[i]
if node.Hidden {
continue
}
var childLabels []string
for _, child := range node.Children {
childLabels = append(childLabels, labels[child])
}
labels[node.Reference.ID()] = documentNodeLabel(node.Snapshot, childLabels)
}
return labels[doc.Nodes[index].Reference.ID()]
}
func (worker *Worker) currentDocumentList(list DocumentListSnapshot) bool {
worker.stateMutex.Lock()
defer worker.stateMutex.Unlock()
+51
View File
@@ -112,6 +112,57 @@ func TestDocumentListDescendantLabelsExcludeHiddenAndPasswordText(t *testing.T)
}
}
func TestDocumentClickableMoveNamesUnnamedSectionFromDescendants(t *testing.T) {
node := func(path, role, text string, children ...string) atspi.TreeNode {
snapshot := atspi.Snapshot{BusName: webID(path).BusName, Path: path, Role: role, Text: text}
entry := atspi.TreeNode{Snapshot: snapshot}
for _, child := range children {
entry.Children = append(entry.Children, webID(child))
}
return entry
}
nodes := []atspi.TreeNode{
node("/doc", "document web", "", "/start", "/clickable"),
node("/start", "static", "Before"),
node("/clickable", "section", "", "/text", "/hidden", "/password"),
node("/text", "static", "Clickable text"),
node("/hidden", "static", "Hidden content"),
node("/password", "password text", "private fixture password"),
}
nodes[2].Snapshot.SupportsClick = true
nodes[4].Geometry = atspi.Geometry{VisibilityKnown: true}
tree, err := atspi.NewAccessibleTree(webID("/doc"), nodes)
if err != nil {
t.Fatal(err)
}
cursor, err := NewDocumentCursor(tree)
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
if _, err := cursor.SyncFocus(ctx, webID("/start")); err != nil {
t.Fatal(err)
}
move, err := cursor.Move(ctx, DocumentClickable, 0, false)
if err != nil || !move.Moved || move.Position.Object.ID() != webID("/clickable") {
t.Fatalf("clickable move: %+v %v", move, err)
}
if move.Position.Snapshot.Name != "Clickable text" {
t.Fatalf("landed snapshot name = %q, want descendant label", move.Position.Snapshot.Name)
}
ref, err := tree.Reference(webID("/clickable"))
if err != nil {
t.Fatal(err)
}
captured, err := tree.Resolve(ref)
if err != nil {
t.Fatal(err)
}
if captured.Name != "" {
t.Fatalf("captured snapshot mutated: name = %q", captured.Name)
}
}
func TestWorkerDocumentListUsesOrderedRequests(t *testing.T) {
tree, _ := webCursor(t)
worker, cancel := startWorker(t, &readAllCapturer{tree: tree}, time.Second)