Cthulhu is no longer a fork. Now it is a screen reader written in Golang.
This commit is contained in:
1 parent
a998fe67de
commit
069fc674dc
708 files changed
+112482
-532
No files matched your search
@@ -0,0 +1,285 @@
|
||||
// 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
|
||||
}
|
||||
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 == ""
|
||||
}
|
||||
Reference in new issue
Block a user