diff --git a/internal/app/browser_disabled_fields_native_test.go b/internal/app/browser_disabled_fields_native_test.go new file mode 100644 index 0000000..5cb0627 --- /dev/null +++ b/internal/app/browser_disabled_fields_native_test.go @@ -0,0 +1,117 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "os" + "os/exec" + "strings" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/review" +) + +func TestNativeBrowserDisabledFieldsRemainReadable(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" { + t.Skip("requires private browser session") + } + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) + defer cancel() + adapter := atspi.New() + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer adapter.Stop(context.Background()) + command := func(args ...string) { + t.Helper() + if output, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil { + t.Fatalf("xdotool: %v: %s", err, output) + } + } + windows, err := exec.CommandContext(ctx, "xdotool", "search", "--onlyvisible", "--name", "Cthulhu Browser Probe").Output() + if err != nil || len(strings.Fields(string(windows))) == 0 { + t.Fatalf("fixture window: %s %v", windows, err) + } + command("windowfocus", strings.Fields(string(windows))[0]) + var focus atspi.ObjectID + for attempt := 0; attempt < 12 && !focus.Valid(); attempt++ { + command("windowfocus", strings.Fields(string(windows))[0]) + command("key", "--clearmodifiers", "Tab") + timer := time.NewTimer(300 * time.Millisecond) + waitFocus: + for { + select { + case event := <-adapter.Events(): + if (event.Type == atspi.FocusEvent || event.Type == atspi.FocusedStateEvent && event.Detail1 == 1) && event.Source.Name == "Read only answer" { + focus = event.Source.ID() + break waitFocus + } + case <-timer.C: + break waitFocus + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + timer.Stop() + } + if !focus.Valid() { + t.Fatal("fixture focus missing") + } + // Capture before the transition to exercise invalidation of a retained tree. + if _, err := adapter.CaptureDocument(ctx, focus); err != nil { + t.Fatal(err) + } + command("windowfocus", strings.Fields(string(windows))[0]) + command("key", "F9") + for { + select { + case event := <-adapter.Events(): + if event.Type != "object:state-changed:sensitive" || event.Source.Name != "Answer" || event.Detail1 != 0 { + continue + } + case <-ctx.Done(): + t.Fatal("disabled state update missing: ", ctx.Err()) + } + break + } + worker, err := review.NewWorker(adapter, 2*time.Second) + if err != nil { + t.Fatal(err) + } + if err := worker.Start(ctx); err != nil { + t.Fatal(err) + } + defer worker.Stop(context.Background()) + worker.SyncFocus(focus) + for _, step := range []struct { + command review.DocumentCaretCommand + want string + }{ + {review.CaretDocumentStart, "Before fields"}, + {review.CaretNextLine, ""}, // Keep the named row, then enter its disabled fields. + {review.CaretNextLine, "Solved"}, + {review.CaretNextCharacter, "o"}, + {review.CaretNextLine, "Confirmed"}, + {review.CaretNextLine, "After fields"}, + {review.CaretPreviousLine, "Confirmed"}, + {review.CaretPreviousLine, "Solved"}, + } { + if err := worker.MoveDocumentCaret(step.command); err != nil { + t.Fatal(err) + } + select { + case result := <-worker.Results(): + if result.Err != nil || !result.Moved || result.DocumentCaretText != step.want { + t.Fatalf("%s: text=%q moved=%t err=%v; want %q", step.command, + result.DocumentCaretText, result.Moved, result.Err, step.want) + } + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } +} diff --git a/internal/atspi/adapter_capture.c b/internal/atspi/adapter_capture.c index 1188024..8ceebae 100644 --- a/internal/atspi/adapter_capture.c +++ b/internal/atspi/adapter_capture.c @@ -924,6 +924,7 @@ static void cthulhu_capture_document_metrics(AtspiAccessible *source, cthulhu_sn g_strcmp0(role, "row header") != 0 && g_strcmp0(role, "table cell") != 0 && g_strcmp0(role, "table row") != 0 && + g_strcmp0(role, "entry") != 0 && g_strcmp0(role, "text") != 0 && g_strcmp0(role, "section") != 0 && g_strcmp0(role, "article") != 0 && diff --git a/internal/review/document_caret_container_test.go b/internal/review/document_caret_container_test.go new file mode 100644 index 0000000..061e627 --- /dev/null +++ b/internal/review/document_caret_container_test.go @@ -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) + } + } +} diff --git a/internal/review/document_caret_nodes.go b/internal/review/document_caret_nodes.go index a5641f9..7b0f7f0 100644 --- a/internal/review/document_caret_nodes.go +++ b/internal/review/document_caret_nodes.go @@ -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 diff --git a/internal/review/document_caret_ranges.go b/internal/review/document_caret_ranges.go index 739be43..c3566c7 100644 --- a/internal/review/document_caret_ranges.go +++ b/internal/review/document_caret_ranges.go @@ -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 } } diff --git a/tests/browser-disabled-fields.html b/tests/browser-disabled-fields.html new file mode 100644 index 0000000..4529410 --- /dev/null +++ b/tests/browser-disabled-fields.html @@ -0,0 +1,14 @@ +
Before fields
+After fields
+ diff --git a/tests/native_browser.py b/tests/native_browser.py index beb935f..55985a6 100644 --- a/tests/native_browser.py +++ b/tests/native_browser.py @@ -12,6 +12,7 @@ from native_test_session import run_private_session BROWSER_PROOFS = { + "disabled-fields": ("./internal/app", "^TestNativeBrowserDisabledFieldsRemainReadable$", 40, 70, "browser-disabled-fields.html"), "live-atomic": ("./internal/atspi", "^TestNativeBrowserAtomicLiveRegion$", 30, 35, "browser-live.html"), "replaced-feed": ("./internal/atspi", "^TestNativeBrowserMissingFeedRefreshesWholeBranch$", 30, 35, "browser-replaced-feed.html"), "nested-document": ("./internal/app", "^TestNativeBrowserNestedDocumentHeadings$", 40, 45),