// Cthulhu screen reader. // Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "log/slog" "strconv" "strings" "time" "unicode/utf8" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/presentation" "git.stormux.org/storm/cthulhu/internal/sound" ) // eventPresenter owns presentation policy but mutates only its event-loop state. type eventPresenter struct { state *eventLoopState logger *slog.Logger speech speechEnqueuer foreground foregroundMonitor inputSource any live *liveSettings browser *browserModeController } func (presenter *eventPresenter) speak(event atspi.Event, capturedIntent ...input.Intent) { state := presenter.state logger := presenter.logger speech := presenter.speech foreground := presenter.foreground inputSource := presenter.inputSource live := presenter.live if live != nil && state.echoPreview.owns(event, live.preferencesPID.Load()) { state.comboHistory.observe(event) // Focus labels and values are serialized by the private preview path. // GTK selection descendants belong to that same presentation. return } browser := presenter.browser ownedAutocompletePopup := state.ownedAutocompletePopup if foreground != nil && !eventBypassesXTermHandoff(event) && (event.Foreground.Epoch != foreground.Foreground().Epoch || event.ForegroundMismatch && !ownedAutocompletePopup(event)) { return } var settings config.Settings if live != nil { settings = live.Load() available := state.presentationOptions.SoundCueAvailable state.presentationOptions = presentationSettingsFromConfig(settings, true) state.presentationOptions.SoundCueAvailable = available } echoKind := "" comboValue, comboUpdate, comboDuplicate := state.comboHistory.observe(event) if comboDuplicate { return } sequence := recentInput(inputSource).Sequence intent := recentInput(inputSource).Intent if len(capturedIntent) != 0 { intent = capturedIntent[0] } table := state.prepareGUITable(event, sequence) if table.skip { return } spoken := presentation.EventSpeechWithOptions(state.calcHeaderSpeechEvent(event), table.options) spoken, appendTableFocus := state.decorateGUITable(event, spoken, table) text := spoken.Text prompt := newPromptPresentation(state, event) if prompt.activation { spoken = presentation.SpeechWithOptions([]string{strings.TrimSpace(event.WindowTitle), event.WindowMessage}, table.options) text = spoken.Text } if comboUpdate { text = comboValue } if event.Type == atspi.CaretEvent { intent := recentInput(inputSource).Intent if len(capturedIntent) != 0 { intent = capturedIntent[0] } switch intent { case input.LineIntent: text = event.Source.Text if text == "" && event.Source.CaretCharacter != "" && strings.TrimSpace(event.Source.CaretCharacter) == "" { text = "blank" } case input.WordIntent: text = event.Source.CaretWord } } if event.Type == atspi.TextInsertedEvent { if event.Source.LiveRegion { text = strings.TrimSpace(event.Payload) if event.Source.LiveAtomic && event.Source.Text != "" { text = strings.TrimSpace(event.Source.Text) } } else { previousText := "" if event.Source.Path == state.typingPath { previousText = state.typingText } text, echoKind = presentation.TypingEcho(event, previousText, state.presentationOptions) } } if event.Type == atspi.TextDeletedEvent && utf8.RuneCountInString(event.Payload) == 1 && text == event.Payload { // Deleting one letter needs the same uppercase voice as character // review. Keep whitespace labels and multi-character deletions as text. echoKind = "character" } if event.Type == atspi.ChildrenAddedEvent && event.Source.LiveRegion { text = strings.TrimSpace(event.Source.Text) } if event.Source.LiveRegion { text = strings.TrimSpace(strings.ReplaceAll(text, "\ufffc", "")) } if speaksTextChanges(event.Source) && event.Source.Path != "" && !event.Source.LiveRegion { state.typingPath = event.Source.Path state.typingText = event.Source.TextBeforeCaret if state.typingText == "" && event.Payload != "\n" && event.Payload != "\t" { state.typingText = event.Source.Text } } if event.Type == atspi.CaretEvent && (intent == input.WordIntent || intent == input.LineIntent) && event.Source.Misspelled && settings.Reading.SpeakMisspelledIndicator && !settings.Reading.OnlySpeakDisplayedText { spoken = presentation.SpeechWithOptions([]string{strings.TrimSuffix(text, ", misspelled"), "misspelled"}, table.options) text = spoken.Text } cues := []sound.Cue(nil) fallbackSpeech := presentationFallbackSpeech(event, state.presentationOptions) fallbackText := fallbackSpeech.Text if prompt.activation { fallbackText = text fallbackSpeech = spoken.WithText(text) } if comboUpdate { fallbackText = text fallbackSpeech = spoken.WithText(text) } if event.Type == atspi.CaretEvent && browser != nil && browser.ready && !event.Source.Editable { intent := recentInput(inputSource).Intent if len(capturedIntent) != 0 { intent = capturedIntent[0] } if intent == input.LineIntent && event.Source.Text != "" { line := atspi.DocumentLine{Contents: []atspi.DocumentContent{{Snapshot: event.Source, Text: event.Source.Text}}} spoken = presentation.DocumentLineSpeechWithOptions(line, state.presentationOptions) text = spoken.Text cues = presentation.DocumentLineSoundCues(line, state.presentationOptions) fallbackOptions := state.presentationOptions fallbackOptions.RoleSoundPresentation = "sound_and_speech" fallbackSpeech = presentation.DocumentLineSpeechWithOptions(line, fallbackOptions) fallbackText = fallbackSpeech.Text } } if event.Type != atspi.CaretEvent && !isTextChangeEvent(event.Type) { cues = presentation.EventSoundCues(event, state.presentationOptions) if event.Source.Role == "combo box" && (comboUpdate || !isFocusGainEvent(event)) { cues = nil } } if prompt.activation { // 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)) } spoken, fallbackSpeech = prompt.focusSpeech(spoken.WithText(text), fallbackSpeech.WithText(fallbackText)) text, fallbackText = spoken.Text, fallbackSpeech.Text cueKey := strings.Join(cueParts, "\x00") // Adjacent characters can have the same text while the caret moves. // Treat each character caret event as a new review position. characterCaret := event.Type == atspi.CaretEvent && text != "" && text == event.Source.CaretCharacter if !characterCaret && !isTextChangeEvent(event.Type) && event.Source.Path != "" && event.Source.Path == state.lastSpokenPath && text == state.lastSpokenText && cueKey == state.lastSound { return } if event.Source.LiveRegion && event.Source.LivePoliteness == "off" { return } if event.Source.LiveRegion && text != "" && text == state.lastLiveText && time.Since(state.lastLiveAt) < 300*time.Millisecond { return } if text == "" && len(cues) == 0 { logger.Debug("accessibility event has no speech text", "type", event.Type, "role", event.Source.Role) return } appendPromptFocus := prompt.appendFocus() prompt.finishFocus() custom := false if live != nil { custom = customEcho(settings, echoKind) } appendTabFocus := isFocusGainEvent(event) && eventWindow(event) == state.tabFocusWindow && state.tabFocusSequence != 0 && sequence == state.tabFocusSequence if isFocusGainEvent(event) { state.tabFocusWindow, state.tabFocusSequence = atspi.ObjectID{}, 0 } var line *speechLine if event.Source.Role != "password text" && event.Source.TextCaretKnown && (event.Type == atspi.CaretEvent && intent == input.LineIntent || isFocusGainEvent(event)) { line = &speechLine{Object: event.Source.ID(), Raw: event.Source.BrailleLine} } browseContext := browser.speech_context_enabled() && event.Source.BusName == browser.document.BusName speech.Enqueue(speechRequest{ Context: event.Source.SpeechContext, ResetContext: !browseContext, VoiceType: ancestor_voice(event.Source.SpeechContext), Window: eventWindow(event), IntroducesWindow: (event.Type == atspi.WindowActivatedEvent || event.Type == atspi.WindowCreateEvent) && activationTitle(event) != "", Text: text, Chunks: spoken.WithText(text).Chunks, FallbackText: fallbackText, FallbackChunks: fallbackSpeech.WithText(fallbackText).Chunks, Role: event.Source.Role, Sound: cues, Cause: event.Type, CustomEcho: custom, Line: line, ExcludeFromHistory: state.historyWindow.Valid() && eventWindow(event) == state.historyWindow, PhoneticHint: characterCaret, Character: event.Type == atspi.CaretEvent && text == event.Source.CaretCharacter || echoKind == "character" || echoKind == "key", Append: appendTabFocus || appendTableFocus || appendPromptFocus || event.Source.LiveRegion && event.Source.LivePoliteness == "polite" || event.Source.Role == "terminal" && event.Type == atspi.TextInsertedEvent, BypassFocusGate: eventBypassesXTermHandoff(event), }) // Activation can describe the window while focus is still in its entry. // Remember that help before the first table row is selected. Keep the // immediate table-focus append timing owned by decorateGUITable. if event.Type == atspi.WindowActivatedEvent && !prompt.activation && state.presentationOptions.SpeakDescription && event.Window.Valid() && event.WindowDescription != "" { state.tableDescriptionWindow = event.Window state.tableDescriptionText = event.WindowDescription } state.rememberGUITable(event, table) if event.Source.LiveRegion { state.lastLiveText, state.lastLiveAt = text, time.Now() } logger.Debug("queued accessible presentation", "type", event.Type, "role", event.Source.Role) prompt.rememberWindow() if !isTextChangeEvent(event.Type) { state.lastSpokenPath = event.Source.Path state.lastSpokenText = text state.lastSound = cueKey } } func presentationFallbackText(event atspi.Event, options presentation.Options) string { return presentationFallbackSpeech(event, options).Text } func presentationFallbackSpeech(event atspi.Event, options presentation.Options) presentation.Speech { if options.RoleSoundPresentation != "sound_only" { return presentation.Speech{} } options.RoleSoundPresentation = "sound_and_speech" return presentation.EventSpeechWithOptions(event, options) }