// 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) }