More browser work.

This commit is contained in:
Storm Dragon committed 2026-10-24 18:33:04 -04:00
1 parent bf4b9d7b54
commit 9462ebfc82
28 files changed
+1000 -94

No files matched your search

+140
View File
@@ -0,0 +1,140 @@
# Browser audit fixes
This work addresses the browser review of `bf4b9d7` against local Orca. The first
implementation batch fixes the four reproduced defects and two navigation costs.
It also adds busy live-region deferral. It does not establish complete Orca parity.
## Implemented behavior
- Crossing objects validates dirty hypertext ancestors, because parent-owned text
can change embedded offsets without changing accessible child identities.
A failed move preserves the position; the refreshed retry reads the insertion.
A local character move within an unchanged child still avoids recapture.
- Atomic updates read the enclosing atomic region for both text changes and child
additions. The source's effective `container-atomic` metadata gates this path.
Chromium exposes `atomic=false` on ordinary descendants, so those default values
do not terminate the ancestor walk.
- Table movement crosses missing cells, including neighbors in lower rows of a
spanning cell. It uses captured cells rather than iterating huge sparse row or
column dimensions. Real blank cells remain stops. Table validation and lookup
operate on the table subtree and its ancestors, preserving refreshes for changed
coordinates, headers and cell data while allowing unrelated page updates.
- Word movement skips whitespace using blocks of at most 128 native Unicode
characters. Only observed whitespace triggers this path, so ordinary word and
character movement do not gain an extra block request. Native word boundaries,
spelling metadata and embedded-object limits retain their existing handling.
- The cursor indexes object-to-range positions once per immutable document view.
A hypertext object can have multiple ranges, and lookup preserves their actual
traversal order. Retired views rebuild both the ranges and their index.
- Busy live regions defer updates until their busy flag clears. The GLib owner
keeps at most 32 region/document identities, without retained native references
or intermediate text. Completion rereads the active region and enters the live
insertion path, without acquiring focus or producing document-loading speech.
Secure input, tab selection, object destruction, document lifecycle and shutdown
retire the relevant deferred state. Live attributes are fetched together once
per candidate event, after foreground filtering.
The browser must expose accurate live attributes. A diagnostic Chromium fixture
which set `aria-busy=true` and replaced descendant text in the same JavaScript
turn reported `container-busy=false` on the initial descendant event. Cthulhu
cannot reliably defer an update which the provider reports as not busy. The
retained regression establishes busy state before the initial accessibility
capture and proves that clearing the flag alone releases the deferred content.
Nested explicit atomic=false boundaries likewise depend on correct effective
`container-atomic` metadata from the provider.
## Automated evidence
The AT-SPI, review and app packages passed with the race detector. The full race
run failed in `internal/sound` at
`TestNewPresentationInterruptsOldCueAndDiscardsPendingCues` with “stale link cues
remained queued.” Ten isolated repetitions also reproduced that failure in an
untouched snapshot of `bf4b9d7`; the sound files were not changed. This remains an
existing gate, so the full suite is not described as passing.
Thirteen selected Chromium fixtures passed in a private X11/D-Bus session:
`generatedlinks,replacement,fragments,namedlinks,linkedheadings,tables,selection,modes,document,editing,tabs,live,live-atomic`.
The final `live-atomic` run also verifies that busy completion requires no new text
insertion. It covers atomic text updates, atomic child additions, non-atomic
updates, suppression while busy, and release after busy becomes false.
Firefox baseline reading passed. Its tab regression timed out on the second
“New Tab” announcement in both the modified checkout and an untouched `bf4b9d7`
snapshot. The recorded events include a selected-state event for the previous
unselected tab. This is an unresolved existing/provider case, not a verified
regression introduced by these changes.
The affected `go vet` checks, normal reader build, BrlAPI helper build, Python
syntax check, parity-ledger validation and whitespace check passed. No installed
reader was replaced. The native fixtures record protocol/snapshot behavior;
they do not prove audible output or physical keyboard/Braille acceptance.
A counting reader crossed 1,000 whitespace characters with three word commands
using 39 reads, including 16 character-block reads. The old implementation used
1,003 reads for a single forward word command. This is an API-call count, not a
measurement of audible latency.
Three 100 ms synthetic benchmark repetitions still show character round trips
near 12 microseconds at 10,000 content nodes. Allocation traffic remains about
16 KB per character round trip and 52 KB per worker heading round trip. These
runs overlapped other checks and are informational, not a controlled timing
comparison. They exclude native IPC, initial capture, speech and physical input.
Run the relevant checks:
```sh
python3 tests/native_test_session.py env GOCACHE=/tmp/cthulhu-cache go test -race ./internal/atspi ./internal/review ./internal/app -count=1
CTHULHU_NATIVE_BROWSER_TESTS=live-atomic,generatedlinks,replacement,fragments,namedlinks,linkedheadings,tables,selection,modes,document,editing,tabs,live python3 tests/native_browser.py
python3 tests/native_firefox.py
go vet ./internal/atspi ./internal/review ./internal/app
go run ./cmd/parityledger validate testdata/parity-ledger.csv
```
## Human acceptance before submission
Review the generated diff under the project's AI-assisted contribution policy.
With an explicitly rebuilt reader, test these workflows in Chromium and Firefox:
1. Read forward and backward through links and changing text between them. Read
All must include inserted text once and continue through generated links.
2. Check atomic status updates retain their surrounding label, non-atomic updates
announce the change, and busy updates wait for the completed region.
3. Navigate ragged and merged-cell tables in all directions. Verify headers,
blank cells, logical row/column retention, nested tables and boundary messages.
Repeat with an unrelated page ticker and then a changed cell/header.
4. Move by word through long indentation, tabs, newlines and Unicode whitespace.
Check reverse movement, emoji, named links, and character-by-character spacing.
5. Type/select in an editor; return from the address bar and another tab; check
Steam browsing/search; verify stale targets never activate replacement controls.
Compare speech and physical Braille where available.
Do not submit or replace the active reader automatically. Preserve logs if a
workflow fails and distinguish provider metadata from reader-owned state.
## Remaining review work
The user authorized addressing the whole review, in priority order. On October
24, the maintainer prioritized stabilizing the current release before adding
new functionality. MathCAT was left to implementation judgment and is deferred
with the other feature additions below. Release work should concentrate on the
existing fixes, the recorded test failures, and live acceptance. These items
remain open and must not be reported as implemented:
- Automatic language switching, including language runs within a line, native
focus, structural navigation, character reading and Read All. The current
speech queue and client selection do not carry per-content language metadata.
- Structured math speech, Braille and exploration. MathCAT's maintained C
interface provides a shared engine, but adds a Rust-built library and its rule
data to packaging. No MathCAT dependency has been added in this batch.
- Constrained label inference for controls without an accessible name or explicit
label relationship. Keep explicit labels authoritative and reject ambiguous
neighboring text. This is a deliberate presentation-policy addition.
- Demand-driven capture for pages exceeding the 30,000-object safety bound.
The bound and whole-document initial capture remain unchanged. A partial tree
must represent unloaded branches explicitly so it cannot silently report false
end-of-document or incomplete element lists as complete results.
MathCAT integration should keep Go as the policy owner, use the upstream C ABI,
serialize engine state on a dedicated worker, and preserve browser responsiveness
when the engine or rules are unavailable. Revisit dependency and packaging choices
after the current release is stable; no MathCAT dependency is being added now.
+6 -5
View File
@@ -112,11 +112,12 @@ var eventTypes = []string{
type Adapter struct { type Adapter struct {
captureLogger atomic.Pointer[slog.Logger] captureLogger atomic.Pointer[slog.Logger]
libreOfficeHeaders libreOfficeHeaderStore // copied choices shared with the event loop libreOfficeHeaders libreOfficeHeaderStore // copied choices shared with the event loop
applicationProcesses map[string]string // GLib owner; bounded provider identity cache applicationProcesses map[string]string // GLib owner; bounded provider identity cache
webNotificationRoots map[ObjectID]bool // copied identities only; no native references webNotificationRoots map[ObjectID]bool // copied identities only; no native references
foregroundReader *xorg.ForegroundReader // GLib thread only deferredLiveRegions deferredLiveRegionStore // GLib owner; copied identities only
foregroundProcesses map[int]bool // GLib thread; provider PID matches for current client foregroundReader *xorg.ForegroundReader // GLib thread only
foregroundProcesses map[int]bool // GLib thread; provider PID matches for current client
foreground atomic.Pointer[ForegroundState] foreground atomic.Pointer[ForegroundState]
echoFocus atomic.Pointer[echoFocus] echoFocus atomic.Pointer[echoFocus]
eventListener *C.AtspiEventListener // GLib owner; released with subscriptions. eventListener *C.AtspiEventListener // GLib owner; released with subscriptions.
+20 -1
View File
@@ -259,6 +259,12 @@ func (adapter *Adapter) ReadDocumentCaret(ctx context.Context, position ObjectRe
return result.caret, err return result.caret, err
} }
// ReadDocumentCharacters shares the normal GLib request/lifetime checks. The
// consumer can skip spaces in one block without one provider request per rune.
func (adapter *Adapter) ReadDocumentCharacters(ctx context.Context, position ObjectReference, offset int) (DocumentCaretText, error) {
return adapter.ReadDocumentCaret(ctx, position, offset, DocumentCaretCharacters)
}
func (adapter *Adapter) readNativeDocumentCaret(ctx context.Context, position ObjectReference, offset int, unit DocumentCaretUnit) (DocumentCaretText, error) { func (adapter *Adapter) readNativeDocumentCaret(ctx context.Context, position ObjectReference, offset int, unit DocumentCaretUnit) (DocumentCaretText, error) {
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return DocumentCaretText{}, err return DocumentCaretText{}, err
@@ -273,7 +279,7 @@ func (adapter *Adapter) readNativeDocumentCaret(ctx context.Context, position Ob
if err != nil { if err != nil {
return DocumentCaretText{}, err return DocumentCaretText{}, err
} }
if unit == DocumentCaretBrailleLine && snapshot.Role == "password text" { if (unit == DocumentCaretBrailleLine || unit == DocumentCaretCharacters) && snapshot.Role == "password text" {
return DocumentCaretText{}, ErrNoDocumentCaret return DocumentCaretText{}, ErrNoDocumentCaret
} }
source := adapter.nativeDocument[position.ID()] source := adapter.nativeDocument[position.ID()]
@@ -303,6 +309,19 @@ func (adapter *Adapter) readNativeDocumentCaret(ctx context.Context, position Ob
// A caret after the final character still belongs to the final line. // A caret after the final character still belongs to the final line.
offset = count - 1 offset = count - 1
} }
if unit == DocumentCaretCharacters {
start := max(0, offset/128*128)
end := min(count, start+128)
content := C.atspi_text_get_text(text, C.gint(start), C.gint(end), &nativeErr)
if nativeErr != nil {
err := adapter.document_object_error(position.ID(), nativeError("read document characters", nativeErr))
C.g_error_free(nativeErr)
C.g_free(C.gpointer(content))
return DocumentCaretText{}, err
}
defer C.g_free(C.gpointer(content))
return DocumentCaretText{Start: start, End: end, RawText: C.GoString(content), CharacterCount: count}, nil
}
if unit != DocumentCaretCharacter && unit != DocumentCaretWord { if unit != DocumentCaretCharacter && unit != DocumentCaretWord {
line, err := adapter.readNativeDocumentLine(ctx, position.ID(), snapshot, text, offset, count) line, err := adapter.readNativeDocumentLine(ctx, position.ID(), snapshot, text, offset, count)
if err != nil { if err != nil {
+34 -16
View File
@@ -105,7 +105,7 @@ func selectNativeEventTarget(event *C.AtspiEvent, eventType string) (*C.AtspiAcc
// captureNativeEventSnapshot enriches the target without changing event ownership. // captureNativeEventSnapshot enriches the target without changing event ownership.
// It runs on the GLib owner and retains no native references. // It runs on the GLib owner and retains no native references.
func (adapter *Adapter) captureNativeEventSnapshot(event *C.AtspiEvent, target *C.AtspiAccessible, eventType string, originID ObjectID) (Snapshot, ObjectID) { func (adapter *Adapter) captureNativeEventSnapshot(event *C.AtspiEvent, target *C.AtspiAccessible, eventType string, originID ObjectID, live nativeLiveProperties) (Snapshot, ObjectID) {
// Document focus/load presentation needs only the title. Real caret // Document focus/load presentation needs only the title. Real caret
// events still capture text, including carets hosted on document roots. // events still capture text, including carets hosted on document roots.
captureCaret := !isDocumentLifecycleEvent(eventType) && eventType != BusyStateEvent captureCaret := !isDocumentLifecycleEvent(eventType) && eventType != BusyStateEvent
@@ -131,20 +131,12 @@ func (adapter *Adapter) captureNativeEventSnapshot(event *C.AtspiEvent, target *
isFirefoxChromeAutocomplete(snapshot, C.cthulhu_in_active_document(target) != 0) isFirefoxChromeAutocomplete(snapshot, C.cthulhu_in_active_document(target) != 0)
} }
if eventType == TextInsertedEvent || eventType == ChildrenAddedEvent { if eventType == TextInsertedEvent || eventType == ChildrenAddedEvent {
key := C.CString("container-live") snapshot.LivePoliteness = live.politeness
live := C.cthulhu_live_attribute(event.source, key) snapshot.LiveRegion = live.politeness != "" && C.cthulhu_in_active_document(event.source) != 0
C.free(unsafe.Pointer(key))
snapshot.LivePoliteness = sanitizeSnapshotField(C.GoString(live))
snapshot.LiveRegion = snapshot.LivePoliteness != "" && C.cthulhu_in_active_document(event.source) != 0
C.g_free(C.gpointer(live))
if snapshot.LiveRegion { if snapshot.LiveRegion {
key = C.CString("container-atomic") snapshot.LiveAtomic = live.atomic
atomic := C.cthulhu_live_attribute(event.source, key)
C.free(unsafe.Pointer(key))
snapshot.LiveAtomic = C.GoString(atomic) == "true"
C.g_free(C.gpointer(atomic))
if eventType == TextInsertedEvent && snapshot.LiveAtomic { if eventType == TextInsertedEvent && snapshot.LiveAtomic {
text := C.cthulhu_live_content(event.source) text := C.cthulhu_atomic_live_content(event.source)
snapshot.Text = sanitizeSnapshotField(C.GoString(text)) snapshot.Text = sanitizeSnapshotField(C.GoString(text))
C.g_free(C.gpointer(text)) C.g_free(C.gpointer(text))
} }
@@ -153,7 +145,7 @@ func (adapter *Adapter) captureNativeEventSnapshot(event *C.AtspiEvent, target *
text := C.cthulhu_live_content(child) text := C.cthulhu_live_content(child)
if snapshot.LiveAtomic { if snapshot.LiveAtomic {
C.g_free(C.gpointer(text)) C.g_free(C.gpointer(text))
text = C.cthulhu_live_content(event.source) text = C.cthulhu_atomic_live_content(event.source)
} }
snapshot.Text = sanitizeSnapshotField(C.GoString(text)) snapshot.Text = sanitizeSnapshotField(C.GoString(text))
C.g_free(C.gpointer(text)) C.g_free(C.gpointer(text))
@@ -336,6 +328,7 @@ func (adapter *Adapter) enrichNativeWindowEvent(event *C.AtspiEvent, target *C.A
eventType == SelectionChangedEvent && snapshot.Selected && strings.EqualFold(snapshot.ToolkitName, "Gecko")) { eventType == SelectionChangedEvent && snapshot.Selected && strings.EqualFold(snapshot.ToolkitName, "Gecko")) {
adapter.selectedTabWindow = focusEvent.Window adapter.selectedTabWindow = focusEvent.Window
adapter.activeDocument = ObjectID{} adapter.activeDocument = ObjectID{}
adapter.deferredLiveRegions = nil
} }
if IsWebDocumentRole(snapshot.Role) && if IsWebDocumentRole(snapshot.Role) &&
(eventType == WindowActivatedEvent || eventType == FocusEvent || eventType == FocusedStateEvent && focusEvent.Detail1 != 0 || eventType == DocumentLoadCompleteEvent) { (eventType == WindowActivatedEvent || eventType == FocusEvent || eventType == FocusedStateEvent && focusEvent.Detail1 != 0 || eventType == DocumentLoadCompleteEvent) {
@@ -430,6 +423,10 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
adapter.applySecureReset() adapter.applySecureReset()
adapter.refreshForeground() adapter.refreshForeground()
if isDocumentLifecycleEvent(eventType) || eventType == DefunctStateEvent && C.cthulhu_event_detail1(event) != 0 {
adapter.deferredLiveRegions.take(originID)
adapter.deferredLiveRegions.forget_document(originID)
}
if eventType == DefunctStateEvent && C.cthulhu_event_detail1(event) != 0 { if eventType == DefunctStateEvent && C.cthulhu_event_detail1(event) != 0 {
adapter.forgetDocumentWindow(originID) adapter.forgetDocumentWindow(originID)
} else if eventType == WindowDestroyedEvent { } else if eventType == WindowDestroyedEvent {
@@ -449,7 +446,7 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
// notification script needs their semantics before that early return, // notification script needs their semantics before that early return,
// including when the provider is outside foreground ownership. // including when the provider is outside foreground ownership.
webNotification := adapter.webNotification(event) webNotification := adapter.webNotification(event)
if invalidated && webNotification == nil && (eventType != DefunctStateEvent || !adapter.nativeFocusIs(originID)) { if invalidated && webNotification == nil && eventType != ChildrenAddedEvent && (eventType != DefunctStateEvent || !adapter.nativeFocusIs(originID)) {
return return
} }
if !shouldSpeakEvent(eventType, int(C.cthulhu_event_detail1(event))) { if !shouldSpeakEvent(eventType, int(C.cthulhu_event_detail1(event))) {
@@ -459,6 +456,20 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
adapter.backgroundEventsFiltered.Add(1) adapter.backgroundEventsFiltered.Add(1)
return return
} }
var live nativeLiveProperties
if eventType == TextInsertedEvent || eventType == ChildrenAddedEvent || eventType == BusyStateEvent {
live = capture_live_properties(event.source)
}
if invalidated && webNotification == nil && eventType == ChildrenAddedEvent && (live.politeness == "" || live.politeness == "off") {
return
}
deferLive, completeLive := adapter.prepare_live_region(event, eventType, originID, live)
if deferLive {
return
}
if completeLive {
eventType = TextInsertedEvent
}
if (isDocumentLifecycleEvent(eventType) || eventType == BusyStateEvent) && C.cthulhu_active_document(event.source) == 0 { if (isDocumentLifecycleEvent(eventType) || eventType == BusyStateEvent) && C.cthulhu_active_document(event.source) == 0 {
return return
} }
@@ -489,7 +500,14 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
C.atspi_accessible_clear_cache_single(event.source) C.atspi_accessible_clear_cache_single(event.source)
} }
payload := C.cthulhu_event_payload(event) payload := C.cthulhu_event_payload(event)
snapshot, popupFor := adapter.captureNativeEventSnapshot(event, target, eventType, originID) snapshot, popupFor := adapter.captureNativeEventSnapshot(event, target, eventType, originID, live)
if completeLive {
text := C.cthulhu_live_content(event.source)
snapshot.Text = sanitizeSnapshotField(C.GoString(text))
snapshot.LiveAtomic = true
C.g_free(C.gpointer(payload))
payload = text
}
// Shared Qt actions can report focus through their original, hidden menu // Shared Qt actions can report focus through their original, hidden menu
// even while another menu emits focus-gained for that very accessible. // even while another menu emits focus-gained for that very accessible.
// The explicit event establishes focus; the queried state may disagree. // The explicit event establishes focus; the queried state may disagree.
+60
View File
@@ -58,6 +58,66 @@ gchar *cthulhu_live_attribute(AtspiAccessible *source, const gchar *key) {
return value; return value;
} }
cthulhu_live_properties cthulhu_capture_live_properties(AtspiAccessible *source) {
cthulhu_live_properties properties = {0};
GError *error = NULL;
GHashTable *attributes = atspi_accessible_get_attributes(source, &error);
if (error != NULL) g_clear_error(&error);
if (attributes == NULL) return properties;
properties.politeness = g_strdup(g_hash_table_lookup(attributes, "container-live"));
properties.atomic = g_strcmp0(g_hash_table_lookup(attributes, "container-atomic"), "true") == 0;
properties.busy = g_strcmp0(g_hash_table_lookup(attributes, "container-busy"), "true") == 0;
g_hash_table_unref(attributes);
return properties;
}
// container-atomic is inherited. Chromium also publishes atomic=false on
// ordinary descendants, so only atomic=true identifies an enclosing region.
// The caller first checks the source's effective container-atomic attribute.
gchar *cthulhu_atomic_live_content(AtspiAccessible *source) {
AtspiAccessible *current = g_object_ref(source);
for (guint depth = 0; current != NULL && depth < 64; depth++) {
gchar *atomic = cthulhu_live_attribute(current, "atomic");
gboolean boundary = g_strcmp0(atomic, "true") == 0;
g_free(atomic);
AtspiRole role = atspi_accessible_get_role(current, NULL);
if (boundary) {
gchar *content = cthulhu_live_content(current);
g_object_unref(current);
return content;
}
if (role == ATSPI_ROLE_DOCUMENT_WEB || role == ATSPI_ROLE_DOCUMENT_FRAME) {
g_object_unref(current);
return cthulhu_live_content(source);
}
AtspiAccessible *parent = atspi_accessible_get_parent(current, NULL);
g_object_unref(current);
current = parent;
}
if (current != NULL) g_object_unref(current);
return NULL;
}
AtspiAccessible *cthulhu_busy_live_container(AtspiAccessible *source) {
AtspiAccessible *current = g_object_ref(source);
for (guint depth = 0; current != NULL && depth < 64; depth++) {
AtspiStateSet *states = atspi_accessible_get_state_set(current);
gboolean busy = states != NULL && atspi_state_set_contains(states, ATSPI_STATE_BUSY);
if (states != NULL) g_object_unref(states);
if (busy) return current;
AtspiRole role = atspi_accessible_get_role(current, NULL);
if (role == ATSPI_ROLE_DOCUMENT_WEB || role == ATSPI_ROLE_DOCUMENT_FRAME) {
g_object_unref(current);
return NULL;
}
AtspiAccessible *parent = atspi_accessible_get_parent(current, NULL);
g_object_unref(current);
current = parent;
}
if (current != NULL) g_object_unref(current);
return NULL;
}
AtspiAccessible *cthulhu_event_active_descendant(AtspiEvent *event) { AtspiAccessible *cthulhu_event_active_descendant(AtspiEvent *event) {
if (!G_IS_VALUE(&event->any_data) || !G_VALUE_HOLDS_OBJECT(&event->any_data)) { if (!G_IS_VALUE(&event->any_data) || !G_VALUE_HOLDS_OBJECT(&event->any_data)) {
return NULL; return NULL;
@@ -86,6 +86,7 @@ func (adapter *Adapter) Start(ctx context.Context) error {
adapter.documentWindows = nil adapter.documentWindows = nil
adapter.documentWindowIDs = nil adapter.documentWindowIDs = nil
adapter.clearNativeDocument() adapter.clearNativeDocument()
adapter.deferredLiveRegions = nil
adapter.clearNativeReview() adapter.clearNativeReview()
adapter.clearMouseApplication() adapter.clearMouseApplication()
}() }()
+8
View File
@@ -102,6 +102,8 @@ gchar *cthulhu_radio_group_name(AtspiAccessible *source);
void cthulhu_capture_set_position(AtspiAccessible *source, GHashTable *attributes, cthulhu_snapshot *snapshot); void cthulhu_capture_set_position(AtspiAccessible *source, GHashTable *attributes, cthulhu_snapshot *snapshot);
gboolean cthulhu_has_set_position(const gchar *role); gboolean cthulhu_has_set_position(const gchar *role);
gchar *cthulhu_live_content(AtspiAccessible *source); gchar *cthulhu_live_content(AtspiAccessible *source);
gchar *cthulhu_atomic_live_content(AtspiAccessible *source);
AtspiAccessible *cthulhu_busy_live_container(AtspiAccessible *source);
gchar *cthulhu_capture_caret_unit(AtspiAccessible *source, AtspiTextGranularity unit); gchar *cthulhu_capture_caret_unit(AtspiAccessible *source, AtspiTextGranularity unit);
AtspiTextRange *cthulhu_text_get_string_at_offset(AtspiText *text, gint offset, AtspiTextRange *cthulhu_text_get_string_at_offset(AtspiText *text, gint offset,
AtspiTextGranularity unit, GError **error); AtspiTextGranularity unit, GError **error);
@@ -111,6 +113,12 @@ gint cthulhu_event_detail1(AtspiEvent *event);
gchar *cthulhu_document_uri(AtspiAccessible *source); gchar *cthulhu_document_uri(AtspiAccessible *source);
gchar *cthulhu_event_payload(AtspiEvent *event); gchar *cthulhu_event_payload(AtspiEvent *event);
gchar *cthulhu_live_attribute(AtspiAccessible *source, const gchar *key); gchar *cthulhu_live_attribute(AtspiAccessible *source, const gchar *key);
typedef struct {
gchar *politeness;
gboolean atomic;
gboolean busy;
} cthulhu_live_properties;
cthulhu_live_properties cthulhu_capture_live_properties(AtspiAccessible *source);
AtspiAccessible *cthulhu_event_active_descendant(AtspiEvent *event); AtspiAccessible *cthulhu_event_active_descendant(AtspiEvent *event);
AtspiAccessible *cthulhu_selected_child(AtspiAccessible *source); AtspiAccessible *cthulhu_selected_child(AtspiAccessible *source);
AtspiAccessible *cthulhu_table_selected_child(AtspiAccessible *source); AtspiAccessible *cthulhu_table_selected_child(AtspiAccessible *source);
+1
View File
@@ -43,6 +43,7 @@ func (adapter *Adapter) applySecureReset() {
if !adapter.secureReset.Swap(false) { if !adapter.secureReset.Swap(false) {
return return
} }
adapter.deferredLiveRegions = nil
adapter.invalidateTree() adapter.invalidateTree()
adapter.treeMutex.Lock() adapter.treeMutex.Lock()
adapter.tree, adapter.documentTree, adapter.documentTextTree = nil, nil, nil adapter.tree, adapter.documentTree, adapter.documentTextTree = nil, nil, nil
+118
View File
@@ -0,0 +1,118 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package atspi
import (
"context"
"os"
"os/exec"
"strings"
"testing"
"time"
)
func TestNativeBrowserAtomicLiveRegion(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" {
t.Skip("requires private browser session")
}
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
adapter := New()
if err := adapter.Start(ctx); err != nil {
t.Fatal(err)
}
defer func() {
stop, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := adapter.Stop(stop); err != nil {
t.Error(err)
}
}()
window, err := exec.CommandContext(ctx, "xdotool", "search", "--onlyvisible", "--name", "Cthulhu Browser Probe").Output()
if err != nil || len(strings.Fields(string(window))) == 0 {
t.Fatalf("browser window: %s: %v", window, err)
}
if out, err := exec.CommandContext(ctx, "xdotool", "windowfocus", strings.Fields(string(window))[0]).CombinedOutput(); err != nil {
t.Fatalf("focus: %s: %v", out, err)
}
for _, test := range []struct {
key, marker, want string
atomic bool
}{
{"F9", "Completed", "Download status: Completed", true},
{"F10", "Verified", "Account status: Verified", true},
{"F11", "Inner update", "Inner update", false},
} {
t.Run(test.key, func(t *testing.T) {
if out, err := exec.CommandContext(ctx, "xdotool", "key", test.key).CombinedOutput(); err != nil {
t.Fatalf("trigger: %s: %v", out, err)
}
deadline := time.NewTimer(5 * time.Second)
defer deadline.Stop()
for {
select {
case event := <-adapter.Events():
if !event.Source.LiveRegion || (!strings.Contains(event.Source.Text, test.marker) && !strings.Contains(event.Payload, test.marker)) {
continue
}
if event.Source.LiveAtomic != test.atomic || event.Source.LiveAtomic && event.Source.Text != test.want {
t.Fatalf("atomic=%v text=%q payload=%q; want atomic=%v text=%q", event.Source.LiveAtomic, event.Source.Text, event.Payload, test.atomic, test.want)
}
if !test.atomic && strings.Contains(event.Source.Text, "Outer context:") {
t.Fatalf("crossed atomic=false boundary: %q", event.Source.Text)
}
return
case <-deadline.C:
t.Fatal("live update missing")
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
})
}
// A busy region must remain silent while its intermediate contents change.
if out, err := exec.CommandContext(ctx, "xdotool", "key", "F12").CombinedOutput(); err != nil {
t.Fatalf("busy start: %s %v", out, err)
}
quiet := time.NewTimer(200 * time.Millisecond)
quietLoop:
for {
select {
case event := <-adapter.Events():
if event.Source.LiveRegion && (strings.Contains(event.Source.Text, "Intermediate update") || strings.Contains(event.Payload, "Intermediate update")) {
t.Fatalf("busy region announced incomplete text: %+v", event)
}
case <-quiet.C:
break quietLoop
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
quiet.Stop()
if out, err := exec.CommandContext(ctx, "xdotool", "key", "F7").CombinedOutput(); err != nil {
t.Fatalf("busy complete: %s %v", out, err)
}
completion := time.NewTimer(5 * time.Second)
defer completion.Stop()
for {
select {
case event := <-adapter.Events():
if event.Source.LiveRegion && strings.Contains(event.Source.Text, "Intermediate update") {
if event.Type != TextInsertedEvent && event.Type != ChildrenAddedEvent {
t.Fatalf("live completion changed focus/lifecycle: %+v", event)
}
if event.Source.Text != "Busy status: Intermediate update" {
t.Fatalf("completion text: %q", event.Source.Text)
}
return
}
case <-completion.C:
t.Fatal("busy completion missing")
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
}
+4 -1
View File
@@ -12,10 +12,13 @@ const (
DocumentCaretLineRemainder DocumentCaretLineRemainder
// DocumentCaretBrailleLine preserves native spacing for display offsets. // DocumentCaretBrailleLine preserves native spacing for display offsets.
DocumentCaretBrailleLine DocumentCaretBrailleLine
// DocumentCaretCharacters returns a bounded block of raw characters for
// whitespace traversal. It does not normalize spaces or query line geometry.
DocumentCaretCharacters
) )
func (unit DocumentCaretUnit) Valid() bool { func (unit DocumentCaretUnit) Valid() bool {
return unit >= DocumentCaretCharacter && unit <= DocumentCaretBrailleLine return unit >= DocumentCaretCharacter && unit <= DocumentCaretCharacters
} }
type DocumentCaretText struct { type DocumentCaretText struct {
+7
View File
@@ -143,6 +143,13 @@ func (tree *AccessibleTree) validate_document_path_locked(ctx context.Context, d
if requireOrder && entry.childrenInvalid { if requireOrder && entry.childrenInvalid {
return ErrIncompleteTree return ErrIncompleteTree
} }
// Hypertext ranges interleave parent-owned text with embedded
// children. An insertion can change those ranges without changing
// the accessible child list. Refresh before crossing objects, while
// allowing movement within an unchanged child to remain local.
if requireOrder && entry.textInvalid && len(entry.snapshot.TextChildren) != 0 {
return ErrStaleText
}
if err := tree.validate_document_row_locked(document, entry.snapshot.ID()); err != nil { if err := tree.validate_document_row_locked(document, entry.snapshot.ID()); err != nil {
return err return err
} }
+46
View File
@@ -0,0 +1,46 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package atspi
type deferredLiveRegion struct{ object, document ObjectID }
// No text or native handles survive the event. Completion re-reads the owned
// active region. Secure input, shutdown and document replacement retire it.
type deferredLiveRegionStore []deferredLiveRegion
func (store *deferredLiveRegionStore) remember(object, document ObjectID) {
if !object.Valid() || !document.Valid() {
return
}
for _, entry := range *store {
if entry.object == object {
return
}
}
if len(*store) == 32 {
*store = (*store)[1:]
}
*store = append(*store, deferredLiveRegion{object, document})
}
func (store *deferredLiveRegionStore) take(object ObjectID) (ObjectID, bool) {
for index, entry := range *store {
if entry.object == object {
*store = append((*store)[:index], (*store)[index+1:]...)
return entry.document, true
}
}
return ObjectID{}, false
}
func (store *deferredLiveRegionStore) forget_document(document ObjectID) {
kept := (*store)[:0]
for _, entry := range *store {
if entry.document != document {
kept = append(kept, entry)
}
}
*store = kept
}
+66
View File
@@ -0,0 +1,66 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package atspi
/*
#include "adapter_native.h"
*/
import "C"
type nativeLiveProperties struct {
politeness string
atomic, busy bool
}
func capture_live_properties(source *C.AtspiAccessible) nativeLiveProperties {
properties := C.cthulhu_capture_live_properties(source)
defer C.g_free(C.gpointer(properties.politeness))
return nativeLiveProperties{sanitizeSnapshotField(C.GoString(properties.politeness)), properties.atomic != 0, properties.busy != 0}
}
// Called after foreground filtering. A completion is transformed into a live
// insertion so it cannot acquire focus or enter document-loading policy.
func (adapter *Adapter) prepare_live_region(event *C.AtspiEvent, eventType string, origin ObjectID, properties nativeLiveProperties) (deferEvent, complete bool) {
if eventType != TextInsertedEvent && eventType != ChildrenAddedEvent && eventType != BusyStateEvent {
return false, false
}
var savedDocument ObjectID
if eventType == BusyStateEvent {
if C.cthulhu_event_detail1(event) != 0 {
return false, false
}
var found bool
savedDocument, found = adapter.deferredLiveRegions.take(origin)
if !found {
return false, false
}
}
if properties.politeness == "" || properties.politeness == "off" {
return eventType == BusyStateEvent, false
}
if eventType != BusyStateEvent && !properties.busy {
return false, false
}
if C.cthulhu_in_active_document(event.source) == 0 {
return eventType == BusyStateEvent, false
}
document := C.cthulhu_document_ancestor(event.source)
if document == nil {
return eventType == BusyStateEvent, false
}
documentID := nativeObjectID(document)
C.g_object_unref(C.gpointer(document))
if savedDocument.Valid() && savedDocument != documentID {
return true, false
}
if properties.busy {
if owner := C.cthulhu_busy_live_container(event.source); owner != nil {
adapter.deferredLiveRegions.remember(nativeObjectID(owner), documentID)
C.g_object_unref(C.gpointer(owner))
return true, false
}
}
return false, eventType == BusyStateEvent
}
+39
View File
@@ -0,0 +1,39 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package atspi
import (
"fmt"
"testing"
)
func TestDeferredLiveRegionLifetime(t *testing.T) {
var store deferredLiveRegionStore
for index := range 40 {
store.remember(testID(fmt.Sprintf("/region/%d", index)), testID("/document"))
}
if len(store) != 32 {
t.Fatalf("unbounded deferred state: %d", len(store))
}
if _, found := store.take(testID("/region/0")); found {
t.Fatal("oldest entry not evicted")
}
store.remember(testID("/region/39"), testID("/document"))
if len(store) != 32 {
t.Fatal("duplicate added a second entry")
}
document, found := store.take(testID("/region/39"))
if !found || document != testID("/document") {
t.Fatal("lost deferred owner")
}
if _, found = store.take(testID("/region/39")); found {
t.Fatal("completed update remained deferred")
}
store.remember(testID("/other_region"), testID("/other_document"))
store.forget_document(testID("/document"))
if len(store) != 1 || store[0].document != testID("/other_document") {
t.Fatal("document retirement retained old entries or removed another document")
}
}
+1
View File
@@ -85,6 +85,7 @@ type DocumentCursor struct {
active atspi.ObjectReference active atspi.ObjectReference
caretDocument atspi.DocumentSnapshot caretDocument atspi.DocumentSnapshot
caretRanges []caretRange caretRanges []caretRange
caretLookup *caretRangeLookup
lineOwners map[int]atspi.ObjectID lineOwners map[int]atspi.ObjectID
} }
+10 -7
View File
@@ -53,6 +53,10 @@ type DocumentCaretReader interface {
ReadDocumentCaret(context.Context, atspi.ObjectReference, int, atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) ReadDocumentCaret(context.Context, atspi.ObjectReference, int, atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error)
} }
type DocumentCharacterReader interface {
ReadDocumentCharacters(context.Context, atspi.ObjectReference, int) (atspi.DocumentCaretText, error)
}
type DocumentCaretMove struct { type DocumentCaretMove struct {
Position DocumentPosition Position DocumentPosition
Text string Text string
@@ -101,11 +105,6 @@ func (cursor *DocumentCursor) MoveCaret(ctx context.Context, reader DocumentCare
return DocumentCaretMove{}, err return DocumentCaretMove{}, err
} }
if command >= TableLeft { if command >= TableLeft {
// Table coordinates and row/header presentation consume the complete
// table, unlike ordinary caret movement's local text path.
if err := cursor.tree.ValidateDocumentRange(ctx, doc, 0, len(doc.Nodes)-1); err != nil {
return DocumentCaretMove{}, err
}
movement, err := cursor.moveTable(ctx, doc, place, command) movement, err := cursor.moveTable(ctx, doc, place, command)
if err == nil && movement.Moved { if err == nil && movement.Moved {
*saved = next *saved = next
@@ -116,6 +115,7 @@ func (cursor *DocumentCursor) MoveCaret(ctx context.Context, reader DocumentCare
if !doc.SameContent(cursor.caretDocument) { if !doc.SameContent(cursor.caretDocument) {
cursor.caretDocument = doc cursor.caretDocument = doc
cursor.caretRanges = caretNodeRanges(doc.Nodes) cursor.caretRanges = caretNodeRanges(doc.Nodes)
cursor.caretLookup = new_caret_range_lookup(cursor.caretRanges, len(doc.Nodes))
cursor.lineOwners = visual_line_owners(doc.Nodes) cursor.lineOwners = visual_line_owners(doc.Nodes)
} }
indices := cursor.caretRanges indices := cursor.caretRanges
@@ -128,13 +128,16 @@ func (cursor *DocumentCursor) MoveCaret(ctx context.Context, reader DocumentCare
var movement DocumentCaretMove var movement DocumentCaretMove
navigationReader := &navigationTextReader{DocumentCaretReader: reader, tree: cursor.tree, document: doc, anchor: current} navigationReader := &navigationTextReader{DocumentCaretReader: reader, tree: cursor.tree, document: doc, anchor: current}
reader = navigationReader reader = navigationReader
if characters, ok := navigationReader.DocumentCaretReader.(DocumentCharacterReader); ok {
reader = &navigationCharacterReader{navigationTextReader: navigationReader, source: characters}
}
switch command { switch command {
case CaretNextWord, CaretPreviousWord, CaretNextCharacter, CaretPreviousCharacter: case CaretNextWord, CaretPreviousWord, CaretNextCharacter, CaretPreviousCharacter:
movement, err = moveCaretText(ctx, reader, doc, place, indices, current, movement, err = moveCaretText(ctx, reader, doc, place, indices, cursor.caretLookup, current,
command == CaretPreviousWord || command == CaretPreviousCharacter, command == CaretPreviousWord || command == CaretPreviousCharacter,
command == CaretNextWord || command == CaretPreviousWord) command == CaretNextWord || command == CaretPreviousWord)
default: default:
movement, err = move_caret_line(ctx, reader, cursor.tree, doc, place, indices, current, command, cursor.lineOwners) movement, err = move_caret_line(ctx, reader, cursor.tree, doc, place, indices, cursor.caretLookup, current, command, cursor.lineOwners)
} }
if err == nil && movement.Moved { if err == nil && movement.Moved {
target, found := doc.Index(place.object.ID()) target, found := doc.Index(place.object.ID())
+2 -2
View File
@@ -15,12 +15,12 @@ import (
// move_caret_line handles line and document boundaries under the cursor lock // move_caret_line handles line and document boundaries under the cursor lock
// held by MoveCaret. Text-unit movement has its separate traversal helper. // held by MoveCaret. Text-unit movement has its separate traversal helper.
func move_caret_object_line(ctx context.Context, reader DocumentCaretReader, doc atspi.DocumentSnapshot, place *documentPlace, indices []caretRange, current int, command DocumentCaretCommand) (DocumentCaretMove, error) { func move_caret_object_line(ctx context.Context, reader DocumentCaretReader, doc atspi.DocumentSnapshot, place *documentPlace, indices []caretRange, lookup *caretRangeLookup, current int, command DocumentCaretCommand) (DocumentCaretMove, error) {
unit := atspi.DocumentCaretLine unit := atspi.DocumentCaretLine
if command == CaretReadLine { if command == CaretReadLine {
unit = atspi.DocumentCaretLineRemainder unit = atspi.DocumentCaretLineRemainder
} }
position, same := caretRangePosition(indices, current, place.offset) position, same := lookup.position(indices, current, place.offset)
backwards := command == CaretPreviousLine || command == CaretDocumentEnd backwards := command == CaretPreviousLine || command == CaretDocumentEnd
if command == CaretDocumentStart { if command == CaretDocumentStart {
position = 0 position = 0
+40 -14
View File
@@ -90,24 +90,50 @@ func caretNodeRanges(nodes []atspi.DocumentNode) []caretRange {
return result return result
} }
func caretRangePosition(ranges []caretRange, current, offset int) (int, bool) { // Object-local range positions also handle a hypertext parent occurring on
// both sides of an embedded child. after preserves provider traversal order,
// which is not monotonically increasing in document node index.
type caretRangeLookup struct {
objects [][]int
after []int
}
func new_caret_range_lookup(ranges []caretRange, nodeCount int) *caretRangeLookup {
lookup := &caretRangeLookup{objects: make([][]int, nodeCount), after: make([]int, nodeCount)}
for position, item := range ranges { for position, item := range ranges {
if item.index == current && offset >= item.start && offset <= max(item.start, item.end-1) { lookup.objects[item.index] = append(lookup.objects[item.index], position)
}
following := len(ranges)
for index := nodeCount - 1; index >= 0; index-- {
lookup.after[index] = following
if positions := lookup.objects[index]; len(positions) > 0 {
following = min(following, positions[0])
}
}
return lookup
}
func (lookup *caretRangeLookup) containing(ranges []caretRange, current, offset int) int {
for _, position := range lookup.objects[current] {
item := ranges[position]
if offset >= item.start && offset < max(item.start+1, item.end) {
return position
}
}
return -1
}
func (lookup *caretRangeLookup) position(ranges []caretRange, current, offset int) (int, bool) {
if position := lookup.containing(ranges, current, offset); position >= 0 {
return position, true
}
// Word and Read All cursors may be just after the final character.
for _, position := range lookup.objects[current] {
if offset == ranges[position].end {
return position, true return position, true
} }
} }
// Word and read-all cursors may be just after the final character. return lookup.after[current], false
for position, item := range ranges {
if item.index == current && offset == item.end {
return position, true
}
}
for position, item := range ranges {
if item.index > current {
return position, false
}
}
return len(ranges), false
} }
func readCaretRange(ctx context.Context, reader DocumentCaretReader, doc atspi.DocumentSnapshot, item caretRange, offset int, unit atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) { func readCaretRange(ctx context.Context, reader DocumentCaretReader, doc atspi.DocumentSnapshot, item caretRange, offset int, unit atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) {
+30 -2
View File
@@ -17,12 +17,12 @@ import (
// Python caret_navigation starts a word move at the adjacent non-space // Python caret_navigation starts a word move at the adjacent non-space
// character, rather than the next/previous word boundary. In particular, // character, rather than the next/previous word boundary. In particular,
// reversing at the end of a word must read that word, not skip over it. // reversing at the end of a word must read that word, not skip over it.
func moveCaretText(ctx context.Context, reader DocumentCaretReader, doc atspi.DocumentSnapshot, place *documentPlace, indices []caretRange, current int, backwards, byWord bool) (DocumentCaretMove, error) { func moveCaretText(ctx context.Context, reader DocumentCaretReader, doc atspi.DocumentSnapshot, place *documentPlace, indices []caretRange, lookup *caretRangeLookup, current int, backwards, byWord bool) (DocumentCaretMove, error) {
step := 1 step := 1
if backwards { if backwards {
step = -1 step = -1
} }
position, same := caretRangePosition(indices, current, place.offset) position, same := lookup.position(indices, current, place.offset)
offset := 0 offset := 0
if same { if same {
offset = place.offset + step offset = place.offset + step
@@ -62,6 +62,16 @@ func moveCaretText(ctx context.Context, reader DocumentCaretReader, doc atspi.Do
if err == nil && character.End > character.Start { if err == nil && character.End > character.Start {
value, _ := utf8.DecodeRuneInString(character.Text) value, _ := utf8.DecodeRuneInString(character.Text)
if character.Text == "" || byWord && unicode.IsSpace(value) || value == '\ufffc' || value == '\ufeff' { if character.Text == "" || byWord && unicode.IsSpace(value) || value == '\ufffc' || value == '\ufeff' {
if blocks, ok := reader.(DocumentCharacterReader); byWord && unicode.IsSpace(value) && ok && named_link_label(node.Snapshot) == "" {
nextOffset, err := skip_word_spaces(ctx, blocks, node.Reference, indices[position], offset, step)
if err != nil {
return DocumentCaretMove{}, err
}
if nextOffset != offset {
offset = nextOffset
continue
}
}
offset += step offset += step
continue continue
} }
@@ -109,3 +119,21 @@ func moveCaretText(ctx context.Context, reader DocumentCaretReader, doc atspi.Do
} }
return DocumentCaretMove{Position: documentPosition(doc, *place)}, nil return DocumentCaretMove{Position: documentPosition(doc, *place)}, nil
} }
// Keep native Unicode offsets and stop at embedded-object range boundaries.
// A broken optional block cannot advance the cursor; ordinary unit reads then
// retain their existing provider-error handling.
func skip_word_spaces(ctx context.Context, reader DocumentCharacterReader, object atspi.ObjectReference, item caretRange, offset, step int) (int, error) {
block, err := reader.ReadDocumentCharacters(ctx, object, offset)
if err != nil {
return offset, err
}
runes := []rune(block.RawText)
if block.Start < 0 || block.Start > offset || block.End <= offset || block.End > block.CharacterCount || len(runes) != block.End-block.Start {
return offset, nil
}
for offset >= item.start && offset < item.end && offset >= block.Start && offset < block.End && unicode.IsSpace(runes[offset-block.Start]) {
offset += step
}
return offset, nil
}
@@ -6,6 +6,7 @@ package review
import ( import (
"context" "context"
"errors"
"testing" "testing"
"unicode/utf8" "unicode/utf8"
@@ -204,3 +205,76 @@ func TestDocumentCaretHypertextCannotEscapePasswordSubtree(t *testing.T) {
t.Fatalf("password subtree exposed caret ranges: %+v", ranges) t.Fatalf("password subtree exposed caret ranges: %+v", ranges)
} }
} }
// Plain-text changes need not change accessible children or their identities.
func TestDocumentCaretRefreshesChangedHypertextAncestor(t *testing.T) {
for _, command := range []DocumentCaretCommand{CaretNextCharacter, CaretPreviousCharacter, CaretNextWord, CaretPreviousWord, CaretNextLine, CaretPreviousLine, CaretReadLine} {
t.Run(command.String(), func(t *testing.T) {
reader := caretTextReader{webID("/body"): "\ufffc\ufffc", webID("/one"): "A", webID("/two"): "B"}
makeNode := func(path, role string, children ...string) atspi.TreeNode {
s := atspi.NewSnapshot(path, "", role, "", "")
s.BusName = webID(path).BusName
s.CharacterCount = utf8.RuneCountInString(reader[webID(path)])
n := atspi.TreeNode{Snapshot: s}
for _, child := range children {
n.Children = append(n.Children, webID(child))
}
return n
}
body := makeNode("/body", "paragraph", "/one", "/two")
body.Snapshot.TextChildren = []atspi.TextChild{{Offset: 0, Child: webID("/one")}, {Offset: 1, Child: webID("/two")}}
nodes := []atspi.TreeNode{makeNode("/doc", "document web", "/body"), body, makeNode("/one", "link"), makeNode("/two", "link")}
tree, err := atspi.NewAccessibleTree(webID("/doc"), nodes)
if err != nil {
t.Fatal(err)
}
cursor, _ := NewDocumentCursor(tree)
path := "/one"
if command == CaretPreviousCharacter || command == CaretPreviousWord || command == CaretPreviousLine {
path = "/two"
}
if _, err = cursor.SyncFocus(context.Background(), webID(path)); err != nil {
t.Fatal(err)
}
reader[webID("/body")] = "\ufffc inserted \ufffc"
tree.InvalidateText(webID("/body"))
if command == CaretReadLine {
cursor.places[0].offset = 1
}
before := cursor.places[0]
move, err := cursor.MoveCaret(context.Background(), reader, command)
if !errors.Is(err, atspi.ErrStaleText) {
t.Fatalf("skipped parent insertion: move=%+v error=%v", move, err)
}
if after := cursor.places[0]; after.object != before.object || after.offset != before.offset {
t.Fatal("failed move changed position")
}
// A successful refresh must expose the insertion on the retry,
// retaining both embedded link identities and the original position.
nodes[1].Snapshot.CharacterCount = utf8.RuneCountInString(reader[webID("/body")])
nodes[1].Snapshot.TextChildren = []atspi.TextChild{{Offset: 0, Child: webID("/one")}, {Offset: nodes[1].Snapshot.CharacterCount - 1, Child: webID("/two")}}
if err := tree.Replace(webID("/doc"), nodes); err != nil {
t.Fatal(err)
}
move, err = cursor.MoveCaret(context.Background(), reader, command)
if err != nil || !move.Moved || move.Position.Object.ID() != webID("/body") {
t.Fatalf("refreshed move skipped insertion: %+v error=%v", move, err)
}
})
}
}
func TestDocumentCaretLocalMoveSurvivesHypertextInsertion(t *testing.T) {
cursor, reader := hypertextCursor(t)
if _, err := cursor.SyncFocus(context.Background(), webID("/link")); err != nil {
t.Fatal(err)
}
cursor.tree.InvalidateText(webID("/body"))
move, err := cursor.MoveCaret(context.Background(), reader, CaretNextCharacter)
if err != nil || move.Text != "e" {
t.Fatalf("local character: %+v %v", move, err)
}
if _, err = cursor.MoveCaret(context.Background(), reader, CaretNextLine); !errors.Is(err, atspi.ErrStaleText) {
t.Fatalf("crossing changed hypertext: %v", err)
}
}
@@ -44,3 +44,21 @@ func (reader *navigationTextReader) did_read(object atspi.ObjectReference) bool
} }
return false return false
} }
// Character blocks belong to one word command and never survive an event or
// recapture. Native spelling and actual word boundaries still use normal reads.
type navigationCharacterReader struct {
*navigationTextReader
source DocumentCharacterReader
}
func (reader *navigationCharacterReader) ReadDocumentCharacters(ctx context.Context, object atspi.ObjectReference, offset int) (atspi.DocumentCaretText, error) {
index, found := reader.document.Index(object.ID())
if !found || reader.document.Nodes[index].Reference != object {
return atspi.DocumentCaretText{}, atspi.ErrStaleObject
}
if err := reader.tree.ValidateDocumentRange(ctx, reader.document, reader.anchor, index); err != nil {
return atspi.DocumentCaretText{}, err
}
return reader.source.ReadDocumentCharacters(ctx, object, offset)
}
+105 -29
View File
@@ -16,42 +16,35 @@ import (
// The virtual coordinate survives a spanning cell so reversing direction keeps // The virtual coordinate survives a spanning cell so reversing direction keeps
// the user's column/row. Nested tables have independent coordinate spaces. // the user's column/row. Nested tables have independent coordinate spaces.
func (cursor *DocumentCursor) moveTable(ctx context.Context, doc atspi.DocumentSnapshot, place *documentPlace, command DocumentCaretCommand) (DocumentCaretMove, error) { func (cursor *DocumentCursor) moveTable(ctx context.Context, doc atspi.DocumentSnapshot, place *documentPlace, command DocumentCaretCommand) (DocumentCaretMove, error) {
nodes := make(map[atspi.ObjectID]atspi.DocumentNode, len(doc.Nodes)) tableIndex, found := document_table_index(doc, place.object.ID())
parents := make(map[atspi.ObjectID]atspi.ObjectID, len(doc.Nodes))
for _, node := range doc.Nodes {
nodes[node.Reference.ID()] = node
for _, child := range node.Children {
parents[child] = node.Reference.ID()
}
}
nearestTable := func(id atspi.ObjectID) atspi.ObjectID {
for depth := 0; id.Valid() && depth < 128; depth++ {
if nodes[id].Snapshot.Role == "table" || nodes[id].Snapshot.Role == "tree table" {
return id
}
id = parents[id]
}
return atspi.ObjectID{}
}
table := nearestTable(place.object.ID())
message := func(text string) (DocumentCaretMove, error) { message := func(text string) (DocumentCaretMove, error) {
return DocumentCaretMove{Position: documentPosition(doc, *place), Text: text}, nil return DocumentCaretMove{Position: documentPosition(doc, *place), Text: text}, nil
} }
if !table.Valid() { if !found {
return message("Not in a table.") return message("Not in a table.")
} }
table := doc.Nodes[tableIndex]
tableEnd := doc.SubtreeEnd(tableIndex)
if err := cursor.tree.ValidateDocumentRange(ctx, doc, tableIndex, tableEnd); err != nil {
return DocumentCaretMove{}, err
}
var current atspi.DocumentNode var current atspi.DocumentNode
for id, depth := place.object.ID(), 0; id.Valid() && id != table && depth < 128; id, depth = parents[id], depth+1 { for id, depth := place.object.ID(), 0; id.Valid() && id != table.Reference.ID() && depth < 128; depth++ {
role := nodes[id].Snapshot.Role node, ok := doc.Node(id)
if (role == "table cell" || role == "row header" || role == "column header") && !nodes[id].Snapshot.TablePositionKnown { if !ok {
return message("Table coordinates unavailable.")
}
if nodes[id].Snapshot.TablePositionKnown {
current = nodes[id]
break break
} }
role := node.Snapshot.Role
if (role == "table cell" || role == "row header" || role == "column header") && !node.Snapshot.TablePositionKnown {
return message("Table coordinates unavailable.")
}
if node.Snapshot.TablePositionKnown {
current = node
break
}
id = node.Parent
} }
rows, columns := nodes[table].Snapshot.TableRowCount, nodes[table].Snapshot.TableColumnCount rows, columns := table.Snapshot.TableRowCount, table.Snapshot.TableColumnCount
if rows <= 0 || columns <= 0 { if rows <= 0 || columns <= 0 {
return message("Table coordinates unavailable.") return message("Table coordinates unavailable.")
} }
@@ -82,8 +75,13 @@ func (cursor *DocumentCursor) moveTable(ctx context.Context, doc atspi.DocumentS
row, col = 0, 0 row, col = 0, 0
} }
cells := make([]atspi.DocumentNode, 0) cells := make([]atspi.DocumentNode, 0)
for _, node := range doc.Nodes { for index := tableIndex + 1; index <= tableEnd; index++ {
if node.Snapshot.TablePositionKnown && !node.Hidden && nearestTable(node.Reference.ID()) == table { node := doc.Nodes[index]
if node.Snapshot.Role == "table" || node.Snapshot.Role == "tree table" {
index = doc.SubtreeEnd(index)
continue
}
if node.Snapshot.TablePositionKnown && !node.Hidden {
cells = append(cells, node) cells = append(cells, node)
} }
} }
@@ -111,6 +109,9 @@ func (cursor *DocumentCursor) moveTable(ctx context.Context, doc atspi.DocumentS
break break
} }
} }
if !target.Reference.Valid() && (dr != 0 || dc != 0) {
target, row, col = next_available_table_cell(cells, current, row, col, dr, dc)
}
if !target.Reference.Valid() { if !target.Reference.Valid() {
return message("Table cell unavailable.") return message("Table cell unavailable.")
} }
@@ -165,3 +166,78 @@ func (cursor *DocumentCursor) moveTable(ctx context.Context, doc atspi.DocumentS
} }
return message("Table cell unavailable.") return message("Table cell unavailable.")
} }
// Use the immutable document index and avoid rebuilding whole-page parent maps.
func document_table_index(doc atspi.DocumentSnapshot, id atspi.ObjectID) (int, bool) {
for depth := 0; id.Valid() && depth < 128; depth++ {
index, ok := doc.Index(id)
if !ok {
break
}
node := doc.Nodes[index]
if node.Snapshot.Role == "table" || node.Snapshot.Role == "tree table" {
return index, true
}
id = node.Parent
}
return 0, false
}
// Search captured cells rather than iterating potentially enormous sparse
// dimensions. Horizontal neighbors can occupy a lower row of a spanning cell.
func next_available_table_cell(cells []atspi.DocumentNode, current atspi.DocumentNode, row, col, dr, dc int) (atspi.DocumentNode, int, int) {
var target atspi.DocumentNode
bestRow, bestCol := row, col
for _, node := range cells {
if node.Reference == current.Reference {
continue
}
s := node.Snapshot
rowEnd, colEnd := s.TableRowIndex+max(1, s.TableRowSpan)-1, s.TableColumnIndex+max(1, s.TableColumnSpan)-1
candidateRow, candidateCol := row, col
if dr != 0 {
if col < s.TableColumnIndex || col > colEnd {
continue
}
if dr > 0 {
candidateRow = max(row, s.TableRowIndex)
if candidateRow > rowEnd {
continue
}
} else {
candidateRow = min(row, rowEnd)
if candidateRow < s.TableRowIndex {
continue
}
}
if target.Reference.Valid() && (dr > 0 && candidateRow >= bestRow || dr < 0 && candidateRow <= bestRow) {
continue
}
} else {
lastRow := row
if current.Reference.Valid() {
lastRow = max(row, current.Snapshot.TableRowIndex+max(1, current.Snapshot.TableRowSpan)-1)
}
candidateRow = max(row, s.TableRowIndex)
if candidateRow > rowEnd || candidateRow > lastRow {
continue
}
if dc > 0 {
candidateCol = max(col, s.TableColumnIndex)
if candidateCol > colEnd {
continue
}
} else {
candidateCol = min(col, colEnd)
if candidateCol < s.TableColumnIndex {
continue
}
}
if target.Reference.Valid() && (candidateRow > bestRow || candidateRow == bestRow && (dc > 0 && candidateCol >= bestCol || dc < 0 && candidateCol <= bestCol)) {
continue
}
}
target, bestRow, bestCol = node, candidateRow, candidateCol
}
return target, bestRow, bestCol
}
+51
View File
@@ -6,6 +6,7 @@ package review
import ( import (
"context" "context"
"errors"
"strings" "strings"
"testing" "testing"
@@ -126,3 +127,53 @@ func TestTableNavigationRowSpanAndNestedTable(t *testing.T) {
t.Fatalf("nested table escaped: %+v %v", got, err) t.Fatalf("nested table escaped: %+v %v", got, err)
} }
} }
func TestTableNavigationSkipsHolesAndKeepsLocalDependencies(t *testing.T) {
makeNode := func(path, role string, children ...string) atspi.TreeNode {
s := atspi.NewSnapshot(path, path, role, "", "")
s.BusName = webID(path).BusName
n := atspi.TreeNode{Snapshot: s}
for _, child := range children {
n.Children = append(n.Children, webID(child))
}
return n
}
nodes := []atspi.TreeNode{makeNode("/doc", "document web", "/table", "/ticker"), makeNode("/table", "table", "/a", "/b", "/c"), makeNode("/ticker", "static")}
nodes[1].Snapshot.TableRowCount, nodes[1].Snapshot.TableColumnCount = 1000000, 3
for _, cell := range []struct {
path string
row, col, span int
}{{"/a", 0, 0, 1000000}, {"/b", 1, 1, 1}, {"/c", 999999, 1, 1}} {
n := makeNode(cell.path, "table cell")
n.Snapshot.TablePositionKnown = true
n.Snapshot.TableRowIndex, n.Snapshot.TableColumnIndex, n.Snapshot.TableRowSpan = cell.row, cell.col, cell.span
nodes = append(nodes, n)
}
tree, err := atspi.NewAccessibleTree(webID("/doc"), nodes)
if err != nil {
t.Fatal(err)
}
cursor, _ := NewDocumentCursor(tree)
if _, err = cursor.SyncFocus(context.Background(), webID("/a")); err != nil {
t.Fatal(err)
}
tree.InvalidateText(webID("/ticker"))
for _, step := range []struct {
command DocumentCaretCommand
path string
}{{TableRight, "/b"}, {TableDown, "/c"}, {TableUp, "/b"}, {TableLeft, "/a"}, {TableRight, "/b"}} {
move, err := cursor.MoveCaret(context.Background(), caretTextReader{}, step.command)
if err != nil || !move.Moved || move.Position.Object.ID() != webID(step.path) {
t.Fatalf("%s: %+v error=%v", step.command, move, err)
}
}
// Changed cells inside the table must still refresh the coordinate/header view.
tree.InvalidateSnapshot(webID("/c"))
before := cursor.places[0]
if _, err = cursor.MoveCaret(context.Background(), caretTextReader{}, TableDown); !errors.Is(err, atspi.ErrStaleSnapshot) {
t.Fatalf("stale cell ignored: %v", err)
}
if after := cursor.places[0]; after.object != before.object || after.tableRow != before.tableRow {
t.Fatal("failed move changed table position")
}
}
+7 -15
View File
@@ -48,19 +48,16 @@ func (reader *visualLineReader) ReadDocumentCaret(ctx context.Context, object at
return text, err return text, err
} }
func move_caret_line(ctx context.Context, source DocumentCaretReader, tree *atspi.AccessibleTree, doc atspi.DocumentSnapshot, place *documentPlace, indices []caretRange, current int, command DocumentCaretCommand, owners map[int]atspi.ObjectID) (DocumentCaretMove, error) { func move_caret_line(ctx context.Context, source DocumentCaretReader, tree *atspi.AccessibleTree, doc atspi.DocumentSnapshot, place *documentPlace, indices []caretRange, lookup *caretRangeLookup, current int, command DocumentCaretCommand, owners map[int]atspi.ObjectID) (DocumentCaretMove, error) {
reader := &visualLineReader{DocumentCaretReader: source, cache: make(map[visualLineKey]atspi.DocumentCaretText)} reader := &visualLineReader{DocumentCaretReader: source, cache: make(map[visualLineKey]atspi.DocumentCaretText)}
next := *place next := *place
// Move beyond the entire current line, even when character/word movement // Move beyond the entire current line, even when character/word movement
// left the cursor in a middle fragment or structural navigation chose its // left the cursor in a middle fragment or structural navigation chose its
// heading container. The object-local mover still handles empty controls. // heading container. The object-local mover still handles empty controls.
if command == CaretNextLine || command == CaretPreviousLine { if command == CaretNextLine || command == CaretPreviousLine {
position := -1 position := lookup.containing(indices, current, place.offset)
for i, index := range indices { if place.origin == StructuralNavigation && lookup.after[current] < len(indices) && (position < 0 || lookup.after[current] < position) {
if index.index == current && place.offset >= index.start && place.offset < max(index.start+1, index.end) || place.origin == StructuralNavigation && index.index > current { position = lookup.after[current]
position = i
break
}
} }
if position >= 0 { if position >= 0 {
parts, err := collect_visual_line(ctx, reader, doc, indices, owners, position, place.offset, true) parts, err := collect_visual_line(ctx, reader, doc, indices, owners, position, place.offset, true)
@@ -77,20 +74,15 @@ func move_caret_line(ctx context.Context, source DocumentCaretReader, tree *atsp
} }
} }
} }
movement, err := move_caret_object_line(ctx, reader, doc, &next, indices, current, command) movement, err := move_caret_object_line(ctx, reader, doc, &next, indices, lookup, current, command)
if err != nil || !movement.Moved { if err != nil || !movement.Moved {
if err == nil { if err == nil {
movement.Position = documentPosition(doc, *place) movement.Position = documentPosition(doc, *place)
} }
return movement, err return movement, err
} }
position := -1 nextIndex, _ := doc.Index(next.object.ID())
for i, index := range indices { position := lookup.containing(indices, nextIndex, next.offset)
if doc.Nodes[index.index].Reference == next.object && next.offset >= index.start && next.offset < max(index.start+1, index.end) {
position = i
break
}
}
if position >= 0 { if position >= 0 {
parts, err := collect_visual_line(ctx, reader, doc, indices, owners, position, next.offset, command != CaretReadLine) parts, err := collect_visual_line(ctx, reader, doc, indices, owners, position, next.offset, command != CaretReadLine)
if err != nil { if err != nil {
@@ -0,0 +1,78 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package review
import (
"context"
"strings"
"testing"
"git.stormux.org/storm/cthulhu/internal/atspi"
)
type blockCaretReader struct {
caretTextReader
calls, blocks int
malformed bool
}
func (reader *blockCaretReader) ReadDocumentCaret(ctx context.Context, object atspi.ObjectReference, offset int, unit atspi.DocumentCaretUnit) (atspi.DocumentCaretText, error) {
reader.calls++
return reader.caretTextReader.ReadDocumentCaret(ctx, object, offset, unit)
}
func (reader *blockCaretReader) ReadDocumentCharacters(_ context.Context, object atspi.ObjectReference, offset int) (atspi.DocumentCaretText, error) {
reader.calls++
reader.blocks++
runes := []rune(reader.caretTextReader[object.ID()])
start := offset / 128 * 128
end := min(len(runes), start+128)
raw := string(runes[start:end])
if reader.malformed {
raw = ""
}
return atspi.DocumentCaretText{Start: start, End: end, RawText: raw, CharacterCount: len(runes)}, nil
}
func TestDocumentWordSpacesUseBoundedBlocks(t *testing.T) {
values := caretTextReader{webID("/text"): "A" + strings.Repeat(" \t\n\u2003", 250) + "猫"}
cursor := newCaretTextCursor(t, values)
reader := &blockCaretReader{caretTextReader: values}
for _, step := range []struct {
command DocumentCaretCommand
text string
}{{CaretNextWord, "猫"}, {CaretPreviousWord, "猫"}, {CaretPreviousWord, "A \t\n\u2003"}} {
move, err := cursor.MoveCaret(context.Background(), reader, step.command)
// Native word providers include varying trailing whitespace. Retain
// their text and verify the actual requested word and Unicode offset.
if err != nil || !strings.HasPrefix(move.Text, strings.TrimSpace(step.text)) {
t.Fatalf("%s: %+v error=%v", step.command, move, err)
}
}
t.Logf("three word commands across 1000 whitespace characters: %d reads, %d blocks", reader.calls, reader.blocks)
if reader.blocks == 0 || reader.calls > 50 {
t.Fatalf("word traversal used %d reads (%d blocks)", reader.calls, reader.blocks)
}
}
func TestDocumentCharacterMovementDoesNotReadBlocks(t *testing.T) {
values := caretTextReader{webID("/text"): "A B"}
cursor := newCaretTextCursor(t, values)
reader := &blockCaretReader{caretTextReader: values}
move, err := cursor.MoveCaret(context.Background(), reader, CaretNextCharacter)
if err != nil || move.Text != " " || reader.blocks != 0 || reader.calls != 1 {
t.Fatalf("move=%+v error=%v calls=%d blocks=%d", move, err, reader.calls, reader.blocks)
}
}
func TestDocumentWordMalformedBlockCannotSkipText(t *testing.T) {
values := caretTextReader{webID("/text"): "A B"}
cursor := newCaretTextCursor(t, values)
reader := &blockCaretReader{caretTextReader: values, malformed: true}
move, err := cursor.MoveCaret(context.Background(), reader, CaretNextWord)
if err != nil || move.Text != "B" || move.Position.Offset != 4 {
t.Fatalf("move=%+v error=%v", move, err)
}
}
+2 -2
View File
@@ -3,7 +3,7 @@ gtk-alert-message,src/cthulhu/speech_generator.py;src/cthulhu/script_utilities.p
yad-simple-prompt,src/cthulhu/formatting.py;src/cthulhu/scripts/default.py,speech,Read a simple YAD frame question before its first focused button,,internal/atspi internal/app,implemented,go test ./internal/app ./internal/atspi -count=1; python3 tests/native_alert.py,Private YAD/Xvfb/AT-SPI fixture captured the exact question on frame activation or first button focus. The live 2026-09-27 log showed a third path where activation targets the focused button; an event-loop regression covers its speech order.,,Maintainer confirmed the YAD question was spoken on the updated build on 2026-09-27.,,YAD exposes the prompt as a frame with layout nodes and a label plus buttons. The generic Python frame format appears to omit the label; this Go addition follows the user's explicit example. A strict simple prompt shape limits frame text capture and bounded first-button refresh handles late labels. yad-simple-prompt,src/cthulhu/formatting.py;src/cthulhu/scripts/default.py,speech,Read a simple YAD frame question before its first focused button,,internal/atspi internal/app,implemented,go test ./internal/app ./internal/atspi -count=1; python3 tests/native_alert.py,Private YAD/Xvfb/AT-SPI fixture captured the exact question on frame activation or first button focus. The live 2026-09-27 log showed a third path where activation targets the focused button; an event-loop regression covers its speech order.,,Maintainer confirmed the YAD question was spoken on the updated build on 2026-09-27.,,YAD exposes the prompt as a frame with layout nodes and a label plus buttons. The generic Python frame format appears to omit the label; this Go addition follows the user's explicit example. A strict simple prompt shape limits frame text capture and bounded first-button refresh handles late labels.
radio-group-focus,src/cthulhu/generator.py;src/cthulhu/speech_generator.py,speech,Speak the radio group question with a focused option,,internal/atspi internal/presentation internal/preferences,implemented,go test ./internal/presentation ./internal/atspi ./internal/preferences -count=1; python3 tests/native_alert.py,Private GTK radio group focus captured Punctuation Level.,,Physical preferences speech pending.,,A named panel or labelled-by relation supplies the group question; each focused radio presents that name before its option. The Braille verbosity radios now have a named frame. radio-group-focus,src/cthulhu/generator.py;src/cthulhu/speech_generator.py,speech,Speak the radio group question with a focused option,,internal/atspi internal/presentation internal/preferences,implemented,go test ./internal/presentation ./internal/atspi ./internal/preferences -count=1; python3 tests/native_alert.py,Private GTK radio group focus captured Punctuation Level.,,Physical preferences speech pending.,,A named panel or labelled-by relation supplies the group question; each focused radio presents that name before its option. The Braille verbosity radios now have a named frame.
yad-list-prompt,src/cthulhu/formatting.py;src/cthulhu/scripts/default.py,speech,Read a simple YAD list question when its window opens,,internal/atspi internal/app,implemented,go test ./internal/app ./internal/atspi -count=1; python3 tests/native_alert.py,Private YAD power list captured Power options after ordinary or synthesized window activation.,,Physical I38 power menu listening pending.,,The I38 list has a labelled question above its choices; foreground recovery and direct activation both capture the same bounded prompt shape. yad-list-prompt,src/cthulhu/formatting.py;src/cthulhu/scripts/default.py,speech,Read a simple YAD list question when its window opens,,internal/atspi internal/app,implemented,go test ./internal/app ./internal/atspi -count=1; python3 tests/native_alert.py,Private YAD power list captured Power options after ordinary or synthesized window activation.,,Physical I38 power menu listening pending.,,The I38 list has a labelled question above its choices; foreground recovery and direct activation both capture the same bounded prompt shape.
web-live-region-announcements,src/cthulhu/scripts/web/script.py; src/cthulhu/liveregions.py,web,Automatically speak active-tab polite and assertive live region updates,Storm Dragon,internal/atspi internal/app,implemented,go test ./internal/app ./internal/atspi -count=1; CTHULHU_NATIVE_BROWSER_TESTS=live python3 tests/native_browser.py,Private Brave/Xvfb fixture captures the Skald-style status update and real child text; physical Skald Xorg test pending.,,Maintainer confirms extensive live browser testing passes in Firefox and Chromium-based browsers on 2026-10-22; element lists and jumping to selected items are explicitly confirmed. See docs/testing/release-acceptance.md.,,Go captures inherited live metadata and child text for text insertions and child additions. A Chromium placeholder update is suppressed. Off regions and inactive documents are filtered. Complete Python live-region review commands and politeness overrides remain pending. web-live-region-announcements,src/cthulhu/scripts/web/script.py; src/cthulhu/liveregions.py,web,Automatically speak active-tab polite and assertive live region updates,Storm Dragon,internal/atspi internal/app,implemented,go test ./internal/app ./internal/atspi -count=1; CTHULHU_NATIVE_BROWSER_TESTS=live python3 tests/native_browser.py; CTHULHU_NATIVE_BROWSER_TESTS=live-atomic python3 tests/native_browser.py,Private Brave/Xvfb fixture captures the Skald-style status update and real child text; physical Skald Xorg test pending.,,Maintainer confirms extensive live browser testing passes in Firefox and Chromium-based browsers on 2026-10-22; element lists and jumping to selected items are explicitly confirmed. See docs/testing/release-acceptance.md.,,Go captures inherited live metadata and child text for text insertions and child additions. A Chromium placeholder update is suppressed. Off regions and inactive documents are filtered. Complete Python live-region review commands and politeness overrides remain pending. October 24 audit fixes resolve atomic ancestors and defer established busy live regions until completion. Private Chromium atomic text/child updates non-atomic updates and busy release without a new insertion pass. Provider metadata limits and pending physical checks are documented in docs/testing/browser-audit-fixes.md.
core-startup,src/cthulhu/cthulhu.py,core,Orchestrate runtime service startup and graceful shutdown,Storm Dragon,internal/app,in-progress,go test ./internal/app ./internal/atspi ./internal/lifecycle -count=1; CTHULHU_XRECORD_TEST=/tmp/cthulhu-xrecord.test python3 tests/native_x11.py,Maintainer accepted the Stage 1 physical Xorg shutdown checklist on 2026-09-11; post-fix cthulhu.sh returned to the prompt with complete shutdown records on Xorg using its LXTerminal test application. The private Xvfb/D-Bus native proof with XRecord also passed.,,,,"Lifecycle and service orchestration only; see lifecycle-welcome-message, lifecycle-farewell-message, lifecycle-start-sound, and lifecycle-stop-sound for the four user-visible deliveries. General startup or shutdown parity is not claimed." core-startup,src/cthulhu/cthulhu.py,core,Orchestrate runtime service startup and graceful shutdown,Storm Dragon,internal/app,in-progress,go test ./internal/app ./internal/atspi ./internal/lifecycle -count=1; CTHULHU_XRECORD_TEST=/tmp/cthulhu-xrecord.test python3 tests/native_x11.py,Maintainer accepted the Stage 1 physical Xorg shutdown checklist on 2026-09-11; post-fix cthulhu.sh returned to the prompt with complete shutdown records on Xorg using its LXTerminal test application. The private Xvfb/D-Bus native proof with XRecord also passed.,,,,"Lifecycle and service orchestration only; see lifecycle-welcome-message, lifecycle-farewell-message, lifecycle-start-sound, and lifecycle-stop-sound for the four user-visible deliveries. General startup or shutdown parity is not claimed."
lifecycle-welcome-message,src/cthulhu/plugins/HelloCthulhu/plugin.py,speech,Speak a randomly selected startup welcome message,Storm Dragon,internal/app,in-progress,go test ./internal/app -run '^(TestRunSpeaksFocusEventsAndStops|TestRandomString)$' -count=1,Maintainer accepted welcome speech in the Stage 1 physical Xorg checklist on 2026-09-11 and confirmed the randomized message wiring worked on 2026-09-12.,,,Maintainer heard the startup welcome message during the accepted Stage 1 physical Xorg checklist on 2026-09-11 and confirmed the randomized message wiring worked on 2026-09-12.,Python presents Cthulhu welcomes you. once through HelloCthulhu after start-application-completed and only while an active script exists. Go creates its speech worker only when enableSpeech is true and randomly selects from a small startup list after service startup without an active-script or once-only gate; delivery is not waited for and has no per-message deadline. The extra variants are an intentional Go behavior extension. General startup parity is not claimed. lifecycle-welcome-message,src/cthulhu/plugins/HelloCthulhu/plugin.py,speech,Speak a randomly selected startup welcome message,Storm Dragon,internal/app,in-progress,go test ./internal/app -run '^(TestRunSpeaksFocusEventsAndStops|TestRandomString)$' -count=1,Maintainer accepted welcome speech in the Stage 1 physical Xorg checklist on 2026-09-11 and confirmed the randomized message wiring worked on 2026-09-12.,,,Maintainer heard the startup welcome message during the accepted Stage 1 physical Xorg checklist on 2026-09-11 and confirmed the randomized message wiring worked on 2026-09-12.,Python presents Cthulhu welcomes you. once through HelloCthulhu after start-application-completed and only while an active script exists. Go creates its speech worker only when enableSpeech is true and randomly selects from a small startup list after service startup without an active-script or once-only gate; delivery is not waited for and has no per-message deadline. The extra variants are an intentional Go behavior extension. General startup parity is not claimed.
lifecycle-farewell-message,src/cthulhu/plugins/ByeCthulhu/plugin.py,speech,Speak a randomly selected graceful-shutdown farewell message,Storm Dragon,internal/app,in-progress,go test ./internal/app -run '^(TestRunSpeaksFocusEventsAndStops|TestRandomString)$' -count=1,Maintainer heard the post-fix farewell speech through cthulhu.sh on Xorg using its LXTerminal test application on 2026-09-11 and confirmed the randomized message wiring worked on 2026-09-12; the launcher returned to the prompt and completed final shutdown records.,,,Maintainer heard the post-fix farewell speech through cthulhu.sh on Xorg on 2026-09-11 and confirmed the randomized message wiring worked on 2026-09-12.,"Python presents Cthulhu lurks beneath the waves. through ByeCthulhu only while an active script exists. Go randomly selects from a small shutdown list only when enableSpeech is true, interrupts owned speech, and waits up to 2 seconds for the backend result before native cleanup; timeout cancels the active request. The extra variants are an intentional Go behavior extension. audit-required: active-script gating, speech-disabled presentation semantics, and Python's overall shutdown watchdog versus Go's per-message deadline are not directly aligned by the named evidence." lifecycle-farewell-message,src/cthulhu/plugins/ByeCthulhu/plugin.py,speech,Speak a randomly selected graceful-shutdown farewell message,Storm Dragon,internal/app,in-progress,go test ./internal/app -run '^(TestRunSpeaksFocusEventsAndStops|TestRandomString)$' -count=1,Maintainer heard the post-fix farewell speech through cthulhu.sh on Xorg using its LXTerminal test application on 2026-09-11 and confirmed the randomized message wiring worked on 2026-09-12; the launcher returned to the prompt and completed final shutdown records.,,,Maintainer heard the post-fix farewell speech through cthulhu.sh on Xorg on 2026-09-11 and confirmed the randomized message wiring worked on 2026-09-12.,"Python presents Cthulhu lurks beneath the waves. through ByeCthulhu only while an active script exists. Go randomly selects from a small shutdown list only when enableSpeech is true, interrupts owned speech, and waits up to 2 seconds for the backend result before native cleanup; timeout cancels the active request. The extra variants are an intentional Go behavior extension. audit-required: active-script gating, speech-disabled presentation semantics, and Python's overall shutdown watchdog versus Go's per-message deadline are not directly aligned by the named evidence."
@@ -19,7 +19,7 @@ managed-descendant-presentation,src/cthulhu/scripts/default.py,focus,Resolve foc
ssip-speak-cancel,src/cthulhu/speechdispatcherfactory.py,speech,Send speech and cancellation through SSIP,Storm Dragon,internal/speech,in-progress,go test ./internal/speech -count=1,Maintainer accepted audible non-consuming Control/Shift interruption in the Stage 1 physical Xorg checklist on 2026-09-11.,,Maintainer heard lifecycle and terminal speech plus Control/Shift interruption during the accepted Stage 1 physical Xorg checklist on 2026-09-11.,,Protocol replies 230 and 225 were confirmed against the local service; broader backend and X11 coverage remains pending. ssip-speak-cancel,src/cthulhu/speechdispatcherfactory.py,speech,Send speech and cancellation through SSIP,Storm Dragon,internal/speech,in-progress,go test ./internal/speech -count=1,Maintainer accepted audible non-consuming Control/Shift interruption in the Stage 1 physical Xorg checklist on 2026-09-11.,,Maintainer heard lifecycle and terminal speech plus Control/Shift interruption during the accepted Stage 1 physical Xorg checklist on 2026-09-11.,,Protocol replies 230 and 225 were confirmed against the local service; broader backend and X11 coverage remains pending.
web-structural-navigation,src/cthulhu/scripts/web/script.py,web,Move by headings and other document structure,Storm Dragon,internal/input internal/review internal/app,implemented,go test -race ./... -count=1; python3 tests/native_browser.py,Private Brave/Xvfb structural and mode/chrome sequences pass on 2026-09-20.,,Maintainer confirms extensive live browser testing passes in Firefox and Chromium-based browsers on 2026-10-22; element lists and jumping to selected items are explicitly confirmed. See docs/testing/release-acceptance.md.,,Structural navigation follows native caret movement. Echo suppression ends on subsequent input. Failed same-object context capture retries. Delayed document carets cannot override editable focus and stale results cannot overwrite unresolved unrelated focus. All 27 browse symbols pass through after routing is disabled. Complete Python structural settings and dialogs remain pending. Parity review corrections and remaining physical gates: docs/testing/parity-review-follow-up.md. web-structural-navigation,src/cthulhu/scripts/web/script.py,web,Move by headings and other document structure,Storm Dragon,internal/input internal/review internal/app,implemented,go test -race ./... -count=1; python3 tests/native_browser.py,Private Brave/Xvfb structural and mode/chrome sequences pass on 2026-09-20.,,Maintainer confirms extensive live browser testing passes in Firefox and Chromium-based browsers on 2026-10-22; element lists and jumping to selected items are explicitly confirmed. See docs/testing/release-acceptance.md.,,Structural navigation follows native caret movement. Echo suppression ends on subsequent input. Failed same-object context capture retries. Delayed document carets cannot override editable focus and stale results cannot overwrite unresolved unrelated focus. All 27 browse symbols pass through after routing is disabled. Complete Python structural settings and dialogs remain pending. Parity review corrections and remaining physical gates: docs/testing/parity-review-follow-up.md.
web-document-recapture,src/cthulhu/ax_object.py,web,Recover heading navigation after browser document replacement,Storm Dragon,internal/atspi internal/review internal/app,implemented,go test -race ./... -count=1; python3 tests/native_browser.py,Private Brave/Xvfb passes immediate H after loading a new page and after activating its return link on 2026-09-20.,,Maintainer confirms extensive live browser testing passes in Firefox and Chromium-based browsers on 2026-10-22; element lists and jumping to selected items are explicitly confirmed. See docs/testing/release-acceptance.md.,,Native caches refresh on recapture. Load completion retains the new root and resets browser context and pending reading. Worker page epochs retire queued and in-flight results across reused paths. Native reload and mode/chrome tests remain covered. Installed Go preview and Brave Origin are not verified. On 2026-09-21 semantic invalidation was scoped to captured objects so browser chrome and other applications cannot discard the document. Focused race tests and the full private Brave suite pass; heading/link jumps assert capture reuse. Large-page latency and logged stale-object failures still require physical retesting. September 22 log regressions: native invalid text-end recovery and bounded embedded text expansion; web name-event suppression; background load preserves verified chrome focus. Focused race tests and private Brave proofs pass. See docs/testing/chromium-browser.md for reference behavior and remaining physical gates. September 23 ownership correction rejects loads outside the current application or known window and after owner loss; accepted loads update the focus owner for correct defunct recovery. Five event-loop regressions and app race tests pass; physical acceptance remains pending. September 24 same-geometry window recovery now requires fresh active/showing/non-iconified state before foreground correlation; private recovery regression fails on the earlier preview and passes on the rebuilt one. Address changes outside the owned field are suppressed; popup-associated typing remains supported. September 25 review: recovery now checks the fresh tree when text/geometry recapture discovers a removed focus; deterministic regression and focused race checks pass. web-document-recapture,src/cthulhu/ax_object.py,web,Recover heading navigation after browser document replacement,Storm Dragon,internal/atspi internal/review internal/app,implemented,go test -race ./... -count=1; python3 tests/native_browser.py,Private Brave/Xvfb passes immediate H after loading a new page and after activating its return link on 2026-09-20.,,Maintainer confirms extensive live browser testing passes in Firefox and Chromium-based browsers on 2026-10-22; element lists and jumping to selected items are explicitly confirmed. See docs/testing/release-acceptance.md.,,Native caches refresh on recapture. Load completion retains the new root and resets browser context and pending reading. Worker page epochs retire queued and in-flight results across reused paths. Native reload and mode/chrome tests remain covered. Installed Go preview and Brave Origin are not verified. On 2026-09-21 semantic invalidation was scoped to captured objects so browser chrome and other applications cannot discard the document. Focused race tests and the full private Brave suite pass; heading/link jumps assert capture reuse. Large-page latency and logged stale-object failures still require physical retesting. September 22 log regressions: native invalid text-end recovery and bounded embedded text expansion; web name-event suppression; background load preserves verified chrome focus. Focused race tests and private Brave proofs pass. See docs/testing/chromium-browser.md for reference behavior and remaining physical gates. September 23 ownership correction rejects loads outside the current application or known window and after owner loss; accepted loads update the focus owner for correct defunct recovery. Five event-loop regressions and app race tests pass; physical acceptance remains pending. September 24 same-geometry window recovery now requires fresh active/showing/non-iconified state before foreground correlation; private recovery regression fails on the earlier preview and passes on the rebuilt one. Address changes outside the owned field are suppressed; popup-associated typing remains supported. September 25 review: recovery now checks the fresh tree when text/geometry recapture discovers a removed focus; deterministic regression and focused race checks pass.
web-caret-line-reading,src/cthulhu/scripts/web/script.py,web,Read displayed document content at the browser caret,Storm Dragon,internal/atspi internal/review internal/app internal/presentation,implemented,"go test -race ./... -count=1; python3 tests/native_browser.py; go test -race ./internal/atspi ./internal/review ./internal/app -count=1; CTHULHU_NATIVE_BROWSER_TESTS=generatedlinks,replacement,tooltips,fragments,namedlinks,linkedheadings python3 tests/native_browser.py",Private Brave/Xvfb H/Down/H and expanded page transition sequences pass on 2026-09-20. On 2026-10-22 the maintainer confirmed reading the entire original Techrights article in Brave without looping after the generated-link recovery fix. See docs/testing/browser-release-review.md#generated-link-recovery-acceptance-2026-10-22.,,Maintainer confirms extensive live browser testing passes in Firefox and Chromium-based browsers on 2026-10-22; element lists and jumping to selected items are explicitly confirmed. See docs/testing/release-acceptance.md. Original Techrights full-article reading accepted after the generated-link fix on 2026-10-22; see docs/testing/browser-release-review.md#generated-link-recovery-acceptance-2026-10-22.,,Captured and assembled document lines use role/state sound categories and speech fallback. Unit tests cover visited links and per-object cue deduplication and sound-only versus speech-only policy. Character and word intents remain separate. Ordinary caret movement updates structural position. Initial read-all is implemented; braille and complete line-presentation parity remain pending. September 22 log regressions: native invalid text-end recovery and bounded embedded text expansion; web name-event suppression; background load preserves verified chrome focus. Focused race tests and private Brave proofs pass. See docs/testing/chromium-browser.md for reference behavior and remaining physical gates. September 24 prerequisite review: pending caret/terminal timers extracted under the same event-loop owner; adjacent-character word navigation and whitespace and embedded-marker traversal corrected; whole caret movement deadline added. Full race and isolated Brave/X11/ownership proofs pass; physical and installed-preview gates remain pending. See docs/testing/chromium-browser.md for dependency order. September 24 log correction: browse word and line speech includes link and heading ancestors with role sound policy; character output retains character granularity. Native Brave assertions cover link words and document-start heading level. See docs/testing/chromium-browser.md. September 25 review: trailing non-text objects no longer cause false stale-location errors for line/document-edge movement; focused regression and private Brave suite pass. Physical listening remains open. Explicitly named links retain whole-label line speech while character and word review use accessible-label offsets independently of native text. Generic unit and private Brave named-link regressions cover empty native text and badges plus both directions across following punctuation. Live speech and physical Braille acceptance remain pending. October 22 generated-link regression verifies exact prefix/body text with forward/reverse lines and Read All in private Brave. The maintainer accepted full-article reading on the original Techrights page after the fix; other live gates remain scoped to their named workflows. See docs/testing/browser-release-review.md#generated-link-recovery-acceptance-2026-10-22. web-caret-line-reading,src/cthulhu/scripts/web/script.py,web,Read displayed document content at the browser caret,Storm Dragon,internal/atspi internal/review internal/app internal/presentation,implemented,"go test -race ./... -count=1; python3 tests/native_browser.py; go test -race ./internal/atspi ./internal/review ./internal/app -count=1; CTHULHU_NATIVE_BROWSER_TESTS=generatedlinks,replacement,tooltips,fragments,namedlinks,linkedheadings python3 tests/native_browser.py",Private Brave/Xvfb H/Down/H and expanded page transition sequences pass on 2026-09-20. On 2026-10-22 the maintainer confirmed reading the entire original Techrights article in Brave without looping after the generated-link recovery fix. See docs/testing/browser-release-review.md#generated-link-recovery-acceptance-2026-10-22.,,Maintainer confirms extensive live browser testing passes in Firefox and Chromium-based browsers on 2026-10-22; element lists and jumping to selected items are explicitly confirmed. See docs/testing/release-acceptance.md. Original Techrights full-article reading accepted after the generated-link fix on 2026-10-22; see docs/testing/browser-release-review.md#generated-link-recovery-acceptance-2026-10-22.,,Captured and assembled document lines use role/state sound categories and speech fallback. Unit tests cover visited links and per-object cue deduplication and sound-only versus speech-only policy. Character and word intents remain separate. Ordinary caret movement updates structural position. Initial read-all is implemented; braille and complete line-presentation parity remain pending. September 22 log regressions: native invalid text-end recovery and bounded embedded text expansion; web name-event suppression; background load preserves verified chrome focus. Focused race tests and private Brave proofs pass. See docs/testing/chromium-browser.md for reference behavior and remaining physical gates. September 24 prerequisite review: pending caret/terminal timers extracted under the same event-loop owner; adjacent-character word navigation and whitespace and embedded-marker traversal corrected; whole caret movement deadline added. Full race and isolated Brave/X11/ownership proofs pass; physical and installed-preview gates remain pending. See docs/testing/chromium-browser.md for dependency order. September 24 log correction: browse word and line speech includes link and heading ancestors with role sound policy; character output retains character granularity. Native Brave assertions cover link words and document-start heading level. See docs/testing/chromium-browser.md. September 25 review: trailing non-text objects no longer cause false stale-location errors for line/document-edge movement; focused regression and private Brave suite pass. Physical listening remains open. Explicitly named links retain whole-label line speech while character and word review use accessible-label offsets independently of native text. Generic unit and private Brave named-link regressions cover empty native text and badges plus both directions across following punctuation. Live speech and physical Braille acceptance remain pending. October 22 generated-link regression verifies exact prefix/body text with forward/reverse lines and Read All in private Brave. The maintainer accepted full-article reading on the original Techrights page after the fix; other live gates remain scoped to their named workflows. See docs/testing/browser-release-review.md#generated-link-recovery-acceptance-2026-10-22. October 24 audit fixes validate dirty hypertext ancestors before crossing objects; refreshed retries include inserted parent text. Word whitespace uses bounded native character blocks and caret ranges have indexed lookup. Existing native navigation regressions pass; see docs/testing/browser-audit-fixes.md for limitations and remaining work.
config-read-preview,src/cthulhu/backends/toml_backend.py,settings,Read existing Cthulhu TOML and merge the startup profile,Storm Dragon,internal/config,implemented,go test ./internal/config ./cmd/cthulhu -count=1,,,,,Read-only preview bridge available only through explicit --config selection. The Go-native schema and default path are intentionally separate; the one-shot importer remains Phase 8 work. config-read-preview,src/cthulhu/backends/toml_backend.py,settings,Read existing Cthulhu TOML and merge the startup profile,Storm Dragon,internal/config,implemented,go test ./internal/config ./cmd/cthulhu -count=1,,,,,Read-only preview bridge available only through explicit --config selection. The Go-native schema and default path are intentionally separate; the one-shot importer remains Phase 8 work.
config-speech-preview,src/cthulhu/speechdispatcherfactory.py,speech,Apply supported active-profile speech settings,Storm Dragon,internal/config internal/speech internal/app,implemented,go test ./internal/config ./internal/speech ./internal/presentation ./internal/app -count=1,,,Maintainer confirms speech preferences; previews; Apply and Cancel; saved settings; and interruption-key settings work in live testing on 2026-10-22. See docs/testing/release-acceptance.md.,,Applies enablement description punctuation capitalization and default voice settings. config-speech-preview,src/cthulhu/speechdispatcherfactory.py,speech,Apply supported active-profile speech settings,Storm Dragon,internal/config internal/speech internal/app,implemented,go test ./internal/config ./internal/speech ./internal/presentation ./internal/app -count=1,,,Maintainer confirms speech preferences; previews; Apply and Cancel; saved settings; and interruption-key settings work in live testing on 2026-10-22. See docs/testing/release-acceptance.md.,,Applies enablement description punctuation capitalization and default voice settings.
typing-printable-key-echo,src/cthulhu/typing_echo_presenter.py,input,Apply printable typing-echo category settings to terminal and editable text insertions,Storm Dragon,internal/config internal/atspi internal/presentation internal/app,implemented,go test ./internal/config ./internal/atspi ./internal/presentation ./internal/app -count=1,Maintainer reported the configured printable echo settings worked in minimal physical Xorg tests on 2026-09-12; broader application coverage remains pending.,,Maintainer confirms thorough live testing of native application and terminal workflows; typing echo; selection; clipboard feedback; and object navigation on 2026-10-22. These workflows pass. See docs/testing/release-acceptance.md.,,Single-character terminal and editable AT-SPI insertions honor enableKeyEcho enableEchoByCharacter and alphabetic numeric punctuation space and diacritical categories. Multi-character insertions remain presentation events. Preferences offer disabled enabled and edit-fields-only radio choices; edit-fields-only uses native editable metadata and Wine editor focus while preserving configured echo voices. Automated focus and persistence checks pass; live speech and physical Braille acceptance for the new mode remain pending. typing-printable-key-echo,src/cthulhu/typing_echo_presenter.py,input,Apply printable typing-echo category settings to terminal and editable text insertions,Storm Dragon,internal/config internal/atspi internal/presentation internal/app,implemented,go test ./internal/config ./internal/atspi ./internal/presentation ./internal/app -count=1,Maintainer reported the configured printable echo settings worked in minimal physical Xorg tests on 2026-09-12; broader application coverage remains pending.,,Maintainer confirms thorough live testing of native application and terminal workflows; typing echo; selection; clipboard feedback; and object navigation on 2026-10-22. These workflows pass. See docs/testing/release-acceptance.md.,,Single-character terminal and editable AT-SPI insertions honor enableKeyEcho enableEchoByCharacter and alphabetic numeric punctuation space and diacritical categories. Multi-character insertions remain presentation events. Preferences offer disabled enabled and edit-fields-only radio choices; edit-fields-only uses native editable metadata and Wine editor focus while preserving configured echo voices. Automated focus and persistence checks pass; live speech and physical Braille acceptance for the new mode remain pending.
1 id python_source subsystem behavior owner go_destination status automated_evidence x11_evidence wayland_evidence speech_evidence braille_evidence notes
3 yad-simple-prompt src/cthulhu/formatting.py;src/cthulhu/scripts/default.py speech Read a simple YAD frame question before its first focused button internal/atspi internal/app implemented go test ./internal/app ./internal/atspi -count=1; python3 tests/native_alert.py Private YAD/Xvfb/AT-SPI fixture captured the exact question on frame activation or first button focus. The live 2026-09-27 log showed a third path where activation targets the focused button; an event-loop regression covers its speech order. Maintainer confirmed the YAD question was spoken on the updated build on 2026-09-27. YAD exposes the prompt as a frame with layout nodes and a label plus buttons. The generic Python frame format appears to omit the label; this Go addition follows the user's explicit example. A strict simple prompt shape limits frame text capture and bounded first-button refresh handles late labels.
4 radio-group-focus src/cthulhu/generator.py;src/cthulhu/speech_generator.py speech Speak the radio group question with a focused option internal/atspi internal/presentation internal/preferences implemented go test ./internal/presentation ./internal/atspi ./internal/preferences -count=1; python3 tests/native_alert.py Private GTK radio group focus captured Punctuation Level. Physical preferences speech pending. A named panel or labelled-by relation supplies the group question; each focused radio presents that name before its option. The Braille verbosity radios now have a named frame.
5 yad-list-prompt src/cthulhu/formatting.py;src/cthulhu/scripts/default.py speech Read a simple YAD list question when its window opens internal/atspi internal/app implemented go test ./internal/app ./internal/atspi -count=1; python3 tests/native_alert.py Private YAD power list captured Power options after ordinary or synthesized window activation. Physical I38 power menu listening pending. The I38 list has a labelled question above its choices; foreground recovery and direct activation both capture the same bounded prompt shape.
6 web-live-region-announcements src/cthulhu/scripts/web/script.py; src/cthulhu/liveregions.py web Automatically speak active-tab polite and assertive live region updates Storm Dragon internal/atspi internal/app implemented go test ./internal/app ./internal/atspi -count=1; CTHULHU_NATIVE_BROWSER_TESTS=live python3 tests/native_browser.py go test ./internal/app ./internal/atspi -count=1; CTHULHU_NATIVE_BROWSER_TESTS=live python3 tests/native_browser.py; CTHULHU_NATIVE_BROWSER_TESTS=live-atomic python3 tests/native_browser.py Private Brave/Xvfb fixture captures the Skald-style status update and real child text; physical Skald Xorg test pending. Maintainer confirms extensive live browser testing passes in Firefox and Chromium-based browsers on 2026-10-22; element lists and jumping to selected items are explicitly confirmed. See docs/testing/release-acceptance.md. Go captures inherited live metadata and child text for text insertions and child additions. A Chromium placeholder update is suppressed. Off regions and inactive documents are filtered. Complete Python live-region review commands and politeness overrides remain pending. Go captures inherited live metadata and child text for text insertions and child additions. A Chromium placeholder update is suppressed. Off regions and inactive documents are filtered. Complete Python live-region review commands and politeness overrides remain pending. October 24 audit fixes resolve atomic ancestors and defer established busy live regions until completion. Private Chromium atomic text/child updates non-atomic updates and busy release without a new insertion pass. Provider metadata limits and pending physical checks are documented in docs/testing/browser-audit-fixes.md.
7 core-startup src/cthulhu/cthulhu.py core Orchestrate runtime service startup and graceful shutdown Storm Dragon internal/app in-progress go test ./internal/app ./internal/atspi ./internal/lifecycle -count=1; CTHULHU_XRECORD_TEST=/tmp/cthulhu-xrecord.test python3 tests/native_x11.py Maintainer accepted the Stage 1 physical Xorg shutdown checklist on 2026-09-11; post-fix cthulhu.sh returned to the prompt with complete shutdown records on Xorg using its LXTerminal test application. The private Xvfb/D-Bus native proof with XRecord also passed. Lifecycle and service orchestration only; see lifecycle-welcome-message, lifecycle-farewell-message, lifecycle-start-sound, and lifecycle-stop-sound for the four user-visible deliveries. General startup or shutdown parity is not claimed.
8 lifecycle-welcome-message src/cthulhu/plugins/HelloCthulhu/plugin.py speech Speak a randomly selected startup welcome message Storm Dragon internal/app in-progress go test ./internal/app -run '^(TestRunSpeaksFocusEventsAndStops|TestRandomString)$' -count=1 Maintainer accepted welcome speech in the Stage 1 physical Xorg checklist on 2026-09-11 and confirmed the randomized message wiring worked on 2026-09-12. Maintainer heard the startup welcome message during the accepted Stage 1 physical Xorg checklist on 2026-09-11 and confirmed the randomized message wiring worked on 2026-09-12. Python presents Cthulhu welcomes you. once through HelloCthulhu after start-application-completed and only while an active script exists. Go creates its speech worker only when enableSpeech is true and randomly selects from a small startup list after service startup without an active-script or once-only gate; delivery is not waited for and has no per-message deadline. The extra variants are an intentional Go behavior extension. General startup parity is not claimed.
9 lifecycle-farewell-message src/cthulhu/plugins/ByeCthulhu/plugin.py speech Speak a randomly selected graceful-shutdown farewell message Storm Dragon internal/app in-progress go test ./internal/app -run '^(TestRunSpeaksFocusEventsAndStops|TestRandomString)$' -count=1 Maintainer heard the post-fix farewell speech through cthulhu.sh on Xorg using its LXTerminal test application on 2026-09-11 and confirmed the randomized message wiring worked on 2026-09-12; the launcher returned to the prompt and completed final shutdown records. Maintainer heard the post-fix farewell speech through cthulhu.sh on Xorg on 2026-09-11 and confirmed the randomized message wiring worked on 2026-09-12. Python presents Cthulhu lurks beneath the waves. through ByeCthulhu only while an active script exists. Go randomly selects from a small shutdown list only when enableSpeech is true, interrupts owned speech, and waits up to 2 seconds for the backend result before native cleanup; timeout cancels the active request. The extra variants are an intentional Go behavior extension. audit-required: active-script gating, speech-disabled presentation semantics, and Python's overall shutdown watchdog versus Go's per-message deadline are not directly aligned by the named evidence.
19 ssip-speak-cancel src/cthulhu/speechdispatcherfactory.py speech Send speech and cancellation through SSIP Storm Dragon internal/speech in-progress go test ./internal/speech -count=1 Maintainer accepted audible non-consuming Control/Shift interruption in the Stage 1 physical Xorg checklist on 2026-09-11. Maintainer heard lifecycle and terminal speech plus Control/Shift interruption during the accepted Stage 1 physical Xorg checklist on 2026-09-11. Protocol replies 230 and 225 were confirmed against the local service; broader backend and X11 coverage remains pending.
20 web-structural-navigation src/cthulhu/scripts/web/script.py web Move by headings and other document structure Storm Dragon internal/input internal/review internal/app implemented go test -race ./... -count=1; python3 tests/native_browser.py Private Brave/Xvfb structural and mode/chrome sequences pass on 2026-09-20. Maintainer confirms extensive live browser testing passes in Firefox and Chromium-based browsers on 2026-10-22; element lists and jumping to selected items are explicitly confirmed. See docs/testing/release-acceptance.md. Structural navigation follows native caret movement. Echo suppression ends on subsequent input. Failed same-object context capture retries. Delayed document carets cannot override editable focus and stale results cannot overwrite unresolved unrelated focus. All 27 browse symbols pass through after routing is disabled. Complete Python structural settings and dialogs remain pending. Parity review corrections and remaining physical gates: docs/testing/parity-review-follow-up.md.
21 web-document-recapture src/cthulhu/ax_object.py web Recover heading navigation after browser document replacement Storm Dragon internal/atspi internal/review internal/app implemented go test -race ./... -count=1; python3 tests/native_browser.py Private Brave/Xvfb passes immediate H after loading a new page and after activating its return link on 2026-09-20. Maintainer confirms extensive live browser testing passes in Firefox and Chromium-based browsers on 2026-10-22; element lists and jumping to selected items are explicitly confirmed. See docs/testing/release-acceptance.md. Native caches refresh on recapture. Load completion retains the new root and resets browser context and pending reading. Worker page epochs retire queued and in-flight results across reused paths. Native reload and mode/chrome tests remain covered. Installed Go preview and Brave Origin are not verified. On 2026-09-21 semantic invalidation was scoped to captured objects so browser chrome and other applications cannot discard the document. Focused race tests and the full private Brave suite pass; heading/link jumps assert capture reuse. Large-page latency and logged stale-object failures still require physical retesting. September 22 log regressions: native invalid text-end recovery and bounded embedded text expansion; web name-event suppression; background load preserves verified chrome focus. Focused race tests and private Brave proofs pass. See docs/testing/chromium-browser.md for reference behavior and remaining physical gates. September 23 ownership correction rejects loads outside the current application or known window and after owner loss; accepted loads update the focus owner for correct defunct recovery. Five event-loop regressions and app race tests pass; physical acceptance remains pending. September 24 same-geometry window recovery now requires fresh active/showing/non-iconified state before foreground correlation; private recovery regression fails on the earlier preview and passes on the rebuilt one. Address changes outside the owned field are suppressed; popup-associated typing remains supported. September 25 review: recovery now checks the fresh tree when text/geometry recapture discovers a removed focus; deterministic regression and focused race checks pass.
22 web-caret-line-reading src/cthulhu/scripts/web/script.py web Read displayed document content at the browser caret Storm Dragon internal/atspi internal/review internal/app internal/presentation implemented go test -race ./... -count=1; python3 tests/native_browser.py; go test -race ./internal/atspi ./internal/review ./internal/app -count=1; CTHULHU_NATIVE_BROWSER_TESTS=generatedlinks,replacement,tooltips,fragments,namedlinks,linkedheadings python3 tests/native_browser.py Private Brave/Xvfb H/Down/H and expanded page transition sequences pass on 2026-09-20. On 2026-10-22 the maintainer confirmed reading the entire original Techrights article in Brave without looping after the generated-link recovery fix. See docs/testing/browser-release-review.md#generated-link-recovery-acceptance-2026-10-22. Maintainer confirms extensive live browser testing passes in Firefox and Chromium-based browsers on 2026-10-22; element lists and jumping to selected items are explicitly confirmed. See docs/testing/release-acceptance.md. Original Techrights full-article reading accepted after the generated-link fix on 2026-10-22; see docs/testing/browser-release-review.md#generated-link-recovery-acceptance-2026-10-22. Captured and assembled document lines use role/state sound categories and speech fallback. Unit tests cover visited links and per-object cue deduplication and sound-only versus speech-only policy. Character and word intents remain separate. Ordinary caret movement updates structural position. Initial read-all is implemented; braille and complete line-presentation parity remain pending. September 22 log regressions: native invalid text-end recovery and bounded embedded text expansion; web name-event suppression; background load preserves verified chrome focus. Focused race tests and private Brave proofs pass. See docs/testing/chromium-browser.md for reference behavior and remaining physical gates. September 24 prerequisite review: pending caret/terminal timers extracted under the same event-loop owner; adjacent-character word navigation and whitespace and embedded-marker traversal corrected; whole caret movement deadline added. Full race and isolated Brave/X11/ownership proofs pass; physical and installed-preview gates remain pending. See docs/testing/chromium-browser.md for dependency order. September 24 log correction: browse word and line speech includes link and heading ancestors with role sound policy; character output retains character granularity. Native Brave assertions cover link words and document-start heading level. See docs/testing/chromium-browser.md. September 25 review: trailing non-text objects no longer cause false stale-location errors for line/document-edge movement; focused regression and private Brave suite pass. Physical listening remains open. Explicitly named links retain whole-label line speech while character and word review use accessible-label offsets independently of native text. Generic unit and private Brave named-link regressions cover empty native text and badges plus both directions across following punctuation. Live speech and physical Braille acceptance remain pending. October 22 generated-link regression verifies exact prefix/body text with forward/reverse lines and Read All in private Brave. The maintainer accepted full-article reading on the original Techrights page after the fix; other live gates remain scoped to their named workflows. See docs/testing/browser-release-review.md#generated-link-recovery-acceptance-2026-10-22. Captured and assembled document lines use role/state sound categories and speech fallback. Unit tests cover visited links and per-object cue deduplication and sound-only versus speech-only policy. Character and word intents remain separate. Ordinary caret movement updates structural position. Initial read-all is implemented; braille and complete line-presentation parity remain pending. September 22 log regressions: native invalid text-end recovery and bounded embedded text expansion; web name-event suppression; background load preserves verified chrome focus. Focused race tests and private Brave proofs pass. See docs/testing/chromium-browser.md for reference behavior and remaining physical gates. September 24 prerequisite review: pending caret/terminal timers extracted under the same event-loop owner; adjacent-character word navigation and whitespace and embedded-marker traversal corrected; whole caret movement deadline added. Full race and isolated Brave/X11/ownership proofs pass; physical and installed-preview gates remain pending. See docs/testing/chromium-browser.md for dependency order. September 24 log correction: browse word and line speech includes link and heading ancestors with role sound policy; character output retains character granularity. Native Brave assertions cover link words and document-start heading level. See docs/testing/chromium-browser.md. September 25 review: trailing non-text objects no longer cause false stale-location errors for line/document-edge movement; focused regression and private Brave suite pass. Physical listening remains open. Explicitly named links retain whole-label line speech while character and word review use accessible-label offsets independently of native text. Generic unit and private Brave named-link regressions cover empty native text and badges plus both directions across following punctuation. Live speech and physical Braille acceptance remain pending. October 22 generated-link regression verifies exact prefix/body text with forward/reverse lines and Read All in private Brave. The maintainer accepted full-article reading on the original Techrights page after the fix; other live gates remain scoped to their named workflows. See docs/testing/browser-release-review.md#generated-link-recovery-acceptance-2026-10-22. October 24 audit fixes validate dirty hypertext ancestors before crossing objects; refreshed retries include inserted parent text. Word whitespace uses bounded native character blocks and caret ranges have indexed lookup. Existing native navigation regressions pass; see docs/testing/browser-audit-fixes.md for limitations and remaining work.
23 config-read-preview src/cthulhu/backends/toml_backend.py settings Read existing Cthulhu TOML and merge the startup profile Storm Dragon internal/config implemented go test ./internal/config ./cmd/cthulhu -count=1 Read-only preview bridge available only through explicit --config selection. The Go-native schema and default path are intentionally separate; the one-shot importer remains Phase 8 work.
24 config-speech-preview src/cthulhu/speechdispatcherfactory.py speech Apply supported active-profile speech settings Storm Dragon internal/config internal/speech internal/app implemented go test ./internal/config ./internal/speech ./internal/presentation ./internal/app -count=1 Maintainer confirms speech preferences; previews; Apply and Cancel; saved settings; and interruption-key settings work in live testing on 2026-10-22. See docs/testing/release-acceptance.md. Applies enablement description punctuation capitalization and default voice settings.
25 typing-printable-key-echo src/cthulhu/typing_echo_presenter.py input Apply printable typing-echo category settings to terminal and editable text insertions Storm Dragon internal/config internal/atspi internal/presentation internal/app implemented go test ./internal/config ./internal/atspi ./internal/presentation ./internal/app -count=1 Maintainer reported the configured printable echo settings worked in minimal physical Xorg tests on 2026-09-12; broader application coverage remains pending. Maintainer confirms thorough live testing of native application and terminal workflows; typing echo; selection; clipboard feedback; and object navigation on 2026-10-22. These workflows pass. See docs/testing/release-acceptance.md. Single-character terminal and editable AT-SPI insertions honor enableKeyEcho enableEchoByCharacter and alphabetic numeric punctuation space and diacritical categories. Multi-character insertions remain presentation events. Preferences offer disabled enabled and edit-fields-only radio choices; edit-fields-only uses native editable metadata and Wine editor focus while preserving configured echo voices. Automated focus and persistence checks pass; live speech and physical Braille acceptance for the new mode remain pending.
+31
View File
@@ -0,0 +1,31 @@
<!doctype html>
<html lang="en"><meta charset="utf-8"><title>Cthulhu Browser Probe</title>
<body>
<button autofocus>Start live region probe</button>
<div aria-live="polite" aria-atomic="true"><span>Download status:</span> <span id="atomic">Waiting</span></div>
<div aria-live="polite" aria-atomic="true"><span>Account status:</span> <span id="addition"></span></div>
<div aria-live="polite" aria-atomic="false"><span>Outer context:</span> <span id="non-atomic">Waiting</span></div>
<div id="busy" aria-live="polite" aria-atomic="true" aria-busy="true"><span>Busy status:</span> <span id="busy-value">Waiting</span></div>
<script>
document.addEventListener('keydown', event => {
if (event.key === 'F9') {
document.getElementById('atomic').firstChild.data = 'Completed';
} else if (event.key === 'F10') {
const child = document.createElement('span');
child.textContent = 'Verified';
document.getElementById('addition').appendChild(child);
} else if (event.key === 'F11') {
document.getElementById('non-atomic').firstChild.data = 'Inner update';
} else if (event.key === 'F12') {
const region = document.getElementById('busy');
region.setAttribute('aria-busy', 'true');
document.getElementById('busy-value').firstChild.data = 'Intermediate update';
} else if (event.key === 'F7') {
document.getElementById('busy').setAttribute('aria-busy', 'false');
} else {
return;
}
event.preventDefault();
});
</script>
</body></html>
+1
View File
@@ -12,6 +12,7 @@ from native_test_session import run_private_session
BROWSER_PROOFS = { BROWSER_PROOFS = {
"live-atomic": ("./internal/atspi", "^TestNativeBrowserAtomicLiveRegion$", 30, 35, "browser-live.html"),
"replaced-feed": ("./internal/atspi", "^TestNativeBrowserMissingFeedRefreshesWholeBranch$", 30, 35, "browser-replaced-feed.html"), "replaced-feed": ("./internal/atspi", "^TestNativeBrowserMissingFeedRefreshesWholeBranch$", 30, 35, "browser-replaced-feed.html"),
"nested-document": ("./internal/app", "^TestNativeBrowserNestedDocumentHeadings$", 40, 45), "nested-document": ("./internal/app", "^TestNativeBrowserNestedDocumentHeadings$", 40, 45),
"reveal": ("./internal/app", "^TestNativeBrowserRevealedHeadings$", 60, 65), "reveal": ("./internal/app", "^TestNativeBrowserRevealedHeadings$", 60, 65),