162 lines
6.5 KiB
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 + "."
|
|
}
|