Files
cthulhu/internal/app/event_lifecycle.go

162 lines
6.5 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"
)
// handleLifecycle returns true when window or document lifecycle consumes an event.
func (handler *accessibilityEventHandler) handleLifecycle(event atspi.Event) bool {
state := handler.state
logger := handler.logger
speech := handler.speech
browser := handler.browser
objectReview := handler.objectReview
focusGate := handler.focusGate
clearOwnedFocus := handler.clearOwnedFocus
if event.Type == atspi.WindowActivatedEvent && strings.TrimSpace(event.WindowTitle) == speechHistoryWindowTitle {
state.historyWindow = eventWindow(event)
}
if event.Type == atspi.WindowDeactivatedEvent || event.Type == atspi.WindowDestroyedEvent {
recentCommand := browser != nil && browser.recentConsumedCommand()
if transientForegroundDeactivation(event, state.foregroundState) {
// Native correlation, not a time heuristic, confirms that
// this is still the actual foreground client.
recentCommand = true
}
if state.owner.end(event, recentCommand) {
clearOwnedFocus()
logger.Debug("cleared accessible window ownership", "type", event.Type, "application", event.Source.ApplicationName, "bus", event.Window.BusName, "window_path", event.Window.Path)
} else {
logger.Debug("ignored unrelated or transient window lifecycle", "type", event.Type, "application", event.Source.ApplicationName, "bus", event.Window.BusName, "window_path", event.Window.Path)
}
return true
}
if event.Type == atspi.DefunctStateEvent {
windowEnded, focusEnded := state.owner.defunct(event)
if focusEnded {
clearOwnedFocus()
logger.Debug("cleared defunct accessibility ownership", "window_ended", windowEnded, "application", event.Source.ApplicationName, "bus", event.Source.BusName, "source_path", event.Source.Path)
}
return true
}
if browser != nil && !focusGate.Suspended() &&
(event.Type == atspi.DocumentReloadEvent || event.Type == atspi.DocumentLoadStoppedEvent || event.Type == atspi.BusyStateEvent) {
if !state.owner.containsLoad(event) {
logger.Debug("ignored document lifecycle outside owned focus", "type", event.Type,
"bus", event.Source.BusName, "window_path", event.Window.Path)
return true
}
if event.FocusOutsideDocument.Valid() {
logger.Debug("kept browser chrome focus during document lifecycle", "type", event.Type)
return true
}
if event.Type == atspi.BusyStateEvent && event.Detail1 == 0 &&
state.chromeLoadedDocument == event.Source.ID() && state.chromeLoadedWindow == eventWindow(event) &&
state.owner.focus.Valid() && state.owner.focus != event.Source.ID() &&
!(browser.ready && browser.document == event.Source.ID()) {
logger.Debug("kept verified chrome focus during document completion")
return true
}
if event.Type == atspi.DocumentReloadEvent || event.Type == atspi.BusyStateEvent && event.Detail1 != 0 {
if state.loadingDocument != event.Source.ID() {
state.loadingDocument = event.Source.ID()
state.loadedDocument = atspi.ObjectID{}
if event.Type == atspi.BusyStateEvent {
speech.Enqueue(speechRequest{Text: "Loading. Please wait.", Role: "system", Cause: "document-loading"})
}
}
browser.loading()
if objectReview != nil {
objectReview.ResetDocument(event.Source.ID())
}
} else {
state.pending.caret.clear()
state.documentLineFocus = atspi.ObjectID{}
state.documentLineRequest = 0
state.documentLineEvent = atspi.Event{}
if state.loadedDocument != event.Source.ID() {
state.lastSpokenPath, state.lastSpokenText, state.lastSound = "", "", ""
}
state.owner.follow(event)
state.current = event.Source
state.focusedID = event.Source.ID()
if event.Type == atspi.BusyStateEvent && state.loadedDocument != event.Source.ID() {
speech.Enqueue(speechRequest{Text: finishedLoadingText(event.Source.Name), Role: "system", Cause: "document-loaded", Append: true})
state.loadedDocument = event.Source.ID()
}
state.loadingDocument = atspi.ObjectID{}
browser.loaded(event.Source.ID())
if objectReview != nil {
objectReview.ResetDocument(event.Source.ID())
}
}
return true
}
if event.Type == atspi.DocumentLoadCompleteEvent && browser != nil && !focusGate.Suspended() {
if !state.owner.containsLoad(event) {
logger.Debug("ignored document load outside owned focus", "bus", event.Source.BusName, "window_path", event.Window.Path)
return true
}
alreadyAnnounced := state.loadedDocument == event.Source.ID() && state.loadingDocument != event.Source.ID()
state.pending.caret.clear()
state.documentLineFocus = atspi.ObjectID{}
state.documentLineRequest = 0
state.documentLineEvent = atspi.Event{}
if !alreadyAnnounced {
state.lastSpokenPath, state.lastSpokenText, state.lastSound = "", "", ""
}
if event.FocusOutsideDocument.Valid() {
state.chromeLoadedDocument = event.Source.ID()
state.chromeLoadedWindow = eventWindow(event)
browser.loaded(event.FocusOutsideDocument)
if objectReview != nil {
objectReview.ResetDocument(event.FocusOutsideDocument)
}
logger.Debug("kept browser chrome focus during document load")
return true
}
state.chromeLoadedDocument = atspi.ObjectID{}
state.chromeLoadedWindow = atspi.ObjectID{}
state.owner.follow(event)
state.current = event.Source
state.focusedID = event.Source.ID()
browser.loaded(event.Source.ID())
if objectReview != nil {
objectReview.ResetDocument(event.Source.ID())
if event.DocumentFragment != "" {
if err := objectReview.JumpToDocumentFragment(event.DocumentFragment); err != nil {
logger.Warn("queue document fragment navigation", "error", err)
}
}
}
if !alreadyAnnounced {
speech.Enqueue(speechRequest{Text: finishedLoadingText(event.Source.Name), Role: "system", Cause: "document-loaded", Append: true})
}
state.loadingDocument = atspi.ObjectID{}
state.loadedDocument = event.Source.ID()
return true
}
return false
}
// Keyboard grabs can make GTK emit deactivate/activate while X11 still owns
// the same foreground client. Native correlation is stronger than a timer.
func transientForegroundDeactivation(event atspi.Event, foreground atspi.ForegroundState) bool {
return event.Type == atspi.WindowDeactivatedEvent && !event.ForegroundMismatch && foreground.Client.Known && foreground.Client.Window != 0
}
func finishedLoadingText(name string) string {
name = strings.TrimSpace(name)
if name == "" {
return "Finished loading."
}
return "Finished loading " + name + "."
}