From 423590e11abde6c16312f370b8b63b18682c3fc4 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Fri, 16 Oct 2026 19:33:18 -0400 Subject: [PATCH] Browser updates. --- README.md | 36 ++++ internal/app/browser_button_names.go | 69 +++++++ internal/app/browser_button_names_test.go | 173 ++++++++++++++++++ internal/app/browser_dynamic_test.go | 45 ++++- internal/app/browser_mode.go | 62 ++++--- internal/app/browser_mode_test.go | 117 ++++++++++++ internal/app/document_native_test.go | 48 ++++- internal/app/event_accessibility.go | 11 ++ internal/app/event_focus.go | 3 + internal/app/event_loop.go | 3 + internal/app/event_navigation_filters.go | 8 - internal/app/event_review_results.go | 5 +- internal/app/pending_presentation.go | 8 +- internal/atspi/adapter.go | 6 + internal/atspi/adapter_document_native.go | 31 +++- .../atspi/adapter_event_callback_native.go | 9 + internal/atspi/adapter_lifecycle_native.go | 32 ++-- internal/atspi/adapter_references_native.go | 2 +- internal/atspi/adapter_tree_test.go | 46 +++++ internal/atspi/pointer_native_test.go | 42 +++++ internal/input/intent.go | 8 +- internal/input/intent_test.go | 9 + internal/review/document.go | 7 + internal/review/document_caret.go | 10 +- internal/review/document_native_caret.go | 1 + internal/review/document_replacement.go | 56 ++++++ internal/review/document_replacement_test.go | 70 +++++++ tests/browser-changing.html | 29 +++ tests/native_browser.py | 1 + tests/native_browser_navigation.py | 164 +++++++++++++++++ tests/native_echo_preview.py | 15 +- 31 files changed, 1056 insertions(+), 70 deletions(-) create mode 100644 internal/app/browser_button_names.go create mode 100644 internal/app/browser_button_names_test.go create mode 100644 internal/atspi/pointer_native_test.go create mode 100644 internal/review/document_replacement.go create mode 100644 internal/review/document_replacement_test.go create mode 100644 tests/browser-changing.html create mode 100644 tests/native_browser_navigation.py diff --git a/README.md b/README.md index a5a4043..cf5d5e3 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,15 @@ matching current X11 foreground metadata; without that evidence it stays quiet. Wine insertion feedback uses the custom key voice when character echo is off, and the custom character voice when character echo is on. +## Browser button updates + +Web buttons announce their current label when focused. Automatic label changes +wait for 350 milliseconds without another change before speaking, so a rapidly +updating counter does not continually interrupt reading. Braille receives each +update immediately. Moving focus, navigating, changing tabs or windows, and +locking the session discard pending announcements. Live regions retain their +existing announcement behavior. + ## Wine Access Install the separate `wine-access` helper for Wine application accessibility. @@ -125,3 +134,30 @@ parent is an administrator or session-launcher responsibility, independent of any init system. An invalid explicit parent fails startup rather than silently ignoring the requested limits. Without that configuration, supervision works without kernel resource quotas; it does not provide a security sandbox. + +## Browser navigation + +Heading and browse-caret commands retain the virtual reading position while +the browser reports delayed native caret events. Each reader command takes +ownership before its result arrives. Native keyboard input, text selection, +pointer presses, document loads and leaving the document release that ownership. +An unsuccessful native caret placement does not undo a successful virtual move. + +When a page replaces a paragraph or heading's only plain text child, navigation +can retain its offset through the same surviving parent and document generation. +Ambiguous children, editable controls and replaced parents do not inherit it. + +The generic changing-document regression starts a private reader, browser +profile, X display, accessibility bus and speech protocol recorder: + +```sh +go build -o /tmp/cthulhu-browser-review ./cmd/cthulhu +CTHULHU_BINARY=/tmp/cthulhu-browser-review python3 tests/native_browser_navigation.py +CTHULHU_NATIVE_BROWSER_TESTS=pointer,positions,dynamic,linkedheadings,document,editing,selection,tabs,modes python3 tests/native_browser.py +``` + +These checks leave the desktop reader unchanged. They verify synthetic X11 +input and recorded speech requests; real-page listening, physical keyboard +and Braille checks remain necessary. On an updating page, traverse headings in +both directions, pause and continue, read changing lines with arrows and word +commands, then verify Tab, selection, editable fields and browser chrome. diff --git a/internal/app/browser_button_names.go b/internal/app/browser_button_names.go new file mode 100644 index 0000000..7ee27b0 --- /dev/null +++ b/internal/app/browser_button_names.go @@ -0,0 +1,69 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/input" +) + +const browserButtonNameDelay = 350 * time.Millisecond + +// The event loop retains only the latest focused web button name. Focus and +// Braille update immediately; speech waits for the label to settle. Document +// generation and input identity prevent a delayed name from interrupting a +// later action, including leaving and returning to the same object. +type pendingBrowserButtonName struct { + pendingPresentation + document atspi.ObjectID + generation uint64 + inputSequence uint64 +} + +func (handler *accessibilityEventHandler) defer_browser_button_name(event atspi.Event, activity input.Activity) bool { + browser := handler.browser + if event.Type != atspi.AccessibleNameEvent || event.Source.LiveRegion || event.Source.Editable || + browser == nil || !browser.ready || !browser.document.Valid() || + event.Source.BusName != browser.document.BusName || event.Source.ID() != browser.focus || + event.Source.ID() != handler.state.focusedID { + return false + } + switch event.Source.Role { + case "button", "push button", "toggle button": + default: + return false + } + pending := &handler.state.pending.browserButtonName + // Duplicate notifications must not postpone an already settled label. + if pending.event.Source.ID() == event.Source.ID() && pending.event.Source.Name == event.Source.Name && + pending.document == browser.document && pending.generation == browser.generation && + pending.inputSequence == activity.TitleInterruptionSequence { + return true + } + pending.schedule(event, input.NoIntent, browserButtonNameDelay) + pending.document, pending.generation = browser.document, browser.generation + pending.inputSequence = activity.TitleInterruptionSequence + handler.logger.Debug("coalesced focused web button name", "bus", event.Source.BusName, "source_path", event.Source.Path) + return true +} + +func (handler *accessibilityEventHandler) flush_browser_button_name() { + pending := &handler.state.pending.browserButtonName + document, generation, inputSequence := pending.document, pending.generation, pending.inputSequence + event, _ := pending.take() + browser := handler.browser + if event.Type == "" || handler.focusGate.Locked() || handler.focusGate.Suspended() || + browser == nil || !browser.ready || browser.document != document || browser.generation != generation || + browser.RecentInput().TitleInterruptionSequence != inputSequence || browser.focus != event.Source.ID() || + handler.state.focusedID != event.Source.ID() || handler.state.current.ID() != event.Source.ID() || + handler.state.owner.window != eventWindow(event) { + return + } + // State events may have refreshed the same control during the quiet period. + event.Source = handler.state.current + handler.speak(event) +} diff --git a/internal/app/browser_button_names_test.go b/internal/app/browser_button_names_test.go new file mode 100644 index 0000000..dabf757 --- /dev/null +++ b/internal/app/browser_button_names_test.go @@ -0,0 +1,173 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package app + +import ( + "context" + "fmt" + "log/slog" + "testing" + "time" + + "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/braille" + "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/review" +) + +func TestProcessEventsCoalescesWebButtonNames(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + speech := &fakeSpeechQueue{spoken: make(chan string, 32)} + frames := make(chan braille.Frame, 32) + adapter := &fakeAdapter{events: make(chan atspi.Event, 32)} + button := atspi.Snapshot{BusName: ":1.42", Path: "/counter", Name: "Counter value 0", Role: "button", Focused: true, Focusable: true} + browser := newBrowserModeController(nil, &modeKeys{}, nil) + browser.focus, browser.document = button.ID(), atspi.NewObjectID(button.BusName, "/document") + browser.ready, browser.mode = true, review.BrowseMode + done := make(chan struct{}) + go func() { + defer close(done) + processEventsWithInput(ctx, slog.New(slog.DiscardHandler), outputQueue{speech: speech, braille: frameRecorder{frames}}, adapter, nil, nil, 0, nil, browser, nil, nil) + }() + defer func() { cancel(); <-done }() + event := atspi.NewEvent(atspi.FocusEvent, button) + event.Window = atspi.NewObjectID(button.BusName, "/window") + adapter.events <- event + expect := func(want string) { + t.Helper() + select { + case got := <-speech.spoken: + if got != want { + t.Fatalf("speech = %q, want %q", got, want) + } + case <-time.After(time.Second): + t.Fatalf("missing speech %q", want) + } + } + expect("Counter value 0, button") + select { + case <-frames: + case <-time.After(time.Second): + t.Fatal("missing focus Braille") + } + // Match the reported page's six updates per second for longer than the + // quiet window. Frame delivery acknowledges each event's processing. + ticker := time.NewTicker(150 * time.Millisecond) + defer ticker.Stop() + for i := 1; i <= 6; i++ { + <-ticker.C + event.Type = atspi.AccessibleNameEvent + event.Source.Name = fmt.Sprintf("Counter value %d", i) + adapter.events <- event + select { + case frame := <-frames: + if frame.Text != event.Source.Name+" button" { + t.Fatalf("current Braille = %q", frame.Text) + } + case <-time.After(time.Second): + t.Fatal("name change did not refresh Braille") + } + select { + case got := <-speech.spoken: + t.Fatalf("changing counter interrupted focus: %q", got) + default: + } + } + // A redundant native focus notification must not discard the final name. + event.Type = atspi.FocusEvent + adapter.events <- event + <-frames + expect("Counter value 6, button") + + // A later action label still speaks once after settling. + event.Type = atspi.AccessibleNameEvent + event.Source.Name = "Update complete" + adapter.events <- event + <-frames + expect("Update complete, button") + + // Moving away cancels a pending name, and does not delay new focus. + event.Source.Name = "Pending counter" + adapter.events <- event + <-frames + event.Type, event.Source.Path, event.Source.Name = atspi.FocusEvent, "/next", "Next control" + adapter.events <- event + expect("Next control, button") + select { + case got := <-speech.spoken: + t.Fatalf("departed button spoke again: %q", got) + case <-time.After(2 * browserButtonNameDelay): + } +} + +func TestWebButtonNameScopeAndCancellation(t *testing.T) { + for _, kind := range []string{"settled", "live", "desktop", "entry", "other focus", "input", "document", "generation", "lock", "suspended", "window", "focus", "clear"} { + t.Run(kind, func(t *testing.T) { + button := atspi.Snapshot{BusName: ":1.42", Path: "/button", Name: "Play", Role: "button"} + event := atspi.NewEvent(atspi.AccessibleNameEvent, button) + event.Window = atspi.NewObjectID(button.BusName, "/window") + state := &eventLoopState{} + state.current, state.focusedID = button, button.ID() + state.owner.window = event.Window + source := fixedInputActivity{input.Activity{TitleInterruptionSequence: 1}} + browser := newBrowserModeController(source, &modeKeys{}, nil) + browser.ready, browser.focus, browser.document = true, button.ID(), atspi.NewObjectID(button.BusName, "/document") + spoken := 0 + handler := &accessibilityEventHandler{state: state, browser: browser, logger: slog.New(slog.DiscardHandler), focusGate: &focusGate{}, speak: func(atspi.Event, ...input.Intent) { spoken++ }} + defer state.pending.clear() + switch kind { + case "live": + event.Source.LiveRegion = true + case "desktop": + browser.ready = false + case "entry": + event.Source.Role = "entry" + case "other focus": + state.focusedID.Path = "/other" + } + deferred := handler.defer_browser_button_name(event, source.activity) + if kind == "live" || kind == "desktop" || kind == "entry" || kind == "other focus" { + if deferred { + t.Fatal("unrelated event was delayed") + } + return + } + if !deferred { + t.Fatal("web button name was not delayed") + } + timer := state.pending.browserButtonName.timer + handler.defer_browser_button_name(event, source.activity) + if state.pending.browserButtonName.timer != timer { + t.Fatal("duplicate name postponed presentation") + } + switch kind { + case "input": + browser.source = fixedInputActivity{input.Activity{TitleInterruptionSequence: 2}} + case "document": + browser.document.Path = "/newDocument" + case "generation": + browser.generation++ + case "lock": + handler.focusGate.secure.Store(true) + case "suspended": + handler.focusGate.suspended.Store(true) + case "window": + state.owner.window.Path = "/otherWindow" + case "focus": + state.focusedID.Path = "/other" + case "clear": + state.pending.clear() + } + handler.flush_browser_button_name() + want := 0 + if kind == "settled" { + want = 1 + } + if spoken != want { + t.Fatalf("presentations = %d, want %d", spoken, want) + } + }) + } +} diff --git a/internal/app/browser_dynamic_test.go b/internal/app/browser_dynamic_test.go index b12df1a..18f1c2f 100644 --- a/internal/app/browser_dynamic_test.go +++ b/internal/app/browser_dynamic_test.go @@ -7,6 +7,7 @@ package app import ( "context" "fmt" + "log/slog" "net/http" "net/http/httptest" "os" @@ -16,6 +17,7 @@ import ( "time" "git.stormux.org/storm/cthulhu/internal/atspi" + "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/review" ) @@ -27,7 +29,7 @@ func TestNativeBrowserDynamicNavigation(t *testing.T) { defer cancel() page := `Dynamic navigation regression Start of page - +

After control

` @@ -109,20 +111,55 @@ func TestNativeBrowserDynamicNavigation(t *testing.T) { } next("Start of page") next("Counter") - waitName := func(name string) { + waitName := func(name string) atspi.Event { t.Helper() for { select { case event := <-adapter.Events(): if event.Type == atspi.AccessibleNameEvent && event.Source.Name == name { - return + return event } case <-ctx.Done(): t.Fatalf("name change to %q was not observed", name) } } } - waitName("Counter updated") + counter := waitName("Counter updated") + // Exercise actual browser name events at the reported update cadence. + // The separate event-loop regression covers routing and immediate Braille. + state := &eventLoopState{} + state.current, state.focusedID, state.owner.window = counter.Source, counter.Source.ID(), counter.Window + browser := newBrowserModeController(nil, &modeKeys{}, nil) + browser.ready, browser.focus, browser.document = true, counter.Source.ID(), document + namePresentations := 0 + handler := &accessibilityEventHandler{state: state, browser: browser, logger: slog.New(slog.DiscardHandler), speak: func(event atspi.Event, _ ...input.Intent) { + namePresentations++ + if event.Source.Name != "Counter 6" { + t.Fatalf("changing native counter spoke before settling: %q", event.Source.Name) + } + }} + defer state.pending.clear() + updates := 0 + for namePresentations == 0 { + select { + case event := <-adapter.Events(): + if event.Type == atspi.AccessibleNameEvent && event.Source.ID() == counter.Source.ID() { + updates++ + state.current = event.Source + if !handler.defer_browser_button_name(event, input.Activity{}) { + t.Fatal("native web button name was not coalesced") + } + } + case <-state.pending.browserButtonName.ready: + handler.flush_browser_button_name() + case <-ctx.Done(): + t.Fatal("native counter did not finish: ", ctx.Err()) + } + } + if updates < 2 { + t.Fatalf("observed only %d native counter updates", updates) + } + t.Logf("coalesced %d native counter updates into one settled announcement", updates) next("After control") next("Avatar") next("Add content") diff --git a/internal/app/browser_mode.go b/internal/app/browser_mode.go index 13502e4..9adc3b4 100644 --- a/internal/app/browser_mode.go +++ b/internal/app/browser_mode.go @@ -54,7 +54,6 @@ type browserModeController struct { navigationDocument atspi.ObjectID navigationFocus atspi.ObjectID navigationInput input.Activity - navigationAt time.Time commandAt time.Time state review.DocumentModeState mode review.DocumentMode @@ -220,7 +219,9 @@ func (browser *browserModeController) accept(result browserModeResult, speech sp return } if result.owner.TopLevel.ID() != browser.document { + browser.mutex.Lock() browser.document = result.owner.TopLevel.ID() + browser.mutex.Unlock() browser.state = review.DocumentModeState{} browser.state.SetSticky(browser.stickyModes[browser.document.BusName]) } @@ -288,10 +289,9 @@ func (browser *browserModeController) navigated(position review.DocumentPosition browser.navigation = position.Snapshot.ID() browser.navigationDocument = position.Document.ID() browser.navigationInput = browser.RecentInput() - browser.navigationAt = time.Now() + browser.document = position.Document.ID() browser.invalidateCaptureContextLocked() browser.mutex.Unlock() - browser.document = position.Document.ID() browser.state = review.DocumentModeState{} browser.state.SetSticky(browser.stickyModes[browser.document.BusName]) browser.mode = browser.state.Current() @@ -334,11 +334,10 @@ func (browser *browserModeController) loaded(focus atspi.ObjectID) { browser.focus = atspi.ObjectID{} browser.navigation = atspi.ObjectID{} browser.navigationDocument = atspi.ObjectID{} - browser.navigationAt = time.Time{} browser.navigationFocus = atspi.ObjectID{} + browser.document = atspi.ObjectID{} browser.invalidateCaptureContextLocked() browser.mutex.Unlock() - browser.document = atspi.ObjectID{} browser.mode = review.InactiveMode browser.state = review.DocumentModeState{} browser.follow(focus) @@ -357,7 +356,6 @@ func (browser *browserModeController) loading() { browser.navigation = atspi.ObjectID{} browser.navigationDocument = atspi.ObjectID{} browser.navigationFocus = atspi.ObjectID{} - browser.navigationAt = time.Time{} browser.mode = review.InactiveMode browser.state = review.DocumentModeState{} browser.invalidateCaptureContextLocked() @@ -369,14 +367,30 @@ func (browser *browserModeController) observeCommand(command input.Command) { target, _, _ := documentReviewCommand(command) browser.mutex.Lock() browser.commandAt = time.Now() - if target == 0 { + if target != 0 || documentCaretCommand(command) != 0 { + // Reader commands continue virtual navigation. Native events from a + // previous placement remain echoes while the next request is pending. + // Activation or native input may have cleared the previous protection; + // arm it now, before the new worker result can arrive. + if browser.document.Valid() { + browser.navigation = browser.focus + browser.navigationDocument = browser.document + } + browser.navigationInput = browser.RecentInput() + } else { browser.navigation = atspi.ObjectID{} browser.navigationDocument = atspi.ObjectID{} - browser.navigationAt = time.Time{} } browser.mutex.Unlock() } +func (browser *browserModeController) pointer_input() { + browser.mutex.Lock() + defer browser.mutex.Unlock() + browser.navigation = atspi.ObjectID{} + browser.navigationDocument = atspi.ObjectID{} +} + // recentConsumedCommand reports whether X11 just dispatched one of Cthulhu's // passive command grabs. Some toolkits emit a transient window deactivation as // a side effect of that grab. Python Cthulhu ignores the same event when its @@ -390,32 +404,23 @@ func (browser *browserModeController) recentConsumedCommand() bool { return !browser.commandAt.IsZero() && time.Since(browser.commandAt) <= 100*time.Millisecond } -// A new unshifted browse arrow is owned by Go. Retain its cursor when the -// previous placement's native caret arrives after that arrow was queued. -func (browser *browserModeController) retainsBrowseCaret(event atspi.Event) bool { - browser.mutex.Lock() - defer browser.mutex.Unlock() - return browser.ready && browser.mode == review.BrowseMode && browser.navigationDocument.Valid() && - browser.navigationDocument.BusName == event.Source.BusName -} - func (browser *browserModeController) suppressNavigationEcho(event atspi.Event) bool { - if event.Type == atspi.WindowActivatedEvent || isMenuRole(event.Source.Role) { + if event.Type == atspi.WindowActivatedEvent || isMenuRole(event.Source.Role) || + event.Source.Editable || event.FocusOutsideDocument.Valid() { return false } browser.mutex.Lock() activity := browser.RecentInput() - // A fast repeat can press the next structural-navigation key before - // Chromium emits the caret/focus events caused by the previous placement. - // Keep a very short grace period for typing-intent keys (the single-letter - // structural bindings), while native caret and focus-navigation keys still - // end suppression immediately. - recentStructural := activity.Intent == input.TypingIntent && - !browser.navigationAt.IsZero() && time.Since(browser.navigationAt) <= 100*time.Millisecond - if activity.Sequence != browser.navigationInput.Sequence && !recentStructural { + // Like Orca, preserve the virtual position while the last input was a + // reader navigation command. Intent expires; input identity does not. + // An active passive grab also covers the short interval before dispatch. + grabActive := false + if keys, ok := browser.keys.(commandGrabState); ok { + grabActive = keys.CommandGrabActive() + } + if activity.InputSequence != browser.navigationInput.InputSequence && !grabActive { browser.navigation = atspi.ObjectID{} browser.navigationDocument = atspi.ObjectID{} - browser.navigationAt = time.Time{} } target, document := browser.navigation, browser.navigationDocument browser.mutex.Unlock() @@ -426,6 +431,9 @@ func (browser *browserModeController) suppressNavigationEcho(event atspi.Event) if event.Type != atspi.CaretEvent && !isFocusEvent(event.Type) { return false } + if atspi.IsWebDocumentRole(event.Source.Role) && event.Source.ID() != document { + return false + } if target.Valid() && event.Source.ID() == target { return true } diff --git a/internal/app/browser_mode_test.go b/internal/app/browser_mode_test.go index 476a895..e5340ef 100644 --- a/internal/app/browser_mode_test.go +++ b/internal/app/browser_mode_test.go @@ -7,11 +7,13 @@ package app import ( "context" "errors" + "log/slog" "testing" "time" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/input" + "git.stormux.org/storm/cthulhu/internal/presentation" "git.stormux.org/storm/cthulhu/internal/review" "git.stormux.org/storm/cthulhu/internal/sound" ) @@ -523,12 +525,127 @@ func TestBrowserNavigationEchoSurvivesFastStructuralRepeat(t *testing.T) { browser.navigated(review.DocumentPosition{Document: owner.TopLevel, Snapshot: target}, target.ID()) tracker.Observe(input.NewKeyEvent(0, true, "h")) + browser.observeCommand(input.DocumentNextHeading) child := atspi.Snapshot{BusName: ":1.42", Path: "/replacement", Role: "static"} if !browser.suppressNavigationEcho(atspi.NewEvent(atspi.CaretEvent, child)) { t.Fatal("fast repeated structural command exposed prior caret echo") } } +func TestBrowserNavigationOwnershipSurvivesQueuedReaderCommands(t *testing.T) { + for _, test := range []struct { + name, key string + command input.Command + }{ + {"heading", "h", input.DocumentNextHeading}, + {"reverse heading", "h", input.DocumentPreviousHeading}, + {"line", "Down", input.DocumentCaretNextLine}, + {"character", "Right", input.DocumentCaretNextCharacter}, + } { + t.Run(test.name, func(t *testing.T) { + tracker := &input.IntentTracker{} + tracker.Observe(input.NewKeyEvent(0, true, "h")) + browser := newBrowserModeController(tracker, &modeKeys{}, nil) + owner := modeContext(t, "heading", false) + target := owner.Ancestors[0] + browser.navigated(review.DocumentPosition{Document: owner.TopLevel, Snapshot: target}, owner.TopLevel.ID()) + tracker.Observe(input.NewKeyEvent(0, true, test.key)) + browser.observeCommand(test.command) + child := atspi.Snapshot{BusName: target.BusName, Path: "/old-heading-child", Role: "static"} + if !browser.suppressNavigationEcho(atspi.NewEvent(atspi.CaretEvent, child)) { + t.Fatal("delayed caret reset the cursor while a reader command was pending") + } + tracker.Observe(input.NewKeyEvent(0, true, "Tab")) + if browser.suppressNavigationEcho(atspi.NewEvent(atspi.FocusEvent, child)) { + t.Fatal("native Tab did not release navigation ownership") + } + }) + } +} + +func TestBrowserNavigationOwnershipSurvivesIdleAndEndsOnPointer(t *testing.T) { + source := &fixedInputActivity{activity: input.Activity{InputSequence: 1, Sequence: 1, Intent: input.TypingIntent}} + browser := newBrowserModeController(source, &modeKeys{}, nil) + owner := modeContext(t, "heading", false) + target := owner.Ancestors[0] + browser.navigated(review.DocumentPosition{Document: owner.TopLevel, Snapshot: target}, owner.TopLevel.ID()) + source.activity = input.Activity{InputSequence: 1} + if !browser.suppressNavigationEcho(atspi.NewEvent(atspi.CaretEvent, target)) { + t.Fatal("expired speech intent released the virtual caret") + } + for _, snapshot := range []atspi.Snapshot{ + {BusName: target.BusName, Path: "/entry", Role: "entry", Editable: true}, + {BusName: target.BusName, Path: "/menu", Role: "menu"}, + {BusName: target.BusName, Path: "/other-document", Role: "document web"}, + } { + if browser.suppressNavigationEcho(atspi.NewEvent(atspi.FocusEvent, snapshot)) { + t.Fatalf("suppressed focus on %s", snapshot.Role) + } + } + chrome := atspi.NewEvent(atspi.FocusEvent, target) + chrome.FocusOutsideDocument = atspi.NewObjectID(target.BusName, "/chrome") + if browser.suppressNavigationEcho(chrome) { + t.Fatal("suppressed chrome focus") + } + handler := &accessibilityEventHandler{state: &eventLoopState{}, browser: browser, logger: slog.New(slog.DiscardHandler)} + handler.handle(atspi.Event{Type: atspi.MouseButtonEvent}) + if browser.suppressNavigationEcho(atspi.NewEvent(atspi.CaretEvent, target)) { + t.Fatal("pointer click did not release the virtual caret") + } +} + +func TestQueuedHeadingOwnsCaretAfterActivation(t *testing.T) { + tracker := &input.IntentTracker{} + browser := newBrowserModeController(tracker, &modeKeys{}, nil) + owner := modeContext(t, "link", false) + target := owner.Ancestors[0] + browser.navigated(review.DocumentPosition{Document: owner.TopLevel, Snapshot: target}, owner.TopLevel.ID()) + tracker.Observe(input.NewKeyEvent(0, true, "Return")) + browser.observeCommand(input.DocumentActivate) + tracker.Observe(input.NewKeyEvent(0, true, "h")) + browser.observeCommand(input.DocumentNextHeading) + // A fragment activation can emit multiple paragraph caret events. The + // second can arrive after the heading request but before its result. + paragraph := atspi.Snapshot{BusName: target.BusName, Path: "/fragment", Role: "paragraph"} + if !browser.suppressNavigationEcho(atspi.NewEvent(atspi.CaretEvent, paragraph)) { + t.Fatal("activation caret was allowed to queue behind the heading move") + } +} + +func TestBrowserVirtualPositionSurvivesNativePlacementFailure(t *testing.T) { + worker, err := review.NewWorker(&documentLineCapturer{}, time.Second) + if err != nil { + t.Fatal(err) + } + owner := modeContext(t, "heading", false) + anchor := owner.TopLevel.ID() + worker.SyncFocus(anchor) + browser := newBrowserModeController(nil, &modeKeys{}, nil) + initial := review.DocumentPosition{Document: owner.TopLevel, Snapshot: owner.Ancestors[0]} + browser.navigated(initial, anchor) + state := &eventLoopState{} + state.current, state.presentationOptions = initial.Snapshot, presentation.DefaultOptions() + queue := &fakeSpeechQueue{spoken: make(chan string, 8)} + handler := &eventReviewHandler{state: state, browser: browser, objectReview: worker, speech: queue, + logger: slog.New(slog.DiscardHandler), syncForeground: func() {}} + target := atspi.Snapshot{BusName: anchor.BusName, Path: "/replacement-text", Role: "static", Name: "Current total"} + result := review.Result{DocumentEpoch: worker.DocumentEpoch(), DocumentFocus: anchor, + DocumentCaretCommand: review.CaretNextLine, DocumentCaretText: target.Name, Moved: true, + DocumentPosition: review.DocumentPosition{Document: owner.TopLevel, Snapshot: target, Offset: 4}, + CaretErr: atspi.ErrStaleSnapshot} + handler.handle(result) + if state.current.ID() != target.ID() || browser.navigation != target.ID() || worker.Focus() != anchor { + t.Fatal("native placement failure displaced the virtual position or its native anchor") + } + // A late result still cannot overwrite a newly focused browser control. + browser.follow(atspi.NewObjectID(anchor.BusName, "/address")) + state.current = atspi.Snapshot{BusName: anchor.BusName, Path: "/address", Role: "entry", Editable: true} + handler.handle(result) + if state.current.Path != "/address" { + t.Fatal("late virtual move displaced browser chrome") + } +} + func TestBrowserLoadRetiresPositionEvenWhenPathIsReused(t *testing.T) { browser := newBrowserModeController(nil, &modeKeys{}, nil) owner := modeContext(t, "heading", false) diff --git a/internal/app/document_native_test.go b/internal/app/document_native_test.go index f2172c4..73bf1fd 100644 --- a/internal/app/document_native_test.go +++ b/internal/app/document_native_test.go @@ -487,6 +487,13 @@ func TestNativeBrowserModes(t *testing.T) { } ctx, cancel := context.WithTimeout(context.Background(), 35*time.Second) defer cancel() + diagnostics, err := os.CreateTemp("", "cthulhu-browser-modes-*.log") + if err != nil { + t.Fatal(err) + } + defer diagnostics.Close() + t.Logf("event diagnostics: %s", diagnostics.Name()) + logger := slog.New(slog.NewJSONHandler(diagnostics, &slog.HandlerOptions{Level: slog.LevelDebug})) adapter := atspi.New() if err := adapter.Start(ctx); err != nil { t.Fatal(err) @@ -522,6 +529,7 @@ func TestNativeBrowserModes(t *testing.T) { // depends on fonts and browser layout. Compare speech with the actual // native range rather than a hard-coded paragraph prefix. nativeSelections := make(chan atspi.Snapshot, 16) + nativeLoads := make(chan atspi.Event, 16) events := make(chan atspi.Event, 64) probe := &browserEventProbe{Adapter: adapter, events: events} probeDone := make(chan struct{}) @@ -544,13 +552,20 @@ func TestNativeBrowserModes(t *testing.T) { case <-ctx.Done(): return } + if event.Type == atspi.DocumentLoadCompleteEvent { + select { + case nativeLoads <- event: + case <-ctx.Done(): + return + } + } } } }() done := make(chan struct{}) go func() { defer close(done) - processEventsWithInput(ctx, slog.New(slog.DiscardHandler), outputQueue{speech: queue, braille: browserBrailleProbe{frames: brailleFrames}}, probe, nil, worker, 20*time.Millisecond, nil, browser, nil, nil) + processEventsWithInput(ctx, logger, outputQueue{speech: queue, braille: browserBrailleProbe{frames: brailleFrames}}, probe, nil, worker, 20*time.Millisecond, nil, browser, nil, nil) }() commandDone := make(chan struct{}) go func() { @@ -561,6 +576,8 @@ func TestNativeBrowserModes(t *testing.T) { return case event := <-commands.Commands(): command := event.Command + logger.Debug("native probe command", "command", command, "input_timestamp", event.Timestamp, "activity", listener.RecentInput()) + browser.observeCommand(command) if command == input.ToggleDocumentMode || command == input.StickyFocusMode || command == input.StickyBrowseMode { browser.toggles <- command } else if target, level, backwards := documentReviewCommand(command); target != 0 { @@ -645,6 +662,29 @@ func TestNativeBrowserModes(t *testing.T) { } } } + waitLoaded := func(name string) { + t.Helper() + // A title can arrive before busy/load events retire the old browse + // grabs. Wait for the actual document completion and its new anchor. + for { + select { + case event := <-nativeLoads: + if event.Source.Name != name { + continue + } + for worker.Focus() != event.Source.ID() || !commands.BrowseNavigationEnabled() { + select { + case <-time.After(time.Millisecond): + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + return + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + } + } waitBraille := func(text string, cursor int) braille.Frame { t.Helper() timer := time.NewTimer(3 * time.Second) @@ -673,6 +713,7 @@ func TestNativeBrowserModes(t *testing.T) { keys("Return") // Reload resets focus; Tab reaches the fixture's first focusable heading. keys("ctrl+l") + waitPrefix("Address and search bar") fixture, err := filepath.Abs("../../tests/browser.html") if err != nil { t.Fatal(err) @@ -682,6 +723,7 @@ func TestNativeBrowserModes(t *testing.T) { } keys("Return") waitPrefix("Cthulhu Browser Probe") + waitLoaded("Cthulhu Browser Probe") // Browser chrome can insert a tab-strip stop after a reload. Follow the // actual focus announcements until the first document heading is reached. foundHeading := false @@ -727,12 +769,14 @@ func TestNativeBrowserModes(t *testing.T) { } keys("Return") waitPrefix("Cthulhu Second Page") + waitLoaded("Cthulhu Second Page") keys("ctrl+w") wait("Reading section, heading level 2") browseReady() // Page changes must reset the virtual position without a Tab/arrow nudge. // Exercise both an address-bar load and activation of a real page link. keys("ctrl+l") + waitPrefix("Address and search bar") nextFixture, err := filepath.Abs("../../tests/browser-next.html") if err != nil { t.Fatal(err) @@ -742,6 +786,7 @@ func TestNativeBrowserModes(t *testing.T) { } keys("Return") waitPrefix("Cthulhu Second Page") + waitLoaded("Cthulhu Second Page") browseReady() keys("KP_Add") select { @@ -762,6 +807,7 @@ func TestNativeBrowserModes(t *testing.T) { browseReady() keys("Return") waitPrefix("Cthulhu Browser Probe") + waitLoaded("Cthulhu Browser Probe") browseReady() keys("h") wait("Browser foundation, heading level 1") diff --git a/internal/app/event_accessibility.go b/internal/app/event_accessibility.go index 2f5367a..1dbca73 100644 --- a/internal/app/event_accessibility.go +++ b/internal/app/event_accessibility.go @@ -65,6 +65,14 @@ func (handler *accessibilityEventHandler) handle(event atspi.Event) { if focusGate.Locked() { return } + if event.Type == atspi.MouseButtonEvent { + state.pending.clear() + if browser != nil { + browser.pointer_input() + } + logger.Debug("released virtual caret for pointer input") + return + } if overflow := adapter.Overflow(); overflow > state.lastOverflow { logger.Warn("AT-SPI event queue overflow", "dropped_events", overflow-state.lastOverflow) state.lastOverflow = overflow @@ -248,6 +256,9 @@ func (handler *accessibilityEventHandler) handle(event atspi.Event) { if handler.acceptFocusedEvent(event, activity) { return } + if handler.defer_browser_button_name(event, activity) { + return + } if handler.handleSelectionAndClipboard(event, activity, intent) { return } diff --git a/internal/app/event_focus.go b/internal/app/event_focus.go index 98f1a1e..25705aa 100644 --- a/internal/app/event_focus.go +++ b/internal/app/event_focus.go @@ -34,6 +34,9 @@ func (handler *accessibilityEventHandler) acceptFocusedEvent(event atspi.Event, presentation.FocusTextWithOptions(event.Source, state.presentationOptions) == presentation.FocusTextWithOptions(state.current, state.presentationOptions) && slices.Equal(presentation.SoundCues(event.Source, state.presentationOptions), presentation.SoundCues(state.current, state.presentationOptions)) if isFocusEvent(event.Type) { + if !duplicateFocus { + state.pending.browserButtonName.clear() + } focusID := event.Source.ID() if isFocusGainEvent(event) { state.focusCaretID = focusID diff --git a/internal/app/event_loop.go b/internal/app/event_loop.go index 16782d2..8616b3b 100644 --- a/internal/app/event_loop.go +++ b/internal/app/event_loop.go @@ -296,6 +296,9 @@ func processEventsWithAttributes( if !ocrSession.Observe(event) { eventHandler.handle(event) } + case <-state.pending.browserButtonName.ready: + syncForeground() + eventHandler.flush_browser_button_name() case <-state.pending.caret.ready: if focusGate.Locked() { state.pending.clear() diff --git a/internal/app/event_navigation_filters.go b/internal/app/event_navigation_filters.go index 7c1a700..26aa87d 100644 --- a/internal/app/event_navigation_filters.go +++ b/internal/app/event_navigation_filters.go @@ -62,14 +62,6 @@ func (handler *accessibilityEventHandler) filterNavigationAndNames(event atspi.E logger.Debug("skipped name change outside focused object", "role", event.Source.Role) return true } - if browser != nil && browser.retainsBrowseCaret(event) && event.Type == atspi.CaretEvent && - !event.Source.Editable && !activity.Selecting && - (activity.Intent == input.CharacterIntent || activity.Intent == input.WordIntent || activity.Intent == input.LineIntent) { - // Unshifted browse arrows are handled by Go. A delayed native - // echo from an earlier move must not reset the next queued move. - logger.Debug("skipped native caret during Go browse navigation") - return true - } if browser != nil && browser.suppressNavigationEcho(event) { logger.Debug("skipped structural navigation echo", "type", event.Type, "role", event.Source.Role) return true diff --git a/internal/app/event_review_results.go b/internal/app/event_review_results.go index 363478a..5712a0e 100644 --- a/internal/app/event_review_results.go +++ b/internal/app/event_review_results.go @@ -165,7 +165,10 @@ func (handler *eventReviewHandler) handle(result review.Result) { } else { presentDocumentResult(logger, speech, result, state.presentationOptions) } - if result.Moved && result.Err == nil && result.CaretErr == nil { + // The virtual move is authoritative. Native placement can fail when + // a page replaces its text after we read it; that must not leave the + // browser controller or Braille at the previous virtual position. + if result.Moved && result.Err == nil { if result.DocumentCaretCommand != review.CaretReadLine || result.DocumentBrailleRoute { state.documentSelection = textSelection{} } diff --git a/internal/app/pending_presentation.go b/internal/app/pending_presentation.go index de0b291..9d8bdd2 100644 --- a/internal/app/pending_presentation.go +++ b/internal/app/pending_presentation.go @@ -45,13 +45,15 @@ func (pending *pendingPresentation) take() (atspi.Event, input.Intent) { // pendingPresentations bounds delayed presentation to one event per family. // Its lifetime is the event loop's lifetime, including adapter closure. type pendingPresentations struct { - caret pendingPresentation - terminalFocus pendingPresentation - terminalDeletion pendingPresentation + caret pendingPresentation + terminalFocus pendingPresentation + terminalDeletion pendingPresentation + browserButtonName pendingBrowserButtonName } func (pending *pendingPresentations) clear() { pending.caret.clear() pending.terminalFocus.clear() pending.terminalDeletion.clear() + pending.browserButtonName.clear() } diff --git a/internal/atspi/adapter.go b/internal/atspi/adapter.go index 8df0dd9..08cf9b8 100644 --- a/internal/atspi/adapter.go +++ b/internal/atspi/adapter.go @@ -43,6 +43,7 @@ const ( WindowDestroyedEvent = "window:destroy" WindowCreateEvent = "window:create" CaretEvent = "object:text-caret-moved" + MouseButtonEvent = "mouse:button" TextSelectionChangedEvent = "object:text-selection-changed" TextInsertedEvent = "object:text-changed:insert" TextDeletedEvent = "object:text-changed:delete" @@ -105,6 +106,7 @@ var eventTypes = []string{ SelectedStateEvent, "object:state-changed:sensitive", "object:state-changed:visited", + MouseButtonEvent, } type Adapter struct { @@ -114,6 +116,7 @@ type Adapter struct { foregroundReader *xorg.ForegroundReader // GLib thread only foreground atomic.Pointer[ForegroundState] echoFocus atomic.Pointer[echoFocus] + eventListener *C.AtspiEventListener // GLib owner; released with subscriptions. foregroundChanges chan struct{} foregroundTitle atomic.Pointer[ForegroundTitle] titleChanges chan struct{} @@ -165,6 +168,9 @@ type Adapter struct { type nativeFocusReference struct { id ObjectID source *C.AtspiAccessible + // Only programmatic browse positions carry this proof. A subsequent native + // focus event replaces the entry and clears it, even for the same object. + navigationDocument ObjectReference } type nativeDocumentWindow struct { diff --git a/internal/atspi/adapter_document_native.go b/internal/atspi/adapter_document_native.go index 4823148..6b12715 100644 --- a/internal/atspi/adapter_document_native.go +++ b/internal/atspi/adapter_document_native.go @@ -177,7 +177,7 @@ func (adapter *Adapter) activateNativeDocumentObject(ctx context.Context, positi if len(adapter.nativeFocus) > 0 { latest := adapter.nativeFocus[len(adapter.nativeFocus)-1].id if latest != focus { - if err := documentPositionMatchesFocus(tree, position, latest); err != nil { + if err := document_position_matches_retained_focus(tree, position, adapter.nativeFocus[len(adapter.nativeFocus)-1]); err != nil { return err } } @@ -428,11 +428,17 @@ func (adapter *Adapter) setNativeDocumentPosition(ctx context.Context, position if source == nil { return ErrStaleObject } + // Document captures are rooted at the outermost web document. Its + // identity remains valid when an unrelated changing label is marked stale. + document, err := tree.Root() + if err != nil { + return err + } // Recheck on the GLib owner: focus can change after the worker queues us. if len(adapter.nativeFocus) > 0 { latest := adapter.nativeFocus[len(adapter.nativeFocus)-1].id if latest != focus { - if err := documentPositionMatchesFocus(tree, position, latest); err != nil { + if err := document_position_matches_retained_focus(tree, position, adapter.nativeFocus[len(adapter.nativeFocus)-1]); err != nil { return err } } @@ -462,6 +468,7 @@ func (adapter *Adapter) setNativeDocumentPosition(ctx context.Context, position // without a native text caret. Python's web setCaretPosition likewise // only attempts SetCaretOffset when the object supports text. adapter.rememberNativeFocus(source, position.ID()) + adapter.nativeFocus[len(adapter.nativeFocus)-1].navigationDocument = document return nil } defer C.g_object_unref(C.gpointer(text)) @@ -498,6 +505,26 @@ func (adapter *Adapter) setNativeDocumentPosition(ctx context.Context, position return errors.New("browser refused document caret") } adapter.rememberNativeFocus(source, position.ID()) + adapter.nativeFocus[len(adapter.nativeFocus)-1].navigationDocument = document + return nil +} + +// A dynamic page can replace the text at a successfully placed browse caret +// before the next command. That remembered position is not evidence that native +// focus left the document. Retain its generation-checked document proof while +// continuing to reject actual focus changes to chrome or another document. +func document_position_matches_retained_focus(tree *AccessibleTree, position ObjectReference, focus nativeFocusReference) error { + err := documentPositionMatchesFocus(tree, position, focus.id) + if !errors.Is(err, ErrObjectNotFound) || !focus.navigationDocument.Valid() { + return err + } + owner, targetErr := tree.DocumentContext(position) + if targetErr != nil { + return targetErr + } + if owner.TopLevel != focus.navigationDocument { + return err + } return nil } diff --git a/internal/atspi/adapter_event_callback_native.go b/internal/atspi/adapter_event_callback_native.go index 9d61ca7..55e1a1f 100644 --- a/internal/atspi/adapter_event_callback_native.go +++ b/internal/atspi/adapter_event_callback_native.go @@ -391,6 +391,15 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) { // Retire copied header identities even while locked. Tab lifecycle only // checks roles; it never captures text or presents output. nativeEventType := C.GoString(C.cthulhu_event_type(event)) + if strings.HasPrefix(nativeEventType, MouseButtonEvent+":") { + if !adapter.secure.Load() && strings.HasSuffix(nativeEventType, "p") { + // Like Orca, observe the shared AT-SPI pointer signal. No object + // capture or pointer coordinates are needed for navigation handoff. + clear(adapter.navigationEcho) + adapter.enqueueSecureEvent(Event{Type: MouseButtonEvent}, secureEpoch) + } + return + } eventType := canonicalTextChangeEventType(nativeEventType) originID := nativeObjectID(event.source) adapter.retireEchoFocus(eventType, int(C.cthulhu_event_detail1(event)), originID) diff --git a/internal/atspi/adapter_lifecycle_native.go b/internal/atspi/adapter_lifecycle_native.go index 71d866a..d69a647 100644 --- a/internal/atspi/adapter_lifecycle_native.go +++ b/internal/atspi/adapter_lifecycle_native.go @@ -115,6 +115,19 @@ func (adapter *Adapter) initialize() error { } adapter.callbackHandle = callbackHandle adapter.callbackData = callbackData + // Own one listener for all subscriptions, as Orca does. The callback + // convenience API lost mouse delivery when mixed with object subscriptions + // in the private native probe. + adapter.eventListener = C.atspi_event_listener_new( + C.AtspiEventListenerCB(C.cthulhu_event_callback_pointer()), C.gpointer(callbackData), nil) + if adapter.eventListener == nil { + C.cthulhu_callback_data_free(callbackData) + adapter.callbackHandle = 0 + adapter.callbackData = nil + callbackHandle.Delete() + C.atspi_exit() + return errors.New("allocate AT-SPI event listener") + } for _, eventType := range eventTypes { if err := adapter.registerEvent(eventType); err != nil { @@ -135,13 +148,7 @@ func (adapter *Adapter) initialize() error { func (adapter *Adapter) registerEvent(eventType string) error { nativeEventType := C.CString(eventType) var registerError *C.GError - registered := C.atspi_event_listener_register_from_callback( - C.AtspiEventListenerCB(C.cthulhu_event_callback_pointer()), - unsafe.Pointer(adapter.callbackData), - nil, - nativeEventType, - ®isterError, - ) + registered := C.atspi_event_listener_register(adapter.eventListener, nativeEventType, ®isterError) C.free(unsafe.Pointer(nativeEventType)) if registerError != nil { err := nativeError("register AT-SPI event listener "+eventType, registerError) @@ -159,18 +166,17 @@ func (adapter *Adapter) deregisterEvents() error { for _, eventType := range eventTypes { nativeEventType := C.CString(eventType) var deregisterError *C.GError - C.atspi_event_listener_deregister_from_callback( - C.AtspiEventListenerCB(C.cthulhu_event_callback_pointer()), - unsafe.Pointer(adapter.callbackData), - nativeEventType, - &deregisterError, - ) + C.atspi_event_listener_deregister(adapter.eventListener, nativeEventType, &deregisterError) C.free(unsafe.Pointer(nativeEventType)) if deregisterError != nil { stopErr = errors.Join(stopErr, nativeError("deregister AT-SPI event listener "+eventType, deregisterError)) C.g_error_free(deregisterError) } } + if adapter.eventListener != nil { + C.g_object_unref(C.gpointer(adapter.eventListener)) + adapter.eventListener = nil + } return stopErr } diff --git a/internal/atspi/adapter_references_native.go b/internal/atspi/adapter_references_native.go index 9e752e9..cf03843 100644 --- a/internal/atspi/adapter_references_native.go +++ b/internal/atspi/adapter_references_native.go @@ -155,7 +155,7 @@ func (adapter *Adapter) rememberNativeFocus(source *C.AtspiAccessible, id Object adapter.nativeFocus = adapter.nativeFocus[1:] } owned := (*C.AtspiAccessible)(C.g_object_ref(C.gpointer(source))) - adapter.nativeFocus = append(adapter.nativeFocus, nativeFocusReference{id, owned}) + adapter.nativeFocus = append(adapter.nativeFocus, nativeFocusReference{id: id, source: owned}) } // Keep an exact document-to-frame association across Chromium tab switches. diff --git a/internal/atspi/adapter_tree_test.go b/internal/atspi/adapter_tree_test.go index c2669f4..1549131 100644 --- a/internal/atspi/adapter_tree_test.go +++ b/internal/atspi/adapter_tree_test.go @@ -404,6 +404,52 @@ func TestQueuedDocumentPositionRejectsDepartedFocus(t *testing.T) { } } +func TestRemovedBrowseCaretRetainsOnlyItsDocument(t *testing.T) { + for _, test := range []struct { + name, path, document string + staleDocument bool + allowed bool + }{ + {"removed browse text", "/gone", "/doc", false, true}, + {"removed native focus", "/gone", "", false, false}, + {"browser chrome", "/chrome", "/doc", false, false}, + {"other document", "/other", "/doc", false, false}, + {"removed other-tab caret", "/gone", "/other", false, false}, + {"replaced document generation", "/gone", "/doc", true, false}, + } { + t.Run(test.name, func(t *testing.T) { + tree, err := NewAccessibleTree(testID("/root"), documentTestNodes()) + if err != nil { + t.Fatal(err) + } + focus := nativeFocusReference{id: testID(test.path)} + if test.document != "" { + focus.navigationDocument, err = tree.Reference(testID(test.document)) + if err != nil { + t.Fatal(err) + } + } + if test.staleDocument { + tree.Invalidate(testID("/doc")) + if err := tree.Replace(testID("/root"), documentTestNodes()); err != nil { + t.Fatal(err) + } + } + position, err := tree.Reference(testID("/heading")) + if err != nil { + t.Fatal(err) + } + if err := document_position_matches_retained_focus(tree, position, focus); (err == nil) != test.allowed { + t.Fatalf("caret ownership error = %v, allowed = %t", err, test.allowed) + } + tree.Invalidate(position.ID()) + if err := document_position_matches_retained_focus(tree, position, focus); err == nil { + t.Fatal("removed target accepted") + } + }) + } +} + func TestSecureInputCancelsCapturesAcrossLockTransitions(t *testing.T) { adapter := New() before, cancelBefore := adapter.secureRequestContext(context.Background()) diff --git a/internal/atspi/pointer_native_test.go b/internal/atspi/pointer_native_test.go new file mode 100644 index 0000000..431b7a7 --- /dev/null +++ b/internal/atspi/pointer_native_test.go @@ -0,0 +1,42 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package atspi + +import ( + "context" + "os" + "os/exec" + "testing" + "time" +) + +func TestNativePointerInput(t *testing.T) { + if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" { + t.Skip("requires private browser session") + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + adapter := New() + if err := adapter.Start(ctx); err != nil { + t.Fatal(err) + } + defer adapter.Stop(context.Background()) + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ticker.C: + if out, err := exec.CommandContext(ctx, "xdotool", "mousemove", "200", "200", "click", "1").CombinedOutput(); err != nil { + t.Fatalf("click: %v %s", err, out) + } + case event := <-adapter.Events(): + if event.Type == MouseButtonEvent { + return + } + case <-ctx.Done(): + t.Fatal("shared AT-SPI pointer press was not delivered") + } + } +} diff --git a/internal/input/intent.go b/internal/input/intent.go index f545dac..fbe8f6c 100644 --- a/internal/input/intent.go +++ b/internal/input/intent.go @@ -24,6 +24,10 @@ const ( ) type Activity struct { + // InputSequence counts every key press without expiring with presentation + // intent. Virtual navigation uses it to distinguish + // native input from delayed application events, even after a quiet period. + InputSequence uint64 // Unlike presentation intent, this counter never expires. Deferred titles // may be waiting for application settings after ordinary input has aged out. TitleInterruptionSequence uint64 @@ -136,8 +140,8 @@ func (tracker *IntentTracker) RecentInput() Activity { tracker.mutex.Lock() defer tracker.mutex.Unlock() if time.Since(tracker.at) > time.Second { - return Activity{TitleInterruptionSequence: tracker.titleInterruptionSequence} + return Activity{InputSequence: tracker.sequence, TitleInterruptionSequence: tracker.titleInterruptionSequence} } - return Activity{Intent: tracker.intent, Sequence: tracker.sequence, Clipboard: tracker.clipboard, Selecting: tracker.selecting, + return Activity{InputSequence: tracker.sequence, Intent: tracker.intent, Sequence: tracker.sequence, Clipboard: tracker.clipboard, Selecting: tracker.selecting, TitleInterruptionSequence: tracker.titleInterruptionSequence} } diff --git a/internal/input/intent_test.go b/internal/input/intent_test.go index a801360..f7fced3 100644 --- a/internal/input/intent_test.go +++ b/internal/input/intent_test.go @@ -113,3 +113,12 @@ func TestTitleInterruptionTracksFreshPressesAndStopKeys(t *testing.T) { t.Fatalf("reset lost interruption history: %d", got) } } + +func TestNavigationInputIdentitySurvivesIdle(t *testing.T) { + tracker := &IntentTracker{} + tracker.Observe(NewKeyEvent(0, true, "h")) + tracker.at = time.Now().Add(-2 * time.Second) + if got := tracker.RecentInput(); got.InputSequence != 1 || got.Sequence != 0 || got.Intent != NoIntent { + t.Fatalf("idle input identity = %+v", got) + } +} diff --git a/internal/review/document.go b/internal/review/document.go index d4b4eff..00203b9 100644 --- a/internal/review/document.go +++ b/internal/review/document.go @@ -62,6 +62,9 @@ type documentPlace struct { offset int tableCell atspi.ObjectID tableRow, tableColumn int + // A single text child can be replaced while its paragraph remains live. + // Retain the generation-checked parent rather than search by label/index. + textParent atspi.ObjectReference } // DocumentCursor remembers at most 16 document positions in least-recently- @@ -187,6 +190,10 @@ func (cursor *DocumentCursor) SyncFocus(ctx context.Context, focus atspi.ObjectI if _, ok := documentObject(doc, saved.object); ok { place = saved place.focus = ref + } else if replacement, ok := replacement_text_node(doc, saved); ok { + place = saved + place.focus, place.object = ref, replacement.Reference + place.offset = min(saved.offset, max(0, replacement.Snapshot.CharacterCount-1)) } } cursor.places = append(cursor.places[:index], cursor.places[index+1:]...) diff --git a/internal/review/document_caret.go b/internal/review/document_caret.go index 4fee4ec..3f144fb 100644 --- a/internal/review/document_caret.go +++ b/internal/review/document_caret.go @@ -100,11 +100,17 @@ func (cursor *DocumentCursor) MoveCaret(ctx context.Context, reader DocumentCare if len(indices) == 0 { return DocumentCaretMove{Position: documentPosition(doc, *place)}, nil } + var movement DocumentCaretMove switch command { case CaretNextWord, CaretPreviousWord, CaretNextCharacter, CaretPreviousCharacter: - return moveCaretText(ctx, reader, doc, place, indices, current, + movement, err = moveCaretText(ctx, reader, doc, place, indices, current, command == CaretPreviousWord || command == CaretPreviousCharacter, command == CaretNextWord || command == CaretPreviousWord) + default: + movement, err = move_caret_line(ctx, reader, cursor.tree, doc, place, indices, current, command) } - return move_caret_line(ctx, reader, cursor.tree, doc, place, indices, current, command) + if err == nil && movement.Moved { + remember_text_parent(doc, place) + } + return movement, err } diff --git a/internal/review/document_native_caret.go b/internal/review/document_native_caret.go index aa0c63e..88101fb 100644 --- a/internal/review/document_native_caret.go +++ b/internal/review/document_native_caret.go @@ -34,6 +34,7 @@ func (cursor *DocumentCursor) syncNativeCaret(ctx context.Context, reader Docume place.object, place.offset, place.origin = node.Reference, offset, CaretNavigation place.tableCell = atspi.ObjectID{} + remember_text_parent(doc, place) return nil } diff --git a/internal/review/document_replacement.go b/internal/review/document_replacement.go new file mode 100644 index 0000000..45a14a3 --- /dev/null +++ b/internal/review/document_replacement.go @@ -0,0 +1,56 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +package review + +import "git.stormux.org/storm/cthulhu/internal/atspi" + +// Orca recovers removed caret contexts from the surviving parent. Here the +// bounded case is one plain text leaf under the same paragraph or heading. +// Multiple children, interactive containers and replaced parent/document +// generations cannot establish that the old offset still belongs here. +func remember_text_parent(doc atspi.DocumentSnapshot, place *documentPlace) { + place.textParent = atspi.ObjectReference{} + for _, parent := range doc.Nodes { + if !replacement_text_parent(parent) || parent.Children[0] != place.object.ID() { + continue + } + for _, child := range doc.Nodes { + if child.Reference == place.object && replacement_text_leaf(child) { + place.textParent = parent.Reference + return + } + } + } +} + +func replacement_text_node(doc atspi.DocumentSnapshot, saved documentPlace) (atspi.DocumentNode, bool) { + if saved.document != doc.Context.TopLevel || !saved.textParent.Valid() || saved.origin != CaretNavigation { + return atspi.DocumentNode{}, false + } + for _, parent := range doc.Nodes { + if parent.Reference != saved.textParent || !replacement_text_parent(parent) { + continue + } + for _, child := range doc.Nodes { + if child.Reference.ID() == parent.Children[0] && replacement_text_leaf(child) { + return child, true + } + } + } + return atspi.DocumentNode{}, false +} + +func replacement_text_parent(node atspi.DocumentNode) bool { + return len(node.Children) == 1 && !node.Hidden && !node.ParentEditable && + !node.Snapshot.Editable && !node.Snapshot.Focusable && !node.Snapshot.SupportsClick && + (node.Snapshot.Role == "paragraph" || node.Snapshot.Role == "heading") +} + +func replacement_text_leaf(node atspi.DocumentNode) bool { + return len(node.Children) == 0 && !node.Hidden && !node.ParentEditable && + !node.Snapshot.Editable && !node.Snapshot.Focusable && !node.Snapshot.SupportsClick && + !node.Snapshot.ExplicitName && node.Snapshot.CharacterCount > 0 && + (node.Snapshot.Role == "static" || node.Snapshot.Role == "text") +} diff --git a/internal/review/document_replacement_test.go b/internal/review/document_replacement_test.go new file mode 100644 index 0000000..40aa52c --- /dev/null +++ b/internal/review/document_replacement_test.go @@ -0,0 +1,70 @@ +// 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 TestDocumentCaretRecoversSingleReplacedTextChild(t *testing.T) { + for _, kind := range []string{"single text", "multiple children", "editable", "replaced paragraph", "replaced document"} { + t.Run(kind, func(t *testing.T) { + reader := caretTextReader{webID("/old"): "Current total 10000 units", webID("/new"): "Current total 10001 units"} + node := func(path, role string, children ...atspi.ObjectID) atspi.TreeNode { + return atspi.TreeNode{Snapshot: atspi.Snapshot{BusName: webID(path).BusName, Path: path, Role: role, + CharacterCount: utf8.RuneCountInString(reader[webID(path)])}, Children: children} + } + nodes := []atspi.TreeNode{node("/doc", "document web", webID("/paragraph")), + node("/paragraph", "paragraph", webID("/old")), node("/old", "static")} + tree, err := atspi.NewAccessibleTree(webID("/doc"), nodes) + if err != nil { + t.Fatal(err) + } + cursor, _ := NewDocumentCursor(tree) + ctx := context.Background() + if _, err := cursor.SyncFocus(ctx, webID("/doc")); err != nil { + t.Fatal(err) + } + move, err := cursor.MoveCaret(ctx, reader, CaretNextLine) + if err != nil || move.Text != reader[webID("/old")] { + t.Fatalf("initial line = %+v, %v", move, err) + } + nodes[1].Children = []atspi.ObjectID{webID("/new")} + nodes[2] = node("/new", "static") + switch kind { + case "multiple children": + nodes[1].Children = append(nodes[1].Children, webID("/extra")) + nodes = append(nodes, node("/extra", "static")) + case "editable": + nodes[1].Snapshot.Editable = true + case "replaced paragraph": + tree.Invalidate(webID("/paragraph")) + case "replaced document": + tree.Invalidate(webID("/doc")) + } + if err := tree.Replace(webID("/doc"), nodes); err != nil { + t.Fatal(err) + } + position, err := cursor.SyncFocus(ctx, webID("/doc")) + if err != nil { + t.Fatal(err) + } + if kind != "single text" { + if position.Object.ID() == webID("/new") { + t.Fatal("ambiguous or replaced context inherited the old caret") + } + return + } + move, err = cursor.MoveCaret(ctx, reader, CaretNextCharacter) + if err != nil || move.Text != "u" || move.Position.Offset != 1 || move.Position.Object.ID() != webID("/new") { + t.Fatalf("Right after text replacement = %+v, %v", move, err) + } + }) + } +} diff --git a/tests/browser-changing.html b/tests/browser-changing.html new file mode 100644 index 0000000..ce08b49 --- /dev/null +++ b/tests/browser-changing.html @@ -0,0 +1,29 @@ + + + +Cthulhu Changing Document Probe + +

Changing document

+
+

Statistics

+

Current total 10000 units

+

Following stable line

+ + + + diff --git a/tests/native_browser.py b/tests/native_browser.py index 317879a..299befa 100644 --- a/tests/native_browser.py +++ b/tests/native_browser.py @@ -10,6 +10,7 @@ import time BROWSER_PROOFS = { + "pointer": ("./internal/atspi", "^TestNativePointerInput$", 15, 20), "progress": ("./internal/app", "^TestNativeBrowserProgress$", 30, 35, "browser-progress.html"), "fragments": ("./internal/review", "^TestNativeBrowserFragmentLines$", 30, 35, "browser-fragments.html"), "positions": ("./internal/atspi", "^TestNativeBrowserSetPositions$", 25, 30, "browser-positions.html"), diff --git a/tests/native_browser_navigation.py b/tests/native_browser_navigation.py new file mode 100644 index 0000000..8ce670c --- /dev/null +++ b/tests/native_browser_navigation.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Changing-document navigation with a real reader, private X11/bus and SSIP.""" +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import time + +sys.dont_write_bytecode = True +from native_echo_preview import PreviewSpeechServer +from native_x11 import wait_for + + +def run_inside(): + binary = os.environ["CTHULHU_BINARY"] + directory = Path(tempfile.mkdtemp(prefix="cthulhu-browser-navigation-")) + print(f"Evidence: {directory}", flush=True) + server = PreviewSpeechServer(directory / "speech.sock") + config = directory / "settings.toml" + config.write_text('''schema-version = 2 +active-profile = "default" +[profiles.default.speech] +output-module = "main" +[profiles.default.braille] +enabled = false +[profiles.default.sound] +enabled = false +[profiles.default.key-echo] +enabled = false +''') + wmConfig = directory / "i3.config" + wmConfig.write_text(f"font pango:monospace 10\nipc-socket {directory / 'i3.sock'}\n") + processes = [] + report = [] + logPath = directory / "reader.log" + try: + processes.append(subprocess.Popen(["i3", "-c", str(wmConfig)], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)) + wait_for(lambda: "0x" in subprocess.check_output( + ["xprop", "-root", "_NET_SUPPORTING_WM_CHECK"], text=True), "private WM missing") + processes.append(subprocess.Popen([ + binary, "--config", str(config), "--no-tentacles", + "--speech-socket", str(directory / "speech.sock"), "--debug-file", str(logPath)], + stdout=subprocess.DEVNULL, stderr=(directory / "reader-stderr.log").open("w"))) + wait_for(lambda: logPath.exists() and "remote D-Bus service ready" in logPath.read_text(), + "private reader did not start") + browser = os.environ.get("CTHULHU_BROWSER") or shutil.which("brave") + fixture = Path(__file__).with_name("browser-changing.html").resolve() + processes.append(subprocess.Popen([ + browser, "--user-data-dir=" + str(directory / "profile"), "--no-first-run", + "--no-default-browser-check", "--disable-background-networking", + "--disable-component-update", "--disable-sync", "--password-store=basic", + "--force-renderer-accessibility", "--ozone-platform=x11", "--new-window", fixture.as_uri()], + stdout=subprocess.DEVNULL, stderr=(directory / "browser.log").open("w"))) + + def key(chord): + subprocess.run(["xdotool", "key", "--clearmodifiers", chord], check=True) + + def logs(): + result = [] + for line in logPath.read_text().splitlines(): + try: + result.append(json.loads(line)) + except json.JSONDecodeError: + pass # A writer can still be completing the last record. + return result + + wait_for(lambda: subprocess.run([ + "xdotool", "search", "--onlyvisible", "--name", "Cthulhu Changing Document Probe"], + capture_output=True).returncode == 0, "private browser did not open") + subprocess.run(["xdotool", "mousemove", "0", "0"], check=True) + for _ in range(12): + offset = len(server.snapshot()) + key("Tab") + time.sleep(0.2) + if any(record["text"] == "Changing document" for record in server.snapshot()[offset:]): + break + else: + raise AssertionError("first document heading did not receive focus") + + def move(chord, expected, delay=0.2): + logOffset, speechOffset = len(logs()), len(server.snapshot()) + key(chord) + result = wait_for(lambda: next((row for row in logs()[logOffset:] + if row.get("message") in {"document navigation completed", "document caret navigation completed"}), None), + f"reader did not complete {chord}", timeout=5) + assert result.get("moved"), (chord, result) + step = {"key": chord, "result": result} + report.append(step) + wait_for(lambda: any(expected(record["text"]) for record in server.snapshot()[speechOffset:]), + f"missing speech for {chord}", timeout=5) + time.sleep(delay) + speech = [record["text"] for record in server.snapshot()[speechOffset:]] + assert not any("Could not" in text for text in speech), (chord, speech) + headings = [text for text in speech if text.startswith("Section ") or text == "Statistics"] + assert all(expected(text) for text in headings), (chord, "repeated earlier heading", speech) + step["speech"] = speech + + for cycle in range(2): + for index in range(1, 11): + name = f"Section {index}" + move("h", lambda text, name=name: text == name, 1.1 if index == 3 else 0.2) + move("h", lambda text: text == "Statistics") + for index in range(10, 0, -1): + name = f"Section {index}" + move("shift+h", lambda text, name=name: text == name) + move("shift+h", lambda text: text == "Changing document") + # Go directly to the final heading and repeatedly traverse text nodes + # which are replaced while the native caret and selection settle. + move("shift+h", lambda text: text == "Statistics") + for _ in range(4): + move("Down", lambda text: text.startswith("Current total ")) + move("Right", lambda text: text == "u") + move("Home", lambda text: text == "C") + move("ctrl+Right", lambda text: text == "Current") + move("Down", lambda text: text == "Following stable line") + move("Up", lambda text: text.startswith("Current total ")) + move("Up", lambda text: text == "Statistics") + logOffset = len(logs()) + subprocess.run(["xdotool", "click", "1"], check=True) + wait_for(lambda: any(row.get("message") == "released virtual caret for pointer input" + for row in logs()[logOffset:]), "shared pointer signal did not reach the reader") + print(f"PASS: {len(report)} navigation commands with changing text", flush=True) + finally: + for process in reversed(processes): + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + server.close() + (directory / "report.json").write_text(json.dumps(report, indent=2)) + (directory / "speech.json").write_text(json.dumps(server.snapshot(), indent=2)) + + +def main(): + if sys.argv[1:] == ["--inside"]: + run_inside() + return + readFd, writeFd = os.pipe() + display = subprocess.Popen(["Xvfb", "-displayfd", str(writeFd), "-screen", "0", "1280x900x24", + "-nolisten", "tcp"], pass_fds=(writeFd,), stderr=subprocess.DEVNULL) + os.close(writeFd) + try: + with os.fdopen(readFd) as stream: + number = stream.readline().strip() + if not number: + raise RuntimeError("private X server failed to start") + environment = dict(os.environ, DISPLAY=":" + number, XDG_SESSION_TYPE="x11") + for name in ("DBUS_SESSION_BUS_ADDRESS", "AT_SPI_BUS_ADDRESS", "WAYLAND_DISPLAY"): + environment.pop(name, None) + subprocess.run(["dbus-run-session", "--", sys.executable, str(Path(__file__).resolve()), "--inside"], + env=environment, check=True, timeout=120) + finally: + display.terminate() + display.wait(timeout=5) + + +if __name__ == "__main__": + main() diff --git a/tests/native_echo_preview.py b/tests/native_echo_preview.py index a3ca110..5767399 100644 --- a/tests/native_echo_preview.py +++ b/tests/native_echo_preview.py @@ -76,18 +76,21 @@ class PreviewSpeechServer: target = self.clients[targetId] if target["active"] is not None: self.finish(targetId, target, target["active"], canceled=True) - elif command == "SPEAK": + elif command == "SPEAK" or command.startswith("CHAR "): parts = [] - for line in stream: - if line == b".\r\n": - break - parts.append(line.decode().rstrip("\r\n")) + if command == "SPEAK": + for line in stream: + if line == b".\r\n": + break + parts.append(line.decode().rstrip("\r\n")) + else: + parts.append(command.removeprefix("CHAR ")) record = {"text": "\n".join(parts), "module": client["module"], "voice": client["voice"], "finished": False, "canceled": False} with self.lock: self.spoken.append(record) client["active"] = record - self.reply(client, "230 OK\r\n200 QUEUED\r\n") + self.reply(client, "230 OK\r\n200 QUEUED\r\n" if command == "SPEAK" else "225 QUEUED\r\n") if client["notifications"]: self.reply(client, f"701-1\r\n701-{clientId}\r\n701 BEGIN\r\n") timer = threading.Timer(0.25, self.finish, args=(clientId, client, record))