// Cthulhu screen reader. // Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // // This program is free software: you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation, either version 3 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along with // this program. If not, see . package app import ( "log/slog" "time" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/braille" "git.stormux.org/storm/cthulhu/internal/presentation" "git.stormux.org/storm/cthulhu/internal/review" ) // eventLoopState is owned only by processEventsWithInput's goroutine. Embedded // groups keep each lifetime visible while retaining the existing individual // reset transitions. Workers observe state only through their existing APIs. type eventLoopState struct { eventForegroundState eventFocusState eventDocumentState eventInputState eventPresentationState } type eventForegroundState struct { owner windowOwner foregroundState atspi.ForegroundState lastOverflow uint64 } type eventFocusState struct { libreOfficeSelection libreOfficeSelectionState current atspi.Snapshot brailleSnapshot atspi.Snapshot focusedID atspi.ObjectID managedContainerID atspi.ObjectID managedDescendantID atspi.ObjectID } type eventDocumentState struct { selectedBrowserTab atspi.ObjectID tabFocusWindow atspi.ObjectID tabFocusSequence uint64 documentLineFocus atspi.ObjectID documentLineRequest uint64 documentLineEvent atspi.Event loadedDocument atspi.ObjectID loadingDocument atspi.ObjectID // A load-complete can verify chrome focus just before a busy:false event // temporarily loses its native focused state. Keep that document from // taking browse keys until focus actually enters it. chromeLoadedDocument atspi.ObjectID chromeLoadedWindow atspi.ObjectID } type eventInputState struct { lastAutocompletePopupItem atspi.ObjectID focusCaretID atspi.ObjectID focusCaretSequence uint64 suppressNextCaretPath string selectionCaretID atspi.ObjectID selectionCaretSequence uint64 selectionCaretAt time.Time autocompleteTyping autocompleteEcho autocompleteAvailable autocompleteAvailability typingPath string typingText string selection textSelection documentSelection textSelection lastClipboardSequence uint64 lastPasteSequence uint64 lastPasteID atspi.ObjectID lastPasteAt time.Time } type eventPresentationState struct { presentationOptions presentation.Options pending pendingPresentations lastSpokenPath string lastSpokenText string lastSound string lastLiveText string lastLiveAt time.Time comboHistory comboPresentation // Retain this identity after the history window closes so delayed GTK // events cannot enter speech history. historyWindow atspi.ObjectID lastFileManagerStatus string lastFileManagerStatusWindow atspi.ObjectID pendingPromptWindow atspi.ObjectID pendingPromptTextAnnounced bool tableDescriptionWindow atspi.ObjectID tableDescriptionText string tableDescriptionAt time.Time lastTable atspi.ObjectID lastTableCell atspi.ObjectID lastTableRow int lastTableColumn int lastTableSequence uint64 lastTableAt time.Time lastTableFullRow bool lastTableText string progressBars ProgressBarHistory } func (state *eventLoopState) ownedAutocompletePopup(event atspi.Event) bool { return event.Source.AutocompletePopup && event.PopupFor.Valid() && state.current.Autocomplete && state.current.ID() == event.PopupFor && state.owner.focus == event.PopupFor && state.owner.window.Valid() && event.Window.Valid() && event.Window != state.owner.window && event.Window.BusName == state.owner.applicationBus && event.Source.BusName == state.owner.applicationBus } // clearOwnedFocus retires only the state cleared by the former event-loop // closure. Session presentation history and chrome load verification survive. func (state *eventLoopState) clearOwnedFocus(brailleOutput brailleFrameEnqueuer, browser *browserModeController, objectReview *review.Worker, speech speechEnqueuer) { state.clearObjectFocus(brailleOutput, browser, objectReview) speech.Interrupt() } // Review metadata can change without new input or a foreground switch. Reset // its navigation state independently so title announcements keep playing. func (state *eventLoopState) clearObjectFocus(brailleOutput brailleFrameEnqueuer, browser *browserModeController, objectReview *review.Worker) { state.libreOfficeSelection = libreOfficeSelectionState{} state.selection = textSelection{} state.documentSelection = textSelection{} state.lastClipboardSequence = 0 state.lastPasteSequence = 0 state.lastPasteID = atspi.ObjectID{} state.lastPasteAt = time.Time{} state.selectionCaretID = atspi.ObjectID{} state.brailleSnapshot = atspi.Snapshot{} if brailleOutput != nil { brailleOutput.EnqueueFrame(braille.Frame{Cursor: -1}) } state.pending.clear() state.pendingPromptWindow = atspi.ObjectID{} state.pendingPromptTextAnnounced = false state.focusedID = atspi.ObjectID{} state.selectedBrowserTab = atspi.ObjectID{} state.tabFocusWindow, state.tabFocusSequence = atspi.ObjectID{}, 0 state.managedContainerID = atspi.ObjectID{} state.managedDescendantID = atspi.ObjectID{} state.current = atspi.Snapshot{} state.focusCaretID, state.focusCaretSequence = atspi.ObjectID{}, 0 state.lastSpokenPath, state.lastSpokenText, state.lastSound = "", "", "" state.lastLiveText, state.lastLiveAt = "", time.Time{} state.comboHistory = comboPresentation{} state.typingPath, state.typingText, state.suppressNextCaretPath = "", "", "" state.lastAutocompletePopupItem = atspi.ObjectID{} state.autocompleteTyping = autocompleteEcho{} state.autocompleteAvailable = autocompleteAvailability{} state.documentLineFocus = atspi.ObjectID{} state.documentLineRequest = 0 state.documentLineEvent = atspi.Event{} state.loadedDocument = atspi.ObjectID{} state.loadingDocument = atspi.ObjectID{} if browser != nil { browser.clear() } if objectReview != nil { objectReview.ClearFocus() } } func (state *eventLoopState) syncForeground(foreground foregroundMonitor, logger *slog.Logger, clearOwnedFocus func()) { if foreground == nil { return } latest := foreground.Foreground() if latest.Epoch != state.foregroundState.Epoch { state.owner.clear() clearOwnedFocus() logger.Debug("X11 foreground ownership changed", "x_window", latest.Client.Window, "pid", latest.Client.PID, "known", latest.Client.Known, "epoch", latest.Epoch) } state.foregroundState = latest }