Files
cthulhu/internal/app/event_focus_ownership.go

131 lines
5.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 (
"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
}