Attempt to fix the rest of speech interrupting when it shouldn't. Also play radio buttons after the ui after they figure out if they are checked or not.

This commit is contained in:
Storm Dragon committed 2026-10-19 19:25:11 -04:00
1 parent 2c6ab2ce76
commit 82887b3897
64 files changed
+501 -225

No files matched your search

-1
View File
@@ -25,7 +25,6 @@ func (ui *internalUI) browserListMessage(message string) {
if ui.gate.Locked() || ui.gate.Suspended() {
return
}
ui.speech.Interrupt()
ui.speech.Enqueue(speechRequest{Text: message, Role: "system", Cause: "browser-list"})
if ui.braille != nil {
ui.braille.EnqueueMessage(message)
-4
View File
@@ -247,9 +247,6 @@ func (browser *browserModeController) set(mode review.DocumentMode, speech speec
text = "Focus mode"
cue = "focus_mode"
}
if manual {
speech.Interrupt()
}
// Append automatic changes so they do not replace the focused control.
speech.Enqueue(speechRequest{Text: text, Role: "system", Sound: []sound.Cue{{Name: cue}}, Cause: "document-mode", Append: !manual})
}
@@ -466,6 +463,5 @@ func (browser *browserModeController) sticky(mode review.DocumentMode, speech sp
if mode == review.FocusMode {
text = "Focus mode is sticky."
}
speech.Interrupt()
speech.Enqueue(speechRequest{Text: text, Role: "system", Cause: "document-mode"})
}
-1
View File
@@ -113,7 +113,6 @@ func presentCharacterAttributes(speech speechEnqueuer, result characterAttribute
message = "No text attributes to speak."
}
}
speech.Interrupt()
speech.Enqueue(speechRequest{Text: message, Role: "system", Cause: "character-attributes"})
}
-1
View File
@@ -35,7 +35,6 @@ func runClipboardWorker(ctx context.Context, requests <-chan struct{}, speech sp
if err == nil {
message = result
}
speech.Interrupt()
speech.Enqueue(speechRequest{Text: message, Role: "system", Cause: "clipboard"})
}
}
+2 -2
View File
@@ -38,8 +38,8 @@ func TestClipboardWorkerAnnouncesContentAndAccessError(t *testing.T) {
case <-time.After(time.Second):
t.Fatal("clipboard announcement missing")
}
if len(speech.interrupt) != 1 {
t.Fatal("clipboard announcement did not interrupt prior speech")
if len(speech.interrupt) != 0 {
t.Fatal("clipboard result interrupted earlier speech")
}
cancel()
select {
+1 -1
View File
@@ -81,7 +81,7 @@ func TestCommandSpeechSurvivesDelayedInitiatingInput(t *testing.T) {
t.Fatalf("command interrupted by initiating input at %d", timestamp)
}
}
if len(worker.pending) != 1 || worker.generation != 0 {
if len(worker.pending) != 1 || worker.generation != 1 {
t.Fatal("command speech was discarded")
}
if !worker.InterruptForInput(input.Event{Pressed: true, Timestamp: 101}) || len(worker.pending) != 0 {
+27
View File
@@ -298,3 +298,30 @@ func TestFocusedTreeSelectionStateChangesRemainAudible(t *testing.T) {
}
}
}
// Drive focus and checked-state events through the real event loop and speech
// worker. The backend call stays in flight while the selection arrives, so any
// automatic CANCEL (including one after SPEAK returns) fails the expected order.
func TestSpeechWorkerRadioSelectionDoesNotCancelLabel(t *testing.T) {
logger := slog.New(slog.DiscardHandler)
speaker := &controlledSpeaker{calls: make(chan speechCall, 8)}
worker := newSpeechWorker(logger, speaker)
if err := worker.Start(t.Context()); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { worker.Stop(context.Background()) })
adapter := &fakeAdapter{events: make(chan atspi.Event, 2)}
option := atspi.Snapshot{BusName: ":1.7", Path: "/option", Name: "Laptop", Role: "radio button"}
adapter.events <- atspi.NewEvent(atspi.FocusEvent, option)
option.Checked = true
selected := atspi.NewEvent("object:state-changed:checked", option)
selected.Detail1 = 1
adapter.events <- selected
close(adapter.events)
if err := processEvents(t.Context(), logger, worker, adapter, 0); err != nil {
t.Fatal(err)
}
for _, want := range []string{"Laptop", "radio button", "not selected", "selected"} {
close(expectSpeechCall(t, speaker, want).done)
}
}
-1
View File
@@ -21,6 +21,5 @@ func presentDateTime(speech speechEnqueuer, settings config.Settings, command in
} else {
text, _ = datetime.FormatTime(settings.TimeFormat, now)
}
speech.Interrupt()
speech.Enqueue(speechRequest{Text: text, Role: "system", Cause: cause})
}
+2 -2
View File
@@ -68,8 +68,8 @@ func TestDateTimeFormats(t *testing.T) {
if got := <-queue.spoken; got != tc.want {
t.Errorf("%s: %q want %q", tc.format, got, tc.want)
}
if len(queue.interrupt) != 1 {
t.Fatal("command did not interrupt")
if len(queue.interrupt) != 0 {
t.Fatal("delayed command result interrupted speech again")
}
}
}
-2
View File
@@ -286,7 +286,6 @@ func presentDocumentCaretResult(logger *slog.Logger, speech speechEnqueuer, resu
fallbackSpeech := presentation.DocumentLineSpeechWithOptions(line, fallback)
request.FallbackText, request.FallbackChunks = fallbackSpeech.Text, fallbackSpeech.Chunks
}
speech.Interrupt()
if request.Text == "" && len(request.Sound) == 0 {
return
}
@@ -452,7 +451,6 @@ func presentDocumentResult(logger *slog.Logger, speech speechEnqueuer, result re
logger.Warn("document caret synchronization failed", "error", result.CaretErr)
}
}
speech.Interrupt()
voiceType := ancestor_voice(result.DocumentCaretContext)
if result.DocumentPosition.LinkedHeading != nil {
voiceType = "hyperlink"
+2 -4
View File
@@ -73,10 +73,8 @@ func TestDocumentPresentation(t *testing.T) {
if got := <-queue.spoken; got != tc.want {
t.Fatalf("got %q want %q", got, tc.want)
}
select {
case <-queue.interrupt:
default:
t.Fatal("did not interrupt speech")
if len(queue.interrupt) != 0 {
t.Fatal("delayed document result interrupted speech again")
}
}
}
+1 -1
View File
@@ -93,6 +93,7 @@ func (handler *accessibilityEventHandler) handle(event atspi.Event) {
"focused", event.Source.Focused,
"browser_tab", event.Source.BrowserTab,
"selected", event.Source.Selected,
"checked", event.Source.Checked,
"radio_group_name", event.Source.RadioGroupName,
"prompt_message_present", event.Source.Text != "" && (event.Source.Role == "frame" || event.Source.Role == "alert") || event.WindowMessage != "",
"snapshot_duration", event.SnapshotDuration,
@@ -217,7 +218,6 @@ func (handler *accessibilityEventHandler) handle(event atspi.Event) {
status := strings.TrimSpace(event.Source.Name)
if state.lastFileManagerStatusWindow != state.owner.window || status != state.lastFileManagerStatus {
state.lastFileManagerStatusWindow, state.lastFileManagerStatus = state.owner.window, status
speech.Interrupt()
speech.Enqueue(speechRequest{Text: status, Role: "status bar", Cause: event.Type})
if brailleOutput != nil {
brailleOptions := config.DefaultSettings().Braille
+7
View File
@@ -23,6 +23,13 @@ func (handler *accessibilityEventHandler) acceptFocusedEvent(event atspi.Event,
browser := handler.browser
brailleOutput := handler.brailleOutput
live := handler.live
// Native state-only captures omit mnemonic lookup. Preserve the owned
// value so GTK's following focus event compares against the same metadata
// and does not replay the just-announced state sound and label.
if isStateChangeEvent(event.Type) && event.Source.ID() == state.current.ID() &&
event.Source.Role == state.current.Role && event.Source.Mnemonic == "" {
event.Source.Mnemonic = state.current.Mnemonic
}
// Compare with the owned focus snapshot, not the last speech request:
// a state/value update can intervene between two toolkit focus events.
// Still refresh review and braille below before suppressing presentation.
-1
View File
@@ -54,7 +54,6 @@ func (handler *accessibilityEventHandler) acceptFocusOwnership(event atspi.Event
text += event.WindowMessage
}
if text != "" {
speech.Interrupt()
speech.Enqueue(speechRequest{Text: text, Role: "frame", Cause: event.Type,
Window: eventWindow(event), IntroducesWindow: true,
ExcludeFromHistory: state.historyWindow.Valid() && eventWindow(event) == state.historyWindow})
+4 -1
View File
@@ -195,7 +195,7 @@ func processEventsWithAttributes(
// Wine control revisions reset object review, but a pending title and
// its duplicate history belong to the foreground window's lifetime.
// These notices carry metadata, not an instruction to stop speech.
// Foreground switches and user input already own speech cancellation.
// User input owns speech cancellation; metadata changes preserve it.
clearWineReviewFocus := func() {
ocrSession.Retire()
state.clearObjectFocus(brailleOutput, browser, objectReview)
@@ -299,6 +299,9 @@ func processEventsWithAttributes(
case <-state.pending.browserButtonName.ready:
syncForeground()
eventHandler.flush_browser_button_name()
case <-state.pending.radioSound.ready:
syncForeground()
presenter.flush_radio_sound(focusGate)
case <-state.pending.caret.ready:
if focusGate.Locked() {
state.pending.clear()
-2
View File
@@ -15,7 +15,6 @@ import (
)
func presentNotifications(speech speechEnqueuer, messages []string) {
speech.Interrupt()
for _, message := range messages {
// Notifications remain audible while XTerm owns keyboard handling.
// The output gate still rejects these requests during screen lock.
@@ -53,7 +52,6 @@ func (announcer *notificationAnnouncer) Announce(entry notification.Entry) {
Urgency: entry.Urgency,
DesktopEntry: entry.DesktopEntry,
})
announcer.speech.Interrupt()
message := entry.Message
if announcer.live != nil && !announcer.live.Load().Reading.MessagesAreDetailed && entry.Summary != "" {
message = entry.Summary
+1
View File
@@ -172,6 +172,7 @@ func (presenter *eventPresenter) speak(event atspi.Event, capturedIntent ...inpu
// The control's own focus event will present its role and cue.
cues = nil
}
cues = presenter.coalesce_radio_sound(event, cues)
cueParts := make([]string, 0, len(cues))
for _, cue := range cues {
cueParts = append(cueParts, cue.Name+":"+strconv.FormatFloat(cue.Pan, 'g', -1, 64))
-3
View File
@@ -55,7 +55,6 @@ func (handler *eventReviewHandler) whereAmI() {
text = "Could not find current location."
role = "system"
}
handler.speech.Interrupt()
handler.speech.Enqueue(speechRequest{Text: text, Chunks: spoken.WithText(text).Chunks, Role: role, Cause: "where-am-i"})
handler.logger.Debug("queued where am I presentation", "role", role)
}
@@ -137,7 +136,6 @@ func (handler *eventReviewHandler) handle(result review.Result) {
fallbackOptions := state.presentationOptions
fallbackOptions.RoleSoundPresentation = "sound_and_speech"
fallback := presentation.DocumentLineSpeechWithOptions(result.DocumentLineData, fallbackOptions)
speech.Interrupt()
if text == "" && len(cues) == 0 {
return
}
@@ -262,7 +260,6 @@ func presentObjectReviewResult(logger *slog.Logger, speech speechEnqueuer, resul
role = "system"
}
}
speech.Interrupt()
speech.Enqueue(speechRequest{Text: text, Chunks: spoken.WithText(text).Chunks, Role: role, Cause: "object-review"})
logger.Debug("queued object review presentation", "direction", result.Direction.String(), "moved", result.Moved, "role", role)
}
-1
View File
@@ -133,7 +133,6 @@ func (state *eventLoopState) ownedAutocompletePopup(event atspi.Event) bool {
// closure. Session presentation history and chrome load verification survive.
func (state *eventLoopState) clearOwnedFocus(brailleOutput brailleFrameEnqueuer, browser *browserModeController, objectReview *review.Worker, speech speechEnqueuer) {
state.clearObjectFocus(brailleOutput, browser, objectReview)
speech.Interrupt()
}
// Review metadata can change without new input or a foreground switch. Reset
-1
View File
@@ -23,7 +23,6 @@ func (handler *accessibilityEventHandler) handleTooltip(event atspi.Event) bool
options := presentationSettingsFromConfig(handler.live.Load(), true)
spoken := presentation.FocusSpeechWithOptions(event.Source, options)
if text := strings.TrimSpace(spoken.Text); text != "" && text != "tool tip" {
handler.speech.Interrupt()
handler.speech.Enqueue(speechRequest{Text: text, Chunks: spoken.WithText(text).Chunks, Role: "system", Cause: "tooltip"})
}
return true
-1
View File
@@ -80,7 +80,6 @@ func presentFlatReviewResult(logger *slog.Logger, speech speechEnqueuer, result
} else {
text = presentation.FlatReviewText(result.FlatPosition.Text, result.FlatUnit == review.Character || result.FlatUnit == review.LineEnd)
}
speech.Interrupt()
request := speechRequest{Text: text, Role: "text", Cause: "flat-review", Character: result.Err == nil && (result.FlatUnit == review.Character || result.FlatUnit == review.LineEnd)}
if result.Err == nil && result.FlatUnit == review.Line {
request.Line = &speechLine{Object: result.FlatPosition.Object, Raw: result.FlatPosition.Text}
-6
View File
@@ -64,12 +64,6 @@ func (gate *focusGate) SetSuspended(suspended bool) bool {
return false
}
gate.retireTentacleOutput()
if suspended {
gate.tentacleOutput.Stop()
}
if suspended && !gate.secure.Load() && gate.speech != nil {
gate.speech.Interrupt()
}
if gate.commands != nil {
gate.commands.SetCommandGrabs(!suspended && !gate.secure.Load())
}
+1 -1
View File
@@ -168,7 +168,7 @@ int main(void) {
case <-ctx.Done():
t.Fatal("presentation did not complete")
}
if got := presenter.interrupts.Load(); got != int32(index+1) {
if got := presenter.interrupts.Load(); got != 0 {
t.Fatalf("feedback %d interrupts=%d", index, got)
}
if got := display.current().Text; got != message {
-11
View File
@@ -122,11 +122,6 @@ func (ui *internalUI) present(output interaction.Presentation) {
if ui.gate.Locked() || ui.gate.Suspended() {
return
}
if speech, ok := ui.speech.(interface{ InterruptInternalUI() }); ok {
speech.InterruptInternalUI()
} else {
ui.speech.Interrupt()
}
if output.Speech != "" {
ui.speech.Enqueue(speechRequest{Text: output.Speech, Role: "system", Cause: "internal-ui"})
}
@@ -150,11 +145,6 @@ func (ui *internalUI) run(ctx context.Context) {
closeErr = ui.keys.EndInternalUI(closeCtx, sessionID)
cancel()
}
if speech, ok := ui.speech.(interface{ InterruptInternalUI() }); ok {
speech.InterruptInternalUI()
} else {
ui.speech.Interrupt()
}
if ui.braille != nil {
ui.braille.SetInternalUIFrame(nil)
}
@@ -263,7 +253,6 @@ func (ui *internalUI) run(ctx context.Context) {
if err != nil {
message = "Actions unavailable"
}
ui.speech.Interrupt()
ui.speech.Enqueue(speechRequest{Text: message, Role: "system", Cause: "internal-ui"})
if ui.braille != nil {
ui.braille.EnqueueMessage(message)
-2
View File
@@ -44,7 +44,6 @@ func (presenter *learnModePresenter) handle(event input.CommandEvent) {
if entering {
presenter.active = true
presenter.lastTime = 0
presenter.speech.Interrupt()
presenter.speech.Enqueue(speechRequest{Text: version.String(), Role: "system", Cause: "learn-mode"})
presenter.speech.Enqueue(speechRequest{Text: learnModeInstructions, Role: "system", Cause: "learn-mode"})
presenter.braille.EnqueueMessage("Learn mode. Press Escape, Cthulhu H, or Cthulhu F1 to exit.")
@@ -79,7 +78,6 @@ func (presenter *learnModePresenter) key(event input.CommandEvent) {
if !presenter.active {
return
}
presenter.speech.Interrupt()
name := spokenLearnKey(event)
presenter.present(name, "learn-key")
settings := presenter.live.Load()
+1 -1
View File
@@ -50,7 +50,7 @@ func TestLearnModePresentsKeyCommandsWithoutExecutingThem(t *testing.T) {
t.Fatalf("phonetic double press = %q", got)
}
presenter.handle(input.CommandEvent{Command: input.ToggleLearnMode, Chord: input.CommandChord{Symbol: 'h', CthulhuModifier: true}, KeyName: "h"})
if presenter.active || speech.texts[len(speech.texts)-1] != "Exiting learn mode." || speech.interrupts < 4 {
if presenter.active || speech.texts[len(speech.texts)-1] != "Exiting learn mode." || speech.interrupts != 0 {
t.Fatalf("exit speech=%q interrupts=%d", speech.texts, speech.interrupts)
}
}
-1
View File
@@ -100,6 +100,5 @@ func (handler *eventReviewHandler) presentCellFormula() {
handler.syncForeground()
handler.state.pending.clear()
text := presentation.LibreOfficeFormulaText(handler.state.current)
handler.speech.Interrupt()
handler.speech.Enqueue(speechRequest{Text: text, Role: "system", Cause: "cell-formula"})
}
-1
View File
@@ -58,7 +58,6 @@ func (handler *eventReviewHandler) configureCalcHeader(command input.Command) {
}
}
state.pending.clear()
handler.speech.Interrupt()
handler.speech.Enqueue(speechRequest{Text: text, Role: "system", Cause: "calc-headers"})
}
-1
View File
@@ -102,7 +102,6 @@ func (handler *accessibilityEventHandler) handleLibreOfficeSelection(event atspi
}
if text := strings.Join(messages, " "); text != "" {
state.pending.clear()
handler.speech.Interrupt()
handler.speech.Enqueue(speechRequest{Text: text, Role: "system", Cause: "calc-selection"})
}
}
-6
View File
@@ -144,18 +144,12 @@ func mouseReviewService(logger *slog.Logger, adapter pointReviewAdapter, speech
state.position = position
continue
}
objectChanged := point.Snapshot.ID() != state.last.Snapshot.ID() || point.Window != state.last.Window
dx, dy := position.X-state.position.X, position.Y-state.position.Y
interrupt := objectChanged || dx*dx+dy*dy > 25*25
spoken := state.presentation(point, settings)
message := spoken.Text
state.position = position
if message == "" {
continue
}
if interrupt {
speech.Interrupt()
}
speech.Enqueue(speechRequest{Text: message, Chunks: spoken.Chunks, Role: point.Snapshot.Role, Cause: "mouse-review"})
braille.EnqueueMessage(message)
logger.Debug("mouse review presentation", "bus", point.Snapshot.BusName,
@@ -68,11 +68,6 @@ func TestProcessEventsWhereAmIRepeatsCurrentFocus(t *testing.T) {
}
reviewRequests <- input.WhereAmI
select {
case <-speech.interrupt:
case <-time.After(time.Second):
t.Fatal("where am I did not interrupt stale speech")
}
select {
case text := <-speech.spoken:
if text != "Example, button, Runs the example" {
t.Fatalf("where am I speech = %q", text)
-2
View File
@@ -28,14 +28,12 @@ func TestPauseChunksInterruptRemainingAnnouncement(t *testing.T) {
logger: slog.New(slog.DiscardHandler), speech: worker}
presenter.state.presentationOptions = presentation.DefaultOptions()
presenter.speak(atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{Name: "Save, draft", Role: "button", Description: "Save document"}))
close(expectSpeechCall(t, speaker, "cancel").done)
first := expectSpeechCall(t, speaker, "Save, draft")
worker.InterruptForUser()
close(expectSpeechCall(t, speaker, "cancel").done)
worker.Enqueue(speechRequest{Text: "New focus"})
close(first.done)
close(expectSpeechCall(t, speaker, "cancel").done) // Settle the interrupted call.
close(expectSpeechCall(t, speaker, "cancel").done) // Begin the new announcement.
close(expectSpeechCall(t, speaker, "New focus").done)
}
+2
View File
@@ -49,6 +49,7 @@ type pendingPresentations struct {
terminalFocus pendingPresentation
terminalDeletion pendingPresentation
browserButtonName pendingBrowserButtonName
radioSound pendingRadioSound
}
func (pending *pendingPresentations) clear() {
@@ -56,4 +57,5 @@ func (pending *pendingPresentations) clear() {
pending.terminalFocus.clear()
pending.terminalDeletion.clear()
pending.browserButtonName.clear()
pending.radioSound.clear()
}
@@ -241,7 +241,6 @@ func TestEchoPreviewFocusSpeechWaitsForBothVoices(t *testing.T) {
}
t.Cleanup(func() { worker.Stop(context.Background()) })
worker.Enqueue(echo_preview_speech("Key echo person", "Selected voice"))
close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done)
label := expectSpeechCall(t, &speaker.controlledSpeaker, "default:Key echo person")
worker.Enqueue(speechRequest{Text: "Later control"})
select {
@@ -257,7 +256,6 @@ func TestEchoPreviewFocusSpeechWaitsForBothVoices(t *testing.T) {
default:
}
close(value.done)
close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done)
close(expectSpeechCall(t, &speaker.controlledSpeaker, "Later control").done)
}
@@ -269,7 +267,6 @@ func TestEchoPreviewFocusSpeechRemainsInterruptible(t *testing.T) {
}
t.Cleanup(func() { worker.Stop(context.Background()) })
worker.Enqueue(echo_preview_speech("Key echo person", "Selected voice"))
close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done)
label := expectSpeechCall(t, &speaker.controlledSpeaker, "default:Key echo person")
close(label.done)
value := expectSpeechCall(t, &speaker.controlledSpeaker, "echo:Selected voice")
@@ -36,16 +36,13 @@ func TestPreferencesConfirmationSurvivesAutomaticFocusUntilSpeechEnds(t *testing
}
t.Cleanup(func() { worker.Stop(context.Background()) })
worker.Enqueue(speechRequest{Text: "Settings applied", Confirmation: true})
close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done)
confirmation := expectSpeechCall(t, &speaker.controlledSpeaker, "Settings applied")
worker.Interrupt() // The window activated after OK must not cancel feedback.
worker.Enqueue(speechRequest{Text: "Window title"})
worker.Enqueue(speechRequest{Text: "Focused control"})
close(confirmation.done) // Represents the synth's END event, not SPEAK acknowledgement.
close(expectSpeechCall(t, &speaker.controlledSpeaker, "Window title").done)
close(expectSpeechCall(t, &speaker.controlledSpeaker, "Focused control").done)
worker.Enqueue(speechRequest{Text: "Later output"})
close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done)
close(expectSpeechCall(t, &speaker.controlledSpeaker, "Later output").done)
}
@@ -63,7 +60,6 @@ func TestPreferencesConfirmationAllowsUserInterruption(t *testing.T) {
gate := &focusGate{braille: display}
output := gatedSpeech{speech: worker, gate: gate}
worker.Enqueue(speechRequest{Text: "Settings applied", Confirmation: true})
close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done)
confirmation := expectSpeechCall(t, &speaker.controlledSpeaker, "Settings applied")
worker.Enqueue(speechRequest{Text: "Discarded focus"})
if !output.InterruptForInput(input.Event{Pressed: true, Name: "Tab", Timestamp: 101}) {
+85
View File
@@ -0,0 +1,85 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/presentation"
"git.stormux.org/storm/cthulhu/internal/sound"
)
const radioFocusSoundDelay = 250 * time.Millisecond
// GTK can focus an unchecked radio before selecting it. Retain one state cue
// in the event loop while speech and Braille update immediately. A checked
// event replaces it; focus/input changes, lock and shutdown retire it.
type pendingRadioSound struct {
pendingPresentation
inputSequence uint64
}
func (presenter *eventPresenter) coalesce_radio_sound(event atspi.Event, cues []sound.Cue) []sound.Cue {
pending := &presenter.state.pending.radioSound
if isFocusGainEvent(event) && event.Source.ID() != pending.event.Source.ID() {
pending.clear()
}
if event.Source.Role != "radio button" {
return cues
}
if event.Type == "object:state-changed:checked" || event.Source.Checked {
pending.clear()
return cues
}
if !isFocusGainEvent(event) {
return cues
}
for index, cue := range cues {
if cue.Name != "radiobutton_unchecked" {
continue
}
sequence := recentInput(presenter.inputSource).InputSequence
if pending.event.Source.ID() != event.Source.ID() || pending.inputSequence != sequence || pending.ready == nil {
pending.schedule(event, input.NoIntent, radioFocusSoundDelay)
pending.inputSequence = sequence
presenter.logger.Debug("deferred unchecked radio focus sound")
}
retained := append([]sound.Cue(nil), cues[:index]...)
return append(retained, cues[index+1:]...)
}
return cues
}
func (presenter *eventPresenter) flush_radio_sound(gate *focusGate) {
pending := &presenter.state.pending.radioSound
sequence := pending.inputSequence
event, _ := pending.take()
state := presenter.state
if event.Type == "" || gate.Locked() || gate.Suspended() ||
state.focusedID != event.Source.ID() || state.current.ID() != event.Source.ID() ||
state.current.Defunct || state.owner.window != eventWindow(event) ||
recentInput(presenter.inputSource).InputSequence != sequence {
return
}
options := state.presentationOptions
if presenter.live != nil {
options = presentationSettingsFromConfig(presenter.live.Load(), true)
options.SoundCueAvailable = state.presentationOptions.SoundCueAvailable
}
event.Type = "object:state-changed:checked"
event.Source = state.current
// Admission already verified the cue. Let the output queue provide its
// normal speech fallback if the asset disappears before delayed playback.
options.SoundCueAvailable = nil
cues := presentation.EventSoundCues(event, options)
if len(cues) == 0 {
return
}
fallback := presentationFallbackSpeech(event, options)
presenter.speech.Enqueue(speechRequest{Sound: cues, FallbackText: fallback.Text, FallbackChunks: fallback.Chunks,
Role: event.Source.Role, Cause: "radio-focus-state"})
presenter.logger.Debug("queued settled radio focus sound", "cue", cues[0].Name)
}
+146
View File
@@ -0,0 +1,146 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"log/slog"
"strings"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/presentation"
"git.stormux.org/storm/cthulhu/internal/sound"
)
func radio_sound_fixture(t *testing.T) (*eventPresenter, *fakeSpeechQueue, func(atspi.Event)) {
t.Helper()
logger := slog.New(slog.DiscardHandler)
state := &eventLoopState{}
state.presentationOptions = presentation.DefaultOptions()
state.presentationOptions.EnableSound = true
state.presentationOptions.PlaySoundForState = true
state.presentationOptions.RoleSoundPresentation = "sound_only"
queue := &fakeSpeechQueue{spoken: make(chan string, 8), requests: make(chan speechRequest, 8)}
presenter := &eventPresenter{state: state, logger: logger, speech: queue}
handler := &accessibilityEventHandler{state: state, logger: logger}
t.Cleanup(state.pending.clear)
return presenter, queue, func(event atspi.Event) {
if !handler.acceptFocusedEvent(event, input.Activity{}) {
presenter.speak(event)
}
}
}
func TestRadioFocusSoundCoalescesSelectionWithoutDelayingLabel(t *testing.T) {
presenter, queue, present := radio_sound_fixture(t)
option := atspi.Snapshot{BusName: ":1.7", Path: "/radio", Role: "radio button", Name: "Laptop", Mnemonic: "<Alt>l"}
present(atspi.NewEvent(atspi.FocusEvent, option))
request := <-queue.requests
if !strings.Contains(request.Text, "Laptop") || len(request.Sound) != 0 || presenter.state.pending.radioSound.ready == nil {
t.Fatalf("focus label/sound = %+v", request)
}
// The old member loses selection before GTK checks the focused member.
old := option
old.Path = "/previous"
present(atspi.NewEvent("object:state-changed:checked", old))
if len(queue.requests) != 0 {
t.Fatal("unfocused member emitted a state sound")
}
option.Checked = true
checked := atspi.NewEvent("object:state-changed:checked", option)
checked.Source.Mnemonic = "" // State-only native captures skip mnemonic lookup.
checked.Detail1 = 1
present(checked)
request = <-queue.requests
if len(request.Sound) != 1 || request.Sound[0].Name != "radiobutton_checked" || presenter.state.pending.radioSound.ready != nil {
t.Fatalf("selected sound = %+v", request)
}
if presenter.state.current.Mnemonic != option.Mnemonic {
t.Fatal("state update discarded focus metadata")
}
present(atspi.NewEvent(atspi.FocusEvent, option))
if len(queue.requests) != 0 {
t.Fatal("duplicate focus replayed selected sound")
}
}
func TestRadioFocusSoundRetiresStalePendingState(t *testing.T) {
for _, change := range []string{"none", "focus", "input", "window", "lock", "handoff", "defunct", "disabled"} {
t.Run(change, func(t *testing.T) {
presenter, queue, present := radio_sound_fixture(t)
option := atspi.Snapshot{BusName: ":1.7", Path: "/radio", Role: "radio button", Name: "Option"}
present(atspi.NewEvent(atspi.FocusEvent, option))
<-queue.requests
gate := &focusGate{}
switch change {
case "focus":
present(atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{BusName: ":1.7", Path: "/next", Role: "button", Name: "Next"}))
<-queue.requests
case "input":
presenter.inputSource = fixedInputActivity{activity: input.Activity{InputSequence: 1}}
case "window":
presenter.state.owner.window = atspi.NewObjectID(":1.7", "/other")
case "lock":
gate.secure.Store(true)
case "handoff":
gate.suspended.Store(true)
case "defunct":
presenter.state.current.Defunct = true
case "disabled":
presenter.state.presentationOptions.EnableSound = false
}
presenter.flush_radio_sound(gate)
if change != "none" {
if len(queue.requests) != 0 {
t.Fatal("retired focus emitted a delayed sound")
}
return
}
request := <-queue.requests
if request.Text != "" || request.FallbackText != "not selected" || len(request.Sound) != 1 || request.Sound[0].Name != "radiobutton_unchecked" {
t.Fatalf("settled unchecked sound/fallback = %+v", request)
}
})
}
}
func TestProcessEventsFlushesUncheckedRadioSound(t *testing.T) {
presenter, queue, _ := radio_sound_fixture(t)
adapter := &fakeAdapter{events: make(chan atspi.Event, 1)}
ctx, cancel := context.WithCancel(t.Context())
done := make(chan error, 1)
go func() {
done <- processEvents(ctx, presenter.logger, queue, adapter, 0, presenter.state.presentationOptions)
}()
t.Cleanup(func() { cancel(); <-done })
adapter.events <- atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{BusName: ":1.7", Path: "/radio", Role: "radio button", Name: "Option"})
for index := range 2 {
select {
case request := <-queue.requests:
if index == 0 && (request.Text == "" || len(request.Sound) != 0) {
t.Fatalf("label was deferred or played unchecked sound: %+v", request)
}
if index == 1 && (len(request.Sound) != 1 || request.Sound[0] != (sound.Cue{Name: "radiobutton_unchecked"})) {
t.Fatalf("unchecked focus sound = %+v", request)
}
case <-time.After(time.Second):
t.Fatal("radio focus presentation did not finish")
}
}
}
func TestDeferredRadioSoundPreservesUnavailableCueFallback(t *testing.T) {
presenter, queue, present := radio_sound_fixture(t)
present(atspi.NewEvent(atspi.FocusEvent, atspi.Snapshot{BusName: ":1.7", Path: "/radio", Role: "radio button", Name: "Option"}))
<-queue.requests
presenter.state.presentationOptions.SoundCueAvailable = func(string) bool { return false }
presenter.speech = outputQueue{speech: queue, sounds: rejectingSound{}}
presenter.flush_radio_sound(nil)
if request := <-queue.requests; request.Text != "not selected" {
t.Fatalf("missing state-sound fallback: %+v", request)
}
}
+21 -9
View File
@@ -118,7 +118,7 @@ func (speaker *continuousSpeaker) SpeakAndWait(ctx context.Context, text string,
}
}
func TestReadAllPacesSpeechAndInterruptsOnNewInputOrFocus(t *testing.T) {
func TestReadAllQueuesPassiveFocusAndInterruptsOnNewInput(t *testing.T) {
for _, cause := range []string{"key", "focus"} {
t.Run(cause, func(t *testing.T) {
speaker := &continuousSpeaker{started: make(chan string, 4), finish: make(chan struct{})}
@@ -167,15 +167,27 @@ func TestReadAllPacesSpeechAndInterruptsOnNewInputOrFocus(t *testing.T) {
}
}
worker.Enqueue(speechRequest{Text: "New focus", Cause: "focus:"})
if cause == "focus" {
if ctx.Err() != nil {
t.Fatal("passive focus canceled Say All")
}
speaker.finish <- struct{}{}
waitText("Third")
if <-progress != 3 {
t.Fatal("passive focus changed the reading position")
}
speaker.finish <- struct{}{}
} else {
if ctx.Err() == nil {
t.Fatal("new input did not cancel Say All")
}
select {
case <-progress:
t.Fatal("reading position advanced after interruption")
default:
}
}
waitText("New focus")
if ctx.Err() == nil {
t.Fatal("read all was not canceled")
}
select {
case <-progress:
t.Fatal("reading position advanced after interruption")
default:
}
})
}
}
+4 -4
View File
@@ -133,13 +133,13 @@ func (presenter remoteSpeech) presentRequest(request speechRequest) bool {
return accepted
}
func (presenter remoteSpeech) SpeakText(text string, interrupt bool) bool {
// The protocol's interrupt flag does not establish user input. External
// announcements obey the same queue policy as accessibility events; callers
// requesting an explicit stop can use CancelSpeech.
func (presenter remoteSpeech) SpeakText(text string, _ bool) bool {
if presenter.speech == nil || text == "" {
return false
}
if interrupt {
presenter.speech.Interrupt()
}
presenter.speech.Enqueue(speechRequest{
Text: text,
Role: "system",
+3 -3
View File
@@ -106,14 +106,14 @@ func TestRemoteSpeechQueuesWithoutInterruptingByDefault(t *testing.T) {
}
}
func TestRemoteSpeechInterruptsBeforeRequestedSpeech(t *testing.T) {
func TestRemoteSpeechQueuesRegardlessOfInterruptFlag(t *testing.T) {
queue := &remoteSpeechQueue{}
presenter := remoteSpeech{speech: queue}
if !presenter.SpeakText("Now", true) {
t.Fatal("SpeakText rejected available speech")
}
if queue.interrupts != 1 {
t.Fatalf("interrupts = %d, want 1", queue.interrupts)
if queue.interrupts != 0 {
t.Fatalf("interrupts = %d, want 0", queue.interrupts)
}
if len(queue.requests) != 1 || queue.requests[0].Text != "Now" || !queue.requests[0].Append {
t.Fatalf("request = %+v, want queued Now speech", queue.requests)
+3 -9
View File
@@ -34,14 +34,8 @@ func (worker *speechWorker) deliver(ctx context.Context, request speechRequest)
if handled, err := worker.deliver_mode(ctx, request); handled {
return err
}
if !request.Append {
if err := worker.speaker.Cancel(ctx); err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
worker.logger.Warn("cancel previous speech", "role", request.Role, "error", err)
}
}
// Incoming output always queues. Only input/command handling may cancel
// earlier speech; a delayed state or focus event is not another user action.
if len(request.Spelling) != 0 {
// One parent request owns its context; letter recursion must not
// re-announce it or alter the original character's voice choice.
@@ -258,7 +252,7 @@ func (worker *speechWorker) logDelivery(ctx context.Context, request speechReque
}
worker.logger.DebugContext(ctx, "delivering accessible presentation",
"role", request.Role, "cause", request.Cause, "utterance", text,
"append", request.Append, "interrupt_previous", !request.Append && readAllIndex <= 0,
"append", true, "interrupt_previous", false,
"character", request.Character, "custom_echo", request.CustomEcho,
"read_all_index", readAllIndex)
}
+6 -10
View File
@@ -25,15 +25,11 @@ import (
"git.stormux.org/storm/cthulhu/internal/input"
)
// Interrupt drops retained requests and wakes the owned cancellation worker.
// Interrupt is for explicit user actions, never automatic presentation.
// It drops retained requests and wakes the owned cancellation worker.
// It must never perform backend I/O in the caller's event loop.
func (worker *speechWorker) Interrupt() {
worker.mutex.Lock()
defer worker.mutex.Unlock()
if worker.confirmationID != 0 || worker.has_uninterruptible_locked() {
return
}
worker.interruptLocked()
worker.InterruptForUser()
}
// The preferences worker changes speech availability after the backend has
@@ -56,7 +52,6 @@ func (worker *speechWorker) interruptLocked() {
if worker.uninterruptibleCancel != nil {
worker.uninterruptibleCancel()
}
worker.presentationDelivery = nil
worker.confirmationID = 0
if worker.beepCancel != nil {
worker.beepCancel()
@@ -127,7 +122,7 @@ func (worker *speechWorker) InterruptForInput(event input.Event) bool {
}
// ProtectCommand ignores delayed copies of input which already dispatched a
// command. A newly dispatched command also interrupts confirmation speech,
// command. A newly dispatched command interrupts existing speech,
// even when its XRecord input has not arrived yet.
func (worker *speechWorker) ProtectCommand(timestamp uint32) {
worker.mutex.Lock()
@@ -138,7 +133,8 @@ func (worker *speechWorker) ProtectCommand(timestamp uint32) {
if timestamp != 0 {
worker.commandTimestamp = timestamp
}
if worker.confirmationID != 0 && !worker.has_uninterruptible_locked() {
if !worker.has_uninterruptible_locked() {
worker.startupUntil = time.Time{}
worker.interruptLocked()
}
}
-3
View File
@@ -34,7 +34,6 @@ func TestSpeechWorkerKeyEchoCompletesBeforeCaretSpeech(t *testing.T) {
}
t.Cleanup(func() { worker.Stop(context.Background()) })
worker.Enqueue(speechRequest{Text: "right", Role: "key", Cause: "key-echo", CustomEcho: custom})
close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done)
voice := ""
if custom {
voice = "echo"
@@ -47,7 +46,6 @@ func TestSpeechWorkerKeyEchoCompletesBeforeCaretSpeech(t *testing.T) {
default:
}
close(echo.done)
close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done)
close(expectSpeechCall(t, &speaker.controlledSpeaker, "h").done)
})
}
@@ -61,7 +59,6 @@ func TestSpeechWorkerKeyEchoWaitStillAllowsInputInterruption(t *testing.T) {
}
t.Cleanup(func() { worker.Stop(context.Background()) })
worker.Enqueue(speechRequest{Text: "right", Role: "key", Cause: "key-echo", CustomEcho: true})
close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done)
echo := expectSpeechCall(t, &speaker.controlledSpeaker, "completed:echo:right")
worker.Enqueue(speechRequest{Text: "stale character", Cause: "object:text-caret-moved"})
if !worker.InterruptForInput(input.Event{Pressed: true, Name: "Left", Timestamp: 100}) {
-37
View File
@@ -1,37 +0,0 @@
// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import "context"
// revokePresentation drops only the retiring owner's pending speech. If that
// owner has reached delivery, cancel it through the existing backend barrier
// while preserving newer core requests behind the barrier. The next delivered
// request owns backend speech, independently of what is still in the queue.
func (worker *speechWorker) revokePresentation(owner context.Context) {
worker.mutex.Lock()
defer worker.mutex.Unlock()
retained := worker.pending[:0]
for _, request := range worker.pending {
if request.presentationGeneration != nil && request.context == owner {
completeSpeechRequest(request, context.Canceled)
} else {
retained = append(retained, request)
}
}
clear(worker.pending[len(retained):])
worker.pending = retained
if worker.presentationDelivery != owner || !worker.running {
return
}
worker.presentationDelivery = nil
if worker.beepCancel != nil {
worker.beepCancel()
}
worker.generation++
worker.interrupting = true
select {
case worker.interrupt <- struct{}{}:
default:
}
}
+2 -3
View File
@@ -24,8 +24,8 @@ func (worker *speechWorker) has_uninterruptible_locked() bool {
return false
}
// Preserve unrelated queued speech, just as ordinary presentation revocation
// does. The existing cancellation barrier prevents CANCEL overtaking it.
// Preserve unrelated queued speech when explicitly stopping protected output.
// The existing cancellation barrier prevents CANCEL overtaking it.
func (worker *speechWorker) stop_uninterruptible() {
worker.mutex.Lock()
defer worker.mutex.Unlock()
@@ -43,7 +43,6 @@ func (worker *speechWorker) stop_uninterruptible() {
return
}
worker.uninterruptibleCancel()
worker.presentationDelivery = nil
if worker.beepCancel != nil {
worker.beepCancel()
}
+7 -9
View File
@@ -125,7 +125,6 @@ type speechWorker struct {
confirmationID uint64 // Owned under mutex; reset on completion or user interruption.
confirmationSerial uint64
presentationDelivery context.Context // Replaced when the next request reaches delivery.
presentationRevision uint64
uninterruptibleContext context.Context
uninterruptibleCancel context.CancelFunc
@@ -183,10 +182,6 @@ func (worker *speechWorker) Enqueue(request speechRequest) {
completeSpeechRequest(request, context.Canceled)
return
}
if worker.readAllCancel != nil && request.Cause != "read-all" && !request.Append {
worker.readAllCancel()
worker.readAllCancel = nil
}
if request.context != nil && request.context.Err() != nil {
worker.mutex.Unlock()
completeSpeechRequest(request, request.context.Err())
@@ -197,6 +192,13 @@ func (worker *speechWorker) Enqueue(request speechRequest) {
completeSpeechRequest(request, errors.New("speech worker is not running"))
return
}
request.Append = true
// A Tentacle ticket gates admission, not the lifetime of accepted speech.
// Passive focus changes can retire the ticket while this output finishes.
// Fresh input still cancels through the worker generation/barrier.
if request.presentationGeneration != nil && !request.Uninterruptible {
request.context = worker.workerContext
}
if request.Confirmation {
worker.confirmationSerial++
request.confirmationID = worker.confirmationSerial
@@ -404,10 +406,6 @@ func (worker *speechWorker) next() (speechRequest, bool) {
if request.Uninterruptible {
worker.uninterruptibleContext, worker.uninterruptibleCancel = request.context, request.cancel
}
worker.presentationDelivery = nil
if request.presentationGeneration != nil {
worker.presentationDelivery = request.context
}
request.deliveryGeneration = worker.generation
copy(worker.pending, worker.pending[1:])
worker.pending[len(worker.pending)-1] = speechRequest{}
+1 -3
View File
@@ -84,7 +84,6 @@ func TestStartupSpeechSurvivesAutomaticFocusInterruption(t *testing.T) {
case <-time.After(time.Second):
t.Fatal("startup speech did not begin")
}
worker.Interrupt() // Automatic window focus must not cancel the welcome.
worker.Enqueue(speechRequest{Text: "Focused control", Cause: "focus:"})
worker.mutex.Lock()
retained := len(worker.pending) == 1 && worker.pending[0].Text == "Focused control" && worker.pending[0].Append
@@ -259,7 +258,6 @@ func TestInterruptDropsPendingAndOrdersNextSpeech(t *testing.T) {
}
t.Cleanup(func() { worker.Stop(context.Background()) })
worker.Enqueue(speechRequest{Text: "Active"})
close(expectSpeechCall(t, speaker, "cancel").done)
active := expectSpeechCall(t, speaker, "Active")
worker.Enqueue(speechRequest{Text: "Discarded"})
worker.Interrupt()
@@ -355,7 +353,7 @@ func TestTerminalOutputAndPromptDoNotCancelEachOther(t *testing.T) {
t.Fatal(err)
}
}
if got := fmt.Sprint(speaker.calls); got != "[Command output Prompt cancel Save]" {
if got := fmt.Sprint(speaker.calls); got != "[Command output Prompt Save]" {
t.Fatalf("speech order = %s", got)
}
}
+2 -5
View File
@@ -65,8 +65,8 @@ func (owner *tentacleOutput) Retire() {
owner.retireLocked()
}
// Stop additionally revokes accepted protected speech during reader shutdown
// or terminal handoff. Ordinary focus/command retirement only retires replies.
// Stop additionally revokes accepted protected speech during reader shutdown,
// screen lock or explicit disable. Ordinary retirement only retires replies.
func (owner *tentacleOutput) Stop() {
if owner == nil {
return
@@ -95,9 +95,6 @@ func (owner *tentacleOutput) retireLocked() {
return
}
owner.cancel()
if owner.speech != nil {
owner.speech.revokePresentation(owner.current)
}
if owner.braille != nil {
owner.braille.retirePresentation(owner.current)
}
+11 -29
View File
@@ -4,7 +4,6 @@ import (
"context"
"log/slog"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/braille"
"git.stormux.org/storm/cthulhu/internal/config"
@@ -23,6 +22,7 @@ func tentacleOutputFixture(t *testing.T) *tentacleOutput {
display := &brailleWorker{logger: slog.New(slog.DiscardHandler), cancel: func() {}, connected: true, wake: make(chan struct{}, 1),
frame: braille.Frame{Text: "Core focus", Cursor: -1}, baseFrame: braille.Frame{Text: "Core focus", Cursor: -1}}
speech := newSpeechWorker(slog.New(slog.DiscardHandler), nil)
speech.workerContext = t.Context()
speech.running = true // Inspect the bounded queue without a delivery goroutine.
owner := &tentacleOutput{speech: speech, braille: display, gate: newFocusGate(nil), live: live}
owner.gate.tentacleOutput = owner
@@ -62,7 +62,7 @@ func TestTentacleIndependentOutputOwnership(t *testing.T) {
for _, request := range owner.speech.pending {
if request.Text == "Tentacle speech" {
gotSpeech = true
if request.context != ticket.ctx || request.ForceSpeech || request.BypassFocusGate || request.Role != "system" {
if request.context != owner.speech.workerContext || request.ForceSpeech || request.BypassFocusGate || request.Role != "system" {
t.Fatal("unsafe speech request")
}
}
@@ -119,17 +119,16 @@ func TestTentacleAcceptedOutputRetirement(t *testing.T) {
owner.speech.Enqueue(speechRequest{Text: "New core output"})
owner.braille.Flash("New core flash", owner.live.Load().Braille)
}
cancel() // Reply was already consumed; its session still owns output lifetime.
cancel() // Retiring the reply cannot cut off speech already accepted.
if !newer && owner.braille.current().Text != "Core focus" {
t.Fatal("revoked flash survived consumption")
}
owner.RetireRevoked()
if newer {
if owner.braille.current().Text != "New core flash" || len(owner.speech.pending) != 1 || owner.speech.pending[0].Text != "New core output" {
t.Fatal("retirement displaced core output")
}
} else if len(owner.speech.pending) != 0 {
t.Fatal("queued retired speech remains")
if len(owner.speech.pending) == 0 || owner.speech.pending[0].Text != "Queued speech" || owner.speech.pending[0].context.Err() != nil {
t.Fatal("retirement discarded accepted speech")
}
if newer && (owner.braille.current().Text != "New core flash" || len(owner.speech.pending) != 2 || owner.speech.pending[1].Text != "New core output") {
t.Fatal("retirement displaced core output")
}
}
}
@@ -190,26 +189,9 @@ func TestTentacleRevocationPreservesQueuedCoreSpeech(t *testing.T) {
t.Cleanup(func() { owner.speech.Stop(context.Background()) })
ticket := owner.Capture(context.Background())
owner.Present(ticket, tentaclewire.Presentation{Speech: "Tentacle output"}, "tentacle/test/probe")
close(expectSpeechCall(t, speaker, "cancel").done)
_ = expectSpeechCall(t, speaker, "Tentacle output") // Remains in flight until context cancellation.
active := expectSpeechCall(t, speaker, "Tentacle output")
owner.speech.Enqueue(speechRequest{Text: "Later core speech", Append: true})
owner.Retire()
cancellations := 0
deadline := time.After(time.Second)
for {
select {
case call := <-speaker.calls:
close(call.done)
if call.text == "cancel" {
cancellations++
continue
}
if call.text != "Later core speech" || cancellations == 0 {
t.Fatalf("unexpected delivery after revocation: %q", call.text)
}
return
case <-deadline:
t.Fatal("core speech failed to resume after revocation")
}
}
close(active.done)
close(expectSpeechCall(t, speaker, "Later core speech").done)
}
@@ -39,7 +39,6 @@ func TestTentacleUninterruptibleSurvivesInputAndFocusUntilEnd(t *testing.T) {
if owner.speech.InterruptForInput(input.Event{Timestamp: 101, Pressed: true}) {
t.Fatal("queued greeting was interrupted")
}
close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done)
greeting := expectSpeechCall(t, &speaker.controlledSpeaker, "Hello world!")
owner.speech.Interrupt()
owner.speech.InterruptForUser()
@@ -70,7 +69,7 @@ func TestTentacleUninterruptibleSurvivesInputAndFocusUntilEnd(t *testing.T) {
}
func TestTentacleUninterruptibleStillRevokes(t *testing.T) {
for _, action := range []string{"session", "disable", "lock", "handoff", "speech off", "mode", "stop", "lifetime"} {
for _, action := range []string{"session", "disable", "lock", "speech off", "mode", "stop", "lifetime"} {
t.Run(action, func(t *testing.T) {
owner, speaker := protected_output_fixture(t)
parent, cancel := context.WithCancel(t.Context())
@@ -84,7 +83,6 @@ func TestTentacleUninterruptibleStillRevokes(t *testing.T) {
if !bounded || time.Until(deadline) > uninterruptibleSpeechTimeout {
t.Fatal("unbounded protected speech")
}
close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done)
_ = expectSpeechCall(t, &speaker.controlledSpeaker, "Protected greeting")
switch action {
case "session":
@@ -93,8 +91,6 @@ func TestTentacleUninterruptibleStillRevokes(t *testing.T) {
owner.Stop()
case "lock":
owner.gate.SetLocked(true, nil, nil, nil, owner.speech, owner.braille)
case "handoff":
owner.gate.SetSuspended(true)
case "speech off":
owner.speech.SetEnabled(false)
case "mode":
@@ -181,7 +177,6 @@ func TestTentacleUninterruptibleCannotResurrectStaleReply(t *testing.T) {
func TestTentacleProtectedStopPreservesCoreQueue(t *testing.T) {
owner, speaker := protected_output_fixture(t)
owner.Present(owner.Capture(t.Context()), tentaclewire.Presentation{Speech: "Greeting", Uninterruptible: true}, "tentacle/test/hello")
close(expectSpeechCall(t, &speaker.controlledSpeaker, "cancel").done)
_ = expectSpeechCall(t, &speaker.controlledSpeaker, "Greeting")
owner.speech.Enqueue(speechRequest{Text: "Core output"})
owner.Stop()
+2 -2
View File
@@ -258,8 +258,8 @@ func TestReaderTentaclePresentation(t *testing.T) {
t.Fatal("reader did not present the command result")
}
runtime.Disable()
if pending[0].context.Err() == nil || owner.braille.current().Text != "Core focus" {
t.Fatal("disable left accepted output live")
if pending[0].context.Err() != nil || owner.braille.current().Text != "Core focus" {
t.Fatal("disable did not preserve accepted speech and retire the flash")
}
} else {
if len(pending) != 0 || owner.braille.current().Text != "Core focus" {
-1
View File
@@ -7,7 +7,6 @@ package app
import "git.stormux.org/storm/cthulhu/internal/version"
func presentVersion(speech speechEnqueuer, atspiVersion, sessionType, sessionDesktop string) {
speech.Interrupt()
speech.Enqueue(speechRequest{
Text: version.Details(atspiVersion, sessionType, sessionDesktop),
Role: "system",
+1 -1
View File
@@ -19,8 +19,8 @@ func TestPresentVersion(t *testing.T) {
presentVersion(speech, "2.58.0", "x11", "GNOME")
select {
case <-speech.interrupt:
t.Fatal("version result interrupted speech again")
default:
t.Fatal("version presentation did not interrupt speech")
}
request := <-speech.requests
want := version.Details("2.58.0", "x11", "GNOME")
-1
View File
@@ -85,7 +85,6 @@ func (controller *webScriptController) present(messages []string) {
}
controller.braille.Flash(message, options)
}
controller.speech.Interrupt()
controller.speech.Enqueue(speechRequest{
Text: message, Role: "notification", Cause: "steam-notification", BypassFocusGate: true,
})
+5 -8
View File
@@ -28,10 +28,10 @@ func TestWindowTitleQueuesFollowingPresentation(t *testing.T) {
activation := ownerEvent(atspi.WindowActivatedEvent, ":1.70", "/window", "/window")
activation.Source.Name, activation.Source.Role = title, "dialog"
activation.WindowTitle = title
presenter.speak(activation)
close(expectSpeechCall(t, speaker, "cancel").done)
titleCall := expectSpeechCall(t, speaker, title)
worker.ProtectCommand(100)
close(expectSpeechCall(t, speaker, "cancel").done)
presenter.speak(activation)
titleCall := expectSpeechCall(t, speaker, title)
if worker.InterruptForInput(input.Event{Pressed: true, Timestamp: 100}) {
t.Fatal("delayed initiating shortcut interrupted the title")
}
@@ -49,14 +49,13 @@ func TestWindowTitleQueuesFollowingPresentation(t *testing.T) {
focus.Source.Path, focus.Source.Name = "/speech", "Speech"
presenter.speak(focus)
close(expectSpeechCall(t, speaker, "cancel").done)
close(expectSpeechCall(t, speaker, "Speech").done)
close(expectSpeechCall(t, speaker, "page tab").done)
})
}
}
func TestWindowTitlePairingIsScoped(t *testing.T) {
func TestWindowTitlesAndOtherWindowsQueueUntilUserInterruption(t *testing.T) {
for _, test := range []struct {
name string
window atspi.ObjectID
@@ -80,7 +79,6 @@ func TestWindowTitlePairingIsScoped(t *testing.T) {
}
t.Cleanup(func() { worker.Stop(context.Background()) })
worker.Enqueue(speechRequest{Text: "Window title", Window: atspi.NewObjectID(":1.70", "/window"), IntroducesWindow: !test.untitled})
close(expectSpeechCall(t, speaker, "cancel").done)
titleCall := expectSpeechCall(t, speaker, "Window title")
if test.interrupt != nil {
test.interrupt(worker)
@@ -88,10 +86,9 @@ func TestWindowTitlePairingIsScoped(t *testing.T) {
}
worker.Enqueue(speechRequest{Text: "Next control", Window: test.window})
close(titleCall.done)
close(expectSpeechCall(t, speaker, "cancel").done)
if test.interrupt != nil {
// The cancellation barrier settles the in-flight title
// before the new focus request performs its own CANCEL.
// before the new focus request can speak.
close(expectSpeechCall(t, speaker, "cancel").done)
}
close(expectSpeechCall(t, speaker, "Next control").done)
+1 -1
View File
@@ -26,7 +26,7 @@ func TestPresentWindowTitleRepeatsDespiteAutomaticPolicy(t *testing.T) {
for range 2 {
titles.present()
}
if len(queue.requests) != 2 || queue.interruptions != 2 || titles.pending != nil {
if len(queue.requests) != 2 || queue.interruptions != 0 || titles.pending != nil {
t.Fatalf("explicit titles = %+v; interruptions %d; pending %+v", queue.requests, queue.interruptions, titles.pending)
}
for _, request := range queue.requests {
-1
View File
@@ -242,7 +242,6 @@ func (titles *windowTitleController) present() {
} else {
titles.lastTitle = text
}
titles.speech.Interrupt()
titles.speech.Enqueue(speechRequest{Text: text, Role: "system", Cause: "present-window-title",
Window: window, ExcludeFromHistory: titles.state.historyWindow.Valid() && window == titles.state.historyWindow})
}
+4 -4
View File
@@ -97,8 +97,8 @@ func TestXTermFocusSuspendsAndResumesPresentationAndCommandGrabs(t *testing.T) {
t.Helper()
select {
case <-speech.interrupt:
case <-time.After(time.Second):
t.Fatal("XTerm focus did not interrupt Cthulhu speech")
t.Fatal("passive XTerm focus interrupted Cthulhu speech")
default:
}
}
waitForGrabs := func(want []bool) {
@@ -199,8 +199,8 @@ func TestXTermFocusDiscardsPendingSpeech(t *testing.T) {
select {
case <-speech.interrupt:
case <-time.After(time.Second):
t.Fatal("XTerm focus did not interrupt pending speech")
t.Fatal("passive XTerm focus interrupted pending speech")
default:
}
time.Sleep(30 * time.Millisecond)
select {
+4
View File
@@ -264,6 +264,10 @@ func (service *Service) playQueued(ctx context.Context, cue queuedCue) error {
}
service.mutex.Unlock()
}
if err := playContext.Err(); err != nil {
return err
}
service.logger.Debug("playing object sound", "cue", cue.Name)
return player.Play(playContext, data, volume, normalizedPan(cue.Pan))
}