Files
2026-10-15 01:10:43 -04:00

196 lines
7.2 KiB
Go

// 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 <https://www.gnu.org/licenses/gpl-3.0.html>.
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
}