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