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.
|
background provider no longer clears another provider's cached document lines.
|
||||||
|
|
||||||
Window creation, activation, deactivation, destruction and defunct events keep
|
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
|
Steam toasts retain their notification paths. Menu popups retain existing
|
||||||
foreground checks. Verified active/showing `mate-panel` menu windows may pass
|
foreground checks. Verified active/showing `mate-panel` menu windows may pass
|
||||||
across provider boundaries, including over an empty desktop; ordinary panel
|
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_start", event.Source.SelectionStart,
|
||||||
"selection_end", event.Source.SelectionEnd)
|
"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()
|
syncForeground()
|
||||||
if handler.handle_window_switcher(event) {
|
if handler.handle_window_switcher(event) {
|
||||||
return
|
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
|
lastSound string
|
||||||
lastLiveText string
|
lastLiveText string
|
||||||
lastLiveAt time.Time
|
lastLiveAt time.Time
|
||||||
|
lastAnnouncement string
|
||||||
|
lastAnnouncementBus string
|
||||||
|
lastAnnouncementAt time.Time
|
||||||
comboHistory comboPresentation
|
comboHistory comboPresentation
|
||||||
echoPreview echoPreviewPresentation
|
echoPreview echoPreviewPresentation
|
||||||
// Retain this identity after the history window closes so delayed GTK
|
// 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.focusCaretID, state.focusCaretSequence = atspi.ObjectID{}, 0
|
||||||
state.lastSpokenPath, state.lastSpokenText, state.lastSound = "", "", ""
|
state.lastSpokenPath, state.lastSpokenText, state.lastSound = "", "", ""
|
||||||
state.lastLiveText, state.lastLiveAt = "", time.Time{}
|
state.lastLiveText, state.lastLiveAt = "", time.Time{}
|
||||||
|
state.lastAnnouncement, state.lastAnnouncementBus, state.lastAnnouncementAt = "", "", time.Time{}
|
||||||
state.comboHistory = comboPresentation{}
|
state.comboHistory = comboPresentation{}
|
||||||
state.echoPreview = echoPreviewPresentation{}
|
state.echoPreview = echoPreviewPresentation{}
|
||||||
state.typingPath, state.typingText, state.suppressNextCaretPath = "", "", ""
|
state.typingPath, state.typingText, state.suppressNextCaretPath = "", "", ""
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ const (
|
|||||||
DocumentLoadCompleteEvent = "document:load-complete"
|
DocumentLoadCompleteEvent = "document:load-complete"
|
||||||
DocumentLoadStoppedEvent = "document:load-stopped"
|
DocumentLoadStoppedEvent = "document:load-stopped"
|
||||||
BusyStateEvent = "object:state-changed:busy"
|
BusyStateEvent = "object:state-changed:busy"
|
||||||
|
AnnouncementEvent = "object:announcement"
|
||||||
)
|
)
|
||||||
|
|
||||||
var eventTypes = []string{
|
var eventTypes = []string{
|
||||||
@@ -96,6 +97,7 @@ var eventTypes = []string{
|
|||||||
DocumentLoadCompleteEvent,
|
DocumentLoadCompleteEvent,
|
||||||
DocumentLoadStoppedEvent,
|
DocumentLoadStoppedEvent,
|
||||||
BusyStateEvent,
|
BusyStateEvent,
|
||||||
|
AnnouncementEvent,
|
||||||
"object:state-changed:checked",
|
"object:state-changed:checked",
|
||||||
"object:state-changed:expanded",
|
"object:state-changed:expanded",
|
||||||
"object:state-changed:indeterminate",
|
"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)
|
progressUpdate := snapshot.Role == "progress bar" && (eventType == ValueChangedEvent || eventType == AccessibleValueEvent)
|
||||||
inactiveProgress := progressUpdate && !adapter.Foreground().Client.Known && C.cthulhu_in_inactive_window(event.source) != 0
|
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 &&
|
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.cthulhu_in_inactive_window(event.source) != 0 {
|
||||||
C.g_free(C.gpointer(payload))
|
C.g_free(C.gpointer(payload))
|
||||||
if ownedTarget {
|
if ownedTarget {
|
||||||
|
|||||||
@@ -17,6 +17,11 @@ func (adapter *Adapter) filterNativeBackgroundEvent(event *C.AtspiEvent, eventTy
|
|||||||
switch eventType {
|
switch eventType {
|
||||||
case WindowCreateEvent, WindowActivatedEvent, WindowDeactivatedEvent, WindowDestroyedEvent, DefunctStateEvent:
|
case WindowCreateEvent, WindowActivatedEvent, WindowDeactivatedEvent, WindowDestroyedEvent, DefunctStateEvent:
|
||||||
return false
|
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()
|
foreground := adapter.Foreground()
|
||||||
if !foreground.Client.Known || webNotification {
|
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.
|
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.
|
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."
|
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