246 lines
9.5 KiB
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)
|
|
}
|