// Cthulhu screen reader. // Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "strings" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/braille" "git.stormux.org/storm/cthulhu/internal/platform/xorg" "git.stormux.org/storm/cthulhu/internal/webscripts" ) // acceptFocusOwnership keeps native window focus, XTerm handoff, and navigation // echoes ahead of input intent and normal presentation. func (handler *accessibilityEventHandler) acceptFocusOwnership(event atspi.Event, notification bool) bool { state := handler.state logger := handler.logger speech := handler.speech focusGate := handler.focusGate objectReview := handler.objectReview brailleOutput := handler.brailleOutput ownedAutocompletePopup := state.ownedAutocompletePopup if isFocusGainEvent(event) { if event.Type == atspi.WindowActivatedEvent { focusBeforeActivation := state.owner.window.Valid() && state.owner.window == eventWindow(event) && state.owner.focus.Valid() && !state.owner.activated // Window ownership and object focus are independent. Providers can // repeat activation for the same top-level around an X11 key grab, // and the activation snapshot can be the frame while the focused // object is one of its descendants. Treating that source mismatch as // a new activation interrupts review speech and resets its cursor. if state.owner.activate(event) { logger.Debug("skipped duplicate accessible window activation", "application", event.Source.ApplicationName, "bus", state.owner.window.BusName, "window_path", state.owner.window.Path, "focus_path", event.Source.Path) return true } state.lastFileManagerStatus, state.lastFileManagerStatusWindow = "", atspi.ObjectID{} logger.Debug("activated accessible window", "application", event.Source.ApplicationName, "bus", state.owner.window.BusName, "window_path", state.owner.window.Path, "focus_path", event.Source.Path) if (event.Source.Role == "alert" || event.Source.Role == "frame") && state.pendingPromptTextAnnounced && state.pendingPromptWindow.Valid() && event.Window == state.pendingPromptWindow { // The prompt's window:create already queued its title and // message. Activation must not cut that announcement short. return true } if focusBeforeActivation { text := strings.TrimSpace(event.WindowTitle) if (event.Source.Role == "button" || event.Source.Role == "tree table" || event.Source.Role == "table cell") && event.WindowMessage != "" { if text != "" { text += ", " } text += event.WindowMessage } if text != "" { speech.Interrupt() speech.Enqueue(speechRequest{Text: text, Role: "frame", Cause: event.Type, Window: eventWindow(event), IntroducesWindow: true, ExcludeFromHistory: state.historyWindow.Valid() && eventWindow(event) == state.historyWindow}) } return true } } else if !ownedAutocompletePopup(event) && !state.owner.follow(event) { logger.Debug("skipped focus outside active window", "type", event.Type, "application", event.Source.ApplicationName, "bus", event.Window.BusName, "window_path", event.Window.Path, "focus_path", event.Source.Path) return true } if isXTermFocus(event) { state.brailleSnapshot = atspi.Snapshot{} if brailleOutput != nil { brailleOutput.EnqueueFrame(braille.Frame{Cursor: -1}) } state.pending.clear() focusGate.SetSuspended(true) state.focusedID = event.Source.ID() state.current = event.Source if objectReview != nil { objectReview.SyncFocus(event.Source.ID()) } logger.Info("suspended for XTerm focus handoff") return true } if !notification { focusGate.SetSuspended(false) } } if focusGate.Suspended() && !notification { logger.Debug("skipped event during XTerm focus handoff", "type", event.Type, "role", event.Source.Role) return true } if event.NavigationEcho { return true } // The native adapter already filters focused-state losses. Keep the // event loop defensive for alternate adapters and tests: Python does // not present or follow the object which just lost focus. if event.Type == atspi.FocusedStateEvent && event.Detail1 == 0 { return true } return false } func isXTermSnapshot(snapshot atspi.Snapshot) bool { return strings.EqualFold(snapshot.ApplicationName, "xterm") } func isXTermFocus(event atspi.Event) bool { if isXTermSnapshot(event.Source) { return true } return event.Source.Role == "terminal" && event.Source.ApplicationName == "" && xorg.ActiveWindowIsXTerm() } func eventBypassesXTermHandoff(event atspi.Event) bool { if webscripts.HandlesNotification(event.Source.ApplicationProcess, event.Type, event.Detail1, event.WebNotification) { return true } role := strings.ToLower(event.Source.Role) if role == "notification" || role == "alert" { return true } switch event.Type { case "window:create", "object:property-change:accessible-name", atspi.AccessibleValueEvent: switch strings.ToLower(event.Source.ApplicationName) { case "notification-daemon", "notify-osd", "xfce4-notifyd", "mate-notification-daemon": return true } } return false }