diff --git a/docs/testing/i3-announcements.md b/docs/testing/i3-announcements.md index 061e9b1..a910625 100644 --- a/docs/testing/i3-announcements.md +++ b/docs/testing/i3-announcements.md @@ -31,7 +31,9 @@ profiles use the existing settings path. Global preferences are usually the most convenient place to set desktop announcement choices. 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 retain their existing policies. Suppressed events are discarded. Braille uses 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. 7. Return to the Fenrir test terminal. Enter and leave an i3 mode and switch 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 changes, and braille to resume. 8. Disable one i3 Reading choice, Apply, and exercise its action. Expect that diff --git a/docs/testing/xterm-handoff.md b/docs/testing/xterm-handoff.md index 4759b94..e477be6 100644 --- a/docs/testing/xterm-handoff.md +++ b/docs/testing/xterm-handoff.md @@ -2,8 +2,13 @@ While input is delegated to a terminal reader in XTerm, Cthulhu suppresses ordinary key echo, input-driven speech interruption and lock-state feedback. -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. +An i3 desktop announcement opens a narrow exception: the configured interruption +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: @@ -27,4 +32,8 @@ reader and physical keyboard handling require live verification. 4. Switch back and repeat with keypad commands where configured. Confirm focus stays on the intended application and no key remains grabbed after 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. diff --git a/internal/app/focus_gate.go b/internal/app/focus_gate.go index e7853c3..36a82cd 100644 --- a/internal/app/focus_gate.go +++ b/internal/app/focus_gate.go @@ -150,10 +150,13 @@ func (speaker gatedSpeech) InterruptForUser() { } func (speaker gatedSpeech) InterruptForInput(event input.Event) bool { - if speaker.gate.InternalUIActive() || speaker.gate.Suspended() { + if speaker.gate.InternalUIActive() || speaker.gate.Locked() { 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 } if speech, ok := speaker.speech.(interface{ InterruptForInput(input.Event) bool }); ok { diff --git a/internal/app/i3.go b/internal/app/i3.go index 73dd2b1..2c5f49d 100644 --- a/internal/app/i3.go +++ b/internal/app/i3.go @@ -127,7 +127,7 @@ func (controller i3Announcements) present(event i3ipc.Event) { if text == "" { 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 { diff --git a/internal/app/i3_test.go b/internal/app/i3_test.go index b0552b3..1d90b93 100644 --- a/internal/app/i3_test.go +++ b/internal/app/i3_test.go @@ -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") + } +} diff --git a/internal/app/input_events.go b/internal/app/input_events.go index 6aa0251..1bfb9db 100644 --- a/internal/app/input_events.go +++ b/internal/app/input_events.go @@ -46,6 +46,18 @@ func processInputEvents( } if guarded, ok := speech.(gatedSpeech); ok && (guarded.gate.Locked() || guarded.gate.Suspended()) { 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 } if modal, ok := commands.(interface{ InternalUIEchoActive() bool }); ok && modal.InternalUIEchoActive() { diff --git a/internal/app/output_queue.go b/internal/app/output_queue.go index 9fe60f1..f2c9cbf 100644 --- a/internal/app/output_queue.go +++ b/internal/app/output_queue.go @@ -64,6 +64,13 @@ func (queue outputQueue) InterruptForInput(event input.Event) bool { 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{} func (discardSpeech) Enqueue(speechRequest) {} diff --git a/internal/app/speech_interrupt.go b/internal/app/speech_interrupt.go index d7a28f8..d5a2cee 100644 --- a/internal/app/speech_interrupt.go +++ b/internal/app/speech_interrupt.go @@ -60,6 +60,7 @@ func (worker *speechWorker) interruptLocked() { if worker.beepCancel != nil { worker.beepCancel() } + worker.handoffSpeech = false worker.titleInput.protectRepeats = false worker.titleWindow = atspi.ObjectID{} 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 { worker.mutex.Lock() 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 { return false } diff --git a/internal/app/speech_worker.go b/internal/app/speech_worker.go index 13560cc..629faa8 100644 --- a/internal/app/speech_worker.go +++ b/internal/app/speech_worker.go @@ -45,6 +45,8 @@ type speechRequest struct { presentationGeneration *uint64 // Checked under the speech owner mutex. presentationRevision *uint64 + InterruptDuringHandoff bool // Desktop speech remains keyboard-interruptible in the delegated terminal. + Window atspi.ObjectID IntroducesWindow bool ReadAll []readAllPart @@ -138,6 +140,7 @@ type speechWorker struct { lastInputTimestamp uint32 titleWindow atspi.ObjectID 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. confirmationSerial uint64 @@ -253,6 +256,9 @@ func (worker *speechWorker) Enqueue(request speechRequest) { worker.pending = worker.pending[:speechQueueCapacity-1] worker.overflow++ } + if request.InterruptDuringHandoff { + worker.handoffSpeech = true + } worker.titleInput.observe(request) worker.presentationRevision++ worker.pending = append(worker.pending, request)