// 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 }