Allow keyboard interruption of i3 speech during terminal handoff
This commit is contained in:
1 parent
a29910d002
commit
e7d02b1f62
9 files changed
+205
-8
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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) {}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in new issue
Block a user