Allow keyboard interruption of i3 speech during terminal handoff

This commit is contained in:
ak47 committed 2026-11-07 15:16:26 -05:00
1 parent a29910d002
commit e7d02b1f62
9 files changed
+205 -8

No files matched your search

+138
View File
@@ -13,6 +13,7 @@ import (
"git.stormux.org/storm/cthulhu/internal/braille"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/i3ipc"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/preferences"
)
@@ -178,3 +179,140 @@ func TestI3UsesSpeechFIFOInterruptionAndDisablePolicy(t *testing.T) {
t.Fatalf("disabled events replayed: %+v", speech.pending)
}
}
func TestI3KeyboardInterruptsWorkspaceAndQueuedTitle(t *testing.T) {
for _, suspended := range []bool{false, true} {
for _, key := range []string{"Control_L", "Shift_L", "a"} {
name := "application/" + key
if suspended {
name = "handoff/" + key
}
t.Run(name, func(t *testing.T) {
logger := slog.New(slog.DiscardHandler)
speaker := &controlledSpeaker{calls: make(chan speechCall, 8)}
worker := newSpeechWorker(logger, speaker)
if err := worker.Start(context.Background()); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := worker.Stop(ctx); err != nil {
t.Error(err)
}
})
live := &liveSettings{}
settings := config.DefaultSettings()
settings.EnableKeyEcho = false
live.Store(settings)
gate := newFocusGate(nil)
gate.SetSuspended(suspended)
output := gatedSpeech{gate: gate, speech: outputQueue{speech: worker, live: live}}
controller := i3Announcements{live: live, output: output}
controller.present(i3ipc.Event{Kind: i3ipc.Workspace, Name: "8"})
active := expectSpeechCall(t, speaker, "Workspace 8")
// Both pieces can be accepted before the key, while only the first is
// speaking. A key must cancel the active utterance and queued continuation.
worker.Enqueue(speechRequest{Text: "Browser - example site", Cause: "window-title"})
events := &fakeInputListener{events: make(chan input.Event, 1)}
events.events <- input.Event{Name: key, Pressed: true, Timestamp: 100}
close(events.events)
processInputEvents(context.Background(), logger, gatedSpeech{gate: gate, speech: worker}, events, input.EchoSettings{}, live, nil)
firstCancel := expectSpeechCall(t, speaker, "cancel")
worker.mutex.Lock()
pending := len(worker.pending)
worker.mutex.Unlock()
if pending != 0 {
t.Fatal("key retained queued title")
}
close(firstCancel.done)
close(active.done)
followup := expectSpeechCall(t, speaker, "cancel")
close(followup.done)
})
}
}
}
func TestI3HandoffInterruptionRespectsSettingsAndGates(t *testing.T) {
for _, tc := range []struct {
name, mode, key string
keys []string
locked, ui, release, stop bool
}{
{name: "all", mode: input.InterruptAllKeys, key: "Shift_L", stop: true},
{name: "common shift", mode: input.InterruptCommonKeys, key: "Shift_L"},
{name: "common control", mode: input.InterruptCommonKeys, key: "Control_L", stop: true},
{name: "custom unselected", mode: input.InterruptCustomKeys, keys: []string{"F8"}, key: "Control_L"},
{name: "custom selected", mode: input.InterruptCustomKeys, keys: []string{"F8"}, key: "F8", stop: true},
{name: "release", mode: input.InterruptAllKeys, key: "Control_L", release: true},
{name: "locked", mode: input.InterruptAllKeys, key: "Control_L", locked: true},
{name: "internal UI", mode: input.InterruptAllKeys, key: "Control_L", ui: true},
} {
t.Run(tc.name, func(t *testing.T) {
logger := slog.New(slog.DiscardHandler)
worker := newSpeechWorker(logger, nil)
worker.running = true
live := &liveSettings{}
settings := config.DefaultSettings()
settings.EnableKeyEcho = true
settings.EnableModifierKeys = true
settings.SpeechInterruption = input.InterruptionSettings{Mode: tc.mode, Keys: tc.keys}
live.Store(settings)
gate := newFocusGate(nil)
gate.SetSuspended(true)
output := gatedSpeech{gate: gate, speech: outputQueue{speech: worker, live: live}}
controller := i3Announcements{live: live, output: output}
controller.present(i3ipc.Event{Kind: i3ipc.Mode, Name: "personal"})
gate.secure.Store(tc.locked)
gate.internalUI = &fakeInternalUIState{active: tc.ui}
events := &fakeInputListener{events: make(chan input.Event, 1)}
events.events <- input.Event{Name: tc.key, Pressed: !tc.release, Timestamp: 100}
close(events.events)
processInputEvents(context.Background(), logger, output, events, input.EchoSettings{}, live, nil)
want := 1
if tc.stop {
want = 0
}
if len(worker.pending) != want {
t.Fatalf("pending=%+v, want %d; key echo must remain delegated", worker.pending, want)
}
})
}
}
func TestI3HandoffInterruptionSurvivesBackendAcceptanceAndResets(t *testing.T) {
logger := slog.New(slog.DiscardHandler)
speaker := &fakeSpeaker{spoken: make(chan string, 1)}
worker := newSpeechWorker(logger, speaker)
worker.running = true
live := &liveSettings{}
live.Store(config.DefaultSettings())
controller := i3Announcements{live: live, output: worker}
controller.present(i3ipc.Event{Kind: i3ipc.Mode, Name: "panel"})
request, ok := worker.next()
if !ok {
t.Fatal("missing desktop speech")
}
if err := worker.deliver(context.Background(), request); err != nil {
t.Fatal(err)
}
<-speaker.spoken // SPEAK can return while the synthesizer is still talking.
key := input.Event{Name: "Control_L", Pressed: true, Timestamp: 100}
if !worker.InterruptForHandoffInput(key) {
t.Fatal("accepted desktop speech could not be stopped")
}
if worker.InterruptForHandoffInput(key) {
t.Fatal("handoff forwarded cancellation after desktop speech retired")
}
worker.Enqueue(speechRequest{Text: "Ordinary application speech"})
if worker.InterruptForHandoffInput(key) {
t.Fatal("ordinary delegated input was forwarded without desktop speech")
}
worker.SetEnabled(false)
controller.present(i3ipc.Event{Kind: i3ipc.Mode, Name: "disabled"})
worker.SetEnabled(true)
if worker.InterruptForHandoffInput(key) {
t.Fatal("disabled desktop request armed handoff cancellation")
}
}