Merged changes from hunter. Attempt to fix cthulhu announcing modifiers when suspended for xterm.
This commit is contained in:
1 parent
27867a48cb
commit
f2cc47c8a0
24 files changed
+679
-67
No files matched your search
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.stormux.org/storm/cthulhu/internal/atspi"
|
||||
"git.stormux.org/storm/cthulhu/internal/config"
|
||||
)
|
||||
|
||||
// Fixture recorded from Niri 26.04 on 2026-10-31: a workspace switch emits
|
||||
@@ -40,10 +41,10 @@ func TestAnnouncementSpeaksPoliteWorkspaceSwitch(t *testing.T) {
|
||||
speech, adapter, done := announcementFixture(t)
|
||||
defer done()
|
||||
|
||||
adapter.events <- announcementEvent("Workspace 2 Mistworld", 1)
|
||||
adapter.events <- announcementEvent("Workspace 2 Example", 1)
|
||||
select {
|
||||
case request := <-speech.requests:
|
||||
if request.Text != "Workspace 2 Mistworld" || request.Role != "system" || request.Cause != atspi.AnnouncementEvent ||
|
||||
if request.Text != "Workspace 2 Example" || request.Role != "system" || request.Cause != atspi.AnnouncementEvent ||
|
||||
!request.Append || !request.BypassFocusGate {
|
||||
t.Fatalf("announcement request = %+v", request)
|
||||
}
|
||||
@@ -52,14 +53,14 @@ func TestAnnouncementSpeaksPoliteWorkspaceSwitch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnnouncementAssertiveInterrupts(t *testing.T) {
|
||||
func TestAnnouncementAssertiveQueues(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 {
|
||||
if request.Text != "Config parse error" || !request.Append {
|
||||
t.Fatalf("assertive request = %+v", request)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
@@ -71,10 +72,10 @@ func TestAnnouncementDedupesTrimmedRepeatButNotNewText(t *testing.T) {
|
||||
speech, adapter, done := announcementFixture(t)
|
||||
defer done()
|
||||
|
||||
adapter.events <- announcementEvent("Workspace 2 Mistworld", 1)
|
||||
adapter.events <- announcementEvent("Workspace 2 Example", 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)
|
||||
adapter.events <- announcementEvent("Workspace 2 Example ", 1)
|
||||
adapter.events <- announcementEvent("Workspace 1 Example", 1)
|
||||
|
||||
var got []string
|
||||
for range 2 {
|
||||
@@ -90,7 +91,7 @@ func TestAnnouncementDedupesTrimmedRepeatButNotNewText(t *testing.T) {
|
||||
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" {
|
||||
if got[0] != "Workspace 2 Example" || got[1] != "Workspace 1 Example" {
|
||||
t.Fatalf("announcements = %v", got)
|
||||
}
|
||||
}
|
||||
@@ -106,3 +107,37 @@ func TestEmptyAnnouncementDoesNotSpeak(t *testing.T) {
|
||||
case <-time.After(30 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnnouncementBrailleAndLockGate(t *testing.T) {
|
||||
live := &liveSettings{}
|
||||
live.Store(config.DefaultSettings())
|
||||
speech := &remoteSpeechQueue{}
|
||||
display := &flashRecorder{}
|
||||
gate := newFocusGate(nil)
|
||||
handler := &accessibilityEventHandler{
|
||||
state: &eventLoopState{}, focusGate: gate,
|
||||
speech: gatedSpeech{gate: gate, speech: outputQueue{speech: speech, braille: display, live: live}},
|
||||
}
|
||||
event := announcementEvent("Workspace 2 Example", 1)
|
||||
handler.presentAnnouncement(event)
|
||||
if len(display.messages) != 1 || display.messages[0] != event.Payload {
|
||||
t.Fatalf("Braille flashes = %v", display.messages)
|
||||
}
|
||||
gate.secure.Store(true)
|
||||
handler.handle(announcementEvent("Locked workspace", 1))
|
||||
if len(display.messages) != 1 {
|
||||
t.Fatal("locked announcement flashed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdenticalAnnouncementFromAnotherProviderIsPreserved(t *testing.T) {
|
||||
speech := &remoteSpeechQueue{}
|
||||
handler := &accessibilityEventHandler{state: &eventLoopState{}, speech: speech}
|
||||
event := announcementEvent("Example message", 1)
|
||||
handler.presentAnnouncement(event)
|
||||
event.Source.BusName = ":1.21"
|
||||
handler.presentAnnouncement(event)
|
||||
if len(speech.requests) != 2 {
|
||||
t.Fatalf("requests = %d, want 2", len(speech.requests))
|
||||
}
|
||||
}
|
||||
@@ -12,12 +12,9 @@ import (
|
||||
"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
|
||||
// the provider's text is presented verbatim. All provider announcements queue
|
||||
// under the reader's input-driven interruption policy. 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
|
||||
@@ -40,7 +37,7 @@ func (handler *accessibilityEventHandler) presentAnnouncement(event atspi.Event)
|
||||
Text: text,
|
||||
Role: "system",
|
||||
Cause: atspi.AnnouncementEvent,
|
||||
Append: event.Detail1 != announcementAssertive,
|
||||
Append: true,
|
||||
BypassFocusGate: true,
|
||||
})
|
||||
}
|
||||
@@ -127,7 +127,7 @@ func (speaker gatedSpeech) InterruptInternalUI() {
|
||||
}
|
||||
|
||||
func (speaker gatedSpeech) InterruptForUser() {
|
||||
if speaker.gate.Locked() {
|
||||
if speaker.gate.Locked() || speaker.gate.Suspended() {
|
||||
return
|
||||
}
|
||||
speaker.gate.dismiss_confirmation()
|
||||
@@ -139,7 +139,7 @@ func (speaker gatedSpeech) InterruptForUser() {
|
||||
}
|
||||
|
||||
func (speaker gatedSpeech) InterruptForInput(event input.Event) bool {
|
||||
if speaker.gate.InternalUIActive() {
|
||||
if speaker.gate.InternalUIActive() || speaker.gate.Suspended() {
|
||||
return false
|
||||
}
|
||||
if speaker.gate.Locked() {
|
||||
|
||||
@@ -44,7 +44,7 @@ func processInputEvents(
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if guarded, ok := speech.(gatedSpeech); ok && guarded.gate.Locked() {
|
||||
if guarded, ok := speech.(gatedSpeech); ok && (guarded.gate.Locked() || guarded.gate.Suspended()) {
|
||||
echo.Reset()
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ func (worker commandRuntime) run(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
case state := <-commands.LockStates():
|
||||
if focusGate.Locked() {
|
||||
if focusGate.Locked() || focusGate.Suspended() {
|
||||
continue
|
||||
}
|
||||
if speechMessages != nil {
|
||||
|
||||
@@ -131,6 +131,11 @@ func newRemoteRuntimeServices(deps remoteServiceDependencies) []lifecycle.Servic
|
||||
Stop: bridgeServer.Stop,
|
||||
})
|
||||
wineManager := wine.New(deps.logger)
|
||||
if deps.live != nil {
|
||||
settings := deps.live.Load()
|
||||
wineManager.ControllerOnly = settings.Wine.ControllerOnly
|
||||
wineManager.ExcludedPrograms = append([]string(nil), settings.Wine.ExcludedPrograms...)
|
||||
}
|
||||
services = append(services, lifecycle.Service{
|
||||
Name: "Wine accessibility", Start: wineManager.Start, Stop: wineManager.Stop,
|
||||
})
|
||||
|
||||
@@ -260,3 +260,69 @@ func TestDelegatedInputSuppressesInterruptAndEcho(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Match speechWorker's forwarding interfaces: a bare fake's Interrupt method
|
||||
// alone cannot catch a bypass through InterruptForInput or InterruptForUser.
|
||||
type forwardedInputSpeech struct{ *fakeSpeechQueue }
|
||||
|
||||
func (speech *forwardedInputSpeech) InterruptForInput(input.Event) bool {
|
||||
speech.Interrupt()
|
||||
return true
|
||||
}
|
||||
func (speech *forwardedInputSpeech) InterruptForUser() { speech.Interrupt() }
|
||||
|
||||
func TestDelegatedInputDoesNotForwardSpeechInterruption(t *testing.T) {
|
||||
for _, suspended := range []bool{true, false} {
|
||||
t.Run(map[bool]string{true: "delegated", false: "resumed"}[suspended], func(t *testing.T) {
|
||||
speech := &forwardedInputSpeech{&fakeSpeechQueue{interrupt: make(chan struct{}, 8)}}
|
||||
gate := newFocusGate(nil)
|
||||
gate.SetSuspended(suspended)
|
||||
guarded := gatedSpeech{speech: speech, gate: gate}
|
||||
if got := guarded.InterruptForInput(input.Event{Pressed: true}); got == suspended {
|
||||
t.Fatalf("interruption admitted = %t, suspended = %t", got, suspended)
|
||||
}
|
||||
guarded.InterruptForUser()
|
||||
want := 2
|
||||
if suspended {
|
||||
want = 0
|
||||
}
|
||||
if len(speech.interrupt) != want {
|
||||
t.Fatalf("interruptions = %d, want %d", len(speech.interrupt), want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestXTermHandoffSuppressesLockStateFeedback(t *testing.T) {
|
||||
for _, suspended := range []bool{true, false} {
|
||||
t.Run(map[bool]string{true: "delegated", false: "resumed"}[suspended], func(t *testing.T) {
|
||||
speech := &fakeSpeechQueue{spoken: make(chan string, 8), requests: make(chan speechRequest, 8)}
|
||||
gate := newFocusGate(nil)
|
||||
gate.SetSuspended(suspended)
|
||||
commands := &fakeCommands{lockStates: make(chan input.LockState)}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
worker := commandRuntime{logger: slog.New(slog.DiscardHandler), commands: commands, focusGate: gate, speechOutput: speech}
|
||||
go func() { done <- worker.run(ctx); close(done) }()
|
||||
defer func() { cancel(); <-done }()
|
||||
for _, name := range []string{"Caps Lock", "Num Lock", "Scroll Lock"} {
|
||||
select {
|
||||
case commands.lockStates <- input.LockState{Name: name, Locked: true}:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("lock state was not consumed")
|
||||
}
|
||||
}
|
||||
cancel()
|
||||
if err := <-done; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := 3
|
||||
if suspended {
|
||||
want = 0
|
||||
}
|
||||
if len(speech.requests) != want {
|
||||
t.Fatalf("lock-state requests = %d, want %d", len(speech.requests), want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user