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:
1 parent
702894ee4a
commit
1780afccba
14 files changed
+393
-2
No files matched your search
Executable
BIN
Binary file not shown.
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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 = "", "", ""
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
Vendored
+1
@@ -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."
|
||||
|
@@ -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()
|
||||
Reference in new issue
Block a user