Files

153 lines
7.1 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 (
"slices"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/braille"
"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/review"
)
func (handler *accessibilityEventHandler) acceptFocusedEvent(event atspi.Event, activity input.Activity) bool {
state := handler.state
logger := handler.logger
objectReview := handler.objectReview
browser := handler.browser
brailleOutput := handler.brailleOutput
live := handler.live
// Compare with the owned focus snapshot, not the last speech request:
// a state/value update can intervene between two toolkit focus events.
// Still refresh review and braille below before suppressing presentation.
duplicateFocus := (event.Type == atspi.FocusEvent || event.Type == atspi.FocusedStateEvent && event.Detail1 != 0) &&
event.Source.Role != "terminal" && !presentsReusedRow(event.Source) &&
!(state.pendingPromptWindow.Valid() && event.Window == state.pendingPromptWindow) &&
state.focusedID.Valid() && event.Source.ID() == state.focusedID &&
event.Source.ID() == state.current.ID() &&
presentation.FocusTextWithOptions(event.Source, state.presentationOptions) == presentation.FocusTextWithOptions(state.current, state.presentationOptions) &&
slices.Equal(presentation.SoundCues(event.Source, state.presentationOptions), presentation.SoundCues(state.current, state.presentationOptions))
if isFocusEvent(event.Type) {
focusID := event.Source.ID()
if isFocusGainEvent(event) {
state.focusCaretID = focusID
state.focusCaretSequence = activity.Sequence
} else {
state.focusCaretID = atspi.ObjectID{}
}
if focusID != state.managedContainerID && focusID != state.managedDescendantID {
state.managedContainerID = atspi.ObjectID{}
state.managedDescendantID = atspi.ObjectID{}
}
// GTK table focus is normalized to its selected cell by the native
// adapter. Retain the captured table owner before the first arrow
// move, even when no container focus event reached this loop.
if event.Source.Role == "table cell" && event.Source.GUITable {
tableID := atspi.NewObjectID(event.Source.BusName, event.Source.TablePath)
if tableID.Valid() {
state.managedContainerID = tableID
state.managedDescendantID = focusID
}
}
state.focusedID = focusID
state.selection.reset(event.Source)
if objectReview != nil {
objectReview.SyncFocus(event.Source.ID())
}
} else if isManagedDescendantEvent(event.Type) {
containerID := atspi.NewObjectID(event.Source.BusName, event.OriginPath)
descendantID := event.Source.ID()
if event.Source.Defunct || event.Source.Role == "invalid" || descendantID.Path == "" {
logger.Debug("skipped invalid managed descendant", "type", event.Type)
return true
}
if state.focusedID.Path == "" ||
(containerID != state.focusedID && descendantID != state.focusedID && containerID != state.managedContainerID) {
logger.Debug("skipped managed descendant outside focused object", "type", event.Type, "role", event.Source.Role)
return true
}
if containerID == state.managedContainerID && descendantID == state.managedDescendantID &&
(!presentsReusedRow(event.Source) || sameManagedDescendant(event.Source, state.current)) {
logger.Debug("skipped unchanged managed descendant", "type", event.Type, "role", event.Source.Role)
return true
}
state.managedContainerID = containerID
state.managedDescendantID = descendantID
if event.Type == atspi.SelectionChangedEvent && sameSelectionItem(event.Source, state.current) {
logger.Debug("skipped selection already presented by focus", "role", event.Source.Role)
return true
}
} else if (isStateChangeEvent(event.Type) || isValueChangeEvent(event.Type)) &&
(state.focusedID.Path == "" || event.Source.ID() != state.focusedID) {
logger.Debug("skipped event outside focused object", "type", event.Type, "role", event.Source.Role)
return true
}
if event.Type == atspi.SelectedStateEvent && sameSelectionItem(event.Source, state.current) {
logger.Debug("skipped selected state already presented by focus", "role", event.Source.Role)
return true
}
if isFocusEvent(event.Type) || isManagedDescendantEvent(event.Type) ||
event.Type == atspi.AccessibleNameEvent && presentsReusedRow(event.Source) &&
event.Source.ID() == state.managedDescendantID ||
(event.Source.Path != "" && event.Source.ID() == state.focusedID) {
state.current = event.Source
}
if isTextChangeEvent(event.Type) {
if !speaksTextChanges(event.Source) && !event.Source.LiveRegion {
logger.Debug("skipped text change outside editable content", "type", event.Type, "role", event.Source.Role)
return true
}
if !event.Source.LiveRegion {
state.suppressNextCaretPath = event.Source.Path
}
}
if event.Type == atspi.CaretEvent && browser != nil && browser.ready && !event.Source.Editable {
state.current = event.Source
}
if brailleOutput != nil && (isFocusGainEvent(event) ||
isManagedDescendantEvent(event.Type) || event.Type == atspi.CaretEvent ||
event.Type == atspi.AccessibleNameEvent &&
(event.Source.ID() == state.focusedID || presentsReusedRow(event.Source)) ||
event.Type == atspi.TextSelectionChangedEvent && event.Source.TextCaretKnown ||
isStateChangeEvent(event.Type) || isValueChangeEvent(event.Type) ||
isTextChangeEvent(event.Type) && speaksTextChanges(event.Source)) &&
event.Source.ID() == state.current.ID() {
source := event.Source
if (isStateChangeEvent(event.Type) || isValueChangeEvent(event.Type)) &&
!source.TextCaretKnown && source.ID() == state.brailleSnapshot.ID() {
source.TextCaretKnown = state.brailleSnapshot.TextCaretKnown
source.BrailleLine = state.brailleSnapshot.BrailleLine
source.BrailleLineStart = state.brailleSnapshot.BrailleLineStart
source.BrailleCaret = state.brailleSnapshot.BrailleCaret
}
if len(source.BrailleContext) == 0 && source.ID() == state.brailleSnapshot.ID() {
source.BrailleContext = state.brailleSnapshot.BrailleContext
}
state.brailleSnapshot = source
brailleOptions := config.DefaultSettings().Braille
textAttributes := config.DefaultTextAttributes()
if live != nil {
settings := live.Load()
brailleOptions, textAttributes = settings.Braille, settings.TextAttributes
}
frame := brailleFocusFrameWithOptions(source, brailleOptions, textAttributes)
if browser != nil && browser.ready && browser.mode == review.BrowseMode && !source.Editable &&
objectReview != nil && browser.document.Valid() && source.Role != "password text" {
focus := objectReview.Focus()
frame.Document = &braille.DocumentRoute{Epoch: objectReview.DocumentEpoch(), FocusBus: focus.BusName, FocusPath: focus.Path,
DocumentBus: browser.document.BusName, DocumentPath: browser.document.Path}
}
brailleOutput.EnqueueFrame(frame)
}
if duplicateFocus {
logger.Debug("skipped unchanged accessible focus", "type", event.Type, "role", event.Source.Role)
}
return duplicateFocus
}