153 lines
7.1 KiB
Go
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
|
|
}
|