Files
cthulhu/internal/app/event_accessibility.go

291 lines
11 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 (
"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 == ""
}