Add the announcement of switched workspaces from Niri.

I haven't tested this with Braille support; I don't remember what I did with the cable.
This commit is contained in:
hjozwiak committed 2026-10-31 15:27:40 -04:00
1 parent 702894ee4a
commit 1780afccba
14 files changed
+393 -2

No files matched your search

BIN
View File
Binary file not shown.
+64
View File
@@ -0,0 +1,64 @@
# AT-SPI announcements
Providers can ask the reader to speak a message without moving focus by
emitting the AT-SPI `object:announcement` event. The event's `any_data`
carries the text and `detail1` carries the politeness (`AtspiLive`: 1 is
polite, 2 is assertive). Orca presents the text verbatim; Cthulhu now does
the same, with a Braille flash through the system-role output queue.
The motivating provider is the Niri compositor (25.08+), which exposes its
own UI through AccessKit. A capture from Niri 26.04 on 2026-10-31 shows a
workspace switch emits `object:property-change:accessible-name` and then
`object:announcement` (polite) with text such as `Workspace 2 Mistworld` on
a label in the `niri` application, about 10 ms before the newly focused
window activates. Niri alternates live-node identities (and may append a
space) so repeated messages still emit; Cthulhu trims the payload and
dedupes one provider's repeat inside 300 ms.
Announcements bypass background-event filtering and foreground epochs: the
provider explicitly requested speech and may never own focus. They do not
move focus, review, or reading state. Screen-lock and secure-input gates
still suppress them. Assertive announcements interrupt; polite ones append.
Payload text is bounded by the adapter's existing snapshot sanitization.
## Automated checks
Run from the repository root with the normal native build dependencies:
```sh
go test ./internal/atspi ./internal/app -run Announcement -count=1
python3 tests/native_test_session.py env CTHULHU_NATIVE_ANNOUNCEMENT=1 go test ./internal/atspi -run '^TestNativeAnnouncementEventDelivered$' -count=1 -v -timeout=40s
```
The native check uses `tests/gtk_announce.py`, a GTK4 fixture whose
`announce` calls must arrive as adapter events with the exact payload and
politeness (medium priority arrives polite, high arrives assertive).
On hosts without a systemd user manager, D-Bus activation of the AT-SPI
registry can fail inside the private session (the pre-existing native
checks fail the same way). Starting the a11y bus and registry manually
inside the session works around it:
```sh
python3 tests/native_test_session.py env CTHULHU_NATIVE_ANNOUNCEMENT=1 sh -c '/usr/lib/at-spi-bus-launcher & sleep 1; ADDR=$(busctl --user call org.a11y.Bus /org/a11y/bus org.a11y.Bus GetAddress | sed "s/^s //;s/\"//g"); AT_SPI_BUS_ADDRESS=$ADDR /usr/lib/at-spi2-registryd & sleep 1; go test ./internal/atspi/ -run "^TestNativeAnnouncementEventDelivered$" -count=1 -v -timeout=40s'
```
## Live acceptance (Niri)
Requires the maintainer's authorization to replace the running reader.
Report automated, audible-speech, and physical-Braille results separately.
1. Start the built reader in a full Niri session (started via a display
manager or `niri-session`, so Niri exposes its accessibility tree).
2. Switch between named, unnamed, and the trailing empty workspace with
Niri's own binds. Each switch speaks the workspace label once, before
the newly focused window, and focus presentation still follows.
3. Burst-switch several times quickly. Only settled announcements speak;
no duplicates from the paired accessible-name change.
4. Cycle the AltTab switcher and open the exit dialog (`Super+Shift+E`)
and the hotkeys list (`Super+Shift+/`). Selected windows and dialog
text are announced as with Orca on the same session.
5. Introduce a temporary config parse error and reload. The error is
announced.
6. Lock the screen and switch workspaces. Nothing is spoken or flashed
until unlock.
+2 -1
View File
@@ -12,7 +12,8 @@ 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
their existing handling. Announcement events are explicit requests to speak
and also bypass the filter; see [announcements](announcements.md). Native alerts, notification daemons and supported
Steam toasts retain their notification paths. Menu popups retain existing
foreground checks. Verified active/showing `mate-panel` menu windows may pass
across provider boundaries, including over an empty desktop; ordinary panel
+108
View File
@@ -0,0 +1,108 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"log/slog"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
)
// Fixture recorded from Niri 26.04 on 2026-10-31: a workspace switch emits
// object:property-change:accessible-name and then object:announcement
// (detail1=1, polite) with the same text on a label in the niri application,
// about 10ms before the newly focused window's activation events.
func announcementEvent(text string, politeness int) atspi.Event {
source := atspi.Snapshot{BusName: ":1.20", Path: "/org/a11y/atspi/accessible/2", Role: "label", Name: text, ApplicationName: "niri"}
event := atspi.NewEventWithPayload(atspi.AnnouncementEvent, source, text)
event.Detail1 = politeness
return event
}
func announcementFixture(t *testing.T) (*fakeSpeechQueue, *fakeAdapter, func()) {
t.Helper()
speech := &fakeSpeechQueue{spoken: make(chan string, 8), requests: make(chan speechRequest, 8)}
adapter := &fakeAdapter{events: make(chan atspi.Event, 8)}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
processEvents(ctx, slog.New(slog.DiscardHandler), speech, adapter, 0)
}()
return speech, adapter, func() { cancel(); <-done }
}
func TestAnnouncementSpeaksPoliteWorkspaceSwitch(t *testing.T) {
speech, adapter, done := announcementFixture(t)
defer done()
adapter.events <- announcementEvent("Workspace 2 Mistworld", 1)
select {
case request := <-speech.requests:
if request.Text != "Workspace 2 Mistworld" || request.Role != "system" || request.Cause != atspi.AnnouncementEvent ||
!request.Append || !request.BypassFocusGate {
t.Fatalf("announcement request = %+v", request)
}
case <-time.After(time.Second):
t.Fatal("announcement was not spoken")
}
}
func TestAnnouncementAssertiveInterrupts(t *testing.T) {
speech, adapter, done := announcementFixture(t)
defer done()
adapter.events <- announcementEvent("Config parse error", 2)
select {
case request := <-speech.requests:
if request.Text != "Config parse error" || request.Append {
t.Fatalf("assertive request = %+v", request)
}
case <-time.After(time.Second):
t.Fatal("assertive announcement was not spoken")
}
}
func TestAnnouncementDedupesTrimmedRepeatButNotNewText(t *testing.T) {
speech, adapter, done := announcementFixture(t)
defer done()
adapter.events <- announcementEvent("Workspace 2 Mistworld", 1)
// Niri can append a space to force re-emission of identical text.
adapter.events <- announcementEvent("Workspace 2 Mistworld ", 1)
adapter.events <- announcementEvent("Workspace 1 Generic Shit", 1)
var got []string
for range 2 {
select {
case request := <-speech.requests:
got = append(got, request.Text)
case <-time.After(time.Second):
t.Fatalf("announcements spoken = %v", got)
}
}
select {
case request := <-speech.requests:
t.Fatalf("duplicate announcement spoken: %+v (all: %v)", request, got)
case <-time.After(30 * time.Millisecond):
}
if got[0] != "Workspace 2 Mistworld" || got[1] != "Workspace 1 Generic Shit" {
t.Fatalf("announcements = %v", got)
}
}
func TestEmptyAnnouncementDoesNotSpeak(t *testing.T) {
speech, adapter, done := announcementFixture(t)
defer done()
adapter.events <- announcementEvent(" ", 1)
select {
case request := <-speech.requests:
t.Fatalf("empty announcement spoken: %+v", request)
case <-time.After(30 * time.Millisecond):
}
}
+7
View File
@@ -110,6 +110,13 @@ func (handler *accessibilityEventHandler) handle(event atspi.Event) {
"selection_start", event.Source.SelectionStart,
"selection_end", event.Source.SelectionEnd)
}
if event.Type == atspi.AnnouncementEvent {
// Announcements are explicit requests to speak from providers that
// may never own focus or the foreground window. Present them before
// any ownership gating, and never let them move focus state.
handler.presentAnnouncement(event)
return
}
syncForeground()
if handler.handle_window_switcher(event) {
return
+46
View File
@@ -0,0 +1,46 @@
// Cthulhu screen reader.
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"strings"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
)
// AtspiLive assertive value carried in an announcement event's Detail1.
const announcementAssertive = 2
// presentAnnouncement speaks an object:announcement payload, mirroring Orca:
// the provider's text is presented verbatim, assertive announcements
// interrupt, and polite ones append. Announcements commonly come from
// providers that never own focus (a compositor announcing a workspace
// switch, for example), so no focus or foreground ownership is required and
// none is taken. The caller's lock gate has already run; the speech output
// gate still rejects the request while the screen is locked.
func (handler *accessibilityEventHandler) presentAnnouncement(event atspi.Event) {
text := strings.TrimSpace(event.Payload)
if text == "" {
return
}
state := handler.state
// Some providers force re-emission of an unchanged message by altering
// trailing whitespace. Treat one trimmed repeat from the same provider
// inside the live-region dedupe window as the same announcement.
if text == state.lastAnnouncement && event.Source.BusName == state.lastAnnouncementBus &&
time.Since(state.lastAnnouncementAt) < 300*time.Millisecond {
return
}
state.lastAnnouncement, state.lastAnnouncementBus, state.lastAnnouncementAt = text, event.Source.BusName, time.Now()
handler.speech.Enqueue(speechRequest{
Text: text,
Role: "system",
Cause: atspi.AnnouncementEvent,
Append: event.Detail1 != announcementAssertive,
BypassFocusGate: true,
})
}
+4
View File
@@ -97,6 +97,9 @@ type eventPresentationState struct {
lastSound string
lastLiveText string
lastLiveAt time.Time
lastAnnouncement string
lastAnnouncementBus string
lastAnnouncementAt time.Time
comboHistory comboPresentation
echoPreview echoPreviewPresentation
// Retain this identity after the history window closes so delayed GTK
@@ -166,6 +169,7 @@ func (state *eventLoopState) clearObjectFocus(brailleOutput brailleFrameEnqueuer
state.focusCaretID, state.focusCaretSequence = atspi.ObjectID{}, 0
state.lastSpokenPath, state.lastSpokenText, state.lastSound = "", "", ""
state.lastLiveText, state.lastLiveAt = "", time.Time{}
state.lastAnnouncement, state.lastAnnouncementBus, state.lastAnnouncementAt = "", "", time.Time{}
state.comboHistory = comboPresentation{}
state.echoPreview = echoPreviewPresentation{}
state.typingPath, state.typingText, state.suppressNextCaretPath = "", "", ""
+2
View File
@@ -67,6 +67,7 @@ const (
DocumentLoadCompleteEvent = "document:load-complete"
DocumentLoadStoppedEvent = "document:load-stopped"
BusyStateEvent = "object:state-changed:busy"
AnnouncementEvent = "object:announcement"
)
var eventTypes = []string{
@@ -96,6 +97,7 @@ var eventTypes = []string{
DocumentLoadCompleteEvent,
DocumentLoadStoppedEvent,
BusyStateEvent,
AnnouncementEvent,
"object:state-changed:checked",
"object:state-changed:expanded",
"object:state-changed:indeterminate",
@@ -566,7 +566,7 @@ func cthulhuAtspiEventCallback(event *C.AtspiEvent, userData unsafe.Pointer) {
progressUpdate := snapshot.Role == "progress bar" && (eventType == ValueChangedEvent || eventType == AccessibleValueEvent)
inactiveProgress := progressUpdate && !adapter.Foreground().Client.Known && C.cthulhu_in_inactive_window(event.source) != 0
if webNotification == nil && !progressUpdate && !adapter.Foreground().Client.Known && eventType != WindowCreateEvent && eventType != WindowActivatedEvent &&
eventType != WindowDeactivatedEvent && eventType != WindowDestroyedEvent && !snapshot.AutocompletePopup &&
eventType != WindowDeactivatedEvent && eventType != WindowDestroyedEvent && eventType != AnnouncementEvent && !snapshot.AutocompletePopup &&
C.cthulhu_in_inactive_window(event.source) != 0 {
C.g_free(C.gpointer(payload))
if ownedTarget {
@@ -17,6 +17,11 @@ func (adapter *Adapter) filterNativeBackgroundEvent(event *C.AtspiEvent, eventTy
switch eventType {
case WindowCreateEvent, WindowActivatedEvent, WindowDeactivatedEvent, WindowDestroyedEvent, DefunctStateEvent:
return false
case AnnouncementEvent:
// An announcement is an explicit request to speak, commonly from a
// provider that never owns focus (for example a compositor's own
// accessible tree). Foreground ownership must not discard it.
return false
}
foreground := adapter.Foreground()
if !foreground.Client.Known || webNotification {
@@ -0,0 +1,74 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package atspi
import (
"bufio"
"context"
"fmt"
"os"
"os/exec"
"strings"
"testing"
"time"
)
func TestNativeAnnouncementEventDelivered(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_ANNOUNCEMENT") != "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())
command := exec.CommandContext(ctx, "/usr/bin/python3", "../../tests/gtk_announce.py")
command.Env = append(os.Environ(), "GDK_BACKEND=x11")
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')
if fields := strings.Fields(line); err != nil || len(fields) != 2 || fields[0] != "ready" {
t.Fatalf("fixture readiness: %q, %v", line, err)
}
// Publish the complete AT-SPI cache before announcing.
if _, err := adapter.CaptureDesktop(ctx); err != nil {
t.Fatal(err)
}
wait := func(text string) Event {
t.Helper()
for {
select {
case event := <-adapter.Events():
if event.Type == AnnouncementEvent && event.Payload == text {
return event
}
case <-ctx.Done():
t.Fatalf("announcement %q missing: %v; filtered=%d", text, ctx.Err(), adapter.backgroundEventsFiltered.Load())
}
}
}
fmt.Fprintln(input, "announce polite Workspace 2 Fixture")
polite := wait("Workspace 2 Fixture")
t.Logf("polite announcement detail1=%d role=%s", polite.Detail1, polite.Source.Role)
fmt.Fprintln(input, "announce assertive Fixture error")
assertive := wait("Fixture error")
t.Logf("assertive announcement detail1=%d role=%s", assertive.Detail1, assertive.Source.Role)
}
+18
View File
@@ -0,0 +1,18 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package atspi
import "testing"
func TestAnnouncementEventIsRegistered(t *testing.T) {
if AnnouncementEvent != "object:announcement" {
t.Fatalf("AnnouncementEvent = %q", AnnouncementEvent)
}
for _, eventType := range eventTypes {
if eventType == AnnouncementEvent {
return
}
}
t.Fatal("object:announcement is not in the registered event types")
}
+1
View File
@@ -71,3 +71,4 @@ edit-field-relation-labels,src/cthulhu/generator.py:_generateLabel;src/cthulhu/s
mozilla-hypertext-reading,src/cthulhu/scripts/web/script.py;src/cthulhu/scripts/web/script_utilities.py,web,Read native heading and link text plus parent text around embedded children without Tab caret echoes interrupting link labels,,internal/atspi internal/review internal/app,implemented,"go test ./... -count=1; go test -race ./internal/review ./internal/atspi ./internal/app -count=1; python3 tests/native_firefox.py; CTHULHU_NATIVE_BROWSER_TESTS=fragments,document,selection,namedlinks python3 tests/native_browser.py",Merge review on 2026-10-20: four parent-text regressions fail against testing before the merge and pass after resolution. Private Firefox baseline and Chromium fragments document selection named-link and editing checks pass.,,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. The original Thunderbird regression message was not separately identified.,Repeated parent-fragment cursor and native routing offsets tested; physical Braille pending.,Hypertext traversal retains parent offsets around single-character embedded children; named-link labels retain virtual character and word offsets. Password subtrees remain excluded. Read and Tab through an HTML email; reverse direction; verify Firefox and Chromium links selection and address-bar editing before submission. Human diff review and original-message live acceptance remain required.
speech-interruption-preferences,src/cthulhu/input_event.py,input,"Choose all keys, common stop keys, or recorded individual interruption keys in Speech preferences",,internal/input internal/config internal/preferences internal/app,implemented,go test ./internal/input ./internal/config ./internal/preferences ./internal/app -run 'Interruption|Interrupt' -count=1,"Private GTK/Xvfb test verifies mode selection, focus traversal, special/modifier/Page Up/multimedia recording, finish/cancel, Apply and draft reload.",Physical compositor acceptance pending.,Maintainer confirms speech preferences; previews; Apply and Cancel; saved settings; and interruption-key settings work in live testing on 2026-10-22. See docs/testing/release-acceptance.md.,Physical display acceptance pending.,All keys remains default. Reader commands retain explicit interruption; unselected keys preserve queued speech and Say All. Control+Enter finishes and Control+Escape cancels without recording the finishing modifier. Settings persist per profile.
pidgin-message-history,src/cthulhu/chat.py;src/cthulhu/scripts/apps/pidgin/chat.py;src/cthulhu/scripts/apps/pidgin/script.py,messaging,Announce verified Pidgin transcript insertions and review or copy nine recent messages,Storm Dragon,internal/messaging internal/atspi internal/app internal/input,implemented,go test ./...; go test -race ./internal/messaging ./internal/input ./internal/atspi ./internal/app; see docs/testing/messaging.md for private native checks,Private GTK transcript and X11 command checks pass; actual Pidgin and physical shortcuts pending.,,Recorded presentation tested; actual Pidgin listening pending.,Single flash recorded; physical Braille pending.,"Legacy defaults only: combined per-provider history, all conversations, no room prefix or typing announcements. Legacy chat preference controls remain unported. Cthulhu+H is the sole default learn-mode chord; F1-F9 review and Shift+F1-F9 copy. Existing explicit assignments and modal OCR F5 refresh take precedence. Steam notification history remains separate. See docs/testing/messaging.md."
atspi-announcements,Orca src/orca/script.py;src/orca/scripts/default.py;src/orca/event_manager.py,focus,"Present object:announcement payloads from any provider, politely or assertively, without requiring focus or foreground ownership",,internal/atspi internal/app,implemented,"go test ./internal/atspi ./internal/app -run Announcement -count=1; native GTK4 fixture delivers polite detail1=1 and assertive detail1=2 payloads through the adapter; live Niri 26.04 event capture recorded the workspace-switch announcement shape",Native fixture runs on private Xvfb; Xorg reader behavior otherwise unchanged,"Live Niri 26.04 capture on 2026-10-31: workspace switch emits accessible-name then object:announcement with the workspace label about 10ms before window activation. Maintainer confirms workspace switching, AltTab announcements and the exit dialog read out with the built reader on Niri on 2026-10-31.","Maintainer live listening on Niri passed for workspace switching, AltTab and the exit dialog on 2026-10-31.",Braille flash follows the system-role output queue; physical display acceptance pending.,"Announcements bypass background-event filtering and foreground epochs because the provider explicitly requests speech and may never own focus (a compositor's own accessible tree under Niri via AccessKit). Screen-lock and secure-input gates still apply. Trimmed repeats from one provider inside 300ms are deduped; Niri alternates live-node identities and may append a space to force re-emission. There is no Orca workspace-specific behavior to mirror: Orca only presents the generic announcement. See docs/testing/announcements.md."
1 id python_source subsystem behavior owner go_destination status automated_evidence x11_evidence wayland_evidence speech_evidence braille_evidence notes
71 mozilla-hypertext-reading src/cthulhu/scripts/web/script.py;src/cthulhu/scripts/web/script_utilities.py web Read native heading and link text plus parent text around embedded children without Tab caret echoes interrupting link labels internal/atspi internal/review internal/app implemented go test ./... -count=1; go test -race ./internal/review ./internal/atspi ./internal/app -count=1; python3 tests/native_firefox.py; CTHULHU_NATIVE_BROWSER_TESTS=fragments,document,selection,namedlinks python3 tests/native_browser.py Merge review on 2026-10-20: four parent-text regressions fail against testing before the merge and pass after resolution. Private Firefox baseline and Chromium fragments document selection named-link and editing checks pass. 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. The original Thunderbird regression message was not separately identified. Repeated parent-fragment cursor and native routing offsets tested; physical Braille pending. Hypertext traversal retains parent offsets around single-character embedded children; named-link labels retain virtual character and word offsets. Password subtrees remain excluded. Read and Tab through an HTML email; reverse direction; verify Firefox and Chromium links selection and address-bar editing before submission. Human diff review and original-message live acceptance remain required.
72 speech-interruption-preferences src/cthulhu/input_event.py input Choose all keys, common stop keys, or recorded individual interruption keys in Speech preferences internal/input internal/config internal/preferences internal/app implemented go test ./internal/input ./internal/config ./internal/preferences ./internal/app -run 'Interruption|Interrupt' -count=1 Private GTK/Xvfb test verifies mode selection, focus traversal, special/modifier/Page Up/multimedia recording, finish/cancel, Apply and draft reload. Physical compositor acceptance pending. Maintainer confirms speech preferences; previews; Apply and Cancel; saved settings; and interruption-key settings work in live testing on 2026-10-22. See docs/testing/release-acceptance.md. Physical display acceptance pending. All keys remains default. Reader commands retain explicit interruption; unselected keys preserve queued speech and Say All. Control+Enter finishes and Control+Escape cancels without recording the finishing modifier. Settings persist per profile.
73 pidgin-message-history src/cthulhu/chat.py;src/cthulhu/scripts/apps/pidgin/chat.py;src/cthulhu/scripts/apps/pidgin/script.py messaging Announce verified Pidgin transcript insertions and review or copy nine recent messages Storm Dragon internal/messaging internal/atspi internal/app internal/input implemented go test ./...; go test -race ./internal/messaging ./internal/input ./internal/atspi ./internal/app; see docs/testing/messaging.md for private native checks Private GTK transcript and X11 command checks pass; actual Pidgin and physical shortcuts pending. Recorded presentation tested; actual Pidgin listening pending. Single flash recorded; physical Braille pending. Legacy defaults only: combined per-provider history, all conversations, no room prefix or typing announcements. Legacy chat preference controls remain unported. Cthulhu+H is the sole default learn-mode chord; F1-F9 review and Shift+F1-F9 copy. Existing explicit assignments and modal OCR F5 refresh take precedence. Steam notification history remains separate. See docs/testing/messaging.md.
74 atspi-announcements Orca src/orca/script.py;src/orca/scripts/default.py;src/orca/event_manager.py focus Present object:announcement payloads from any provider, politely or assertively, without requiring focus or foreground ownership internal/atspi internal/app implemented go test ./internal/atspi ./internal/app -run Announcement -count=1; native GTK4 fixture delivers polite detail1=1 and assertive detail1=2 payloads through the adapter; live Niri 26.04 event capture recorded the workspace-switch announcement shape Native fixture runs on private Xvfb; Xorg reader behavior otherwise unchanged Live Niri 26.04 capture on 2026-10-31: workspace switch emits accessible-name then object:announcement with the workspace label about 10ms before window activation. Maintainer confirms workspace switching, AltTab announcements and the exit dialog read out with the built reader on Niri on 2026-10-31. Maintainer live listening on Niri passed for workspace switching, AltTab and the exit dialog on 2026-10-31. Braille flash follows the system-role output queue; physical display acceptance pending. Announcements bypass background-event filtering and foreground epochs because the provider explicitly requests speech and may never own focus (a compositor's own accessible tree under Niri via AccessKit). Screen-lock and secure-input gates still apply. Trimmed repeats from one provider inside 300ms are deduped; Niri alternates live-node identities and may append a space to force re-emission. There is no Orca workspace-specific behavior to mirror: Orca only presents the generic announcement. See docs/testing/announcements.md.
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env python3
# Copyright (c) 2026 Storm Dragon
# Authors: Storm Dragon, Angus Kola
# SPDX-License-Identifier: GPL-3.0-or-later
"""GTK4 fixture emitting AT-SPI announcement events on stdin commands.
Commands (one per line):
announce polite <text>
announce assertive <text>
"""
import os
import sys
import gi
gi.require_version("Gtk", "4.0")
gi.require_version("GdkX11", "4.0")
from gi.repository import GdkX11, GLib, Gtk
pendingInput = ""
window = Gtk.Window(title="Announcement fixture")
window.set_default_size(320, 200)
label = Gtk.Label(label="Announcement fixture")
window.set_child(label)
window.present()
def receive_command(_source, _condition):
global pendingInput
content = os.read(sys.stdin.fileno(), 4096)
if not content:
loop.quit()
return False
pendingInput += content.decode()
while "\n" in pendingInput:
command, pendingInput = pendingInput.split("\n", 1)
parts = command.split(" ", 2)
if len(parts) == 3 and parts[0] == "announce":
priority = Gtk.AccessibleAnnouncementPriority.MEDIUM
if parts[1] == "assertive":
priority = Gtk.AccessibleAnnouncementPriority.HIGH
window.announce(parts[2], priority)
print("announced", parts[2], flush=True)
return True
GLib.io_add_watch(sys.stdin.fileno(), GLib.PRIORITY_DEFAULT, GLib.IO_IN, receive_command)
def announce_ready():
surface = window.get_surface()
xid = GdkX11.X11Surface.get_xid(surface)
print("ready", xid, flush=True)
return False
GLib.timeout_add(250, announce_ready)
loop = GLib.MainLoop()
loop.run()