Browser updates.

This commit is contained in:
Storm Dragon committed 2026-10-16 19:33:18 -04:00
1 parent 2fa9bc8724
commit 423590e11a
31 files changed
+1056 -70

No files matched your search

+36
View File
@@ -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.
+69
View File
@@ -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)
}
+173
View File
@@ -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)
}
})
}
}
+41 -4
View File
@@ -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 := `<!doctype html><title>Dynamic navigation regression</title>
<a href="#end">Start of page</a>
<button aria-label="Counter" onfocus="this.setAttribute('aria-label', 'Counter updated')">Counter</button>
<button aria-label="Counter" onfocus="this.setAttribute('aria-label', 'Counter updated'); let n = 0; const timer = setInterval(() => { this.setAttribute('aria-label', 'Counter ' + ++n); if (n === 6) clearInterval(timer); }, 150)">Counter</button>
<p>After control</p>
<svg role="img" aria-label="Avatar" width="16" height="16"><circle cx="8" cy="8" r="8"/></svg>
<button aria-label="Add content" onfocus="const p = document.createElement('p'); p.textContent = 'New content'; document.body.appendChild(p); this.setAttribute('aria-label', 'Add content done')">Add content</button>`
@@ -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")
+35 -27
View File
@@ -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
}
+117
View File
@@ -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)
+47 -1
View File
@@ -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")
+11
View File
@@ -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
}
+3
View File
@@ -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
+3
View File
@@ -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()
-8
View File
@@ -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
+4 -1
View File
@@ -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{}
}
+5 -3
View File
@@ -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()
}
+6
View File
@@ -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 {
+29 -2
View File
@@ -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
}
@@ -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)
+19 -13
View File
@@ -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,
&registerError,
)
registered := C.atspi_event_listener_register(adapter.eventListener, nativeEventType, &registerError)
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
}
+1 -1
View File
@@ -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.
+46
View File
@@ -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())
+42
View File
@@ -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")
}
}
}
+6 -2
View File
@@ -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}
}
+9
View File
@@ -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)
}
}
+7
View File
@@ -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:]...)
+8 -2
View File
@@ -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
}
+1
View File
@@ -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
}
+56
View File
@@ -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")
}
@@ -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)
}
})
}
}
+29
View File
@@ -0,0 +1,29 @@
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<title>Cthulhu Changing Document Probe</title>
<style>section { min-height: 120px; } .rows { display: grid; grid-template-columns: 1fr 1fr; }</style>
<h1 tabindex="0">Changing document</h1>
<div class="rows" id="rows"></div>
<h2>Statistics</h2>
<p id="counter">Current total 10000 units</p>
<p>Following stable line</p>
<label>Editable value <input></label>
<button>Ordinary action</button>
<script>
const rows = document.getElementById('rows');
for (let index = 1; index <= 10; index++) {
const section = document.createElement('section');
const heading = document.createElement('h3');
heading.textContent = `Section ${index}`;
section.append(heading, document.createTextNode('Changing value '), document.createElement('span'));
rows.append(section);
}
let value = 10000;
setInterval(() => {
value++;
document.querySelectorAll('section span').forEach(span => span.replaceChildren(String(value)));
document.getElementById('counter').replaceChildren(`Current total ${value} units`);
}, 80);
</script>
</html>
+1
View File
@@ -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"),
+164
View File
@@ -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()
+9 -6
View File
@@ -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))