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

+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)
}
})
}
}