// 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 + "." }