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

+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 = "", "", ""