diff --git a/docs/testing/background-events.md b/docs/testing/background-events.md index 6d3ff6b..35be82b 100644 --- a/docs/testing/background-events.md +++ b/docs/testing/background-events.md @@ -83,6 +83,46 @@ ready to replace the reader. 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. +## 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 GTK can report the window manager's enclosing frame while X11's active client diff --git a/internal/atspi/adapter_event_callback_native.go b/internal/atspi/adapter_event_callback_native.go index a8c3263..c36451c 100644 --- a/internal/atspi/adapter_event_callback_native.go +++ b/internal/atspi/adapter_event_callback_native.go @@ -398,6 +398,15 @@ func (adapter *Adapter) enrichNativeWindowEvent(event *C.AtspiEvent, target *C.A //export cthulhuAtspiEventCallback 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 { return } @@ -408,6 +417,9 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) { return } secureEpoch := adapter.secureEpoch.Load() + if deferredSelection && (selectionEpoch != secureEpoch || adapter.secure.Load()) { + return + } // Retire copied header identities even while locked. Tab lifecycle only // checks roles; it never captures text or presents output. nativeEventType := C.GoString(C.cthulhu_event_type(event)) @@ -422,10 +434,20 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) { } eventType := canonicalObjectChangeEventType(nativeEventType) 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 { 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 { adapter.retireLibreOfficeHeaders(event.source, eventType, originID) } @@ -438,6 +460,18 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) { } 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() if isDocumentLifecycleEvent(eventType) || eventType == DefunctStateEvent && C.cthulhu_event_detail1(event) != 0 { adapter.deferredLiveRegions.take(originID) @@ -505,6 +539,13 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) { adapter.backgroundEventsFiltered.Add(1) 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 { defer C.g_object_unref(C.gpointer(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") { return } + if !deferredSelection && !selectionBoundary { + C.cthulhu_flush_selection(adapter.callbackData) + } deferLive, completeLive := adapter.prepare_live_region(event, eventType, originID, live) if deferLive { return @@ -532,6 +576,13 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) { if target == nil { 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) snapshot, popupFor, payload := adapter.captureNativeEventContent(event, target, eventType, originID, live, completeLive) eventType, inlineMenuID := normalizeNativeMenuFocus(event, target, eventType, originID, &snapshot) diff --git a/internal/atspi/adapter_event_queue.c b/internal/atspi/adapter_event_queue.c new file mode 100644 index 0000000..9dc6a1a --- /dev/null +++ b/internal/atspi/adapter_event_queue.c @@ -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; + } +} diff --git a/internal/atspi/adapter_events.c b/internal/atspi/adapter_events.c index 60b0dec..d43e642 100644 --- a/internal/atspi/adapter_events.c +++ b/internal/atspi/adapter_events.c @@ -872,6 +872,7 @@ uintptr_t cthulhu_callback_value(const cthulhu_callback_data *data) { void cthulhu_callback_data_free(cthulhu_callback_data *data) { if (data == NULL) return; + cthulhu_clear_selection(data); // Dispatch has stopped and the Go request mutex excludes new sources. // Destroy notifications remove each source and release its retained ref. while (data->captures != NULL) { diff --git a/internal/atspi/adapter_native.h b/internal/atspi/adapter_native.h index 76c3414..db21b57 100644 --- a/internal/atspi/adapter_native.h +++ b/internal/atspi/adapter_native.h @@ -85,6 +85,9 @@ typedef struct { uintptr_t callback; GMutex mutex; GList *captures; + AtspiEvent *pendingSelection; + guint selectionIdle; + uint64_t selectionEpoch; } cthulhu_callback_data; 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); AtspiAccessible *cthulhu_window_ancestor(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_editable_root(AtspiAccessible *source); AtspiAccessible *cthulhu_read_only_combo(AtspiAccessible *source); diff --git a/internal/atspi/adapter_secure_native.go b/internal/atspi/adapter_secure_native.go index 0753d33..9eed248 100644 --- a/internal/atspi/adapter_secure_native.go +++ b/internal/atspi/adapter_secure_native.go @@ -43,6 +43,7 @@ func (adapter *Adapter) applySecureReset() { if !adapter.secureReset.Swap(false) { return } + C.cthulhu_clear_selection(adapter.callbackData) adapter.deferredLiveRegions = nil adapter.invalidateTree() adapter.treeMutex.Lock() diff --git a/internal/atspi/table_refresh_native_test.go b/internal/atspi/table_refresh_native_test.go new file mode 100644 index 0000000..5fc84b2 --- /dev/null +++ b/internal/atspi/table_refresh_native_test.go @@ -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" + }) +} diff --git a/testdata/parity-ledger.csv b/testdata/parity-ledger.csv index fcf3c86..8816d37 100644 --- a/testdata/parity-ledger.csv +++ b/testdata/parity-ledger.csv @@ -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. 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." -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. 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. diff --git a/tests/atspi_refresh_signals.c b/tests/atspi_refresh_signals.c new file mode 100644 index 0000000..e97ab33 --- /dev/null +++ b/tests/atspi_refresh_signals.c @@ -0,0 +1,55 @@ +// Copyright (c) 2026 Storm Dragon +// Authors: Storm Dragon, Angus Kola +// SPDX-License-Identifier: GPL-3.0-or-later + +#include +#include + +// 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); +} diff --git a/tests/gtk_table_refresh.py b/tests/gtk_table_refresh.py new file mode 100644 index 0000000..f4fab19 --- /dev/null +++ b/tests/gtk_table_refresh.py @@ -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()