// Cthulhu screen reader. // Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "log/slog" "strings" "time" "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/notification" "git.stormux.org/storm/cthulhu/internal/presentation" "git.stormux.org/storm/cthulhu/internal/review" ) // accessibilityEventHandler handles native events on the event-loop goroutine. // A return drops or consumes this event; the select loop then receives the next. type accessibilityEventHandler struct { state *eventLoopState titles *windowTitleController logger *slog.Logger speech speechEnqueuer adapter Adapter objectReview *review.Worker browser *browserModeController inputSource any focusGate *focusGate notifications *notification.Presenter live *liveSettings brailleOutput brailleFrameEnqueuer foreground foregroundMonitor debounce time.Duration clearOwnedFocus func() syncForeground func() flushTerminalDeletion func() speak func(atspi.Event, ...input.Intent) webScripts *webScriptController wineReview *wineReviewFocusController } func (handler *accessibilityEventHandler) handle(event atspi.Event) { state := handler.state logger := handler.logger speech := handler.speech adapter := handler.adapter browser := handler.browser inputSource := handler.inputSource focusGate := handler.focusGate notifications := handler.notifications live := handler.live brailleOutput := handler.brailleOutput foreground := handler.foreground debounce := handler.debounce syncForeground := handler.syncForeground flushTerminalDeletion := handler.flushTerminalDeletion speak := handler.speak webScripts := handler.webScripts ownedAutocompletePopup := state.ownedAutocompletePopup if focusGate.Locked() { return } if overflow := adapter.Overflow(); overflow > state.lastOverflow { logger.Warn("AT-SPI event queue overflow", "dropped_events", overflow-state.lastOverflow) state.lastOverflow = overflow } if logger.Enabled(context.Background(), slog.LevelDebug) { logger.Debug("received accessibility event", "type", event.Type, "native_type", event.NativeType, "application", event.Source.ApplicationName, "bus", event.Source.BusName, "source_path", event.Source.Path, "origin_path", event.OriginPath, "window_bus", event.Window.BusName, "window_path", event.Window.Path, "window_recovered", event.WindowRecovered, "focus_outside_document", event.FocusOutsideDocument.Path, "role", event.Source.Role, "browser_tab", event.Source.BrowserTab, "selected", event.Source.Selected, "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, "editable", event.Source.Editable, "autocomplete", event.Source.Autocomplete, "autocomplete_popup", event.Source.AutocompletePopup, "event_detail1", event.Detail1, "native_caret_known", event.Source.TextCaretKnown, "native_caret_offset", event.Source.BrailleCaret, "native_line_start", event.Source.BrailleLineStart, "character_count", event.Source.CharacterCount, "selection_start", event.Source.SelectionStart, "selection_end", event.Source.SelectionEnd) } syncForeground() notification := eventBypassesXTermHandoff(event) if handler.wineReview != nil && handler.wineReview.owns(state.current.ID()) && !notification { return } ownedTooltip := event.Type == atspi.ShowingStateEvent && event.Source.Role == "tool tip" && state.owner.applicationBus != "" && state.owner.applicationBus == event.Source.BusName && event.Foreground.Epoch == state.foregroundState.Epoch if webScripts.handle(event) { return } if handler.handle_progress_bar(event) { return } ending := event.Type == atspi.DefunctStateEvent || event.Type == atspi.WindowDestroyedEvent || event.Type == atspi.WindowDeactivatedEvent if foreground != nil && !notification && (event.Foreground.Epoch != state.foregroundState.Epoch || event.ForegroundMismatch && !ending && !ownedAutocompletePopup(event) && !ownedTooltip) { if event.Type == atspi.WindowActivatedEvent { check := event.ForegroundCheck client := event.Foreground.Client logger.Debug("ignored accessibility event outside X11 foreground", "type", event.Type, "bus", event.Window.BusName, "window_path", event.Window.Path, "x_window", client.Window, "epoch", event.Foreground.Epoch, "current_epoch", state.foregroundState.Epoch, "reason", check.Reason, "x_pid", client.PID, "accessible_pid", check.AccessiblePID, "active", check.Active, "showing", check.Showing, "iconified", check.Iconified, "accessible_bounds", check.Bounds, "x_bounds", [4]int{client.X, client.Y, client.Width, client.Height}, "x_outer_bounds", [4]int{client.OuterX, client.OuterY, client.OuterWidth, client.OuterHeight}) } else { logger.Debug("ignored accessibility event outside X11 foreground", "type", event.Type, "bus", event.Window.BusName, "window_path", event.Window.Path, "x_window", state.foregroundState.Client.Window, "epoch", event.Foreground.Epoch) } return } var applicationSuppressed bool event, applicationSuppressed = handler.prepareApplicationEvent(event) if applicationSuppressed { return } if handler.handleLifecycle(event) { return } consumed := handler.acceptFocusOwnership(event, notification) if isFocusGainEvent(event) && !notification && live != nil && live.onApplication != nil && state.owner.applicationBus == event.Source.BusName && state.owner.window == eventWindow(event) { application := handler.titles.applicationForEvent(event) live.onApplication(application) } if event.Type == atspi.WindowActivatedEvent && state.owner.window == eventWindow(event) { handler.titles.activated(event) } if consumed { return } if notifications != nil { message, speechMessages, handled, save := notificationEvent(event) if save { notifications.Save(message) } if handled { presentNotifications(speech, speechMessages) return } if save { speak(event) return } } if handler.handleTooltip(event) { return } activity := recentInput(inputSource) if inputSource == nil && browser != nil { activity = browser.RecentInput() } intent := activity.Intent if isTextChangeEvent(event.Type) && event.Source.Editable && event.Source.Role != "terminal" && !event.Source.LiveRegion && state.focusedID.Path != "" && event.Source.ID() != state.focusedID && event.Source.ID() != state.managedDescendantID { // Settings pages initialize editable number fields while focus // remains in their navigation tree. These are not user edits. logger.Debug("skipped text change outside focused editor", "type", event.Type, "role", event.Source.Role) return } if event.Type == atspi.CaretEvent && !event.Source.Editable && !activity.Selecting && state.focusedID.Path != "" && isFocusCaretControl(state.current.Role) && (intent == input.NoIntent || intent == input.OtherIntent) && presentation.EventText(event) == "\ufffc" { // Gecko can move a containing panel's caret to an embedded // control just after Tab. Preserve the control's focus, speech, // and braille rather than adopting that object's placeholder. logger.Debug("skipped embedded-object caret after control focus", "role", event.Source.Role) return } if handler.handleBrowserTab(event, activity) { return } if shouldPresentFileManagerStatus(event, state.current, state.owner, intent) { 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 textAttributes := config.DefaultTextAttributes() if live != nil { settings := live.Load() brailleOptions, textAttributes = settings.Braille, settings.TextAttributes } brailleOutput.EnqueueFrame(brailleFocusFrameWithOptions(event.Source, brailleOptions, textAttributes)) } } return } if handler.filterNavigationAndNames(event, activity, intent) { return } event, autocompleteRequest, autocompleteConsumed := handler.handleAutocomplete(event, activity, intent) if autocompleteConsumed { return } if state.pending.terminalDeletion.event.Type != "" && event.Type != atspi.CaretEvent { isReplacement := event.Type == atspi.TextInsertedEvent && event.Source.Path != "" && event.Source.Path == state.pending.terminalDeletion.event.Source.Path && event.Detail1 == state.pending.terminalDeletion.event.Detail1 if isReplacement { state.pending.terminalDeletion.clear() logger.Debug("coalesced terminal deletion with following insertion", "role", event.Source.Role) } else { flushTerminalDeletion() } } if state.pending.terminalFocus.event.Type != "" { if event.Type == atspi.TextInsertedEvent && event.Source.Path != "" && event.Source.Path == state.pending.terminalFocus.event.Source.Path { state.pending.terminalFocus.clear() logger.Debug("coalesced terminal focus with following insertion", "role", event.Source.Role) } else if isFocusEvent(event.Type) { state.pending.terminalFocus.clear() } } if handler.acceptFocusedEvent(event, activity) { return } if handler.handleSelectionAndClipboard(event, activity, intent) { return } if handler.handleCaret(event, activity, intent) { return } state.pending.caret.clear() if isFocusEvent(event.Type) && event.Source.Role == "terminal" { state.pending.terminalFocus.schedule(event, input.NoIntent, debounce) return } if event.Type == atspi.TextDeletedEvent && event.Source.Role == "terminal" { state.pending.terminalDeletion.schedule(event, input.NoIntent, terminalMutationDebounce) return } speak(event) if autocompleteRequest.Text != "" { speech.Enqueue(autocompleteRequest) } } // Thunar and PCManFM can leave AT-SPI focus on a file-view panel while arrowing // through icons. The status-bar name is then the only changing accessible text // for the selection. A normally focused file item keeps its own presentation. func shouldPresentFileManagerStatus(event atspi.Event, current atspi.Snapshot, owner windowOwner, intent input.Intent) bool { if event.Type != atspi.AccessibleNameEvent || event.Source.Role != "status bar" || !strings.EqualFold(event.Source.ApplicationName, "thunar") && !strings.EqualFold(event.Source.ApplicationName, "pcmanfm") || strings.TrimSpace(event.Source.Name) == "" || !owner.window.Valid() || event.Window != owner.window || event.Source.BusName != owner.applicationBus { return false } if intent != input.LineIntent && intent != input.CharacterIntent && intent != input.WordIntent { return false } return current.Role == "panel" || current.Role == "directory pane" || current.Role == "tree table" && current.Name == "" || current.Role == "table cell" && current.Name == "" }