Files
cthulhu/internal/app/event_presentation.go

246 lines
9.5 KiB
Go

// 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
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(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
}
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}
}
speech.Enqueue(speechRequest{
Context: event.Source.SpeechContext, 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,
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),
})
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)
}