Fix some issues with disabled edit fields.

This commit is contained in:
lilmike committed 2026-10-27 09:20:20 -07:00
1 parent d8b59aa05b
commit 7d720e131d
7 files changed
+240 -11

No files matched your search

@@ -0,0 +1,71 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package review
import (
"context"
"testing"
"git.stormux.org/storm/cthulhu/internal/atspi"
)
func TestDocumentCaretReadsFieldsInsideNamedClickableContainer(t *testing.T) {
reader := caretTextReader{webID("/before"): "Before", webID("/letter"): "A", webID("/after"): "After"}
node := func(path, role, name string, count int, children ...string) atspi.TreeNode {
snapshot := atspi.NewSnapshot(path, name, role, "", "")
snapshot.BusName, snapshot.CharacterCount = webID(path).BusName, count
result := atspi.TreeNode{Snapshot: snapshot}
for _, child := range children {
result.Children = append(result.Children, webID(child))
}
return result
}
container := node("/row", "section", "Completed row", 2, "/dragger", "/cells")
container.Snapshot.SupportsClick, container.Snapshot.ExplicitName, container.Snapshot.Focusable = true, true, true
container.Snapshot.TextChildren = []atspi.TextChild{{Offset: 0, Child: webID("/dragger")}, {Offset: 1, Child: webID("/cells")}}
entry := node("/letter", "entry", "Letter", 1)
entry.Snapshot.Editable, entry.Snapshot.SensitiveKnown = true, true // Disabled fields can retain editable state.
tree, err := atspi.NewAccessibleTree(webID("/doc"), []atspi.TreeNode{
node("/doc", "document web", "", 0, "/before", "/row", "/after"),
node("/before", "static", "", 6), container,
node("/dragger", "button", "Reorder row", 0, "/icon"),
node("/icon", "image", "Reorder icon", 0),
node("/cells", "section", "", 0, "/letter", "/password"), entry,
node("/password", "password text", "Secret", 0, "/secret"),
node("/secret", "static", "Private fixture", 15),
node("/after", "static", "", 5),
})
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("/before")); err != nil {
t.Fatal(err)
}
for _, step := range []struct {
command DocumentCaretCommand
path, text string
}{
{CaretNextLine, "/row", ""},
{CaretNextCharacter, "/dragger", ""},
{CaretNextCharacter, "/letter", "A"},
{CaretReadLine, "/letter", "A"},
{CaretNextLine, "/after", "After"},
{CaretPreviousLine, "/letter", "A"},
{CaretPreviousCharacter, "/dragger", ""},
{CaretPreviousLine, "/row", ""},
{CaretPreviousLine, "/before", "Before"},
} {
move, err := cursor.MoveCaret(ctx, reader, step.command)
if err != nil || !move.Moved || move.Position.Object.ID() != webID(step.path) || move.Text != step.text {
t.Fatalf("%s: object=%s text=%q moved=%t err=%v; want %s %q", step.command,
move.Position.Object.ID().Path, move.Text, move.Moved, err, step.path, step.text)
}
}
}
+34 -9
View File
@@ -14,18 +14,22 @@ import (
)
func wholeCaretNode(snapshot atspi.Snapshot) bool {
if wholeCaretControl(snapshot) {
return true
}
// An explicitly named custom clickable is an atomic stop just like a
// named link. Ordinary clickable text remains readable by character/word.
return snapshot.SupportsClick && snapshot.ExplicitName && strings.TrimSpace(snapshot.Name) != "" &&
!snapshot.Editable && !atspi.IsWebDocumentRole(snapshot.Role)
}
func wholeCaretControl(snapshot atspi.Snapshot) bool {
if snapshot.Role == "link" {
return snapshot.ExplicitName && strings.TrimSpace(snapshot.Name) != ""
}
if snapshot.Role == "image" {
return snapshot.Name != "" || snapshot.Description != ""
}
// An explicitly named custom clickable is an atomic stop just like a
// named link. Ordinary clickable text remains readable by character/word.
if snapshot.SupportsClick && snapshot.ExplicitName && strings.TrimSpace(snapshot.Name) != "" &&
!snapshot.Editable && !atspi.IsWebDocumentRole(snapshot.Role) {
return true
}
switch snapshot.Role {
case "button", "check box", "check menu item", "list box", "menu item", "page tab",
"radio menu item", "radio button", "push button", "toggle button":
@@ -44,7 +48,7 @@ func passive_caret_tooltip(snapshot atspi.Snapshot) bool {
// caret_node_indices preserves provider order and treats whole controls as one
// stop. MoveCaret holds the document cursor lock while using these indices.
func caret_node_indices(nodes []atspi.DocumentNode, object atspi.ObjectReference) ([]int, int) {
func caret_node_indices(nodes []atspi.DocumentNode, object atspi.ObjectReference) ([]int, int, map[atspi.ObjectID]bool) {
// Container text normally embeds its child text. Whole controls are one
// caret stop even when their label is a child or they have no text interface.
// Python's web _treatObjectAsWhole makes the same distinction.
@@ -52,6 +56,25 @@ func caret_node_indices(nodes []atspi.DocumentNode, object atspi.ObjectReference
current := -1
skipped := make(map[atspi.ObjectID]bool)
protected := make(map[atspi.ObjectID]bool, len(nodes))
// Named custom clickables can contain independent controls, including
// disabled entries. Keep the container's label as a stop without pruning
// those controls. Ordinary labels and semantic controls remain atomic.
controls := make(map[atspi.ObjectID]bool)
for index := len(nodes) - 1; index >= 0; index-- {
node := nodes[index]
snapshot := node.Snapshot
field := documentFormFieldRole(snapshot) &&
(snapshot.Role != "text" || snapshot.Editable || snapshot.Focusable || snapshot.ReadOnly)
if !node.Hidden && (controls[node.Reference.ID()] || field || snapshot.Editable || snapshot.Role == "link") {
controls[node.Parent] = true
}
}
expanded := make(map[atspi.ObjectID]bool)
for _, node := range nodes {
if controls[node.Reference.ID()] && wholeCaretNode(node.Snapshot) && !wholeCaretControl(node.Snapshot) {
expanded[node.Reference.ID()] = true
}
}
var skipChildren func([]atspi.ObjectID)
children := make(map[atspi.ObjectID][]atspi.ObjectID, len(nodes))
for _, node := range nodes {
@@ -90,7 +113,9 @@ func caret_node_indices(nodes []atspi.DocumentNode, object atspi.ObjectReference
}
if wholeCaretNode(node.Snapshot) {
indices = append(indices, index)
skipChildren(node.Children)
if !expanded[node.Reference.ID()] {
skipChildren(node.Children)
}
} else if node.Snapshot.Editable &&
(node.Snapshot.Role == "entry" || node.Snapshot.Role == "text") &&
(node.Snapshot.CharacterCount == 0 || len(node.Children) == 0) {
@@ -102,7 +127,7 @@ func caret_node_indices(nodes []atspi.DocumentNode, object atspi.ObjectReference
indices = append(indices, index)
}
}
return indices, current
return indices, current, expanded
}
// Python's web hasValidName/isUselessEmptyElement rejects standalone unnamed
+2 -2
View File
@@ -18,7 +18,7 @@ import (
type caretRange struct{ index, start, end int }
func caretNodeRanges(nodes []atspi.DocumentNode) []caretRange {
indices, _ := caret_node_indices(nodes, atspi.ObjectReference{})
indices, _, expanded := 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 {
@@ -74,7 +74,7 @@ func caretNodeRanges(nodes []atspi.DocumentNode) []caretRange {
}
if eligible[index] {
result = append(result, caretRange{index, 0, caret_text_count(node.Snapshot)})
if wholeCaretNode(node.Snapshot) {
if wholeCaretNode(node.Snapshot) && !expanded[node.Reference.ID()] {
return
}
}