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

+7 -2
View File
@@ -31,7 +31,9 @@ profiles use the existing settings path. Global preferences are usually the most
convenient place to set desktop announcement choices. convenient place to set desktop announcement choices.
Announcements queue with other incoming speech; normal user-input interruption Announcements queue with other incoming speech; normal user-input interruption
still applies. They can speak during Fenrir terminal handoff. Screen lock, still applies. They can speak during Fenrir terminal handoff, and the configured interruption
keys stop that local speech there too. Terminal echo and grabs remain delegated;
Cthulhu cancels only its own speech clients. Screen lock,
internal reader UI, speech disable, speech mode, and braille flash preferences internal reader UI, speech disable, speech mode, and braille flash preferences
retain their existing policies. Suppressed events are discarded. Braille uses retain their existing policies. Suppressed events are discarded. Braille uses
the existing flash duration and restores the current application frame without the existing flash duration and restores the current application frame without
@@ -113,7 +115,10 @@ Use an expendable window and named workspaces for move and mark checks.
add no scratchpad announcement. add no scratchpad announcement.
7. Return to the Fenrir test terminal. Enter and leave an i3 mode and switch 7. Return to the Fenrir test terminal. Enter and leave an i3 mode and switch
workspaces. Expect Cthulhu's desktop announcements while Fenrir continues workspaces. Expect Cthulhu's desktop announcements while Fenrir continues
handling terminal content and keys. Leave the terminal for an accessible handling terminal content and keys. Press Control during an announcement,
then repeat with Shift when using the all-keys interruption setting. Expect
Cthulhu's active speech and queued continuation to stop promptly, with no
Cthulhu key echo. Leave the terminal for an accessible
application; expect normal Cthulhu application focus, reading keys, title application; expect normal Cthulhu application focus, reading keys, title
changes, and braille to resume. changes, and braille to resume.
8. Disable one i3 Reading choice, Apply, and exercise its action. Expect that 8. Disable one i3 Reading choice, Apply, and exercise its action. Expect that
+12 -3
View File
@@ -2,8 +2,13 @@
While input is delegated to a terminal reader in XTerm, Cthulhu suppresses While input is delegated to a terminal reader in XTerm, Cthulhu suppresses
ordinary key echo, input-driven speech interruption and lock-state feedback. ordinary key echo, input-driven speech interruption and lock-state feedback.
It releases its command grabs. On return, Caps Lock tracking adopts the current An i3 desktop announcement opens a narrow exception: the configured interruption
XKB lock state before treating Caps Lock as the reader modifier again. keys can stop Cthulhu's active and queued speech during handoff, without key echo
or cancelling the terminal reader's own Speech Dispatcher client. Ordinary
terminal input still causes no Cthulhu cancellation when no such announcement
has been admitted. It releases its command grabs. On return, Caps Lock tracking
adopts the current XKB lock state before treating Caps Lock as the reader
modifier again.
Focused checks: Focused checks:
@@ -27,4 +32,8 @@ reader and physical keyboard handling require live verification.
4. Switch back and repeat with keypad commands where configured. Confirm 4. Switch back and repeat with keypad commands where configured. Confirm
focus stays on the intended application and no key remains grabbed after focus stays on the intended application and no key remains grabbed after
handoff. Restore the original lock state. handoff. Restore the original lock state.
5. Record the exact keys, focused application, speech and Braille for failures. 5. While an i3 workspace or mode announcement is speaking in XTerm, press
Control, or Shift when using the “all keys” interruption setting. Expect
Cthulhu's current announcement and queued continuation to stop promptly.
Fenrir must continue receiving terminal keys, with no Cthulhu key echo.
6. Record the exact keys, focused application, speech and Braille for failures.
+5 -2
View File
@@ -150,10 +150,13 @@ func (speaker gatedSpeech) InterruptForUser() {
} }
func (speaker gatedSpeech) InterruptForInput(event input.Event) bool { func (speaker gatedSpeech) InterruptForInput(event input.Event) bool {
if speaker.gate.InternalUIActive() || speaker.gate.Suspended() { if speaker.gate.InternalUIActive() || speaker.gate.Locked() {
return false return false
} }
if speaker.gate.Locked() { if speaker.gate.Suspended() {
if speech, ok := speaker.speech.(interface{ InterruptForHandoffInput(input.Event) bool }); ok {
return speech.InterruptForHandoffInput(event)
}
return false return false
} }
if speech, ok := speaker.speech.(interface{ InterruptForInput(input.Event) bool }); ok { if speech, ok := speaker.speech.(interface{ InterruptForInput(input.Event) bool }); ok {
+1 -1
View File
@@ -127,7 +127,7 @@ func (controller i3Announcements) present(event i3ipc.Event) {
if text == "" { if text == "" {
return return
} }
controller.output.Enqueue(speechRequest{Text: text, Role: "system", Cause: "i3-desktop", Append: true, BypassFocusGate: true}) controller.output.Enqueue(speechRequest{Text: text, Role: "system", Cause: "i3-desktop", Append: true, BypassFocusGate: true, InterruptDuringHandoff: true})
} }
func i3Service(controller i3Announcements) lifecycle.Service { func i3Service(controller i3Announcements) lifecycle.Service {
+138
View File
@@ -13,6 +13,7 @@ import (
"git.stormux.org/storm/cthulhu/internal/braille" "git.stormux.org/storm/cthulhu/internal/braille"
"git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/i3ipc" "git.stormux.org/storm/cthulhu/internal/i3ipc"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/preferences" "git.stormux.org/storm/cthulhu/internal/preferences"
) )
@@ -178,3 +179,140 @@ func TestI3UsesSpeechFIFOInterruptionAndDisablePolicy(t *testing.T) {
t.Fatalf("disabled events replayed: %+v", speech.pending) 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")
}
}
+12
View File
@@ -46,6 +46,18 @@ func processInputEvents(
} }
if guarded, ok := speech.(gatedSpeech); ok && (guarded.gate.Locked() || guarded.gate.Suspended()) { if guarded, ok := speech.(gatedSpeech); ok && (guarded.gate.Locked() || guarded.gate.Suspended()) {
echo.Reset() echo.Reset()
// Keep terminal echo and grabs delegated, but allow a selected key
// to stop local desktop speech explicitly admitted during handoff.
if !guarded.gate.Locked() {
interruption := input.InterruptionSettings{Mode: input.InterruptAllKeys}
if live != nil {
interruption = live.Load().SpeechInterruption
}
if interruption.ShouldInterrupt(event) && guarded.InterruptForInput(event) {
logger.Debug("queued desktop speech interrupted during terminal handoff",
"input_timestamp", event.Timestamp, "input_keycode", event.Keycode)
}
}
continue continue
} }
if modal, ok := commands.(interface{ InternalUIEchoActive() bool }); ok && modal.InternalUIEchoActive() { if modal, ok := commands.(interface{ InternalUIEchoActive() bool }); ok && modal.InternalUIEchoActive() {
+7
View File
@@ -64,6 +64,13 @@ func (queue outputQueue) InterruptForInput(event input.Event) bool {
return true return true
} }
func (queue outputQueue) InterruptForHandoffInput(event input.Event) bool {
if speech, ok := queue.speech.(interface{ InterruptForHandoffInput(input.Event) bool }); ok {
return speech.InterruptForHandoffInput(event)
}
return false
}
type discardSpeech struct{} type discardSpeech struct{}
func (discardSpeech) Enqueue(speechRequest) {} func (discardSpeech) Enqueue(speechRequest) {}
+17
View File
@@ -60,6 +60,7 @@ func (worker *speechWorker) interruptLocked() {
if worker.beepCancel != nil { if worker.beepCancel != nil {
worker.beepCancel() worker.beepCancel()
} }
worker.handoffSpeech = false
worker.titleInput.protectRepeats = false worker.titleInput.protectRepeats = false
worker.titleWindow = atspi.ObjectID{} worker.titleWindow = atspi.ObjectID{}
if worker.readAllCancel != nil { if worker.readAllCancel != nil {
@@ -102,6 +103,22 @@ func (worker *speechWorker) BeginReadAll(ctx context.Context, timestamp uint32)
func (worker *speechWorker) InterruptForInput(event input.Event) bool { func (worker *speechWorker) InterruptForInput(event input.Event) bool {
worker.mutex.Lock() worker.mutex.Lock()
defer worker.mutex.Unlock() defer worker.mutex.Unlock()
return worker.interrupt_for_input_locked(event)
}
// Terminal keystrokes normally belong entirely to its delegated reader. Only
// explicitly admitted local desktop speech opens this cancellation path; the
// backend still cancels Cthulhu's own clients, never the terminal reader's.
func (worker *speechWorker) InterruptForHandoffInput(event input.Event) bool {
worker.mutex.Lock()
defer worker.mutex.Unlock()
if !worker.handoffSpeech {
return false
}
return worker.interrupt_for_input_locked(event)
}
func (worker *speechWorker) interrupt_for_input_locked(event input.Event) bool {
if event.Timestamp != 0 && worker.commandTimestamp != 0 && int32(event.Timestamp-worker.commandTimestamp) <= 0 { if event.Timestamp != 0 && worker.commandTimestamp != 0 && int32(event.Timestamp-worker.commandTimestamp) <= 0 {
return false return false
} }
+6
View File
@@ -45,6 +45,8 @@ type speechRequest struct {
presentationGeneration *uint64 // Checked under the speech owner mutex. presentationGeneration *uint64 // Checked under the speech owner mutex.
presentationRevision *uint64 presentationRevision *uint64
InterruptDuringHandoff bool // Desktop speech remains keyboard-interruptible in the delegated terminal.
Window atspi.ObjectID Window atspi.ObjectID
IntroducesWindow bool IntroducesWindow bool
ReadAll []readAllPart ReadAll []readAllPart
@@ -138,6 +140,7 @@ type speechWorker struct {
lastInputTimestamp uint32 lastInputTimestamp uint32
titleWindow atspi.ObjectID titleWindow atspi.ObjectID
titleInput titleInputPolicy titleInput titleInputPolicy
handoffSpeech bool // Armed by accepted desktop speech; cleared by cancellation, including after SPEAK returns.
confirmationID uint64 // Owned under mutex; reset on completion or user interruption. confirmationID uint64 // Owned under mutex; reset on completion or user interruption.
confirmationSerial uint64 confirmationSerial uint64
@@ -253,6 +256,9 @@ func (worker *speechWorker) Enqueue(request speechRequest) {
worker.pending = worker.pending[:speechQueueCapacity-1] worker.pending = worker.pending[:speechQueueCapacity-1]
worker.overflow++ worker.overflow++
} }
if request.InterruptDuringHandoff {
worker.handoffSpeech = true
}
worker.titleInput.observe(request) worker.titleInput.observe(request)
worker.presentationRevision++ worker.presentationRevision++
worker.pending = append(worker.pending, request) worker.pending = append(worker.pending, request)