More browser improvements.

This commit is contained in:
ak47 committed 2026-10-16 23:48:58 -04:00
1 parent ebb9ad48d0
commit f753e9b505
28 files changed
+603 -81

No files matched your search

+19 -1
View File
@@ -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:
+109
View File
@@ -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.
+43
View File
@@ -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
+2 -1
View File
@@ -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
}
-4
View File
@@ -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()
-2
View File
@@ -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)
}
+1 -1
View File
@@ -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)
}
}
+1 -1
View File
@@ -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
View File
@@ -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 {
+12 -1
View File
@@ -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);
}
+34 -7
View File
@@ -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
+22
View File
@@ -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)
}
}
}
+14 -2
View File
@@ -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()
+6
View File
@@ -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":
+37 -6
View File
@@ -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)
}
})
}
}
+1 -1
View File
@@ -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 {
+2 -1
View File
@@ -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)
}
}
+26 -6
View File
@@ -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
+31
View File
@@ -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
+1
View File
@@ -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.
1 id python_source subsystem behavior owner go_destination status automated_evidence x11_evidence wayland_evidence speech_evidence braille_evidence notes
57 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.
58 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.
59 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.
60 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.
+3
View File
@@ -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)));
+22
View File
@@ -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"