Browser updates.
This commit is contained in:
1 parent
2fa9bc8724
commit
423590e11a
31 files changed
+1056
-70
No files matched your search
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -115,6 +115,19 @@ func (adapter *Adapter) initialize() error {
|
||||
}
|
||||
adapter.callbackHandle = callbackHandle
|
||||
adapter.callbackData = callbackData
|
||||
// Own one listener for all subscriptions, as Orca does. The callback
|
||||
// convenience API lost mouse delivery when mixed with object subscriptions
|
||||
// in the private native probe.
|
||||
adapter.eventListener = C.atspi_event_listener_new(
|
||||
C.AtspiEventListenerCB(C.cthulhu_event_callback_pointer()), C.gpointer(callbackData), nil)
|
||||
if adapter.eventListener == nil {
|
||||
C.cthulhu_callback_data_free(callbackData)
|
||||
adapter.callbackHandle = 0
|
||||
adapter.callbackData = nil
|
||||
callbackHandle.Delete()
|
||||
C.atspi_exit()
|
||||
return errors.New("allocate AT-SPI event listener")
|
||||
}
|
||||
|
||||
for _, eventType := range eventTypes {
|
||||
if err := adapter.registerEvent(eventType); err != nil {
|
||||
@@ -135,13 +148,7 @@ func (adapter *Adapter) initialize() error {
|
||||
func (adapter *Adapter) registerEvent(eventType string) error {
|
||||
nativeEventType := C.CString(eventType)
|
||||
var registerError *C.GError
|
||||
registered := C.atspi_event_listener_register_from_callback(
|
||||
C.AtspiEventListenerCB(C.cthulhu_event_callback_pointer()),
|
||||
unsafe.Pointer(adapter.callbackData),
|
||||
nil,
|
||||
nativeEventType,
|
||||
®isterError,
|
||||
)
|
||||
registered := C.atspi_event_listener_register(adapter.eventListener, nativeEventType, ®isterError)
|
||||
C.free(unsafe.Pointer(nativeEventType))
|
||||
if registerError != nil {
|
||||
err := nativeError("register AT-SPI event listener "+eventType, registerError)
|
||||
@@ -159,18 +166,17 @@ func (adapter *Adapter) deregisterEvents() error {
|
||||
for _, eventType := range eventTypes {
|
||||
nativeEventType := C.CString(eventType)
|
||||
var deregisterError *C.GError
|
||||
C.atspi_event_listener_deregister_from_callback(
|
||||
C.AtspiEventListenerCB(C.cthulhu_event_callback_pointer()),
|
||||
unsafe.Pointer(adapter.callbackData),
|
||||
nativeEventType,
|
||||
&deregisterError,
|
||||
)
|
||||
C.atspi_event_listener_deregister(adapter.eventListener, nativeEventType, &deregisterError)
|
||||
C.free(unsafe.Pointer(nativeEventType))
|
||||
if deregisterError != nil {
|
||||
stopErr = errors.Join(stopErr, nativeError("deregister AT-SPI event listener "+eventType, deregisterError))
|
||||
C.g_error_free(deregisterError)
|
||||
}
|
||||
}
|
||||
if adapter.eventListener != nil {
|
||||
C.g_object_unref(C.gpointer(adapter.eventListener))
|
||||
adapter.eventListener = nil
|
||||
}
|
||||
return stopErr
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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:]...)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"),
|
||||
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
|
||||
Reference in new issue
Block a user