196 lines
7.2 KiB
Go
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
|
|
}
|