diff --git a/README.md b/README.md index 9786b03..688d646 100644 --- a/README.md +++ b/README.md @@ -288,6 +288,13 @@ line. Left/Right, Ctrl+Left/Right, and Home/End review that label by character, word, or boundary, including links with no native text or only a numeric badge. The label's review position remains separate from the browser's text caret. +Browse arrows, word navigation and Read All skip non-focusable, non-editable +tooltips and their contents. This avoids repeatedly entering a tooltip that +disappears when native caret placement moves focus away from its owner. +Focusable or editable tooltip contents remain navigable. Verify the native +focus-triggered case with +`CTHULHU_NATIVE_BROWSER_TESTS=tooltips python3 tests/native_browser.py`. + Enter activates the current link or clickable, including explicitly named custom controls and clickable ancestors of ordinary text. Activation itself is silent whether it succeeds or fails; the page's own focus changes and live regions still diff --git a/internal/app/browser_tooltip_native_test.go b/internal/app/browser_tooltip_native_test.go new file mode 100644 index 0000000..0dc8547 --- /dev/null +++ b/internal/app/browser_tooltip_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" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/review" +) + +func TestNativeBrowserTooltipNavigation(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() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `Tooltip navigation regression +`) + })) + defer server.Close() + 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) + } + } + command("key", "--clearmodifiers", "ctrl+l") + command("type", "--clearmodifiers", "--delay", "0", server.URL) + command("key", "Return") + var document atspi.ObjectID + for !document.Valid() { + select { + case event := <-adapter.Events(): + if event.Type == atspi.DocumentLoadCompleteEvent && event.Source.Name == "Tooltip navigation regression" { + document = event.Source.ID() + } + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + 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(document) + move := func(command review.DocumentCaretCommand, want string) { + t.Helper() + if err := worker.MoveDocumentCaret(command); err != nil { + t.Fatal(err) + } + select { + case result := <-worker.Results(): + if result.Err != nil || result.CaretErr != nil || !result.Moved || result.DocumentCaretText != want { + t.Fatalf("%s: text=%q want=%q err=%v caret=%v diagnostics=%+v", command, + result.DocumentCaretText, want, result.Err, result.CaretErr, result.DocumentDiagnostics) + } + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + move(review.CaretNextLine, "Example project") + // Capture after focus has exposed the tooltip. This proves the fixture + // supplies a real native tooltip subtree, rather than merely hidden text. + for { + tree, err := adapter.CaptureDocument(ctx, document) + if err != nil { + t.Fatal(err) + } + ref, _ := tree.Reference(document) + doc, err := tree.Document(ctx, ref) + found := false + if err == nil { + for _, node := range doc.Nodes { + if node.Snapshot.Role == "tool tip" && !node.Hidden && len(node.Children) > 0 { + found = true + } + } + } + if found { + break + } + select { + case <-adapter.Events(): + case <-ctx.Done(): + t.Fatal("visible native tooltip missing: ", ctx.Err()) + } + } + move(review.CaretNextLine, "After project") + move(review.CaretPreviousLine, "Example project") + move(review.CaretLineEnd, "t") + move(review.CaretNextCharacter, "A") + move(review.CaretPreviousCharacter, "t") +} diff --git a/internal/review/document_caret_nodes.go b/internal/review/document_caret_nodes.go index 12db2f7..a5641f9 100644 --- a/internal/review/document_caret_nodes.go +++ b/internal/review/document_caret_nodes.go @@ -34,6 +34,14 @@ func wholeCaretNode(snapshot atspi.Snapshot) bool { return false } +// Passive tooltips appear when their owner receives focus and disappear when +// caret placement moves focus away. Entering their text can strand navigation +// on a removed object and repeatedly recover to the owner's container. Match +// Orca's web caret policy while retaining focusable/editable tooltip content. +func passive_caret_tooltip(snapshot atspi.Snapshot) bool { + return snapshot.Role == "tool tip" && !snapshot.Focusable && !snapshot.Editable +} + // 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) { @@ -73,7 +81,7 @@ func caret_node_indices(nodes []atspi.DocumentNode, object atspi.ObjectReference if node.Hidden || skipped[node.Reference.ID()] { continue } - if snapshot.Role == "password text" { + if snapshot.Role == "password text" || passive_caret_tooltip(snapshot) { skipChildren(node.Children) continue } diff --git a/internal/review/document_caret_ranges.go b/internal/review/document_caret_ranges.go index 4717d6b..e707c4c 100644 --- a/internal/review/document_caret_ranges.go +++ b/internal/review/document_caret_ranges.go @@ -31,7 +31,7 @@ func caretNodeRanges(nodes []atspi.DocumentNode) []caretRange { var visit func(int) visit = func(index int) { node := nodes[index] - if node.Hidden || node.Snapshot.Role == "password text" { + if node.Hidden || node.Snapshot.Role == "password text" || passive_caret_tooltip(node.Snapshot) { return } children := node.Snapshot.TextChildren diff --git a/internal/review/document_tooltip_test.go b/internal/review/document_tooltip_test.go new file mode 100644 index 0000000..179d34d --- /dev/null +++ b/internal/review/document_tooltip_test.go @@ -0,0 +1,101 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package review + +import ( + "context" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" +) + +func tooltip_cursor(t *testing.T, focusable, editable, hypertext bool) (*DocumentCursor, caretTextReader) { + t.Helper() + reader := caretTextReader{webID("/before"): "Project", webID("/tiptext"): "Project", webID("/after"): "After"} + node := func(path, role string, children ...atspi.ObjectID) atspi.TreeNode { + return atspi.TreeNode{Snapshot: atspi.Snapshot{BusName: webID(path).BusName, Path: path, + Role: role, StatesKnown: true, CharacterCount: len(reader[webID(path)])}, Children: children} + } + tip := node("/tip", "tool tip", webID("/tiptext")) + tip.Snapshot.Focusable, tip.Snapshot.Editable = focusable, editable + if hypertext { + // Text around an embedded child must not bypass subtree exclusion. + tip.Snapshot.CharacterCount = 3 + tip.Snapshot.TextChildren = []atspi.TextChild{{Offset: 1, Child: webID("/tiptext")}} + reader[webID("/tip")] = "x\ufffcy" + } + tree, err := atspi.NewAccessibleTree(webID("/doc"), []atspi.TreeNode{ + node("/doc", "document web", webID("/before"), webID("/tip"), webID("/after")), + node("/before", "static"), tip, node("/tiptext", "static"), node("/after", "static"), + }) + if err != nil { + t.Fatal(err) + } + cursor, err := NewDocumentCursor(tree) + if err != nil { + t.Fatal(err) + } + if _, err := cursor.SyncFocus(context.Background(), webID("/before")); err != nil { + t.Fatal(err) + } + return cursor, reader +} + +func TestDocumentCaretSkipsPassiveTooltip(t *testing.T) { + for _, hypertext := range []bool{false, true} { + for _, command := range []DocumentCaretCommand{CaretNextLine, CaretNextCharacter, CaretNextWord} { + t.Run(command.String(), func(t *testing.T) { + cursor, reader := tooltip_cursor(t, false, false, hypertext) + if command != CaretNextLine { + cursor.places[0].offset = len("Project") - 1 + } + move, err := cursor.MoveCaret(context.Background(), reader, command) + if err != nil || !move.Moved || move.Position.Object.ID() != webID("/after") { + t.Fatalf("forward: %+v, %v", move, err) + } + reverse := CaretPreviousLine + if command == CaretNextCharacter { + reverse = CaretPreviousCharacter + } else if command == CaretNextWord { + reverse = CaretPreviousWord + } + cursor.places[0].offset = 0 + move, err = cursor.MoveCaret(context.Background(), reader, reverse) + if err != nil || !move.Moved || move.Position.Object.ID() != webID("/before") { + t.Fatalf("backward: %+v, %v", move, err) + } + }) + } + } +} + +func TestDocumentCaretPreservesInteractiveTooltip(t *testing.T) { + for _, focusable := range []bool{false, true} { + cursor, reader := tooltip_cursor(t, focusable, !focusable, false) + move, err := cursor.MoveCaret(context.Background(), reader, CaretNextLine) + if err != nil || !move.Moved || move.Position.Object.ID() != webID("/tiptext") { + t.Fatalf("interactive tooltip: %+v, %v", move, err) + } + } +} + +func TestReadAllSkipsPassiveTooltip(t *testing.T) { + cursor, reader := tooltip_cursor(t, false, false, true) + worker, err := NewWorker(&readAllCapturer{tree: cursor.tree, caretTextReader: reader}, time.Second) + if err != nil { + t.Fatal(err) + } + worker.SyncFocus(webID("/before")) + result := worker.readAll(context.Background(), cursor) + if result.Err != nil || len(result.ReadAllLines) != 2 { + t.Fatalf("Read All: %+v", result) + } + for index, want := range []string{"Project", "After"} { + if got := result.ReadAllLines[index].Contents[0].Text; got != want { + t.Fatalf("line %d = %q, want %q", index, got, want) + } + } +} diff --git a/tests/native_browser.py b/tests/native_browser.py index 380b1f7..7f4e0f2 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 = { + "tooltips": ("./internal/app", "^TestNativeBrowserTooltipNavigation$", 30, 35), "menu": ("./internal/app", "^TestNativeBrowserMenuControls$", 30, 35), "lists": ("./internal/app", "^TestNativeBrowserLists$", 35, 40, "browser-lists.html"), "pointer": ("./internal/atspi", "^TestNativePointerInput$", 15, 20),