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
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user