Make unrelated atspi events filtering better.

This commit is contained in:
Storm Dragon committed 2026-10-20 23:37:50 -04:00
1 parent 41283a5d8d
commit ce5f196956
25 files changed
+654 -41

No files matched your search

+1
View File
@@ -8,6 +8,7 @@ Cthulhu is a Linux screen reader written in Go.
- [Progress reporting](PROGRESS_BARS.md): web progress bars, Steam shader processing, and verification.
- [Steam keyboard focus](STEAM_FOCUS.md): Xorg workspace focus recovery and verification.
- [Flatpak foreground accessibility](docs/testing/flatpak-foreground.md): process identity and proxy matching, tests, and live acceptance.
- [Background event filtering](docs/testing/background-events.md): early foreground checks, notification exceptions, and performance verification.
- [Qt control states](docs/testing/qt-control-states.md): menu checkability, native regressions, and the Qt 5 provider limitation.
- [Wayland keyboard access](WAYLAND_SUPPORT.md): shared protocol support, limitations, and verification.
+77
View File
@@ -0,0 +1,77 @@
# Background accessibility events
On Xorg, Cthulhu rejects known background control updates before resolving
selected descendants or capturing their text, values, attributes and geometry.
The native adapter owns this decision on the GLib thread. It uses the existing
foreground window and epoch; there is no additional window ownership cache.
Events from another provider can be rejected using copied bus identities and
libatspi's cached role and application name, without querying that provider.
Events from another window on the same provider undergo the existing window
correlation before content capture. Lifecycle invalidation still runs, but a
background provider no longer clears another provider's cached document lines.
Window creation, activation, deactivation, destruction and defunct events keep
their existing handling. Native alerts, notification daemons and supported
Steam toasts retain their notification paths. Menu popups retain existing
foreground checks. Same-provider autocomplete relations and tooltip candidates
continue through the existing event-loop ownership checks; they do not acquire
focus merely by passing the early filter.
When notification metadata or a detached document's containing window is
unknown, the adapter retains the existing validation path. The optimization
does not introduce an X11 ownership requirement for native Wayland sessions.
Orca's `src/orca/event_manager.py` was used as a reference for keeping global
window monitoring and filtering noisy content events before presentation.
## Automated checks
Run from the repository root with the normal native build dependencies:
```sh
go test ./... -count=1
go test -race ./internal/atspi ./internal/app ./internal/review -count=1
python3 tests/native_test_session.py env CTHULHU_NATIVE_BACKGROUND_EVENTS=1 CTHULHU_NATIVE_WAYLAND_POLICY=1 go test -race ./internal/atspi -run '^TestNative(BackgroundEventFiltering|WaylandForegroundPolicy)$' -count=1 -v -timeout=40s
python3 tests/native_webscripts.py
CTHULHU_NATIVE_BROWSER_TESTS=browser,tabs,live,tooltips python3 tests/native_browser.py
go build -o /tmp/cthulhu-background-events ./cmd/cthulhu
CTHULHU_BINARY=/tmp/cthulhu-background-events python3 tests/native_ownership.py
```
The flood regression uses two real GTK providers, then two windows on one
provider. Each burst changes the table selection 300 times. A private D-Bus
monitor asserts that the rejected updates cause no Selection, Table, Text or
Value interface calls. The explicit alert used as a completion marker is
excluded from this content-query assertion. Returning to the noisy window must
deliver its current table selection. The separate cache regression verifies
that invalidation preserves other providers' document lines.
All native checks use disposable X servers and private D-Bus/runtime sockets.
They do not replace the active desktop reader. Browser checks cover address
suggestions, page controls, tabs, live regions and tooltips. Ownership checks
cover GTK/Qt menus, a GTK menu popup, dialogs, same-process windows, empty
workspaces and Brave window recovery. Notification checks preserve Steam toast
capture while rejecting ordinary background Chromium text changes.
## Live acceptance
Private fixtures do not establish audible speech, physical Braille or actual
FastSMRW update latency. After reviewing the diff, preserve the current debug
log and rebuild/start the candidate through the normal local launcher when
ready to replace the reader.
1. Read headings and lines in Brave while FastSMRW receives new items. Compare
responsiveness with quiet periods. Background selection changes should not
delay reading or take speech/focus ownership.
2. Switch to FastSMRW and move through results. Confirm the current selection
is spoken; open its Open Links popup, move between links, and close it.
3. Switch between two windows from the same application. Open and close a
dialog and a menu; confirm speech follows the active control.
4. In Brave, type into the address bar and move through suggestions. Return to
the page, switch tabs, and verify normal editing and reading still work.
5. Check supported background notifications. If using Braille, confirm it
follows the foreground control and still displays supported notifications.
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.
+14 -1
View File
@@ -5,7 +5,7 @@ Build a separate test binary and run the focused checks:
```sh
go build -o /tmp/cthulhu-reading-descriptions ./cmd/cthulhu
go test ./internal/preferences ./internal/presentation -count=1
go test ./internal/app -run 'GUITable|PauseChunks' -count=1
go test ./internal/app -run 'GUITable|GUIListTable|Context|PauseChunks' -count=1
CTHULHU_BINARY=/tmp/cthulhu-reading-descriptions CTHULHU_PREFS_READING_ONLY=1 /usr/bin/python3 tests/native_preferences.py
```
@@ -33,6 +33,19 @@ session:
6. Check the modifier-key table and an application table to confirm their
existing row and cell reading still works. Check the new descriptions on a
physical Braille display when available.
7. In FastSMRW, move through the timeline and switch workspaces to a terminal.
Confirm no enter/leave container announcements occur. Also Tab into and out
of Preferences tables and a native application's list: names, rows and cells
should still be spoken, without a separate container exit announcement.
8. In browser browse mode, navigate into and out of a list and data table.
Confirm enabled context announcements still work, including during Say All.
Switch to focus mode, browser chrome or another workspace and confirm there
is no container exit announcement. Return to browse mode and check context
is announced again when entering a container.
9. Focus a button with a mnemonic, such as Preferences' Apply button. With
pause breaks enabled, confirm the mnemonic follows as a separate chunk.
Disable pause breaks and confirm the announcement is combined. Restore the
preferred setting.
Automated checks do not establish audible speech, physical keyboard or Braille
acceptance, or behavior of the installed reader.
+1 -1
View File
@@ -248,7 +248,7 @@ func (browser *browserModeController) set(mode review.DocumentMode, speech speec
cue = "focus_mode"
}
// Append automatic changes so they do not replace the focused control.
speech.Enqueue(speechRequest{Text: text, Role: "system", Sound: []sound.Cue{{Name: cue}}, Cause: "document-mode", Append: !manual})
speech.Enqueue(speechRequest{Text: text, Role: "system", Sound: []sound.Cue{{Name: cue}}, Cause: "document-mode", Append: !manual, ResetContext: mode != review.BrowseMode})
}
func (browser *browserModeController) toggle(speech speechEnqueuer) {
if !browser.ready || browser.mode == review.InactiveMode {
+4 -11
View File
@@ -139,17 +139,13 @@ func TestNativeGUIListTableSpeech(t *testing.T) {
}
text := strings.Join(parts, " | ")
t.Log(text)
if strings.Count(text, activation.WindowDescription) != 1 || strings.Count(text, "Leaving Probe items list.") != 1 ||
strings.Count(text, "Alpha") != 1 || strings.Count(text, "Beta") != 1 || strings.Count(text, "Probe items") != 2 {
if strings.Count(text, activation.WindowDescription) != 1 || strings.Contains(text, "Leaving ") ||
strings.Count(text, "Alpha") != 1 || strings.Count(text, "Beta") != 1 || strings.Count(text, "Probe items") != 1 {
t.Fatalf("unexpected launcher speech: %s", text)
}
// Exiting through Shift+Tab should produce the only list-exit message.
if len(parts) < 3 || parts[len(parts)-3] != "Leaving Probe items list." {
t.Fatalf("list exit occurred before returning to the entry: %s", text)
}
}
func TestGUIListTableNameHonorsContextSettings(t *testing.T) {
func TestGUIListTableNameWithoutBrowseContext(t *testing.T) {
for _, contextEnabled := range []bool{true, false} {
state := &eventLoopState{}
state.presentationOptions = presentation.DefaultOptions()
@@ -162,10 +158,7 @@ func TestGUIListTableNameHonorsContextSettings(t *testing.T) {
event := atspi.NewManagedDescendantEvent(atspi.SelectionChangedEvent, list.Path, cell)
result := state.prepareGUITable(event, 1)
spoken, _ := state.decorateGUITable(event, presentation.Speech{Text: "Alpha"}, result)
want := "Alpha"
if !contextEnabled {
want = "Results, Alpha"
}
want := "Results, Alpha"
if spoken.Text != want {
t.Fatalf("list context enabled=%t: got %q, want %q", contextEnabled, spoken.Text, want)
}
+2 -1
View File
@@ -213,8 +213,9 @@ func (presenter *eventPresenter) speak(event atspi.Event, capturedIntent ...inpu
(event.Type == atspi.CaretEvent && intent == input.LineIntent || isFocusGainEvent(event)) {
line = &speechLine{Object: event.Source.ID(), Raw: event.Source.BrailleLine}
}
browseContext := browser.speech_context_enabled() && event.Source.BusName == browser.document.BusName
speech.Enqueue(speechRequest{
Context: event.Source.SpeechContext, VoiceType: ancestor_voice(event.Source.SpeechContext),
Context: event.Source.SpeechContext, ResetContext: !browseContext, VoiceType: ancestor_voice(event.Source.SpeechContext),
Window: eventWindow(event),
IntroducesWindow: (event.Type == atspi.WindowActivatedEvent || event.Type == atspi.WindowCreateEvent) &&
activationTitle(event) != "",
+1 -1
View File
@@ -63,7 +63,7 @@ func (handler *eventReviewHandler) handle(result review.Result) {
handler.refreshPresentation()
state := handler.state
logger := handler.logger
speech := handler.speech
speech := contextSpeechQueue{speechEnqueuer: handler.speech, enabled: handler.browser.speech_context_enabled()}
objectReview := handler.objectReview
browser := handler.browser
brailleOutput := handler.brailleOutput
+1 -17
View File
@@ -9,7 +9,6 @@ import (
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/presentation"
)
@@ -67,22 +66,7 @@ func (state *eventLoopState) decorateGUITable(event atspi.Event, spoken presenta
result.table != state.lastTable && event.Source.TableName != "" &&
(isFocusGainEvent(event) || isManagedDescendantEvent(event.Type)) &&
event.Type != atspi.WindowActivatedEvent {
// A GTK table exposed as a list already gets its name from list
// context. Retain the table label when that context is disabled.
listContext := false
for _, ancestor := range event.Source.SpeechContext {
if ancestor.ID() == result.table && ancestor.Role == "list" && ancestor.Name == event.Source.TableName {
reading := config.DefaultSettings().Reading
if result.options.Reading != nil {
reading = *result.options.Reading
}
listContext = presentation.ContextEnabled("list", reading)
break
}
}
if !listContext {
spoken = spoken.Prepend(event.Source.TableName, ", ")
}
spoken = spoken.Prepend(event.Source.TableName, ", ")
}
appendFocus := isFocusGainEvent(event) && event.Type != atspi.WindowActivatedEvent &&
state.tableDescriptionWindow.Valid() && event.Window == state.tableDescriptionWindow &&
+28
View File
@@ -127,6 +127,34 @@ type PauseWaitSpeaker struct {
line int
}
func TestPauseChunksIncludeMnemonics(t *testing.T) {
for _, enabled := range []bool{false, true} {
settings := config.DefaultSettings()
settings.EnableSound = false
settings.Reading.EnablePauseBreaks = enabled
live := &liveSettings{}
live.Store(settings)
logger := slog.New(slog.DiscardHandler)
queue := &fakeSpeechQueue{spoken: make(chan string, 1), requests: make(chan speechRequest, 1)}
presenter := eventPresenter{state: &eventLoopState{}, logger: logger, speech: queue, live: live}
presenter.speak(atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{
Name: "Open", Role: "button", Mnemonic: "<Alt>o",
}))
speaker := &spellingTestSpeaker{}
worker := newSpeechWorker(logger, speaker)
if err := worker.deliver(context.Background(), <-queue.requests); err != nil {
t.Fatal(err)
}
want := []string{"Open button Alt o"}
if enabled {
want = []string{"Open", "button", "Alt o"}
}
if !reflect.DeepEqual(speaker.parts, want) {
t.Fatalf("pause breaks=%v: speech=%q, want %q", enabled, speaker.parts, want)
}
}
}
func (speaker *PauseWaitSpeaker) SpeakVoiceAndWait(ctx context.Context, text, voice string, began func()) error {
if began != nil {
began()
+21
View File
@@ -9,6 +9,7 @@ import (
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/presentation"
"git.stormux.org/storm/cthulhu/internal/review"
)
func (worker *speechWorker) context_messages(ancestors []atspi.Snapshot) []string {
@@ -72,9 +73,29 @@ func (worker *speechWorker) context_messages_for(ancestors []atspi.Snapshot, say
}
func (worker *speechWorker) deliver_context(ctx context.Context, request speechRequest) error {
if request.ResetContext {
worker.speechContext = nil
return nil
}
return worker.deliver_context_for(ctx, request.Context, false)
}
func (browser *browserModeController) speech_context_enabled() bool {
return browser != nil && browser.ready && browser.mode == review.BrowseMode && browser.document.Valid()
}
// Review results can also come from focus mode or native text. Capture the
// mode on the event loop rather than reading browser state on speech delivery.
type contextSpeechQueue struct {
speechEnqueuer
enabled bool
}
func (queue contextSpeechQueue) Enqueue(request speechRequest) {
request.ResetContext = request.ResetContext || !queue.enabled
queue.speechEnqueuer.Enqueue(request)
}
func (worker *speechWorker) deliver_context_for(ctx context.Context, ancestors []atspi.Snapshot, sayAll bool) error {
for _, message := range worker.context_messages_for(ancestors, sayAll) {
worker.logDelivery(ctx, speechRequest{Role: "system", Cause: "reading-context", Append: true}, message, -1)
+96
View File
@@ -0,0 +1,96 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"log/slog"
"reflect"
"testing"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/presentation"
"git.stormux.org/storm/cthulhu/internal/review"
)
func TestContextAnnouncementsOnlyInBrowseMode(t *testing.T) {
logger := slog.New(slog.DiscardHandler)
queue := &fakeSpeechQueue{spoken: make(chan string, 16), requests: make(chan speechRequest, 16)}
speaker := &spellingTestSpeaker{}
worker := newSpeechWorker(logger, speaker)
worker.reading = func() config.ReadingSettings { return config.DefaultSettings().Reading }
browser := &browserModeController{}
state := &eventLoopState{}
state.presentationOptions = presentation.DefaultOptions()
presenter := eventPresenter{state: state, logger: logger, speech: queue, browser: browser}
table := atspi.Snapshot{BusName: ":1.42", Path: "/table", Role: "table", Name: "Timeline"}
control := atspi.Snapshot{BusName: table.BusName, Path: "/item", Role: "text", Name: "Item",
SpeechContext: []atspi.Snapshot{table}}
check := func(snapshot atspi.Snapshot, want []string) {
t.Helper()
// Each step represents fresh focus, even if its label is unchanged.
state.lastSpokenPath = ""
presenter.speak(atspi.NewEvent(atspi.FocusEvent, snapshot))
speaker.parts = nil
if len(queue.requests) != 1 {
t.Fatalf("queued %d requests", len(queue.requests))
}
if err := worker.deliver(context.Background(), <-queue.requests); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(speaker.parts, want) {
t.Fatalf("speech = %q, want %q", speaker.parts, want)
}
}
check(control, []string{"Item", "text"})
terminal := atspi.Snapshot{BusName: ":1.43", Path: "/terminal", Role: "terminal", Name: "Terminal",
SpeechContext: []atspi.Snapshot{}}
check(terminal, []string{"Terminal", "terminal"})
if len(worker.speechContext) != 0 {
t.Fatal("native context was retained")
}
browser.ready, browser.mode = true, review.BrowseMode
browser.document = atspi.NewObjectID(table.BusName, "/document")
check(control, []string{"Timeline table", "Item", "text"})
outside := control
outside.Name, outside.SpeechContext = "Outside", []atspi.Snapshot{}
check(outside, []string{"Leaving Timeline table.", "Outside", "text"})
check(control, []string{"Timeline table", "Item", "text"})
// A workspace switch must clear browser context without saying its exit,
// even while asynchronous browser discovery still holds the old mode.
check(terminal, []string{"Terminal", "terminal"})
check(control, []string{"Timeline table", "Item", "text"})
browser.mode = review.FocusMode
check(control, []string{"Item", "text"})
browser.mode = review.BrowseMode
check(control, []string{"Timeline table", "Item", "text"})
browser.ready = false
check(control, []string{"Item", "text"})
}
func TestReviewContextSuppressedOutsideBrowseMode(t *testing.T) {
logger := slog.New(slog.DiscardHandler)
for _, enabled := range []bool{false, true} {
queue := &fakeSpeechQueue{spoken: make(chan string, 4), requests: make(chan speechRequest, 4)}
scoped := contextSpeechQueue{speechEnqueuer: queue, enabled: enabled}
list := atspi.Snapshot{BusName: ":1.42", Path: "/list", Role: "list", Name: "Items"}
speaker := &PauseWaitSpeaker{}
worker := newSpeechWorker(logger, speaker)
worker.reading = func() config.ReadingSettings { return config.DefaultSettings().Reading }
worker.speechContext = []atspi.Snapshot{{BusName: ":1.42", Path: "/previous", Role: "table"}}
scoped.Enqueue(speechRequest{ReadAll: []readAllPart{{Text: "Inside", Context: []atspi.Snapshot{list}}}})
if err := worker.deliver(context.Background(), <-queue.requests); err != nil {
t.Fatal(err)
}
want := []string{"Inside"}
if enabled {
want = []string{"Leaving table.", "Items list", "Inside"}
}
if !reflect.DeepEqual(speaker.parts, want) {
t.Fatalf("browse=%v: speech=%q, want %q", enabled, speaker.parts, want)
}
}
}
+1 -1
View File
@@ -83,7 +83,7 @@ func (worker *speechWorker) deliver(ctx context.Context, request speechRequest)
if err := ctx.Err(); err != nil {
return err
}
if part.Context != nil {
if part.Context != nil && !request.ResetContext {
if err := worker.deliver_context_for(ctx, part.Context, true); err != nil {
return err
}
+1
View File
@@ -50,6 +50,7 @@ type speechRequest struct {
VoiceType string
Line *speechLine
Context []atspi.Snapshot
ResetContext bool // Silently discard browse context outside browse mode.
Spelling []string // One cancellable queue item, with separate letter utterances.
SpellingVoices []string
Sound []sound.Cue
+1
View File
@@ -132,6 +132,7 @@ type Adapter struct {
treeRequests chan treeCaptureRequest
overflow atomic.Uint64
treeInvalidations atomic.Uint64
backgroundEventsFiltered atomic.Uint64 // rejected before native target/snapshot queries
secureMutex sync.Mutex
secureContext context.Context
secureCancel context.CancelFunc
@@ -455,6 +455,10 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
if !shouldSpeakEvent(eventType, int(C.cthulhu_event_detail1(event))) {
return
}
if adapter.filterNativeBackgroundEvent(event, eventType, originID, webNotification != nil) {
adapter.backgroundEventsFiltered.Add(1)
return
}
if (isDocumentLifecycleEvent(eventType) || eventType == BusyStateEvent) && C.cthulhu_active_document(event.source) == 0 {
return
}
@@ -0,0 +1,71 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package atspi
/*
#include "adapter_native.h"
*/
import "C"
// Reject routine background updates before resolving a selected descendant or
// capturing text, attributes, values and geometry. Global lifecycle events and
// supported notifications retain their existing handling. No new ownership is
// inferred from a background control's FOCUSED or SELECTED state.
func (adapter *Adapter) filterNativeBackgroundEvent(event *C.AtspiEvent, eventType string, origin ObjectID, webNotification bool) bool {
switch eventType {
case WindowCreateEvent, WindowActivatedEvent, WindowDeactivatedEvent, WindowDestroyedEvent, DefunctStateEvent:
return false
}
foreground := adapter.Foreground()
if !foreground.Client.Known || webNotification {
return false
}
// This reads libatspi's cached role/application name only. Missing metadata
// takes the established path so a newly published alert is not lost.
if C.cthulhu_may_be_background_notification(event.source) != 0 {
return false
}
if foreground.Client.Window == 0 || adapter.foregroundWindow.Valid() && origin.BusName != adapter.foregroundWindow.BusName {
return true
}
window := C.cthulhu_window_ancestor(event.source)
if window == nil {
// Chromium can detach its retained document while replacing the frame.
// The existing recovery path must resolve that association.
return false
}
defer C.g_object_unref(C.gpointer(window))
focusedMenuItem := false
if eventType == FocusEvent || eventType == FocusedStateEvent && event.detail1 != 0 {
switch C.atspi_accessible_get_role(event.source, nil) {
case C.ATSPI_ROLE_MENU_ITEM, C.ATSPI_ROLE_CHECK_MENU_ITEM, C.ATSPI_ROLE_RADIO_MENU_ITEM:
focusedMenuItem = true
}
}
if adapter.matchForeground(window, nativeObjectID(window), nil, focusedMenuItem) {
return false
}
if origin.BusName == adapter.foregroundWindow.BusName {
// Browser suggestions and tooltips can occupy a different top-level
// without taking X11 focus. Preserve their existing event-loop checks;
// a popup-for relation alone never grants presentation ownership here.
role := C.atspi_accessible_get_role(event.source, nil)
if eventType == ShowingStateEvent && role == C.ATSPI_ROLE_TOOL_TIP {
return false
}
switch role {
case C.ATSPI_ROLE_LIST, C.ATSPI_ROLE_LIST_BOX, C.ATSPI_ROLE_LIST_ITEM,
C.ATSPI_ROLE_MENU, C.ATSPI_ROLE_MENU_ITEM, C.ATSPI_ROLE_PUSH_BUTTON, C.ATSPI_ROLE_TOGGLE_BUTTON:
if popupFor := C.cthulhu_autocomplete_popup_target(event.source); popupFor != nil {
id := nativeObjectID(popupFor)
C.g_object_unref(C.gpointer(popupFor))
if id.Valid() && id.BusName == origin.BusName {
return false
}
}
}
}
return true
}
+16
View File
@@ -521,6 +521,22 @@ const gchar *cthulhu_accessible_bus_name(AtspiAccessible *source) {
return ATSPI_OBJECT(source)->app->bus_name;
}
// Cache-only classification: never contact an unrelated provider just to
// decide whether its event can be ignored. Unknown cache entries retain the
// ordinary validation path, including native alerts and notification daemons.
gboolean cthulhu_may_be_background_notification(AtspiAccessible *source) {
if (source == NULL || !(source->cached_properties & ATSPI_CACHE_ROLE)) return TRUE;
if (source->role == ATSPI_ROLE_ALERT || source->role == ATSPI_ROLE_NOTIFICATION) return TRUE;
AtspiApplication *application = source->parent.app;
AtspiAccessible *root = application != NULL ? application->root : NULL;
if (root == NULL || !(root->cached_properties & ATSPI_CACHE_NAME)) return TRUE;
const gchar *name = root->name != NULL ? root->name : "";
return g_ascii_strcasecmp(name, "notification-daemon") == 0 ||
g_ascii_strcasecmp(name, "mate-notification-daemon") == 0 ||
g_ascii_strcasecmp(name, "xfce4-notifyd") == 0 ||
g_ascii_strcasecmp(name, "notify-osd") == 0;
}
// Return the containing top-level window. An embedded web dialog is part of
// its frame; a standalone dialog directly below the application owns itself.
AtspiAccessible *cthulhu_window_ancestor(AtspiAccessible *source) {
+1
View File
@@ -129,6 +129,7 @@ AtspiAccessible *cthulhu_inline_menu_container(AtspiAccessible *source);
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);
AtspiAccessible *cthulhu_document_ancestor(AtspiAccessible *source);
AtspiAccessible *cthulhu_editable_document(AtspiAccessible *source);
AtspiEventListenerCB cthulhu_event_callback_pointer(void);
+6 -1
View File
@@ -503,7 +503,12 @@ func (adapter *Adapter) invalidateTreeEventWithPolicy(eventType string, detail1
return
}
if eventType != CaretEvent {
clear(adapter.documentLineCache)
// Background providers cannot change the owned document's text lines.
for id := range adapter.documentLineCache {
if id.BusName == sourceID.BusName {
delete(adapter.documentLineCache, id)
}
}
}
adapter.treeMutex.Lock()
trees := []*AccessibleTree{adapter.tree, adapter.documentTree, adapter.documentTextTree}
@@ -0,0 +1,208 @@
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package atspi
import (
"bufio"
"context"
"fmt"
"io"
"os"
"os/exec"
"strings"
"testing"
"time"
"github.com/godbus/dbus/v5"
)
func TestBackgroundInvalidationPreservesOtherProviderLines(t *testing.T) {
owned := NewObjectID(":1.42", "/line")
other := NewObjectID(":1.43", "/line")
adapter := New()
adapter.documentLineCache = map[ObjectID]DocumentCaretText{owned: {}, other: {}}
adapter.invalidateTreeEvent(TextInsertedEvent, 0, other, ObjectID{})
if _, ok := adapter.documentLineCache[owned]; !ok {
t.Fatal("background text change cleared the owned provider's line cache")
}
if _, ok := adapter.documentLineCache[other]; ok {
t.Fatal("changed provider's line cache survived invalidation")
}
adapter.invalidateTreeEvent(AccessibleNameEvent, 0, owned, ObjectID{})
if len(adapter.documentLineCache) != 0 {
t.Fatal("owned provider change failed to invalidate its lines")
}
}
func TestNativeBackgroundEventFiltering(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_BACKGROUND_EVENTS") != "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())
type fixture struct {
input io.WriteCloser
output *bufio.Reader
xid string
}
start := func(name string) fixture {
command := exec.CommandContext(ctx, "/usr/bin/python3", "../../tests/gtk_background_events.py", name)
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() })
reader := bufio.NewReader(output)
line, err := reader.ReadString('\n')
fields := strings.Fields(line)
if err != nil || len(fields) != 2 || fields[0] != "ready" {
t.Fatalf("fixture readiness: %q, %v", line, err)
}
return fixture{input, reader, fields[1]}
}
wait := func(predicate func(Event) bool) Event {
t.Helper()
var last Event
count := 0
for {
select {
case event := <-adapter.Events():
last = event
count++
if predicate(event) {
return event
}
case <-ctx.Done():
t.Fatalf("native event missing: %v; filtered=%d received=%d last=%s %s %q", ctx.Err(), adapter.backgroundEventsFiltered.Load(), count, last.Type, last.Source.Role, last.Source.Name)
}
}
}
focus := func(xid string) {
t.Helper()
if output, err := exec.CommandContext(ctx, "xdotool", "windowfocus", "--sync", xid).CombinedOutput(); err != nil {
t.Fatalf("focus: %s, %v", output, err)
}
}
background := start("Background fixture")
foreground := start("Foreground fixture")
// Publish the complete AT-SPI cache, including table cells, before flooding.
if _, err := adapter.CaptureDesktop(ctx); err != nil {
t.Fatal(err)
}
focus(foreground.xid)
fmt.Fprintln(foreground.input, "edit")
owned := wait(func(event Event) bool {
return event.Type == TextInsertedEvent && event.Payload == "Updated text" && !event.ForegroundMismatch
})
sessionBus, err := dbus.ConnectSessionBus()
if err != nil {
t.Fatal(err)
}
defer sessionBus.Close()
var address string
if err := sessionBus.Object("org.a11y.Bus", "/org/a11y/bus").CallWithContext(ctx, "org.a11y.Bus.GetAddress", 0).Store(&address); err != nil {
t.Fatal(err)
}
control, err := dbus.Connect(address)
if err != nil {
t.Fatal(err)
}
defer control.Close()
monitor, err := dbus.Connect(address)
if err != nil {
t.Fatal(err)
}
defer monitor.Close()
if err := monitor.BusObject().CallWithContext(ctx, "org.freedesktop.DBus.Monitoring.BecomeMonitor", 0, []string{"type='method_call'"}, uint32(0)).Err; err != nil {
t.Fatal(err)
}
messages := make(chan *dbus.Message, 65536)
monitor.Eavesdrop(messages)
flood := func(generation string, sameProvider bool) {
t.Helper()
before := adapter.backgroundEventsFiltered.Load()
fmt.Fprintln(background.input, "flood "+generation)
marker := wait(func(event Event) bool {
return event.Type == TextInsertedEvent && event.Payload == "Flood complete "+generation
})
if (marker.Source.BusName == owned.Source.BusName) != sameProvider {
t.Fatal("fixture provider identity did not match test case")
}
if count := adapter.backgroundEventsFiltered.Load() - before; count < 300 {
t.Fatalf("filtered %d events; want at least 300 real selection updates", count)
}
if err := control.Object(marker.Source.BusName, dbus.ObjectPath(marker.Source.Path)).CallWithContext(ctx, "org.freedesktop.DBus.Peer.Ping", 0).Err; err != nil {
t.Fatal(err)
}
queries := 0
reading:
for {
select {
case message := <-messages:
if message.Headers[dbus.FieldDestination].Value() != marker.Source.BusName {
continue
}
member := message.Headers[dbus.FieldMember].Value()
if member == "Ping" {
break reading
}
if message.Headers[dbus.FieldPath].Value() == dbus.ObjectPath(marker.Source.Path) {
continue
}
switch message.Headers[dbus.FieldInterface].Value() {
case "org.a11y.atspi.Selection", "org.a11y.atspi.Table", "org.a11y.atspi.Text", "org.a11y.atspi.Value":
queries++
}
case <-ctx.Done():
t.Fatal("monitor barrier missing")
}
}
if queries != 0 {
t.Fatalf("background flood caused %d selection/table/text/value queries", queries)
}
t.Logf("%s: filtered at least 300 updates with zero content queries", generation)
}
flood("other-provider", false)
// The same application can have an inactive sibling on the same bus.
fmt.Fprintln(background.input, "sibling")
line, err := background.output.ReadString('\n')
fields := strings.Fields(line)
if err != nil || len(fields) != 2 || fields[0] != "sibling" {
t.Fatalf("sibling readiness: %q, %v", line, err)
}
if _, err := adapter.CaptureDesktop(ctx); err != nil {
t.Fatal(err)
}
focus(fields[1])
owned = wait(func(event Event) bool {
return !event.ForegroundMismatch && event.Source.Name == "Background fixture sibling entry"
})
flood("same-provider", true)
// Returning to the previously noisy window must read its current selection.
focus(background.xid)
fmt.Fprintln(background.input, "focus-table")
wait(func(event Event) bool {
return !event.ForegroundMismatch && event.Source.Name == "Result 1" && event.Source.GUITable
})
t.Log("returning to the noisy application delivers current table focus")
}
+5 -1
View File
@@ -62,6 +62,7 @@ func TestNativeWebScriptNotificationCapture(t *testing.T) {
fmt.Fprintln(input, "show")
if processName == "chromium" {
fmt.Fprintln(input, "update")
fmt.Fprintln(input, "barrier")
}
var root ObjectID
for !root.Valid() {
@@ -71,7 +72,10 @@ func TestNativeWebScriptNotificationCapture(t *testing.T) {
continue
}
if processName == "chromium" {
if event.Type != TextInsertedEvent || event.Payload != "Username is now online" {
if event.Type == TextInsertedEvent && event.Payload == "Username is now online" {
t.Fatal("ordinary background Chromium text reached snapshot delivery")
}
if event.Type != TextInsertedEvent || event.Payload != "Notification barrier" || event.Source.Role != "alert" {
continue
}
if event.WebNotification != nil {
+6 -6
View File
@@ -38,17 +38,17 @@ static const struct { const char *id; const char *label; const char *description
{"speak_misspelled_indicator", "Speak misspelled-word indicator",
"Says misspelled when reading text that the application's spell checker has marked as incorrect."},
{"speak_context_blockquote", "Announce block quotes during navigation",
"Announces when you enter or leave a block quote while navigating text."},
"Announces when you enter or leave a block quote in browser browse mode."},
{"speak_context_list", "Announce lists during navigation",
"Announces when you enter or leave a list while navigating."},
"Announces when you enter or leave a list in browser browse mode."},
{"speak_context_table", "Announce tables during navigation",
"Announces when you enter or leave a data table while navigating."},
"Announces when you enter or leave a data table in browser browse mode."},
{"speak_context_panel", "Announce panels during navigation",
"Announces when you enter or leave a panel or group of controls while navigating."},
"Announces when you enter or leave a panel or group of controls in browser browse mode."},
{"speak_context_landmark", "Announce landmarks during navigation",
"Announces when you enter or leave a landmark, such as a page's main content or navigation region."},
"Announces when you enter or leave a landmark in browser browse mode, such as a page's main content or navigation region."},
{"speak_context_non_landmark_form", "Announce forms during navigation",
"Announces when you enter or leave a form that is not identified as a landmark."},
"Announces when you enter or leave a form that is not a landmark in browser browse mode."},
{"read_full_row_in_gui_table", "Speak full row in GUI tables",
"Reads all cells in a row when moving between rows in application tables, including this settings table. Left and Right read individual cells."},
{"read_full_row_in_document_table", "Speak full row in document tables",
+1
View File
@@ -10,6 +10,7 @@ lifecycle-farewell-message,src/cthulhu/plugins/ByeCthulhu/plugin.py,speech,Speak
lifecycle-start-sound,src/cthulhu/sound_theme_manager.py,sound,Play the startup sound cue,Storm Dragon,internal/sound,in-progress,go test ./internal/sound -run '^(TestBundledSoundsDecodeInProcess|TestSoundWorkerBoundsQueueAndStopsActiveCue|TestStopPlaysShutdownCueAfterStartupCueOverflow)$' -count=1,Maintainer heard the startup cue during the 2026-09-11 physical Xorg preview; the desktop and window manager were not recorded.,,,,"Python plays the start cue from the configured theme when enableSound is true and the theme is not none; startup uses a nonblocking, interrupting, best-effort helper command with no per-cue deadline at this call. Go creates the sound service only when enableSound is true, theme is not none, and volume is greater than zero; it enqueues start after service startup into a bounded 8-cue queue, with a 3-second playback deadline, and can drop the startup cue on overflow. audit-required: Python helper-process and Go in-process queue saturation are not directly compared by live evidence. September 25 review: nonblocking queue drains prevent a consumer/producer deadlock during cue replacement and live reconfiguration; concurrent regression and race checks pass."
lifecycle-stop-sound,src/cthulhu/sound_theme_manager.py,sound,Play the graceful-shutdown sound cue,Storm Dragon,internal/sound,in-progress,go test ./internal/sound -run '^(TestBundledSoundsDecodeInProcess|TestSoundWorkerBoundsQueueAndStopsActiveCue|TestStopPlaysShutdownCueAfterStartupCueOverflow|TestStopTimeoutPreservesShutdownCueForRetry)$' -count=1; go test ./internal/app -run '^(TestRunReachesSoundStopAfterEarlierServiceStopError|TestRunReportsSoundStopError)$' -count=1,Maintainer heard the post-fix stop cue through cthulhu.sh on Xorg using its LXTerminal test application on 2026-09-11; the launcher returned to the prompt and completed final shutdown records.,,,,"Python plays stop only when enableSound is true and the theme is not none; it interrupts current sound, waits up to 1 second through the helper, then continues shutdown. Go runs stop as the final reverse lifecycle service with its own 3-second deadline, cancels active cues, discards pending cues, and preserves a timed-out stop cue for one retry. audit-required: Python helper-isolation versus Go in-process cancellation and retry ordering are not directly aligned by the named evidence."
focus-events,src/cthulhu/event_manager.py,focus,Receive focus events and capture immutable accessible snapshots with stable object paths,Storm Dragon,internal/atspi,implemented,go test ./internal/atspi ./internal/app ./internal/logging -count=1,GTK yad Zenity and LXTerminal live tests passed 2026-09-10; post-fix AT-SPI shutdown passed through cthulhu.sh with its LXTerminal test application on Xorg 2026-09-11.,,,,Queue overflow duplicate-object suppression and nonblocking diagnostics have automated coverage; physical diagnostics saturation remains pending.
background-event-filtering,Orca src/orca/event_manager.py,focus,Reject routine background updates before selected-child and content capture,,internal/atspi,in-progress,go test ./... -count=1; private background flood and notification checks; docs/testing/background-events.md,Private GTK floods from another provider and same-provider sibling produce zero selection/table/text/value queries; GTK Qt Brave ownership and browser checks pass,Private Wayland policy check preserves AT-SPI ownership without X11 authority,Actual FastSMRW and Brave listening pending,Physical Braille pending,Existing lifecycle notification menu autocomplete and detached-document validation paths are preserved; unrelated providers no longer clear owned text-line caches.
xterm-focus-handoff,src/cthulhu/input_event_manager.py,input,Suspend Cthulhu while XTerm owns focus and restore when a known non-XTerm window takes focus,Storm Dragon,internal/app internal/atspi internal/platform/xorg,in-progress,NO_COLOR=1 GOCACHE=/tmp/cthulhu-build-cache go test ./internal/app -run '^(TestXTermFocus|TestProcessEvents)' -count=1; NO_COLOR=1 GOCACHE=/tmp/cthulhu-build-cache go test ./internal/platform/xorg -count=1; python3 tests/native_x11.py,Private Xvfb/D-Bus proof passes: XTerm focus suspends speech and command grabs; Insert+Q reaches a real XTerm; an unknown active window remains suspended; a known non-XTerm window restores all existing command and release checks.,,Pending human listening with Fenrir in XTerm,,Follow-up gates delegated modifier interruption and echo; commands dispatch on press with balanced shutdown draining. Go retains the existing XTerm identity matching focus monitoring grab suspension and event suppression boundary. Notifications bypass suppression. Unknown active windows remain suspended so an incomplete match cannot strand a delegated terminal reader; live Fenrir handoff remains pending.
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,in-progress,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 reported that all combined YAD I38 and LibreOffice tests worked great on 2026-09-12.,,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. Physical listening pending.
terminal-caret-character,src/cthulhu/scripts/default.py,terminal,Speak the character at the caret,Storm Dragon,internal/app,in-progress,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 heard terminal caret speech during the accepted Stage 1 physical Xorg checklist on 2026-09-11.,,Uses AT-SPI character granularity with line fallback debounces caret movement and suppresses unchanged path-and-announcement combinations; broader terminal application and editing coverage remains pending.
1 id python_source subsystem behavior owner go_destination status automated_evidence x11_evidence wayland_evidence speech_evidence braille_evidence notes
10 lifecycle-start-sound src/cthulhu/sound_theme_manager.py sound Play the startup sound cue Storm Dragon internal/sound in-progress go test ./internal/sound -run '^(TestBundledSoundsDecodeInProcess|TestSoundWorkerBoundsQueueAndStopsActiveCue|TestStopPlaysShutdownCueAfterStartupCueOverflow)$' -count=1 Maintainer heard the startup cue during the 2026-09-11 physical Xorg preview; the desktop and window manager were not recorded. Python plays the start cue from the configured theme when enableSound is true and the theme is not none; startup uses a nonblocking, interrupting, best-effort helper command with no per-cue deadline at this call. Go creates the sound service only when enableSound is true, theme is not none, and volume is greater than zero; it enqueues start after service startup into a bounded 8-cue queue, with a 3-second playback deadline, and can drop the startup cue on overflow. audit-required: Python helper-process and Go in-process queue saturation are not directly compared by live evidence. September 25 review: nonblocking queue drains prevent a consumer/producer deadlock during cue replacement and live reconfiguration; concurrent regression and race checks pass.
11 lifecycle-stop-sound src/cthulhu/sound_theme_manager.py sound Play the graceful-shutdown sound cue Storm Dragon internal/sound in-progress go test ./internal/sound -run '^(TestBundledSoundsDecodeInProcess|TestSoundWorkerBoundsQueueAndStopsActiveCue|TestStopPlaysShutdownCueAfterStartupCueOverflow|TestStopTimeoutPreservesShutdownCueForRetry)$' -count=1; go test ./internal/app -run '^(TestRunReachesSoundStopAfterEarlierServiceStopError|TestRunReportsSoundStopError)$' -count=1 Maintainer heard the post-fix stop cue through cthulhu.sh on Xorg using its LXTerminal test application on 2026-09-11; the launcher returned to the prompt and completed final shutdown records. Python plays stop only when enableSound is true and the theme is not none; it interrupts current sound, waits up to 1 second through the helper, then continues shutdown. Go runs stop as the final reverse lifecycle service with its own 3-second deadline, cancels active cues, discards pending cues, and preserves a timed-out stop cue for one retry. audit-required: Python helper-isolation versus Go in-process cancellation and retry ordering are not directly aligned by the named evidence.
12 focus-events src/cthulhu/event_manager.py focus Receive focus events and capture immutable accessible snapshots with stable object paths Storm Dragon internal/atspi implemented go test ./internal/atspi ./internal/app ./internal/logging -count=1 GTK yad Zenity and LXTerminal live tests passed 2026-09-10; post-fix AT-SPI shutdown passed through cthulhu.sh with its LXTerminal test application on Xorg 2026-09-11. Queue overflow duplicate-object suppression and nonblocking diagnostics have automated coverage; physical diagnostics saturation remains pending.
13 background-event-filtering Orca src/orca/event_manager.py focus Reject routine background updates before selected-child and content capture internal/atspi in-progress go test ./... -count=1; private background flood and notification checks; docs/testing/background-events.md Private GTK floods from another provider and same-provider sibling produce zero selection/table/text/value queries; GTK Qt Brave ownership and browser checks pass Private Wayland policy check preserves AT-SPI ownership without X11 authority Actual FastSMRW and Brave listening pending Physical Braille pending Existing lifecycle notification menu autocomplete and detached-document validation paths are preserved; unrelated providers no longer clear owned text-line caches.
14 xterm-focus-handoff src/cthulhu/input_event_manager.py input Suspend Cthulhu while XTerm owns focus and restore when a known non-XTerm window takes focus Storm Dragon internal/app internal/atspi internal/platform/xorg in-progress NO_COLOR=1 GOCACHE=/tmp/cthulhu-build-cache go test ./internal/app -run '^(TestXTermFocus|TestProcessEvents)' -count=1; NO_COLOR=1 GOCACHE=/tmp/cthulhu-build-cache go test ./internal/platform/xorg -count=1; python3 tests/native_x11.py Private Xvfb/D-Bus proof passes: XTerm focus suspends speech and command grabs; Insert+Q reaches a real XTerm; an unknown active window remains suspended; a known non-XTerm window restores all existing command and release checks. Pending human listening with Fenrir in XTerm Follow-up gates delegated modifier interruption and echo; commands dispatch on press with balanced shutdown draining. Go retains the existing XTerm identity matching focus monitoring grab suspension and event suppression boundary. Notifications bypass suppression. Unknown active windows remain suspended so an incomplete match cannot strand a delegated terminal reader; live Fenrir handoff remains pending.
15 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 in-progress 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 reported that all combined YAD I38 and LibreOffice tests worked great on 2026-09-12. 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. Physical listening pending.
16 terminal-caret-character src/cthulhu/scripts/default.py terminal Speak the character at the caret Storm Dragon internal/app in-progress 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 heard terminal caret speech during the accepted Stage 1 physical Xorg checklist on 2026-09-11. Uses AT-SPI character granularity with line fallback debounces caret movement and suppresses unchanged path-and-announcement combinations; broader terminal application and editing coverage remains pending.
+82
View File
@@ -0,0 +1,82 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Storm Dragon
# Authors: Storm Dragon, Angus Kola
# SPDX-License-Identifier: GPL-3.0-or-later
"""Controlled selection flood and same-process sibling window fixture."""
import os
import sys
import gi
gi.require_version("Gtk", "3.0")
gi.require_version("Atk", "1.0")
gi.require_version("GdkX11", "3.0")
from gi.repository import Atk, GdkX11, GLib, Gtk
fixtureName = sys.argv[1]
windows = []
pendingInput = ""
def make_window(title):
window = Gtk.Window(title=title)
window.set_default_size(350, 260)
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
window.add(box)
entry = Gtk.Entry()
entry.get_accessible().set_name(title + " entry")
box.add(entry)
store = Gtk.ListStore(str)
for index in range(40):
store.append(["Result " + str(index)])
table = Gtk.TreeView(model=store)
table.get_accessible().set_name(title + " results")
table.append_column(Gtk.TreeViewColumn("Result", Gtk.CellRendererText(), text=0))
box.add(table)
marker = Gtk.Label(label="Marker")
marker.get_accessible().set_role(Atk.Role.ALERT)
box.add(marker)
window.show_all()
entry.grab_focus()
windows.append(window)
return window, entry, table, marker
window, entry, table, marker = make_window(fixtureName)
sibling = None
def receive_command(_source, _condition):
global pendingInput, sibling
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("flood "):
generation = command.split()[1]
selection = table.get_selection()
for index in range(300):
selection.select_path(Gtk.TreePath.new_from_indices([index % 40]))
marker.set_text("Flood complete " + generation)
elif command == "sibling":
sibling, _, _, _ = make_window(fixtureName + " sibling")
print("sibling", GdkX11.X11Window.get_xid(sibling.get_window()), flush=True)
elif command == "edit":
entry.set_text("Updated text")
entry.set_position(-1)
elif command == "focus-table":
table.grab_focus()
table.set_cursor(Gtk.TreePath.new_from_indices([1]))
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()), flush=True) or False)
Gtk.main()
+5
View File
@@ -26,6 +26,9 @@ body = Gtk.Label(label="Playing: Example Game")
toast.add(username)
toast.add(body)
box.add(toast)
barrier = Gtk.Label(label="Marker")
barrier.get_accessible().set_role(Atk.Role.ALERT)
box.add(barrier)
window.connect("destroy", Gtk.main_quit)
window.show_all()
foreground = Gtk.Window(title="Foreground fixture")
@@ -52,6 +55,8 @@ def receive_command(source: object, condition: object) -> bool:
toast.show_all()
elif command == "update":
body.set_text("Username is now online")
elif command == "barrier":
barrier.set_text("Notification barrier")
elif command == "destroy":
toast.destroy()
elif command == "add":