More browser improvements.
This commit is contained in:
1 parent
ebb9ad48d0
commit
f753e9b505
28 files changed
+603
-81
No files matched your search
@@ -161,7 +161,25 @@ 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.
|
||||
When a larger subtree disappears, navigation can restart within its nearest
|
||||
surviving noninteractive container. Ambiguous children, editable controls and
|
||||
replaced parents do not inherit the old offset. Removing a native focus anchor
|
||||
does not reset a still-valid virtual reading position elsewhere in that document.
|
||||
|
||||
Enter activates the current link or clickable, including explicitly named custom
|
||||
controls and clickable ancestors of ordinary text. Activation itself is silent
|
||||
whether it succeeds or fails; the page's own focus changes and live regions still
|
||||
speak. Ordinary text does not cause an unnecessary document recapture. A removed
|
||||
target or replaced document cannot silently substitute a different action.
|
||||
|
||||
Successful virtual navigation presents the requested content even if the browser
|
||||
refuses native caret placement; synchronization failures remain in diagnostic logs.
|
||||
Stale native placement gets one refresh and retry at the same target and offset.
|
||||
Braille routing also recognizes the temporary caret produced by clearing a
|
||||
selection, so it cannot reset the routed reading position.
|
||||
|
||||
See [browser release review](docs/testing/browser-release-review.md) for findings,
|
||||
automated checks and remaining live acceptance.
|
||||
|
||||
The generic changing-document regression starts a private reader, browser
|
||||
profile, X display, accessibility bus and speech protocol recorder:
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
# Browser release review
|
||||
|
||||
Reviewed `423590e` (Browser updates) and `ebb9ad4` (Better logging for browser)
|
||||
on the Go `testing` branch, including the surrounding navigation and activation
|
||||
paths. References were the local Orca web caret-context and removed-child recovery
|
||||
code and Python Cthulhu's `master:src/cthulhu/scripts/web/script.py` clickable
|
||||
activation. Reference checkouts were not changed.
|
||||
|
||||
## Findings and changes
|
||||
|
||||
- Native selection clearing could emit a temporary caret at the selection start
|
||||
before Braille routing finished. A private Chromium mode test reproduced a
|
||||
routed offset of one being reset to zero, so Right read “e” instead of “x”.
|
||||
Native echo tracking now recognizes selection-collapse endpoints separately
|
||||
from the requested caret. A new nonempty selection remains native input.
|
||||
- Recovering a removed native focus anchor reset an otherwise valid virtual
|
||||
position. Recovery now rebinds the anchor within the same document generation,
|
||||
retaining the virtual object and offset.
|
||||
- The recent single-text-child recovery did not cover replacement of several
|
||||
children or a containing paragraph. Navigation now retains at most 32 ancestor
|
||||
references per saved position and can restart inside a surviving noninteractive
|
||||
container. This fallback transfers no offset and is excluded from activation.
|
||||
Document changes, hidden content and editable contexts remain boundaries.
|
||||
- Native caret placement failure was spoken after successful structural reading.
|
||||
The content now speaks without that misleading failure announcement; diagnostic
|
||||
errors remain available. Arrow navigation now uses the same bounded stale
|
||||
placement retry as structural navigation, without advancing twice.
|
||||
- Name-event echo suppression could bypass both live-region presentation and the
|
||||
new focused-button coalescer. Live regions and buttons now reach those existing
|
||||
handlers; button updates still refresh Braille immediately and debounce speech.
|
||||
- Enter failures announced “Could not activate current item.” All activation
|
||||
results now remain silent, without interrupting page-generated feedback.
|
||||
- Snapshot capture recognized only `click`, although activation supported
|
||||
`click-ancestor`, `press`, `jump`, `open` and `activate`. Capture now recognizes
|
||||
the same names. Explicitly named custom clickables are whole browse stops;
|
||||
ordinary clickable text remains readable and activates its clickable ancestor.
|
||||
- Enter on ordinary text needlessly recaptured the document. A distinct
|
||||
nonactivatable result avoids that work. Native action names are fetched once
|
||||
rather than repeatedly for each preferred action spelling, and metadata capture
|
||||
stops after finding an activation action.
|
||||
- Action refresh could accept a replaced document generation with reused object
|
||||
paths. It now rejects that case when the retained tree establishes replacement.
|
||||
|
||||
No additional confirmed defect was found in the build-identity logging changes.
|
||||
These changes do not establish complete Orca browsing parity or a failure rate on
|
||||
arbitrary real pages. Browser/provider timeouts and genuinely missing document
|
||||
context can still produce a location error. Virtual movement cannot guarantee
|
||||
native Tab/selection placement when a provider refuses to move its caret.
|
||||
|
||||
## Automated checks
|
||||
|
||||
The review uses existing package tests plus focused regressions for silent
|
||||
activation, removed focus anchors, surviving-container recovery, custom clickable
|
||||
stops, document-generation rejection, live names, button coalescing and native
|
||||
placement retries. The private Chromium fixtures cover pointer input, list
|
||||
positions, tabs, linked headings, dynamic pages, live regions, structural commands,
|
||||
editing, selection and browse/focus modes. Firefox has baseline and tab checks.
|
||||
|
||||
The expanded real-reader changing-page test uses private i3, Xvfb, D-Bus, a browser
|
||||
profile and recorded SSIP output. It executes 76 navigation commands, checks silent
|
||||
Enter on ordinary text, and activates a custom clickable twice, requiring exactly
|
||||
the page's “Action count 1” and “Action count 2” messages. It does not use the
|
||||
desktop reader or audible speech.
|
||||
|
||||
Relevant commands:
|
||||
|
||||
```sh
|
||||
GOCACHE=/tmp/cthulhu-go-cache GOPROXY=off go test -race ./... -count=1 -timeout=120s
|
||||
go vet ./...
|
||||
go build -o /tmp/cthulhu-browser-review ./cmd/cthulhu
|
||||
go build -tags brlapi -o /tmp/cthulhu-browser-review-braille ./cmd/cthulhu-braille
|
||||
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,live python3 tests/native_browser.py
|
||||
python3 tests/native_firefox.py
|
||||
```
|
||||
|
||||
## Live acceptance still required
|
||||
|
||||
The contribution policy requires human review and live testing before submission.
|
||||
Automated speech requests and Braille frames do not verify audible output, physical
|
||||
keys or a physical Braille display. The active reader has not been replaced.
|
||||
|
||||
1. Quit the current Cthulhu with Cthulhu+Q. Press Ctrl+Escape to enter i3 personal
|
||||
mode, then separately press Shift+G. These are separate key presses. Shift+G
|
||||
runs `~/devel/cthulhu-go.sh` and rebuilds the reader and Braille helper.
|
||||
2. Open `file:///home/angus/devel/cthulhu/tests/browser-changing.html` in Brave.
|
||||
Tab to “Changing document”. Use H through Sections 1–10 and Statistics, then
|
||||
Shift+H backwards. Pause for several seconds partway through each direction.
|
||||
Expect the next heading in order, with matching Braille and no unexpected jump
|
||||
to an earlier heading or location/caret error.
|
||||
3. At Statistics, press Down to the changing counter. Try Right, Home,
|
||||
Ctrl+Right, Down and Up. Expect the requested character/word/line and matching
|
||||
Braille despite the counter updating. Down from the counter should reach
|
||||
“Following stable line”.
|
||||
4. On “Following stable line”, press Enter: expect no action feedback. Press
|
||||
Down to “Custom action”, then Enter twice. Expect only the page's “Action count
|
||||
1” and “Action count 2” messages, one per press. No “activated”, “activating” or
|
||||
activation error should be added.
|
||||
5. On a stable text line, press Home, Shift+Right twice and Shift+Left once.
|
||||
Expect selection feedback for the first two characters and removal of the
|
||||
second. If using Braille, route to the second character to clear selection;
|
||||
Right must then read the third character, with the Braille cursor there.
|
||||
6. Check Tab and Shift+Tab into the editable field, type, and use its arrows.
|
||||
Check Ctrl+L and browser tab switching. Expect native editing/chrome keys to
|
||||
work and browsing to resume at the correct page after returning.
|
||||
7. Repeat heading traversal and Enter on the real updating pages that previously
|
||||
failed. Report the exact key sequence, spoken text, focused control, Braille
|
||||
text/cursor and any unexpected keyboard behavior. Unexpected results should
|
||||
be diagnosed from `~/devel/cthulhu-go.log` before another restart.
|
||||
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"testing"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"git.stormux.org/storm/cthulhu/internal/review"
|
||||
)
|
||||
|
||||
func TestBrowserActivationResultsStaySilent(t *testing.T) {
|
||||
for _, activationErr := range []error{nil, review.ErrNoCurrent, atspi.ErrStaleObject, errors.New("action refused")} {
|
||||
queue := &fakeSpeechQueue{spoken: make(chan string, 8), interrupt: make(chan struct{}, 8)}
|
||||
focus := atspi.Snapshot{BusName: ":1.42", Path: "/clickable", Role: "section"}
|
||||
browser := newBrowserModeController(nil, &modeKeys{}, nil)
|
||||
browser.ready, browser.mode, browser.focus = true, review.BrowseMode, focus.ID()
|
||||
state := &eventLoopState{}
|
||||
state.current = focus
|
||||
handler := eventReviewHandler{state: state,
|
||||
logger: slog.New(slog.DiscardHandler), speech: queue,
|
||||
objectReview: &review.Worker{}, browser: browser, focusGate: &focusGate{}, syncForeground: func() {}}
|
||||
handler.handle(review.Result{DocumentActivate: true, DocumentFocus: focus.ID(), Err: activationErr})
|
||||
if len(queue.spoken) != 0 || len(queue.interrupt) != 0 {
|
||||
t.Fatalf("activation %v produced feedback or interrupted page speech", activationErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowserNavigationDoesNotSuppressLiveNames(t *testing.T) {
|
||||
browser := newBrowserModeController(nil, &modeKeys{}, nil)
|
||||
browser.navigation = atspi.NewObjectID(":1.42", "/heading")
|
||||
browser.navigationDocument = atspi.NewObjectID(":1.42", "/document")
|
||||
event := atspi.NewEvent(atspi.AccessibleNameEvent, atspi.Snapshot{
|
||||
BusName: ":1.42", Path: "/status", Role: "status bar", Name: "Download complete", LiveRegion: true})
|
||||
if browser.suppressNavigationEcho(event) {
|
||||
t.Fatal("reader navigation swallowed a live-region name update")
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,11 @@ func TestProcessEventsCoalescesWebButtonNames(t *testing.T) {
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("missing focus Braille")
|
||||
}
|
||||
// Structural navigation also owns this button. Updates must still reach
|
||||
// Braille and the coalescer while delayed caret/focus echoes are suppressed.
|
||||
browser.mutex.Lock()
|
||||
browser.navigation, browser.navigationDocument = button.ID(), browser.document
|
||||
browser.mutex.Unlock()
|
||||
// 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)
|
||||
@@ -75,6 +80,8 @@ func TestProcessEventsCoalescesWebButtonNames(t *testing.T) {
|
||||
default:
|
||||
}
|
||||
}
|
||||
// Native input releases echo ownership before the next focus event.
|
||||
browser.pointer_input()
|
||||
// A redundant native focus notification must not discard the final name.
|
||||
event.Type = atspi.FocusEvent
|
||||
adapter.events <- event
|
||||
|
||||
@@ -424,7 +424,8 @@ func (browser *browserModeController) suppressNavigationEcho(event atspi.Event)
|
||||
}
|
||||
target, document := browser.navigation, browser.navigationDocument
|
||||
browser.mutex.Unlock()
|
||||
if document.Valid() && event.Type == atspi.AccessibleNameEvent &&
|
||||
if document.Valid() && event.Type == atspi.AccessibleNameEvent && !event.Source.LiveRegion &&
|
||||
event.Source.Role != "button" && event.Source.Role != "push button" && event.Source.Role != "toggle button" &&
|
||||
event.Source.ID().BusName == document.BusName {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -450,10 +450,6 @@ func presentDocumentResult(logger *slog.Logger, speech speechEnqueuer, result re
|
||||
}
|
||||
if result.CaretErr != nil {
|
||||
logger.Warn("document caret synchronization failed", "error", result.CaretErr)
|
||||
spoken = spoken.Append("Could not move the browser caret.", ". ")
|
||||
text = spoken.Text
|
||||
fallbackSpeech = fallbackSpeech.Append("Could not move the browser caret.", ". ")
|
||||
fallbackText = fallbackSpeech.Text
|
||||
}
|
||||
}
|
||||
speech.Interrupt()
|
||||
|
||||
@@ -112,8 +112,6 @@ func (handler *eventReviewHandler) handle(result review.Result) {
|
||||
}
|
||||
if result.Err != nil {
|
||||
logger.Debug("document activation failed", "error", result.Err)
|
||||
speech.Interrupt()
|
||||
speech.Enqueue(speechRequest{Text: "Could not activate current item.", Role: "system", Cause: "document-activate"})
|
||||
} else {
|
||||
logger.Debug("document activation completed", "elapsed", result.Elapsed)
|
||||
}
|
||||
|
||||
@@ -141,7 +141,7 @@ func TestNativeLinkedHeadings(t *testing.T) {
|
||||
if err != nil || !move.Moved || move.Position.LinkedHeading != nil {
|
||||
t.Fatalf("plain-text heading with multiple links: %+v, %v", move, err)
|
||||
}
|
||||
if target, err := cursor.ActivationTarget(); !errors.Is(err, review.ErrNoCurrent) {
|
||||
if target, err := cursor.ActivationTarget(); target.Valid() || !errors.Is(err, review.ErrNoDocumentAction) {
|
||||
t.Fatalf("ambiguous activation target = %v, %v", target, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ func TestPauseChunksPreservePresentationDecorations(t *testing.T) {
|
||||
presentDocumentResult(logger, queue, review.Result{Moved: true, DocumentWrapped: true,
|
||||
DocumentPosition: review.DocumentPosition{Snapshot: atspi.Snapshot{Name: "Read, draft", Role: "link"}},
|
||||
CaretErr: errors.New("caret unavailable")}, options)
|
||||
}, []string{"Read, draft", "link", "Wrapping to top.", "Could not move the browser caret."}},
|
||||
}, []string{"Read, draft", "link", "Wrapping to top."}},
|
||||
{"tooltip", func(queue *fakeSpeechQueue) {
|
||||
handler := accessibilityEventHandler{state: &eventLoopState{}, speech: queue, live: live}
|
||||
event := atspi.NewEvent(atspi.ShowingStateEvent, atspi.Snapshot{Name: "Save, draft", Role: "tool tip"})
|
||||
|
||||
+37
-36
@@ -127,42 +127,43 @@ type Adapter struct {
|
||||
foregroundRecovery int // GLib thread: bounded retries while a new client publishes AT-SPI
|
||||
recoveringForeground bool
|
||||
|
||||
events chan Event
|
||||
treeRequests chan treeCaptureRequest
|
||||
overflow atomic.Uint64
|
||||
treeInvalidations atomic.Uint64
|
||||
secureMutex sync.Mutex
|
||||
secureContext context.Context
|
||||
secureCancel context.CancelFunc
|
||||
secureEpoch atomic.Uint64
|
||||
secure atomic.Bool
|
||||
secureReset atomic.Bool
|
||||
secureResync atomic.Bool
|
||||
captureSound atomic.Bool
|
||||
captureTextAttributes atomic.Bool
|
||||
running atomic.Bool
|
||||
ready atomic.Bool
|
||||
loopDone chan struct{}
|
||||
stopMutex sync.Mutex
|
||||
treeMutex sync.Mutex
|
||||
tree *AccessibleTree
|
||||
documentTree *AccessibleTree
|
||||
documentTextTree *AccessibleTree
|
||||
nativeDocument map[ObjectID]*C.AtspiAccessible
|
||||
documentLineCache map[ObjectID]DocumentCaretText // GLib owner; at most 64 bounded text objects
|
||||
nativeReview map[ObjectID]*C.AtspiAccessible
|
||||
nativeReviewBus string
|
||||
navigationEcho map[ObjectID]uint8
|
||||
navigationCaretOffset int
|
||||
nativeFocus []nativeFocusReference // owned only by the GLib thread
|
||||
mouseApplication *C.AtspiAccessible // GLib owner; released on shutdown
|
||||
mouseApplicationPID int
|
||||
documentWindows map[ObjectID]nativeDocumentWindow
|
||||
documentWindowIDs []ObjectID
|
||||
selectedTabWindow ObjectID
|
||||
activeDocument ObjectID
|
||||
callbackHandle cgo.Handle
|
||||
callbackData *C.cthulhu_callback_data
|
||||
events chan Event
|
||||
treeRequests chan treeCaptureRequest
|
||||
overflow atomic.Uint64
|
||||
treeInvalidations atomic.Uint64
|
||||
secureMutex sync.Mutex
|
||||
secureContext context.Context
|
||||
secureCancel context.CancelFunc
|
||||
secureEpoch atomic.Uint64
|
||||
secure atomic.Bool
|
||||
secureReset atomic.Bool
|
||||
secureResync atomic.Bool
|
||||
captureSound atomic.Bool
|
||||
captureTextAttributes atomic.Bool
|
||||
running atomic.Bool
|
||||
ready atomic.Bool
|
||||
loopDone chan struct{}
|
||||
stopMutex sync.Mutex
|
||||
treeMutex sync.Mutex
|
||||
tree *AccessibleTree
|
||||
documentTree *AccessibleTree
|
||||
documentTextTree *AccessibleTree
|
||||
nativeDocument map[ObjectID]*C.AtspiAccessible
|
||||
documentLineCache map[ObjectID]DocumentCaretText // GLib owner; at most 64 bounded text objects
|
||||
nativeReview map[ObjectID]*C.AtspiAccessible
|
||||
nativeReviewBus string
|
||||
navigationEcho map[ObjectID]uint8
|
||||
navigationCaretOffset int
|
||||
navigationSelectionOffsets map[int]bool
|
||||
nativeFocus []nativeFocusReference // owned only by the GLib thread
|
||||
mouseApplication *C.AtspiAccessible // GLib owner; released on shutdown
|
||||
mouseApplicationPID int
|
||||
documentWindows map[ObjectID]nativeDocumentWindow
|
||||
documentWindowIDs []ObjectID
|
||||
selectedTabWindow ObjectID
|
||||
activeDocument ObjectID
|
||||
callbackHandle cgo.Handle
|
||||
callbackData *C.cthulhu_callback_data
|
||||
}
|
||||
|
||||
type nativeFocusReference struct {
|
||||
|
||||
@@ -507,8 +507,19 @@ cthulhu_snapshot cthulhu_capture_snapshot(AtspiAccessible *source, gboolean capt
|
||||
if (error != NULL) {
|
||||
g_clear_error(&error);
|
||||
}
|
||||
if (g_strcmp0(actionName, "click") == 0) {
|
||||
// Web providers use different action names for activation. Keep
|
||||
// this list aligned with activateNativeDocumentObject so browse
|
||||
// Enter can reach objects exposing click-ancestor or press.
|
||||
if (actionName != NULL &&
|
||||
(g_ascii_strcasecmp(actionName, "click") == 0 ||
|
||||
g_ascii_strcasecmp(actionName, "click-ancestor") == 0 ||
|
||||
g_ascii_strcasecmp(actionName, "press") == 0 ||
|
||||
g_ascii_strcasecmp(actionName, "jump") == 0 ||
|
||||
g_ascii_strcasecmp(actionName, "open") == 0 ||
|
||||
g_ascii_strcasecmp(actionName, "activate") == 0)) {
|
||||
snapshot.supportsClick = TRUE;
|
||||
g_free(actionName);
|
||||
break;
|
||||
}
|
||||
g_free(actionName);
|
||||
}
|
||||
|
||||
@@ -207,13 +207,27 @@ func (adapter *Adapter) activateNativeDocumentObject(ctx context.Context, positi
|
||||
}
|
||||
defer C.g_object_unref(C.gpointer(action))
|
||||
names := []string{"click", "click-ancestor", "press", "jump", "open", "activate"}
|
||||
count := int(C.atspi_action_get_n_actions(action, nil))
|
||||
var nativeErr *C.GError
|
||||
count := int(C.atspi_action_get_n_actions(action, &nativeErr))
|
||||
if nativeErr != nil {
|
||||
err := nativeError("get document actions", nativeErr)
|
||||
C.g_error_free(nativeErr)
|
||||
return err
|
||||
}
|
||||
// Read each action name once. Previously each preferred spelling caused
|
||||
// another full round of synchronous accessibility calls.
|
||||
actionNames := make([]string, min(max(count, 0), 64))
|
||||
for index := range actionNames {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
name := C.atspi_action_get_name(action, C.gint(index), nil)
|
||||
actionNames[index] = C.GoString(name)
|
||||
C.g_free(C.gpointer(name))
|
||||
}
|
||||
for _, wanted := range names {
|
||||
for index := 0; index < count; index++ {
|
||||
name := C.atspi_action_get_name(action, C.gint(index), nil)
|
||||
matched := strings.EqualFold(C.GoString(name), wanted)
|
||||
C.g_free(C.gpointer(name))
|
||||
if !matched {
|
||||
for index, name := range actionNames {
|
||||
if !strings.EqualFold(name, wanted) {
|
||||
continue
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
@@ -449,9 +463,10 @@ func (adapter *Adapter) setNativeDocumentPosition(ctx context.Context, position
|
||||
}
|
||||
echoIDs = append(echoIDs, documentCaretEchoAncestors(tree, position)...)
|
||||
adapter.navigationCaretOffset = offset
|
||||
adapter.navigationSelectionOffsets = nil
|
||||
adapter.navigationEcho = make(map[ObjectID]uint8, len(echoIDs))
|
||||
for _, id := range echoIDs {
|
||||
adapter.navigationEcho[id] = 7
|
||||
adapter.navigationEcho[id] = 15
|
||||
}
|
||||
component := C.atspi_accessible_get_component_iface(source)
|
||||
if component != nil {
|
||||
@@ -491,6 +506,18 @@ func (adapter *Adapter) setNativeDocumentPosition(ctx context.Context, position
|
||||
adapter.navigationCaretOffset = int(caretOffset)
|
||||
// Python clears the previous selection before moving its browse caret.
|
||||
for selections := min(int(C.atspi_text_get_n_selections(text, nil)), 16); selections > 0; selections-- {
|
||||
// Removing a selection can first collapse to its start or end. Those
|
||||
// intermediate caret events must not overwrite the requested position,
|
||||
// particularly when routing Braille before the result reaches the UI.
|
||||
selection := C.atspi_text_get_selection(text, 0, nil)
|
||||
if selection != nil {
|
||||
if adapter.navigationSelectionOffsets == nil {
|
||||
adapter.navigationSelectionOffsets = make(map[int]bool)
|
||||
}
|
||||
adapter.navigationSelectionOffsets[int(selection.start_offset)] = true
|
||||
adapter.navigationSelectionOffsets[int(selection.end_offset)] = true
|
||||
C.g_free(C.gpointer(selection))
|
||||
}
|
||||
C.atspi_text_remove_selection(text, 0, nil)
|
||||
}
|
||||
moved := C.atspi_text_set_caret_offset(text, caretOffset, &nativeErr)
|
||||
|
||||
@@ -517,17 +517,7 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
|
||||
}
|
||||
}
|
||||
focusEvent.SnapshotDuration = time.Since(snapshotStarted)
|
||||
bit := uint8(0)
|
||||
switch eventType {
|
||||
case FocusEvent:
|
||||
bit = 1
|
||||
case FocusedStateEvent:
|
||||
bit = 2
|
||||
case CaretEvent:
|
||||
if focusEvent.Detail1 == adapter.navigationCaretOffset {
|
||||
bit = 4
|
||||
}
|
||||
}
|
||||
bit := documentNavigationEchoBit(focusEvent, adapter.navigationCaretOffset, adapter.navigationSelectionOffsets)
|
||||
if adapter.navigationEcho[snapshot.ID()]&bit != 0 {
|
||||
focusEvent.NavigationEcho = true
|
||||
adapter.navigationEcho[snapshot.ID()] &^= bit
|
||||
|
||||
@@ -4,6 +4,28 @@
|
||||
|
||||
package atspi
|
||||
|
||||
func documentNavigationEchoBit(event Event, offset int, selectionOffsets map[int]bool) uint8 {
|
||||
switch event.Type {
|
||||
case FocusEvent:
|
||||
return 1
|
||||
case FocusedStateEvent:
|
||||
return 2
|
||||
case CaretEvent:
|
||||
// New native selection is user input, even if its endpoint happens
|
||||
// to match a previously requested caret or collapsed selection.
|
||||
if event.Source.TextCaretKnown && event.Source.SelectionEnd > event.Source.SelectionStart {
|
||||
return 0
|
||||
}
|
||||
if event.Detail1 == offset {
|
||||
return 4
|
||||
}
|
||||
if event.Source.TextCaretKnown && selectionOffsets[event.Detail1] {
|
||||
return 8
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Chromium can echo a static text caret on its enclosing label/paragraph.
|
||||
// Include only a single-child chain sharing the complete text offset domain.
|
||||
// Composite parents have their own hypertext offsets and cannot be matched.
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) 2026 Storm Dragon
|
||||
// Authors: Storm Dragon, Angus Kola
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package atspi
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDocumentRoutingSelectionCollapseEcho(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
offset, start, end int
|
||||
want uint8
|
||||
}{
|
||||
{0, 0, 0, 8}, // Selection removal temporarily collapses at zero.
|
||||
{1, 0, 0, 4}, // Requested route completes at one.
|
||||
{2, 0, 0, 0}, // An unrelated native movement is not an echo.
|
||||
{0, 0, 1, 0}, // A fresh native selection must never be swallowed.
|
||||
{1, 0, 1, 0},
|
||||
} {
|
||||
event := NewEvent(CaretEvent, Snapshot{TextCaretKnown: true, SelectionStart: test.start, SelectionEnd: test.end})
|
||||
event.Detail1 = test.offset
|
||||
if got := documentNavigationEchoBit(event, 1, map[int]bool{0: true, 1: true}); got != test.want {
|
||||
t.Fatalf("offset %d, selection %d:%d: bit %d, want %d", test.offset, test.start, test.end, got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,10 @@ type DocumentMoveResult struct {
|
||||
Wrapped bool
|
||||
}
|
||||
|
||||
// ErrNoDocumentAction is a valid reading location without an unambiguous
|
||||
// activation target. It does not require a document recapture.
|
||||
var ErrNoDocumentAction = errors.New("current document item has no activation target")
|
||||
|
||||
type documentPlace struct {
|
||||
document atspi.ObjectReference
|
||||
object atspi.ObjectReference
|
||||
@@ -65,6 +69,8 @@ type documentPlace struct {
|
||||
// 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
|
||||
// Bounded surviving ancestry is a navigation fallback, never an action target.
|
||||
recoveryParents []atspi.ObjectReference
|
||||
}
|
||||
|
||||
// DocumentCursor remembers at most 16 document positions in least-recently-
|
||||
@@ -158,13 +164,13 @@ func (cursor *DocumentCursor) ActivationTarget() (atspi.ObjectReference, error)
|
||||
return link.Reference, nil
|
||||
}
|
||||
if ambiguous {
|
||||
return atspi.ObjectReference{}, ErrNoCurrent
|
||||
return atspi.ObjectReference{}, ErrNoDocumentAction
|
||||
}
|
||||
}
|
||||
if clickable.Valid() {
|
||||
return clickable, nil
|
||||
}
|
||||
return atspi.ObjectReference{}, ErrNoCurrent
|
||||
return atspi.ObjectReference{}, ErrNoDocumentAction
|
||||
}
|
||||
|
||||
func (cursor *DocumentCursor) SyncFocus(ctx context.Context, focus atspi.ObjectID) (DocumentPosition, error) {
|
||||
@@ -209,11 +215,15 @@ func (cursor *DocumentCursor) sync_focus(ctx context.Context, focus atspi.Object
|
||||
place = saved
|
||||
place.focus, place.object = ref, replacement.Reference
|
||||
place.offset = min(saved.offset, max(0, replacement.Snapshot.CharacterCount-1))
|
||||
} else if parent, ok := survivingDocumentParent(doc, saved); ok {
|
||||
recovery = "surviving-parent"
|
||||
place.object = parent.Reference
|
||||
}
|
||||
}
|
||||
cursor.places = append(cursor.places[:index], cursor.places[index+1:]...)
|
||||
break
|
||||
}
|
||||
remember_text_parent(doc, &place)
|
||||
if len(cursor.places) == 16 {
|
||||
cursor.places = cursor.places[1:]
|
||||
}
|
||||
@@ -376,6 +386,7 @@ func (cursor *DocumentCursor) Move(ctx context.Context, target DocumentTarget, l
|
||||
place.object = node.Reference
|
||||
place.origin = StructuralNavigation
|
||||
place.offset = 0
|
||||
remember_text_parent(doc, place)
|
||||
return DocumentMoveResult{Position: documentPosition(doc, *place), Moved: true}, nil
|
||||
}
|
||||
}
|
||||
@@ -403,6 +414,7 @@ func (cursor *DocumentCursor) Move(ctx context.Context, target DocumentTarget, l
|
||||
place.object = node.Reference
|
||||
place.origin = StructuralNavigation
|
||||
place.offset = 0
|
||||
remember_text_parent(doc, place)
|
||||
return DocumentMoveResult{
|
||||
Position: documentPosition(doc, *place),
|
||||
Moved: true,
|
||||
|
||||
@@ -6,6 +6,7 @@ package review
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -18,6 +19,75 @@ type refreshingActivator struct {
|
||||
staleOnce bool
|
||||
}
|
||||
|
||||
func TestActivationDoesNotRecaptureNonClickableText(t *testing.T) {
|
||||
cursor := newCaretTextCursor(t, caretTextReader{webID("/text"): "Ordinary paragraph"})
|
||||
capturer := &fakeCapturer{calls: make(chan captureCall, 1)}
|
||||
worker, _ := NewWorker(capturer, time.Second)
|
||||
worker.SyncFocus(webID("/text"))
|
||||
result, _ := worker.activateDocumentCaret(context.Background(), cursor, 0)
|
||||
if !errors.Is(result.Err, ErrNoDocumentAction) || len(capturer.calls) != 0 {
|
||||
t.Fatalf("plain text triggered a capture: %+v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivationRejectsReplacedDocumentGeneration(t *testing.T) {
|
||||
tree := activationTree(t, true)
|
||||
cursor, _ := NewDocumentCursor(tree)
|
||||
cursor.SyncFocus(context.Background(), webID("/focused"))
|
||||
cursor.places[0].object, _ = tree.Reference(webID("/selected"))
|
||||
tree.Invalidate(webID("/doc"))
|
||||
nodes := []atspi.TreeNode{
|
||||
{Snapshot: atspi.Snapshot{BusName: webID("/doc").BusName, Path: "/doc", Role: "document web"}, Children: []atspi.ObjectID{webID("/focused"), webID("/selected")}},
|
||||
{Snapshot: atspi.Snapshot{BusName: webID("/focused").BusName, Path: "/focused", Role: "link"}},
|
||||
{Snapshot: atspi.Snapshot{BusName: webID("/selected").BusName, Path: "/selected", Role: "link"}},
|
||||
}
|
||||
if err := tree.Replace(webID("/doc"), nodes); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
activator := &refreshingActivator{readAllCapturer: &readAllCapturer{tree: tree}}
|
||||
worker, _ := NewWorker(activator, time.Second)
|
||||
worker.SyncFocus(webID("/focused"))
|
||||
result, _ := worker.activateDocumentCaret(context.Background(), cursor, 0)
|
||||
if result.Err == nil || activator.target.Valid() {
|
||||
t.Fatalf("activation crossed a document generation: %+v, %v", result, activator.target)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBrowseNamedCustomClickableAndActivateAncestor(t *testing.T) {
|
||||
reader := caretTextReader{webID("/start"): "Before action", webID("/label"): "Visible label", webID("/after"): "After action"}
|
||||
for _, explicit := range []bool{false, true} {
|
||||
nodes := []atspi.TreeNode{
|
||||
{Snapshot: atspi.Snapshot{BusName: webID("/doc").BusName, Path: "/doc", Role: "document web"}, Children: []atspi.ObjectID{webID("/start"), webID("/custom"), webID("/after")}},
|
||||
{Snapshot: atspi.Snapshot{BusName: webID("/start").BusName, Path: "/start", Role: "static", CharacterCount: 13}},
|
||||
{Snapshot: atspi.Snapshot{BusName: webID("/custom").BusName, Path: "/custom", Role: "section", Name: "Perform action", SupportsClick: true, ExplicitName: explicit}, Children: []atspi.ObjectID{webID("/label")}},
|
||||
{Snapshot: atspi.Snapshot{BusName: webID("/label").BusName, Path: "/label", Role: "static", CharacterCount: 13}},
|
||||
{Snapshot: atspi.Snapshot{BusName: webID("/after").BusName, Path: "/after", Role: "static", CharacterCount: 12}},
|
||||
}
|
||||
tree, err := atspi.NewAccessibleTree(webID("/doc"), nodes)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cursor, _ := NewDocumentCursor(tree)
|
||||
cursor.SyncFocus(context.Background(), webID("/start"))
|
||||
move, err := cursor.MoveCaret(context.Background(), reader, CaretNextLine)
|
||||
want := webID("/label")
|
||||
if explicit {
|
||||
want = webID("/custom")
|
||||
}
|
||||
if err != nil || !move.Moved || move.Position.Object.ID() != want {
|
||||
t.Fatalf("custom clickable stop: %+v, %v", move, err)
|
||||
}
|
||||
target, err := cursor.ActivationTarget()
|
||||
if err != nil || target.ID() != webID("/custom") {
|
||||
t.Fatalf("activation target = %v, %v", target, err)
|
||||
}
|
||||
move, err = cursor.MoveCaret(context.Background(), reader, CaretNextLine)
|
||||
if err != nil || move.Text != "After action" {
|
||||
t.Fatalf("clickable trapped navigation: %+v, %v", move, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (activator *refreshingActivator) ActivateDocumentObject(_ context.Context, target atspi.ObjectReference, _ atspi.ObjectID) error {
|
||||
if activator.staleOnce {
|
||||
activator.staleOnce = false
|
||||
|
||||
@@ -103,6 +103,7 @@ func (worker *Worker) route_document_braille(ctx context.Context, cursor *Docume
|
||||
place := &cursor.places[len(cursor.places)-1]
|
||||
place.object, place.offset, place.origin = node.Reference, route.offset, CaretNavigation
|
||||
place.tableCell = atspi.ObjectID{}
|
||||
remember_text_parent(doc, place)
|
||||
snapshot, err := cursor.tree.Resolve(node.Reference)
|
||||
result.DocumentPosition = DocumentPosition{Document: owner.TopLevel, Object: node.Reference, Snapshot: snapshot, Offset: route.offset, Origin: CaretNavigation}
|
||||
cursor.mu.Unlock()
|
||||
|
||||
@@ -20,6 +20,12 @@ func wholeCaretNode(snapshot atspi.Snapshot) bool {
|
||||
if snapshot.Role == "image" {
|
||||
return snapshot.Name != "" || snapshot.Description != ""
|
||||
}
|
||||
// An explicitly named custom clickable is an atomic stop just like a
|
||||
// named link. Ordinary clickable text remains readable by character/word.
|
||||
if snapshot.SupportsClick && snapshot.ExplicitName && strings.TrimSpace(snapshot.Name) != "" &&
|
||||
!snapshot.Editable && !atspi.IsWebDocumentRole(snapshot.Role) {
|
||||
return true
|
||||
}
|
||||
switch snapshot.Role {
|
||||
case "button", "check box", "check menu item", "list box", "menu item", "page tab",
|
||||
"radio menu item", "radio button", "push button", "toggle button":
|
||||
|
||||
@@ -12,17 +12,48 @@ import "git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
// 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() {
|
||||
place.recoveryParents = nil
|
||||
// Nodes are in provider preorder. Walking backwards follows the parent
|
||||
// chain in one scan, without rescanning all nodes for each ancestor.
|
||||
var parentID atspi.ObjectID
|
||||
plainText := false
|
||||
for index := len(doc.Nodes) - 1; index >= 0; index-- {
|
||||
node := doc.Nodes[index]
|
||||
if node.Reference == place.object {
|
||||
parentID, plainText = node.Parent, replacement_text_leaf(node)
|
||||
continue
|
||||
}
|
||||
for _, child := range doc.Nodes {
|
||||
if child.Reference == place.object && replacement_text_leaf(child) {
|
||||
place.textParent = parent.Reference
|
||||
return
|
||||
if !parentID.Valid() || node.Reference.ID() != parentID {
|
||||
continue
|
||||
}
|
||||
if len(place.recoveryParents) == 0 && plainText && replacement_text_parent(node) {
|
||||
place.textParent = node.Reference
|
||||
}
|
||||
if node.Reference == place.document || len(place.recoveryParents) == 32 {
|
||||
break
|
||||
}
|
||||
place.recoveryParents = append(place.recoveryParents, node.Reference)
|
||||
parentID = node.Parent
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation may restart within a surviving container when a page replaces
|
||||
// several text children at once. Never transfer offsets or use this fallback
|
||||
// for actions, and never cross a document generation, hidden or editable area.
|
||||
func survivingDocumentParent(doc atspi.DocumentSnapshot, saved documentPlace) (atspi.DocumentNode, bool) {
|
||||
if saved.document != doc.Context.TopLevel {
|
||||
return atspi.DocumentNode{}, false
|
||||
}
|
||||
for _, parent := range saved.recoveryParents {
|
||||
for _, node := range doc.Nodes {
|
||||
if node.Reference == parent && !node.Hidden && !node.ParentEditable &&
|
||||
!node.Snapshot.Editable && !node.Snapshot.Focusable && !node.Snapshot.SupportsClick &&
|
||||
!atspi.IsWebDocumentRole(node.Snapshot.Role) {
|
||||
return node, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return atspi.DocumentNode{}, false
|
||||
}
|
||||
|
||||
func replacement_text_node(doc atspi.DocumentSnapshot, saved documentPlace) (atspi.DocumentNode, bool) {
|
||||
|
||||
@@ -7,6 +7,7 @@ package review
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
@@ -59,6 +60,8 @@ func TestDocumentCaretRecoversSingleReplacedTextChild(t *testing.T) {
|
||||
wantRecovery := "saved-object-missing"
|
||||
if kind == "replaced document" {
|
||||
wantRecovery = "document-replaced"
|
||||
} else if kind == "multiple children" {
|
||||
wantRecovery = "surviving-parent"
|
||||
}
|
||||
if recovery != wantRecovery {
|
||||
t.Fatalf("recovery = %q, want %q", recovery, wantRecovery)
|
||||
@@ -78,3 +81,75 @@ func TestDocumentCaretRecoversSingleReplacedTextChild(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentRecoveryKeepsVirtualPlaceWhenNativeAnchorDisappears(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
reader := caretTextReader{webID("/text"): "First paragraph", webID("/next"): "Selected paragraph"}
|
||||
cursor := newCaretTextCursor(t, reader)
|
||||
move, err := cursor.MoveCaret(ctx, reader, CaretNextLine)
|
||||
if err != nil || move.Position.Object.ID() != webID("/next") {
|
||||
t.Fatalf("select second paragraph: %+v, %v", move, err)
|
||||
}
|
||||
worker, _ := NewWorker(&readAllCapturer{tree: cursor.tree}, time.Second)
|
||||
worker.SyncFocus(webID("/text"))
|
||||
nodes := []atspi.TreeNode{
|
||||
{Snapshot: atspi.Snapshot{BusName: webID("/doc").BusName, Path: "/doc", Role: "document web"}, Children: []atspi.ObjectID{webID("/next")}},
|
||||
{Snapshot: atspi.Snapshot{BusName: webID("/next").BusName, Path: "/next", Role: "static", CharacterCount: len(reader[webID("/next")])}},
|
||||
}
|
||||
if err := cursor.tree.Replace(webID("/doc"), nodes); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result := Result{DocumentFocus: webID("/text")}
|
||||
if !worker.recoverCapturedDocumentFocus(cursor, cursor.tree, &result) {
|
||||
t.Fatal("removed native anchor was not recovered")
|
||||
}
|
||||
if _, err := cursor.SyncFocus(ctx, result.DocumentFocus); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
move, err = cursor.MoveCaret(ctx, reader, CaretNextCharacter)
|
||||
if err != nil || move.Text != "e" || move.Position.Object.ID() != webID("/next") || move.Position.Offset != 1 {
|
||||
t.Fatalf("virtual position reset after native anchor removal: %+v, %v", move, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocumentRecoveryUsesSurvivingContainerWithoutTransferringOffset(t *testing.T) {
|
||||
for _, command := range []DocumentCaretCommand{CaretNextLine, CaretPreviousLine} {
|
||||
t.Run(command.String(), func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
reader := caretTextReader{webID("/before"): "Previous paragraph", webID("/old"): "Old contents", webID("/new"): "Replacement contents"}
|
||||
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: len(reader[webID(path)])}, Children: children}
|
||||
}
|
||||
nodes := []atspi.TreeNode{node("/doc", "document web", webID("/before"), webID("/section")),
|
||||
node("/before", "static"), node("/section", "section", 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)
|
||||
cursor.SyncFocus(ctx, webID("/doc"))
|
||||
cursor.MoveCaret(ctx, reader, CaretNextLine)
|
||||
cursor.MoveCaret(ctx, reader, CaretNextLine)
|
||||
cursor.MoveCaret(ctx, reader, CaretNextWord)
|
||||
nodes[2].Children = []atspi.ObjectID{webID("/new")}
|
||||
nodes = append(nodes[:3], node("/new", "static"))
|
||||
if err := tree.Replace(webID("/doc"), nodes); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
position, recovery, err := cursor.sync_focus(ctx, webID("/doc"))
|
||||
if err != nil || recovery != "surviving-parent" || position.Object.ID() != webID("/section") || position.Offset != 0 {
|
||||
t.Fatalf("container recovery: %+v, %s, %v", position, recovery, err)
|
||||
}
|
||||
move, err := cursor.MoveCaret(ctx, reader, command)
|
||||
want := "Replacement contents"
|
||||
if command == CaretPreviousLine {
|
||||
want = "Previous paragraph"
|
||||
}
|
||||
if err != nil || !move.Moved || move.Text != want {
|
||||
t.Fatalf("navigation after replacement: %+v, %v", move, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -104,7 +104,7 @@ func TestLinkedHeadingNavigationAndActivation(t *testing.T) {
|
||||
}
|
||||
target, err := cursor.ActivationTarget()
|
||||
if tc.multiple {
|
||||
if move.Position.LinkedHeading != nil || !errors.Is(err, ErrNoCurrent) {
|
||||
if move.Position.LinkedHeading != nil || !errors.Is(err, ErrNoDocumentAction) {
|
||||
t.Fatalf("ambiguous heading resolved: %+v, %v", move.Position.LinkedHeading, err)
|
||||
}
|
||||
} else if err != nil || target.ID() != webID(tc.link) || move.Position.LinkedHeading == nil || !move.Position.LinkedHeading.Visited {
|
||||
|
||||
@@ -7,6 +7,7 @@ package review
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -206,7 +207,7 @@ func TestDocumentFragmentErrorsDoNotMoveCursor(t *testing.T) {
|
||||
reader.err = failure
|
||||
before := cursor.places[0]
|
||||
_, err := cursor.MoveCaret(context.Background(), reader, CaretNextLine)
|
||||
if !errors.Is(err, failure) || cursor.places[0] != before {
|
||||
if !errors.Is(err, failure) || !reflect.DeepEqual(cursor.places[0], before) {
|
||||
t.Fatalf("error %v: %v; changed cursor", failure, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,7 +150,13 @@ func (worker *Worker) jumpDocumentFragment(ctx context.Context, cursor *Document
|
||||
}
|
||||
|
||||
func (worker *Worker) placeDocumentWithRetry(ctx context.Context, cursor *DocumentCursor, result Result) Result {
|
||||
result = worker.placeDocument(ctx, cursor, result)
|
||||
place := func(result Result) Result {
|
||||
if result.DocumentCaretCommand != 0 {
|
||||
return worker.placeDocumentCaret(ctx, result)
|
||||
}
|
||||
return worker.placeDocument(ctx, cursor, result)
|
||||
}
|
||||
result = place(result)
|
||||
if !documentCaretNeedsRecapture(result.CaretErr) || !worker.CurrentDocumentResult(result) {
|
||||
return result
|
||||
}
|
||||
@@ -160,7 +166,9 @@ func (worker *Worker) placeDocumentWithRetry(ctx context.Context, cursor *Docume
|
||||
oldPosition := result.DocumentPosition
|
||||
result.DocumentDiagnostics.CaptureAttempts++
|
||||
captureContext, cancel := context.WithTimeout(ctx, worker.captureTimeout)
|
||||
captureStarted := time.Now()
|
||||
tree, err := worker.capturer.CaptureDocument(captureContext, result.DocumentFocus)
|
||||
result.CaptureDuration += time.Since(captureStarted)
|
||||
cancel()
|
||||
if err != nil || tree == nil || !worker.CurrentDocumentResult(result) {
|
||||
return result
|
||||
@@ -181,7 +189,7 @@ func (worker *Worker) placeDocumentWithRetry(ctx context.Context, cursor *Docume
|
||||
result.DocumentPosition.Document = owner.TopLevel
|
||||
result.DocumentPosition.Snapshot = snapshot
|
||||
result.CaretErr = nil
|
||||
result = worker.placeDocument(ctx, cursor, result)
|
||||
result = place(result)
|
||||
if result.Err == nil && result.CaretErr == nil && cursor != nil {
|
||||
cursor.rebindPlace(tree, oldPosition, result.DocumentPosition)
|
||||
}
|
||||
@@ -249,7 +257,7 @@ func (worker *Worker) moveDocumentCaret(ctx context.Context, cursor *DocumentCur
|
||||
}
|
||||
result.Err = move()
|
||||
if !documentNeedsCapture(result.Err) {
|
||||
return worker.placeDocumentCaret(ctx, result), cursor
|
||||
return worker.placeDocumentWithRetry(ctx, cursor, result), cursor
|
||||
}
|
||||
result.CaptureReason = result.Err
|
||||
result.DocumentDiagnostics.CaptureAttempts++
|
||||
@@ -277,7 +285,7 @@ func (worker *Worker) moveDocumentCaret(ctx context.Context, cursor *DocumentCur
|
||||
result.Err = move()
|
||||
if result.Moved || !documentNeedsCapture(result.Err) ||
|
||||
!worker.CurrentDocumentResult(result) || worker.focus() != result.DocumentFocus {
|
||||
return worker.placeDocumentCaret(ctx, result), cursor
|
||||
return worker.placeDocumentWithRetry(ctx, cursor, result), cursor
|
||||
}
|
||||
// A dynamic page can replace the captured target between recapture and
|
||||
// SyncFocus. Retry once only if the first attempt did not move the caret.
|
||||
@@ -295,7 +303,7 @@ func (worker *Worker) moveDocumentCaret(ctx context.Context, cursor *DocumentCur
|
||||
cursor, _ = NewDocumentCursor(tree)
|
||||
}
|
||||
result.Err = move()
|
||||
return worker.placeDocumentCaret(ctx, result), cursor
|
||||
return worker.placeDocumentWithRetry(ctx, cursor, result), cursor
|
||||
}
|
||||
|
||||
func (worker *Worker) placeDocumentCaret(ctx context.Context, result Result) Result {
|
||||
@@ -416,7 +424,8 @@ func (worker *Worker) refreshDocumentCursor(ctx context.Context, cursor *Documen
|
||||
return cursor, err
|
||||
}
|
||||
if saved.document.Valid() && (saved.focus.ID() == focus || saved.object.ID() == focus) {
|
||||
if saved.document.ID() != position.Document.ID() {
|
||||
if saved.document.ID() != position.Document.ID() ||
|
||||
cursor.tree == tree && saved.document != position.Document {
|
||||
return cursor, atspi.ErrStaleObject
|
||||
}
|
||||
target, err := tree.Reference(saved.object.ID())
|
||||
@@ -450,6 +459,17 @@ func (worker *Worker) recoverCapturedDocumentFocus(cursor *DocumentCursor, tree
|
||||
if !worker.recoverDocumentFocus(result.DocumentFocus, root.ID(), result.DocumentEpoch) {
|
||||
return false
|
||||
}
|
||||
// Losing the native anchor does not invalidate a virtual position elsewhere
|
||||
// in the same generation. Rebind only the anchor so SyncFocus can retain
|
||||
// the selected text or recover its surviving parent after this capture.
|
||||
if cursor.tree == tree {
|
||||
cursor.mu.Lock()
|
||||
place := &cursor.places[len(cursor.places)-1]
|
||||
if place.document == root && place.focus.ID() == result.DocumentFocus {
|
||||
place.focus = root
|
||||
}
|
||||
cursor.mu.Unlock()
|
||||
}
|
||||
result.DocumentFocus = root.ID()
|
||||
result.DocumentDiagnostics.FocusRecovered = true
|
||||
return true
|
||||
|
||||
@@ -39,6 +39,37 @@ type boundaryNavigator struct {
|
||||
offset int
|
||||
}
|
||||
|
||||
type refreshingCaretNavigator struct {
|
||||
*readAllCapturer
|
||||
caretTextReader
|
||||
placements []int
|
||||
}
|
||||
|
||||
func (navigator *refreshingCaretNavigator) SetDocumentCaretPosition(_ context.Context, _ atspi.ObjectReference, _ atspi.ObjectID, offset int) error {
|
||||
navigator.placements = append(navigator.placements, offset)
|
||||
if len(navigator.placements) == 1 {
|
||||
return atspi.ErrStaleSnapshot
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestWorkerRetriesNativeCaretPlacementWithoutRepeatingMovement(t *testing.T) {
|
||||
text := caretTextReader{webID("/text"): "First line"}
|
||||
cursor := newCaretTextCursor(t, text)
|
||||
navigator := &refreshingCaretNavigator{readAllCapturer: &readAllCapturer{tree: cursor.tree}, caretTextReader: text}
|
||||
worker, _ := NewWorker(navigator, time.Second)
|
||||
worker.SyncFocus(webID("/text"))
|
||||
result, _ := worker.moveDocumentCaret(context.Background(), cursor, CaretNextCharacter, 0)
|
||||
if result.Err != nil || result.CaretErr != nil || result.DocumentCaretText != "i" || result.DocumentPosition.Offset != 1 ||
|
||||
len(navigator.placements) != 2 || navigator.placements[0] != 1 || navigator.placements[1] != 1 {
|
||||
t.Fatalf("retry skipped or lost requested character: %+v, placements %v", result, navigator.placements)
|
||||
}
|
||||
next, _ := worker.moveDocumentCaret(context.Background(), cursor, CaretNextCharacter, 0)
|
||||
if next.Err != nil || next.DocumentCaretText != "r" || next.DocumentPosition.Offset != 2 {
|
||||
t.Fatalf("retry lost the saved position: %+v", next)
|
||||
}
|
||||
}
|
||||
|
||||
func (navigator *boundaryNavigator) SetDocumentCaretPosition(_ context.Context, position atspi.ObjectReference, focus atspi.ObjectID, offset int) error {
|
||||
navigator.position, navigator.focus, navigator.offset = position, focus, offset
|
||||
return nil
|
||||
|
||||
Vendored
+1
@@ -57,3 +57,4 @@ steam-notifications,src/cthulhu/scripts/apps/steamwebhelper/script.py;tests/test
|
||||
libreoffice-calc-basic,Orca src/orca/scripts/apps/soffice/script.py; Orca src/orca/ax_utilities_table.py; Orca src/orca/speech_generator.py,libreoffice,Present Calc cells and recover logical focus around editing and sheet changes,Storm Dragon,internal/app internal/atspi internal/presentation,in-progress,GOCACHE=/tmp/cthulhu-go-cache go test -race ./internal/atspi ./internal/app ./internal/presentation -count=1; /usr/bin/python3 tests/native_libreoffice.py,Private real LibreOffice Calc 26.2.6.3 on Xvfb and a private AT-SPI bus validates cell movement blanks F2 typing Escape Enter and sheet changes.,,Recorded speech assertions pass; audible and physical-input acceptance pending.,Recorded value and coordinate frames pass; physical Braille and editor routing acceptance pending.,Current Orca is the primary Office reference. Dedicated application policy follows verified process identity after foreground/security gates; native capture remains bounded. Formula presence and explicit formula/basic Where Am I speech are implemented with bounded uncached cell attributes and recorded real-Calc checks; physical acceptance remains pending. Bounded selection deltas for small ranges and row/column selections plus count summaries for large selections and Select All are implemented. Real Calc checks cover disjoint cells and huge selections; physical speech/input/Braille acceptance remains pending. Dynamic row and column headers now use session choices scoped by provider bus document view window and exact sheet name; native reads are bounded to two cells. Four assignable commands preserve existing shortcuts. Private i3 Calc checks cover heading edits independent axis clearing sheet round trips both workbook-return directions with distinct headings and Find and Replace typing/cancellation. Frame activation and X11 foreground recovery avoid stale descendant searches; physical speech/input/Braille acceptance remains pending. Full-row reading Writer workflows and full topology reporting for large selections remain open. See LIBREOFFICE_SUPPORT.md.
|
||||
steam-shader-progress,Steam shader dialog AT-SPI events,web,Report Steam Vulkan shader percentages through shared progress output,,internal/webscripts internal/app,in-progress,go test ./internal/app ./internal/webscripts ./internal/presentation ./internal/sound -count=1; python3 tests/native_progress.py; CTHULHU_NATIVE_BROWSER_TESTS=progress python3 tests/native_browser.py,Private GTK and Brave HTML/ARIA progress checks passed.,,Tone requests verified; live Steam listening pending.,Unrelated Braille ownership covered by unit tests; physical check pending.,English static shader-status name changes only; verified steamwebhelper identity; see PROGRESS_BARS.md.
|
||||
wayland-shared-keyboard-access,src/cthulhu/input_event_manager.py,input,Monitor keys and grab reader shortcuts through the shared accessibility keyboard interface,Storm Dragon,internal/platform/wayland internal/app internal/sessionbus,in-progress,Private dbus-run-session race tests cover wire protocol runtime dispatch configuration rollback secure transitions and cleanup; go test ./... -count=1; python3 tests/check_speech.py,Existing native_x11.py keyboard harness passed; browser tabs and editing passed; browser modes passed on isolated rerun after one failure whose cause was not established.,Shared D-Bus fixture passed without an X display. Live niri and physical key suppression remain unverified.,Pending live listening on Wayland,Pending physical Braille on Wayland,No compositor-specific code. See WAYLAND_SUPPORT.md for supported commands limitations and live acceptance. X11 production listeners are unchanged; the suspension probe now links its existing xkbcommon dependency.
|
||||
web-browse-recovery-activation,Orca src/orca/scripts/web/script_utilities.py; master:src/cthulhu/scripts/web/script.py,web,Recover dynamic browse positions and activate custom clickables silently,,internal/atspi internal/review internal/app,in-progress,go test -race ./... -count=1; python3 tests/native_browser.py; python3 tests/native_browser_navigation.py; python3 tests/native_firefox.py,Private Chromium and Firefox fixtures; real-reader changing-page and activation checks use private Xvfb and D-Bus.,,Recorded SSIP checks verify page-only activation feedback; audible listening remains pending.,Selection-collapse regression and native mode frames covered; physical routing remains pending.,See docs/testing/browser-release-review.md for findings and live acceptance. No full Orca parity claim.
|
||||
|
@@ -8,6 +8,8 @@
|
||||
<h2>Statistics</h2>
|
||||
<p id="counter">Current total 10000 units</p>
|
||||
<p>Following stable line</p>
|
||||
<div role="group" aria-label="Custom action" onclick="document.getElementById('action-result').textContent = 'Action count ' + (++actionCount)">Visible custom label</div>
|
||||
<div id="action-result" role="status" aria-live="polite" aria-atomic="true"></div>
|
||||
<label>Editable value <input></label>
|
||||
<button>Ordinary action</button>
|
||||
<script>
|
||||
@@ -20,6 +22,7 @@ for (let index = 1; index <= 10; index++) {
|
||||
rows.append(section);
|
||||
}
|
||||
let value = 10000;
|
||||
let actionCount = 0;
|
||||
setInterval(() => {
|
||||
value++;
|
||||
document.querySelectorAll('section span').forEach(span => span.replaceChildren(String(value)));
|
||||
|
||||
@@ -119,6 +119,28 @@ enabled = false
|
||||
move("Down", lambda text: text == "Following stable line")
|
||||
move("Up", lambda text: text.startswith("Current total "))
|
||||
move("Up", lambda text: text == "Statistics")
|
||||
|
||||
move("Down", lambda text: text.startswith("Current total "))
|
||||
move("Down", lambda text: text == "Following stable line")
|
||||
logOffset, speechOffset = len(logs()), len(server.snapshot())
|
||||
key("Return")
|
||||
wait_for(lambda: any(row.get("message") == "document activation failed"
|
||||
for row in logs()[logOffset:]), "plain-text Enter did not complete")
|
||||
time.sleep(0.2)
|
||||
assert not server.snapshot()[speechOffset:], "plain-text Enter produced reader feedback"
|
||||
|
||||
move("Down", lambda text: text == "Custom action")
|
||||
for count in (1, 2):
|
||||
logOffset, speechOffset = len(logs()), len(server.snapshot())
|
||||
key("Return")
|
||||
wait_for(lambda: any(row.get("message") == "document activation completed"
|
||||
for row in logs()[logOffset:]), "custom clickable activation failed")
|
||||
wait_for(lambda: any(record["text"] == f"Action count {count}"
|
||||
for record in server.snapshot()[speechOffset:]),
|
||||
"page did not report exactly one activation")
|
||||
time.sleep(0.2)
|
||||
speech = [record["text"] for record in server.snapshot()[speechOffset:]]
|
||||
assert speech == [f"Action count {count}"], ("activation added feedback", speech)
|
||||
logOffset = len(logs())
|
||||
subprocess.run(["xdotool", "click", "1"], check=True)
|
||||
wait_for(lambda: any(row.get("message") == "released virtual caret for pointer input"
|
||||
|
||||
Reference in new issue
Block a user