131 lines
5.1 KiB
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
|
|
}
|