Filter out stale accessibility events.

This commit is contained in:
Storm Dragon committed 2026-11-04 14:23:54 -05:00
1 parent 9e15f8652d
commit f23e89ed93
10 files changed
+469 -2

No files matched your search

+40
View File
@@ -83,6 +83,46 @@ ready to replace the reader.
Preserve a fresh log if lag or a missing announcement remains, with the time Preserve a fresh log if lag or a missing announcement remains, with the time
and the key or update that triggered it. Debug logs can contain private text. and the key or update that triggered it. Debug logs can contain private text.
## Foreground GTK table refreshes
A foreground table rebuild can publish stale focus/selected/active-descendant
claims for retired cells and repeat the same container selection event hundreds
of times. The adapter rejects targets already known to be invalid or defunct
before snapshot capture. Unknown metadata follows the existing query path;
focus and selected state alone are not used to reject a live explicit claim.
For GTK tables, repeated selection events from the same container retain one
boxed native event until the next GLib idle turn. The adapter then queries the
current selection. No timer or fixed delay is added. A different table or a
live presentation event flushes the pending selection first. Routine structural
invalidation still runs without forcing a snapshot between every removed row.
Non-GTK controls, browser tabs, menus and multiple-selection target resolution
keep their existing behavior. Lock epochs reject a deferred event from before
a lock transition; reset and shutdown release the pending event and idle source.
```sh
python3 tests/native_test_session.py env XDG_SESSION_TYPE=x11 CTHULHU_NATIVE_TABLE_REFRESH=1 go test -race ./internal/atspi -run '^TestNativeGUITableRefresh$' -count=1 -v -timeout=40s
python3 tests/native_atspi.py --table-only
```
The refresh fixture uses GTK's actual accessibility connection to emit every
obsolete claim, bypassing the ATK bridge's own producer-side compression. It
checks that dead cells never become captured focus/selection events, a burst
does not produce hundreds of selection snapshots, the final selected row is
delivered, subsequent navigation works, and queued table selection cannot
restore the old row after focus moves to an entry. The existing table harness
also covers multiple selected rows, GTK lists exposing the Table interface,
Tab/Shift+Tab focus exit, and recorded row speech.
After reviewing and rebuilding the candidate, listen in FastSMRW while a new
timeline entry arrives. Arrow both directions during and after the update;
confirm the current row is spoken without a long silence or a backlog of old
rows. Open and close Open Links and its link menu, then leave and return to the
timeline. Check another GTK table's ordinary arrows and supported multiple
selection, plus browser tabs and a Qt menu. If using Braille, verify row content
and focus return as well. These live checks remain separate from private tests;
preserve a fresh log and the time of any remaining silence.
## Window frame geometry ## Window frame geometry
GTK can report the window manager's enclosing frame while X11's active client GTK can report the window manager's enclosing frame while X11's active client
@@ -398,6 +398,15 @@ func (adapter *Adapter) enrichNativeWindowEvent(event *C.AtspiEvent, target *C.A
//export cthulhuAtspiEventCallback //export cthulhuAtspiEventCallback
func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) { func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
dispatch_native_event(event, userData, false, 0)
}
//export cthulhuAtspiSelectionCallback
func cthulhuAtspiSelectionCallback(event *C.AtspiEvent, userData unsafe.Pointer, epoch C.uint64_t) {
dispatch_native_event(event, userData, true, uint64(epoch))
}
func dispatch_native_event(event *C.AtspiEvent, userData unsafe.Pointer, deferredSelection bool, selectionEpoch uint64) {
if event == nil || userData == nil { if event == nil || userData == nil {
return return
} }
@@ -408,6 +417,9 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
return return
} }
secureEpoch := adapter.secureEpoch.Load() secureEpoch := adapter.secureEpoch.Load()
if deferredSelection && (selectionEpoch != secureEpoch || adapter.secure.Load()) {
return
}
// Retire copied header identities even while locked. Tab lifecycle only // Retire copied header identities even while locked. Tab lifecycle only
// checks roles; it never captures text or presents output. // checks roles; it never captures text or presents output.
nativeEventType := C.GoString(C.cthulhu_event_type(event)) nativeEventType := C.GoString(C.cthulhu_event_type(event))
@@ -422,10 +434,20 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
} }
eventType := canonicalObjectChangeEventType(nativeEventType) eventType := canonicalObjectChangeEventType(nativeEventType)
originID := nativeObjectID(event.source) originID := nativeObjectID(event.source)
adapter.retireEchoFocus(eventType, int(C.cthulhu_event_detail1(event)), originID)
// Retired GTK cells can emit hundreds of obsolete focus/selection claims
// during a rebuild. Leave lifecycle retirement on its established path,
// but avoid all snapshot and foreground queries for known-dead targets.
switch eventType {
case FocusEvent, FocusedStateEvent, SelectedStateEvent, SelectionChangedEvent, ActiveDescendantEvent:
if C.cthulhu_accessible_known_dead(event.source) != 0 ||
eventType == ActiveDescendantEvent && C.cthulhu_accessible_known_dead(C.cthulhu_event_active_descendant(event)) != 0 {
return
}
}
if eventType == AccessibleNameEvent || eventType == DefunctStateEvent && C.cthulhu_event_detail1(event) != 0 { if eventType == AccessibleNameEvent || eventType == DefunctStateEvent && C.cthulhu_event_detail1(event) != 0 {
C.cthulhu_forget_menu_checkability(event.source) C.cthulhu_forget_menu_checkability(event.source)
} }
adapter.retireEchoFocus(eventType, int(C.cthulhu_event_detail1(event)), originID)
if eventType != DefunctStateEvent || C.cthulhu_event_detail1(event) != 0 { if eventType != DefunctStateEvent || C.cthulhu_event_detail1(event) != 0 {
adapter.retireLibreOfficeHeaders(event.source, eventType, originID) adapter.retireLibreOfficeHeaders(event.source, eventType, originID)
} }
@@ -438,6 +460,18 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
} }
adapter.applySecureReset() adapter.applySecureReset()
gtkSelection := eventType == SelectionChangedEvent && C.cthulhu_gtk_table(event.source, 0) != 0
// Structural events which only invalidate caches do not need a captured
// selection between every removed row. A structural event that survives
// invalidation below still flushes before its ordinary presentation path.
selectionBoundary := eventType != DefunctStateEvent && !strings.HasPrefix(eventType, ChildrenChangedEvent)
if !deferredSelection && selectionBoundary {
if gtkSelection {
C.cthulhu_flush_other_selection(adapter.callbackData, event.source)
} else {
C.cthulhu_flush_selection(adapter.callbackData)
}
}
adapter.refreshForeground() adapter.refreshForeground()
if isDocumentLifecycleEvent(eventType) || eventType == DefunctStateEvent && C.cthulhu_event_detail1(event) != 0 { if isDocumentLifecycleEvent(eventType) || eventType == DefunctStateEvent && C.cthulhu_event_detail1(event) != 0 {
adapter.deferredLiveRegions.take(originID) adapter.deferredLiveRegions.take(originID)
@@ -505,6 +539,13 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
adapter.backgroundEventsFiltered.Add(1) adapter.backgroundEventsFiltered.Add(1)
return return
} }
// Seed an unknown toolkit identity only after foreground filtering, so a
// background provider never delays this path with a toolkit lookup.
if !deferredSelection && eventType == SelectionChangedEvent &&
(gtkSelection || C.cthulhu_gtk_table(event.source, 1) != 0) {
C.cthulhu_defer_selection(adapter.callbackData, event, C.uint64_t(secureEpoch))
return
}
if tab := addedSelectedBrowserTab(event, eventType); tab != nil { if tab := addedSelectedBrowserTab(event, eventType); tab != nil {
defer C.g_object_unref(C.gpointer(tab)) defer C.g_object_unref(C.gpointer(tab))
returnedTarget = tab returnedTarget = tab
@@ -517,6 +558,9 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
if invalidated && webNotification == nil && eventType == ChildrenAddedEvent && (live.politeness == "" || live.politeness == "off") { if invalidated && webNotification == nil && eventType == ChildrenAddedEvent && (live.politeness == "" || live.politeness == "off") {
return return
} }
if !deferredSelection && !selectionBoundary {
C.cthulhu_flush_selection(adapter.callbackData)
}
deferLive, completeLive := adapter.prepare_live_region(event, eventType, originID, live) deferLive, completeLive := adapter.prepare_live_region(event, eventType, originID, live)
if deferLive { if deferLive {
return return
@@ -532,6 +576,13 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
if target == nil { if target == nil {
return return
} }
if (eventType == FocusEvent || eventType == FocusedStateEvent || eventType == SelectedStateEvent ||
eventType == ActiveDescendantEvent || eventType == SelectionChangedEvent) && C.cthulhu_accessible_known_dead(target) != 0 {
if ownedTarget {
C.g_object_unref(C.gpointer(target))
}
return
}
refreshNativeEventTarget(event, target, eventType) refreshNativeEventTarget(event, target, eventType)
snapshot, popupFor, payload := adapter.captureNativeEventContent(event, target, eventType, originID, live, completeLive) snapshot, popupFor, payload := adapter.captureNativeEventContent(event, target, eventType, originID, live, completeLive)
eventType, inlineMenuID := normalizeNativeMenuFocus(event, target, eventType, originID, &snapshot) eventType, inlineMenuID := normalizeNativeMenuFocus(event, target, eventType, originID, &snapshot)
+90
View File
@@ -0,0 +1,90 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
#include "adapter_native.h"
extern void cthulhuAtspiSelectionCallback(AtspiEvent *event, void *userData, uint64_t epoch);
// This check uses only libatspi's cache. Unknown metadata retains the normal
// query path; a stale non-FOCUSED state alone never rejects an explicit claim.
gboolean cthulhu_accessible_known_dead(AtspiAccessible *source) {
if (source == NULL) return TRUE;
if (source->states != NULL && atspi_state_set_contains(source->states, ATSPI_STATE_DEFUNCT)) return TRUE;
return (source->cached_properties & ATSPI_CACHE_ROLE) && source->role == ATSPI_ROLE_INVALID;
}
gboolean cthulhu_gtk_table(AtspiAccessible *source, gboolean queryToolkit) {
if (source == NULL || !(source->cached_properties & ATSPI_CACHE_ROLE)) return FALSE;
if (source->role != ATSPI_ROLE_TABLE && source->role != ATSPI_ROLE_TREE_TABLE &&
source->role != ATSPI_ROLE_LIST && source->role != ATSPI_ROLE_TREE) return FALSE;
AtspiApplication *application = ATSPI_OBJECT(source)->app;
// libatspi caches toolkit identity per application. Seed that cache on
// the first table selection if focus capture has not needed it yet.
if (queryToolkit && application != NULL && application->toolkit_name == NULL) {
gchar *toolkit = atspi_accessible_get_toolkit_name(source, NULL);
g_free(toolkit);
}
if (application == NULL || g_ascii_strcasecmp(application->toolkit_name != NULL ? application->toolkit_name : "", "gtk") != 0) return FALSE;
if (source->role == ATSPI_ROLE_TABLE || source->role == ATSPI_ROLE_TREE_TABLE) return TRUE;
if ((source->role != ATSPI_ROLE_LIST && source->role != ATSPI_ROLE_TREE) ||
!(source->cached_properties & ATSPI_CACHE_INTERFACES)) return FALSE;
AtspiTable *table = atspi_accessible_get_table_iface(source);
if (table == NULL) return FALSE;
g_object_unref(table);
return TRUE;
}
// All queue state and boxed events belong exclusively to the GLib owner.
// Only one table can be pending. A different table or another live event
// flushes it first, preserving ordering across focus/lifecycle boundaries.
void cthulhu_flush_selection(cthulhu_callback_data *data) {
if (data->selectionIdle != 0) {
g_source_remove(data->selectionIdle);
data->selectionIdle = 0;
}
AtspiEvent *event = data->pendingSelection;
uint64_t epoch = data->selectionEpoch;
data->pendingSelection = NULL;
if (event == NULL) return;
cthulhuAtspiSelectionCallback(event, data, epoch);
g_boxed_free(ATSPI_TYPE_EVENT, event);
}
void cthulhu_flush_other_selection(cthulhu_callback_data *data, AtspiAccessible *source) {
if (data->pendingSelection != NULL && data->pendingSelection->source != source) {
cthulhu_flush_selection(data);
}
}
static gboolean cthulhu_selection_idle(gpointer userData) {
cthulhu_callback_data *data = userData;
data->selectionIdle = 0;
cthulhu_flush_selection(data);
return G_SOURCE_REMOVE;
}
void cthulhu_defer_selection(cthulhu_callback_data *data, AtspiEvent *event, uint64_t epoch) {
cthulhu_flush_other_selection(data, event->source);
if (data->pendingSelection != NULL) g_boxed_free(ATSPI_TYPE_EVENT, data->pendingSelection);
data->pendingSelection = g_boxed_copy(ATSPI_TYPE_EVENT, event);
data->selectionEpoch = epoch;
if (data->selectionIdle == 0) {
// No timer/debounce delay: read the latest selection when the ready
// provider signals have drained. Ordinary arrows still flush on their
// following live focus/descendant event, or at the next idle turn.
data->selectionIdle = g_idle_add(cthulhu_selection_idle, data);
}
}
void cthulhu_clear_selection(cthulhu_callback_data *data) {
if (data == NULL) return;
if (data->selectionIdle != 0) {
g_source_remove(data->selectionIdle);
data->selectionIdle = 0;
}
if (data->pendingSelection != NULL) {
g_boxed_free(ATSPI_TYPE_EVENT, data->pendingSelection);
data->pendingSelection = NULL;
}
}
+1
View File
@@ -872,6 +872,7 @@ uintptr_t cthulhu_callback_value(const cthulhu_callback_data *data) {
void cthulhu_callback_data_free(cthulhu_callback_data *data) { void cthulhu_callback_data_free(cthulhu_callback_data *data) {
if (data == NULL) return; if (data == NULL) return;
cthulhu_clear_selection(data);
// Dispatch has stopped and the Go request mutex excludes new sources. // Dispatch has stopped and the Go request mutex excludes new sources.
// Destroy notifications remove each source and release its retained ref. // Destroy notifications remove each source and release its retained ref.
while (data->captures != NULL) { while (data->captures != NULL) {
+9
View File
@@ -85,6 +85,9 @@ typedef struct {
uintptr_t callback; uintptr_t callback;
GMutex mutex; GMutex mutex;
GList *captures; GList *captures;
AtspiEvent *pendingSelection;
guint selectionIdle;
uint64_t selectionEpoch;
} cthulhu_callback_data; } cthulhu_callback_data;
gchar *cthulhu_table_context(AtspiAccessible *source, gint *rows, gint *columns, gchar *cthulhu_table_context(AtspiAccessible *source, gint *rows, gint *columns,
@@ -140,6 +143,12 @@ const gchar *cthulhu_accessible_path(AtspiAccessible *source);
const gchar *cthulhu_accessible_bus_name(AtspiAccessible *source); const gchar *cthulhu_accessible_bus_name(AtspiAccessible *source);
AtspiAccessible *cthulhu_window_ancestor(AtspiAccessible *source); AtspiAccessible *cthulhu_window_ancestor(AtspiAccessible *source);
gboolean cthulhu_may_be_background_notification(AtspiAccessible *source); gboolean cthulhu_may_be_background_notification(AtspiAccessible *source);
gboolean cthulhu_accessible_known_dead(AtspiAccessible *source);
gboolean cthulhu_gtk_table(AtspiAccessible *source, gboolean queryToolkit);
void cthulhu_defer_selection(cthulhu_callback_data *data, AtspiEvent *event, uint64_t epoch);
void cthulhu_flush_selection(cthulhu_callback_data *data);
void cthulhu_flush_other_selection(cthulhu_callback_data *data, AtspiAccessible *source);
void cthulhu_clear_selection(cthulhu_callback_data *data);
AtspiAccessible *cthulhu_document_ancestor(AtspiAccessible *source); AtspiAccessible *cthulhu_document_ancestor(AtspiAccessible *source);
AtspiAccessible *cthulhu_editable_root(AtspiAccessible *source); AtspiAccessible *cthulhu_editable_root(AtspiAccessible *source);
AtspiAccessible *cthulhu_read_only_combo(AtspiAccessible *source); AtspiAccessible *cthulhu_read_only_combo(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
} }
C.cthulhu_clear_selection(adapter.callbackData)
adapter.deferredLiveRegions = nil adapter.deferredLiveRegions = nil
adapter.invalidateTree() adapter.invalidateTree()
adapter.treeMutex.Lock() adapter.treeMutex.Lock()
+139
View File
@@ -0,0 +1,139 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package atspi
import (
"bufio"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func TestNativeGUITableRefresh(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_TABLE_REFRESH") != "1" {
t.Skip("requires private Xorg/AT-SPI 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 adapter.Stop(context.Background())
flags, err := exec.CommandContext(ctx, "pkg-config", "--cflags", "--libs", "atspi-2").Output()
if err != nil {
t.Fatal(err)
}
library := filepath.Join(t.TempDir(), "refresh-signals.so")
args := append([]string{"-shared", "-fPIC", "-o", library, "../../tests/atspi_refresh_signals.c"}, strings.Fields(string(flags))...)
if output, err := exec.CommandContext(ctx, "cc", args...).CombinedOutput(); err != nil {
t.Fatalf("compile signal fixture: %v: %s", err, output)
}
command := exec.CommandContext(ctx, "/usr/bin/python3", "../../tests/gtk_table_refresh.py", library)
input, err := command.StdinPipe()
if err != nil {
t.Fatal(err)
}
output, err := command.StdoutPipe()
if err != nil {
t.Fatal(err)
}
command.Stderr = os.Stderr
if err := command.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { input.Close(); command.Process.Kill(); command.Wait() })
line, err := bufio.NewReader(output).ReadString('\n')
fields := strings.Fields(line)
if err != nil || len(fields) != 3 || fields[0] != "ready" {
t.Fatalf("fixture readiness: %q, %v", line, err)
}
if output, err := exec.CommandContext(ctx, "xdotool", "windowfocus", "--sync", fields[1]).CombinedOutput(); err != nil {
t.Fatalf("focus: %s, %v", output, err)
}
wait := func(predicate func(Event) bool) Event {
t.Helper()
var last Event
for {
select {
case event := <-adapter.Events():
last = event
if predicate(event) {
return event
}
case <-ctx.Done():
t.Fatalf("event missing: %v; last=%s %q", ctx.Err(), last.Type, last.Source.Name)
}
}
}
initial := wait(func(event Event) bool {
return event.Source.Name == "Original row 0" && event.Source.GUITable && !event.ForegroundMismatch
})
// Populate libatspi's cells so the retired objects are known before the
// provider emits stale claims about them, as in a previously read timeline.
if initial.Source.BusName != fields[2] {
t.Fatal("signal fixture is not using GTK's provider connection")
}
tree, err := adapter.CaptureDesktop(ctx)
if err != nil {
t.Fatal(err)
}
var oldPaths []string
tree.mu.RLock()
for id, entry := range tree.nodes {
if strings.HasPrefix(entry.snapshot.Name, "Original row ") {
oldPaths = append(oldPaths, id.Path)
}
}
tree.mu.RUnlock()
if len(oldPaths) != 300 {
t.Fatalf("cached %d original cells, want 300", len(oldPaths))
}
fmt.Fprintln(input, "refresh", initial.Source.TablePath, strings.Join(oldPaths, ","))
invalidEvents, selectionEvents := 0, 0
finalSelection := false
wait(func(event Event) bool {
if event.Type != DefunctStateEvent && (event.Source.Role == "invalid" || event.Source.Defunct) {
invalidEvents++
}
if event.Type == SelectionChangedEvent {
selectionEvents++
finalSelection = finalSelection || event.Source.Name == "Updated row 7" && !event.ForegroundMismatch
}
return event.Type == TextInsertedEvent && event.Payload == "Refresh complete"
})
if !finalSelection {
t.Error("refresh did not deliver the final selected row")
}
if invalidEvents != 0 {
t.Errorf("captured %d obsolete cell events during refresh", invalidEvents)
}
if selectionEvents > 20 {
t.Errorf("captured %d selection events for one synchronous refresh", selectionEvents)
}
t.Logf("refresh: %d invalid captures, %d selection captures", invalidEvents, selectionEvents)
for _, row := range []int{8, 9, 8} {
fmt.Fprintln(input, "select", row)
wait(func(event Event) bool {
return event.Source.Name == fmt.Sprint("Updated row ", row) && event.Source.GUITable && !event.ForegroundMismatch
})
}
// A table update immediately followed by editor focus must not restore the
// old row later when a deferred selection callback finally runs.
fmt.Fprintln(input, "select 9\nentry")
wait(func(event Event) bool { return event.Source.Name == "After table" && event.Source.Focused })
fmt.Fprintln(input, "barrier")
wait(func(event Event) bool {
if event.Type == SelectionChangedEvent || event.Type == ActiveDescendantEvent {
t.Errorf("table event delivered after editor focus: %s %q", event.Type, event.Source.Name)
}
return event.Type == TextInsertedEvent && event.Payload == "Focus boundary complete"
})
}
+1 -1
View File
@@ -19,7 +19,7 @@ xterm-focus-handoff,src/cthulhu/input_event_manager.py,input,Suspend Cthulhu whi
focus-speech,src/cthulhu/scripts/default.py,speech,Speak the focused accessible object including common control states and range values,Storm Dragon,internal/atspi internal/presentation internal/app,implemented,go test ./internal/atspi ./internal/presentation ./internal/app -count=1,Maintainer accepted post-change YAD value and LibreOffice focus value and menu behavior on physical Xorg on 2026-09-12. The combined log had no accessibility-event speech or sound queue overflow; 599 debug diagnostics were dropped during a LibreOffice state-event burst.,,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.,,Focus presentation includes common states and range values. Focused value changes speak only the value; non-focused state and value events are filtered; menu selection and table-cell verbosity are reduced. Relations position context background progress updates complete role-specific formatting and diagnostics saturation acceptance remain pending. Parity review corrections and remaining physical gates: docs/testing/parity-review-follow-up.md. September 25 review: window-owned title tracking suppresses repeated name events and preserves empty-to-restored title changes; event-loop regression passes. Combo selection follow-up: focus/name/value event regressions verify selection-only speech without repeated combo cues and restore the cue on re-entry; focused app/presentation race checks pass. Current general native focus listening is accepted by the maintainer on 2026-10-22. focus-speech,src/cthulhu/scripts/default.py,speech,Speak the focused accessible object including common control states and range values,Storm Dragon,internal/atspi internal/presentation internal/app,implemented,go test ./internal/atspi ./internal/presentation ./internal/app -count=1,Maintainer accepted post-change YAD value and LibreOffice focus value and menu behavior on physical Xorg on 2026-09-12. The combined log had no accessibility-event speech or sound queue overflow; 599 debug diagnostics were dropped during a LibreOffice state-event burst.,,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.,,Focus presentation includes common states and range values. Focused value changes speak only the value; non-focused state and value events are filtered; menu selection and table-cell verbosity are reduced. Relations position context background progress updates complete role-specific formatting and diagnostics saturation acceptance remain pending. Parity review corrections and remaining physical gates: docs/testing/parity-review-follow-up.md. September 25 review: window-owned title tracking suppresses repeated name events and preserves empty-to-restored title changes; event-loop regression passes. Combo selection follow-up: focus/name/value event regressions verify selection-only speech without repeated combo cues and restore the cue on re-entry; focused app/presentation race checks pass. Current general native focus listening is accepted by the maintainer on 2026-10-22.
terminal-caret-character,src/cthulhu/scripts/default.py,terminal,Speak the character at the caret,Storm Dragon,internal/app,implemented,go test ./internal/atspi ./internal/presentation ./internal/app -count=1,Maintainer accepted terminal caret behavior in the launcher's LXTerminal test application on Xorg during the Stage 1 physical checklist on 2026-09-11.,,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.,,Uses AT-SPI character granularity with line fallback debounces caret movement and suppresses unchanged path-and-announcement combinations; the maintainer confirms thorough native and terminal live testing on 2026-10-22. terminal-caret-character,src/cthulhu/scripts/default.py,terminal,Speak the character at the caret,Storm Dragon,internal/app,implemented,go test ./internal/atspi ./internal/presentation ./internal/app -count=1,Maintainer accepted terminal caret behavior in the launcher's LXTerminal test application on Xorg during the Stage 1 physical checklist on 2026-09-11.,,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.,,Uses AT-SPI character granularity with line fallback debounces caret movement and suppresses unchanged path-and-announcement combinations; the maintainer confirms thorough native and terminal live testing on 2026-10-22.
terminal-text-change-echo,src/cthulhu/scripts/terminal/script.py,terminal,Speak sanitized inserted or deleted text from terminals and editable text controls,Storm Dragon,internal/app,implemented,go test ./internal/atspi ./internal/presentation ./internal/app -count=1,"Maintainer accepted terminal insertion in LXTerminal on Xorg on 2026-09-11, accepted the post-change LibreOffice Writer typing behavior on physical Xorg on 2026-09-12, and confirmed YAD close-and-prompt coalescing on physical Xorg on 2026-09-13. Physical acceptance of terminal deletion-redraw coalescing is 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.,,"Text changes are limited to terminals and editable text or paragraph roles. A change suppresses the next caret event from the same object so typing does not replay the line. Same-terminal insertion within the event-coalescing window suppresses a redundant interrupting terminal-focus presentation. A terminal deletion waits 50 ms and is suppressed only when the same terminal inserts replacement text at the same AT-SPI offset; standalone and different-offset deletions remain presentable. Debug speech delivery records retain the final utterance with cause and policy metadata; normal logging omits speech content. Broader application coverage remains incomplete. Focusable writable browser editors also own their editable paragraph events, preventing rejected insertions from replaying the growing line through caret speech. Private Chromium coverage and live acceptance steps are in docs/testing/browser-editor.md; original-page ChatGPT listening and physical Braille acceptance remain pending." terminal-text-change-echo,src/cthulhu/scripts/terminal/script.py,terminal,Speak sanitized inserted or deleted text from terminals and editable text controls,Storm Dragon,internal/app,implemented,go test ./internal/atspi ./internal/presentation ./internal/app -count=1,"Maintainer accepted terminal insertion in LXTerminal on Xorg on 2026-09-11, accepted the post-change LibreOffice Writer typing behavior on physical Xorg on 2026-09-12, and confirmed YAD close-and-prompt coalescing on physical Xorg on 2026-09-13. Physical acceptance of terminal deletion-redraw coalescing is 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.,,"Text changes are limited to terminals and editable text or paragraph roles. A change suppresses the next caret event from the same object so typing does not replay the line. Same-terminal insertion within the event-coalescing window suppresses a redundant interrupting terminal-focus presentation. A terminal deletion waits 50 ms and is suppressed only when the same terminal inserts replacement text at the same AT-SPI offset; standalone and different-offset deletions remain presentable. Debug speech delivery records retain the final utterance with cause and policy metadata; normal logging omits speech content. Broader application coverage remains incomplete. Focusable writable browser editors also own their editable paragraph events, preventing rejected insertions from replaying the growing line through caret speech. Private Chromium coverage and live acceptance steps are in docs/testing/browser-editor.md; original-page ChatGPT listening and physical Braille acceptance remain pending."
managed-descendant-presentation,src/cthulhu/scripts/default.py,focus,Resolve focused container selection and active-descendant changes to the managed child,Storm Dragon,internal/atspi internal/app,implemented,go test ./internal/atspi ./internal/app -count=1; python3 tests/native_x11.py; python3 tests/native_atspi.py --table-only,The isolated GTK/Xvfb/D-Bus selection fixture and physical I38 run-dialog completion selection speech passed on 2026-09-12 without regressing X11 pass-through command consumption release ordering lock states lifecycle or grab cleanup.,,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.,,The generic adapter registers both selection and active-descendant events resolves their managed child preserves the originating container path and filters events unless the container or child owns focus. Duplicate target presentations are suppressed. No I38-specific workaround was added. Broader GTK and Qt managed-descendant coverage remains pending. October 17: GTK controls with list role and Table interface retain selected-cell focus and table ownership without normalizing ordinary lists or choosing among multiple selected rows. Private native focus and recorded-speech tests cover first Down and genuine Shift+Tab exit; activation descriptions are not repeated on first row selection. Current physical listening and Braille acceptance remain pending. See docs/testing/gui-list-tables.md. November 1: real Audacious Qt and GTK fixtures cover table-capable trees; visible GUI row columns; submenu and playlist focus return; context menus; and Settings navigation. See docs/testing/audacious.md. Audible speech and physical Braille acceptance remain pending. managed-descendant-presentation,src/cthulhu/scripts/default.py,focus,Resolve focused container selection and active-descendant changes to the managed child,Storm Dragon,internal/atspi internal/app,implemented,go test ./internal/atspi ./internal/app -count=1; python3 tests/native_x11.py; python3 tests/native_atspi.py --table-only,The isolated GTK/Xvfb/D-Bus selection fixture and physical I38 run-dialog completion selection speech passed on 2026-09-12 without regressing X11 pass-through command consumption release ordering lock states lifecycle or grab cleanup.,,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.,,The generic adapter registers both selection and active-descendant events resolves their managed child preserves the originating container path and filters events unless the container or child owns focus. Duplicate target presentations are suppressed. No I38-specific workaround was added. Broader GTK and Qt managed-descendant coverage remains pending. October 17: GTK controls with list role and Table interface retain selected-cell focus and table ownership without normalizing ordinary lists or choosing among multiple selected rows. Private native focus and recorded-speech tests cover first Down and genuine Shift+Tab exit; activation descriptions are not repeated on first row selection. Current physical listening and Braille acceptance remain pending. See docs/testing/gui-list-tables.md. November 1: real Audacious Qt and GTK fixtures cover table-capable trees; visible GUI row columns; submenu and playlist focus return; context menus; and Settings navigation. See docs/testing/audacious.md. Audible speech and physical Braille acceptance remain pending. November 4: foreground GTK refresh handling rejects known-dead targets before capture and coalesces repeated same-table selection events at GLib idle while preserving live focus boundaries and structural invalidation. Private refresh and existing table regressions are documented in docs/testing/background-events.md; actual FastSMRW listening and physical Braille remain 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. 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. Automatic structural focus is configurable globally and per application on 2026-10-28; defaults off and preserves sticky modes. Private GTK Apply/reload and Chromium native typing checks pass. Other Python structural settings remain pending. New option live listening and physical Braille acceptance remain pending; see docs/testing/structural-focus.md. 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. Automatic structural focus is configurable globally and per application on 2026-10-28; defaults off and preserves sticky modes. Private GTK Apply/reload and Chromium native typing checks pass. Other Python structural settings remain pending. New option live listening and physical Braille acceptance remain pending; see docs/testing/structural-focus.md. 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.
1 id python_source subsystem behavior owner go_destination status automated_evidence x11_evidence wayland_evidence speech_evidence braille_evidence notes
19 focus-speech src/cthulhu/scripts/default.py speech Speak the focused accessible object including common control states and range values Storm Dragon internal/atspi internal/presentation internal/app implemented go test ./internal/atspi ./internal/presentation ./internal/app -count=1 Maintainer accepted post-change YAD value and LibreOffice focus value and menu behavior on physical Xorg on 2026-09-12. The combined log had no accessibility-event speech or sound queue overflow; 599 debug diagnostics were dropped during a LibreOffice state-event burst. 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. Focus presentation includes common states and range values. Focused value changes speak only the value; non-focused state and value events are filtered; menu selection and table-cell verbosity are reduced. Relations position context background progress updates complete role-specific formatting and diagnostics saturation acceptance remain pending. Parity review corrections and remaining physical gates: docs/testing/parity-review-follow-up.md. September 25 review: window-owned title tracking suppresses repeated name events and preserves empty-to-restored title changes; event-loop regression passes. Combo selection follow-up: focus/name/value event regressions verify selection-only speech without repeated combo cues and restore the cue on re-entry; focused app/presentation race checks pass. Current general native focus listening is accepted by the maintainer on 2026-10-22.
20 terminal-caret-character src/cthulhu/scripts/default.py terminal Speak the character at the caret Storm Dragon internal/app implemented go test ./internal/atspi ./internal/presentation ./internal/app -count=1 Maintainer accepted terminal caret behavior in the launcher's LXTerminal test application on Xorg during the Stage 1 physical checklist on 2026-09-11. 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. Uses AT-SPI character granularity with line fallback debounces caret movement and suppresses unchanged path-and-announcement combinations; the maintainer confirms thorough native and terminal live testing on 2026-10-22.
21 terminal-text-change-echo src/cthulhu/scripts/terminal/script.py terminal Speak sanitized inserted or deleted text from terminals and editable text controls Storm Dragon internal/app implemented go test ./internal/atspi ./internal/presentation ./internal/app -count=1 Maintainer accepted terminal insertion in LXTerminal on Xorg on 2026-09-11, accepted the post-change LibreOffice Writer typing behavior on physical Xorg on 2026-09-12, and confirmed YAD close-and-prompt coalescing on physical Xorg on 2026-09-13. Physical acceptance of terminal deletion-redraw coalescing is 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. Text changes are limited to terminals and editable text or paragraph roles. A change suppresses the next caret event from the same object so typing does not replay the line. Same-terminal insertion within the event-coalescing window suppresses a redundant interrupting terminal-focus presentation. A terminal deletion waits 50 ms and is suppressed only when the same terminal inserts replacement text at the same AT-SPI offset; standalone and different-offset deletions remain presentable. Debug speech delivery records retain the final utterance with cause and policy metadata; normal logging omits speech content. Broader application coverage remains incomplete. Focusable writable browser editors also own their editable paragraph events, preventing rejected insertions from replaying the growing line through caret speech. Private Chromium coverage and live acceptance steps are in docs/testing/browser-editor.md; original-page ChatGPT listening and physical Braille acceptance remain pending.
22 managed-descendant-presentation src/cthulhu/scripts/default.py focus Resolve focused container selection and active-descendant changes to the managed child Storm Dragon internal/atspi internal/app implemented go test ./internal/atspi ./internal/app -count=1; python3 tests/native_x11.py; python3 tests/native_atspi.py --table-only The isolated GTK/Xvfb/D-Bus selection fixture and physical I38 run-dialog completion selection speech passed on 2026-09-12 without regressing X11 pass-through command consumption release ordering lock states lifecycle or grab cleanup. 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. The generic adapter registers both selection and active-descendant events resolves their managed child preserves the originating container path and filters events unless the container or child owns focus. Duplicate target presentations are suppressed. No I38-specific workaround was added. Broader GTK and Qt managed-descendant coverage remains pending. October 17: GTK controls with list role and Table interface retain selected-cell focus and table ownership without normalizing ordinary lists or choosing among multiple selected rows. Private native focus and recorded-speech tests cover first Down and genuine Shift+Tab exit; activation descriptions are not repeated on first row selection. Current physical listening and Braille acceptance remain pending. See docs/testing/gui-list-tables.md. November 1: real Audacious Qt and GTK fixtures cover table-capable trees; visible GUI row columns; submenu and playlist focus return; context menus; and Settings navigation. See docs/testing/audacious.md. Audible speech and physical Braille acceptance remain pending. The generic adapter registers both selection and active-descendant events resolves their managed child preserves the originating container path and filters events unless the container or child owns focus. Duplicate target presentations are suppressed. No I38-specific workaround was added. Broader GTK and Qt managed-descendant coverage remains pending. October 17: GTK controls with list role and Table interface retain selected-cell focus and table ownership without normalizing ordinary lists or choosing among multiple selected rows. Private native focus and recorded-speech tests cover first Down and genuine Shift+Tab exit; activation descriptions are not repeated on first row selection. Current physical listening and Braille acceptance remain pending. See docs/testing/gui-list-tables.md. November 1: real Audacious Qt and GTK fixtures cover table-capable trees; visible GUI row columns; submenu and playlist focus return; context menus; and Settings navigation. See docs/testing/audacious.md. Audible speech and physical Braille acceptance remain pending. November 4: foreground GTK refresh handling rejects known-dead targets before capture and coalesces repeated same-table selection events at GLib idle while preserving live focus boundaries and structural invalidation. Private refresh and existing table regressions are documented in docs/testing/background-events.md; actual FastSMRW listening and physical Braille remain pending.
23 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.
24 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. Automatic structural focus is configurable globally and per application on 2026-10-28; defaults off and preserves sticky modes. Private GTK Apply/reload and Chromium native typing checks pass. Other Python structural settings remain pending. New option live listening and physical Braille acceptance remain pending; see docs/testing/structural-focus.md. Parity review corrections and remaining physical gates: docs/testing/parity-review-follow-up.md.
25 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.
+55
View File
@@ -0,0 +1,55 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
#include <atspi/atspi.h>
#include <dbus/dbus.h>
// Use GTK's actual accessibility connection, bypassing the ATK bridge's
// producer-side event compression. The reader must handle a provider which
// delivers every stale claim, as in the captured timeline refresh.
static void emit_event(DBusConnection *bus, const char *path, const char *member,
const char *detail, int detail1, const char *child) {
DBusMessage *message = dbus_message_new_signal(path, "org.a11y.atspi.Event.Object", member);
DBusMessageIter body, variant, reference, properties;
int zero = 0;
dbus_message_iter_init_append(message, &body);
dbus_message_iter_append_basic(&body, DBUS_TYPE_STRING, &detail);
dbus_message_iter_append_basic(&body, DBUS_TYPE_INT32, &detail1);
dbus_message_iter_append_basic(&body, DBUS_TYPE_INT32, &zero);
dbus_message_iter_open_container(&body, DBUS_TYPE_VARIANT, child != NULL ? "(so)" : "i", &variant);
if (child != NULL) {
const char *sender = dbus_bus_get_unique_name(bus);
dbus_message_iter_open_container(&variant, DBUS_TYPE_STRUCT, NULL, &reference);
dbus_message_iter_append_basic(&reference, DBUS_TYPE_STRING, &sender);
dbus_message_iter_append_basic(&reference, DBUS_TYPE_OBJECT_PATH, &child);
dbus_message_iter_close_container(&variant, &reference);
} else {
dbus_message_iter_append_basic(&variant, DBUS_TYPE_INT32, &zero);
}
dbus_message_iter_close_container(&body, &variant);
dbus_message_iter_open_container(&body, DBUS_TYPE_ARRAY, "{sv}", &properties);
dbus_message_iter_close_container(&body, &properties);
dbus_connection_send(bus, message, NULL);
dbus_message_unref(message);
}
const char *refresh_bus_name(void) {
return dbus_bus_get_unique_name(atspi_get_a11y_bus());
}
void emit_refresh(const char *tablePath, const char *cellPaths) {
DBusConnection *bus = atspi_get_a11y_bus();
gchar **paths = g_strsplit(cellPaths, ",", -1);
for (guint index = 0; paths[index] != NULL; index++) {
emit_event(bus, paths[index], "StateChanged", "defunct", 1, NULL);
}
for (guint index = 0; paths[index] != NULL; index++) {
emit_event(bus, paths[index], "StateChanged", "selected", 1, NULL);
emit_event(bus, tablePath, "SelectionChanged", "", 0, NULL);
emit_event(bus, paths[index], "StateChanged", "focused", 1, NULL);
emit_event(bus, tablePath, "ActiveDescendantChanged", "", 1, paths[index]);
}
g_strfreev(paths);
dbus_connection_flush(bus);
}
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Storm Dragon
# Authors: Storm Dragon, Angus Kola
# SPDX-License-Identifier: GPL-3.0-or-later
"""Controlled GTK table replacement and obsolete cell event flood."""
import ctypes
import os
import sys
import gi
gi.require_version("Atk", "1.0")
gi.require_version("Gtk", "3.0")
gi.require_version("GdkX11", "3.0")
from gi.repository import Atk, GdkX11, GLib, Gtk
window = Gtk.Window(title="Table refresh probe")
window.set_default_size(400, 300)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
window.add(box)
store = Gtk.ListStore(str)
for index in range(300):
store.append(["Original row " + str(index)])
table = Gtk.TreeView(model=store)
table.append_column(Gtk.TreeViewColumn("Row", Gtk.CellRendererText(), text=0))
table.get_accessible().set_name("Refresh results")
scroll = Gtk.ScrolledWindow()
scroll.add(table)
box.pack_start(scroll, True, True, 0)
entry = Gtk.Entry()
entry.get_accessible().set_name("After table")
box.pack_start(entry, False, False, 0)
marker = Gtk.Label(label="Marker")
marker.get_accessible().set_role(Atk.Role.ALERT)
box.pack_start(marker, False, False, 0)
window.show_all()
table.set_cursor(0)
table.grab_focus()
pendingInput = ""
signals = ctypes.CDLL(sys.argv[1])
signals.emit_refresh.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
signals.emit_refresh.restype = None
signals.refresh_bus_name.restype = ctypes.c_char_p
def receive_command(_source, _condition):
global pendingInput
content = os.read(sys.stdin.fileno(), 4096)
if not content:
Gtk.main_quit()
return False
pendingInput += content.decode()
while "\n" in pendingInput:
command, pendingInput = pendingInput.split("\n", 1)
if command.startswith("refresh "):
_, tablePath, cellPaths = command.split()
signals.emit_refresh(tablePath.encode(), cellPaths.encode())
store.clear()
for index in range(300):
store.append(["Updated row " + str(index)])
table.set_cursor(0)
# End with a real selection, without giving GTK another focus
# change to repair a lost deferred selection for us.
table.get_selection().select_path(7)
marker.set_text("Refresh complete")
elif command.startswith("select "):
table.set_cursor(int(command.split()[1]))
elif command == "entry":
entry.grab_focus()
elif command == "barrier":
entry.set_text("Focus boundary complete")
elif command == "quit":
Gtk.main_quit()
return False
return True
GLib.io_add_watch(sys.stdin, GLib.IO_IN | GLib.IO_HUP, receive_command)
GLib.idle_add(lambda: print("ready", GdkX11.X11Window.get_xid(window.get_window()), signals.refresh_bus_name().decode(), flush=True) or False)
Gtk.main()