diff --git a/README.md b/README.md index 97efd57..68e1d6e 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/docs/testing/browser-release-review.md b/docs/testing/browser-release-review.md new file mode 100644 index 0000000..957d67b --- /dev/null +++ b/docs/testing/browser-release-review.md @@ -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. diff --git a/internal/app/browser_activation_test.go b/internal/app/browser_activation_test.go new file mode 100644 index 0000000..bdbddb2 --- /dev/null +++ b/internal/app/browser_activation_test.go @@ -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") + } +} diff --git a/internal/app/browser_button_names_test.go b/internal/app/browser_button_names_test.go index dabf757..67f45ee 100644 --- a/internal/app/browser_button_names_test.go +++ b/internal/app/browser_button_names_test.go @@ -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 diff --git a/internal/app/browser_mode.go b/internal/app/browser_mode.go index 9adc3b4..343d463 100644 --- a/internal/app/browser_mode.go +++ b/internal/app/browser_mode.go @@ -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 } diff --git a/internal/app/document_review.go b/internal/app/document_review.go index 2077398..90a9cf6 100644 --- a/internal/app/document_review.go +++ b/internal/app/document_review.go @@ -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() diff --git a/internal/app/event_review_results.go b/internal/app/event_review_results.go index 5712a0e..375e354 100644 --- a/internal/app/event_review_results.go +++ b/internal/app/event_review_results.go @@ -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) } diff --git a/internal/app/linked_headings_native_test.go b/internal/app/linked_headings_native_test.go index 81aba44..51dfef9 100644 --- a/internal/app/linked_headings_native_test.go +++ b/internal/app/linked_headings_native_test.go @@ -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) } } diff --git a/internal/app/pause_chunks_test.go b/internal/app/pause_chunks_test.go index 94d8c7b..c6ec355 100644 --- a/internal/app/pause_chunks_test.go +++ b/internal/app/pause_chunks_test.go @@ -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"}) diff --git a/internal/atspi/adapter.go b/internal/atspi/adapter.go index 08cf9b8..5e876f6 100644 --- a/internal/atspi/adapter.go +++ b/internal/atspi/adapter.go @@ -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 { diff --git a/internal/atspi/adapter_capture.c b/internal/atspi/adapter_capture.c index f1dd73e..e9e626d 100644 --- a/internal/atspi/adapter_capture.c +++ b/internal/atspi/adapter_capture.c @@ -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); } diff --git a/internal/atspi/adapter_document_native.go b/internal/atspi/adapter_document_native.go index 6b12715..31d6314 100644 --- a/internal/atspi/adapter_document_native.go +++ b/internal/atspi/adapter_document_native.go @@ -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) diff --git a/internal/atspi/adapter_event_callback_native.go b/internal/atspi/adapter_event_callback_native.go index 55e1a1f..ffe3624 100644 --- a/internal/atspi/adapter_event_callback_native.go +++ b/internal/atspi/adapter_event_callback_native.go @@ -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 diff --git a/internal/atspi/document_caret_echo.go b/internal/atspi/document_caret_echo.go index 8055352..dd78dff 100644 --- a/internal/atspi/document_caret_echo.go +++ b/internal/atspi/document_caret_echo.go @@ -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. diff --git a/internal/atspi/document_caret_echo_test.go b/internal/atspi/document_caret_echo_test.go new file mode 100644 index 0000000..a8f8502 --- /dev/null +++ b/internal/atspi/document_caret_echo_test.go @@ -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) + } + } +} diff --git a/internal/review/document.go b/internal/review/document.go index 5740415..e2c73ac 100644 --- a/internal/review/document.go +++ b/internal/review/document.go @@ -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, diff --git a/internal/review/document_activation_test.go b/internal/review/document_activation_test.go index 3ee2e35..fafe61d 100644 --- a/internal/review/document_activation_test.go +++ b/internal/review/document_activation_test.go @@ -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 diff --git a/internal/review/document_braille_routing.go b/internal/review/document_braille_routing.go index 8e8143c..bb726dc 100644 --- a/internal/review/document_braille_routing.go +++ b/internal/review/document_braille_routing.go @@ -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() diff --git a/internal/review/document_caret_nodes.go b/internal/review/document_caret_nodes.go index 22a3a7e..5792262 100644 --- a/internal/review/document_caret_nodes.go +++ b/internal/review/document_caret_nodes.go @@ -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": diff --git a/internal/review/document_replacement.go b/internal/review/document_replacement.go index 45a14a3..1c3f3c6 100644 --- a/internal/review/document_replacement.go +++ b/internal/review/document_replacement.go @@ -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) { diff --git a/internal/review/document_replacement_test.go b/internal/review/document_replacement_test.go index 4ae41f2..241af55 100644 --- a/internal/review/document_replacement_test.go +++ b/internal/review/document_replacement_test.go @@ -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) + } + }) + } +} diff --git a/internal/review/document_test.go b/internal/review/document_test.go index 5e57c97..29631b5 100644 --- a/internal/review/document_test.go +++ b/internal/review/document_test.go @@ -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 { diff --git a/internal/review/document_visual_line_test.go b/internal/review/document_visual_line_test.go index f078a2d..5cb4bd5 100644 --- a/internal/review/document_visual_line_test.go +++ b/internal/review/document_visual_line_test.go @@ -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) } } diff --git a/internal/review/document_worker.go b/internal/review/document_worker.go index 3f73917..d0e1dde 100644 --- a/internal/review/document_worker.go +++ b/internal/review/document_worker.go @@ -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 diff --git a/internal/review/document_worker_test.go b/internal/review/document_worker_test.go index 89912cb..cc9a0da 100644 --- a/internal/review/document_worker_test.go +++ b/internal/review/document_worker_test.go @@ -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 diff --git a/testdata/parity-ledger.csv b/testdata/parity-ledger.csv index 806be88..5fb364b 100644 --- a/testdata/parity-ledger.csv +++ b/testdata/parity-ledger.csv @@ -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. diff --git a/tests/browser-changing.html b/tests/browser-changing.html index ce08b49..eb28d7c 100644 --- a/tests/browser-changing.html +++ b/tests/browser-changing.html @@ -8,6 +8,8 @@

Statistics

Current total 10000 units

Following stable line

+
Visible custom label
+