Files

340 lines
10 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 (
"context"
"log/slog"
"time"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/notification"
"git.stormux.org/storm/cthulhu/internal/presentation"
"git.stormux.org/storm/cthulhu/internal/review"
)
func processEvents(
ctx context.Context,
logger *slog.Logger,
speech speechEnqueuer,
adapter Adapter,
debounce time.Duration,
configuredPresentation ...presentation.Options,
) error {
return processEventsWithReview(ctx, logger, speech, adapter, nil, debounce, configuredPresentation...)
}
func processEventsWithReview(
ctx context.Context,
logger *slog.Logger,
speech speechEnqueuer,
adapter Adapter,
reviewRequests <-chan input.Command,
debounce time.Duration,
configuredPresentation ...presentation.Options,
) error {
return processEventsWithObjectReview(ctx, logger, speech, adapter, reviewRequests, nil, debounce, configuredPresentation...)
}
func processEventsWithObjectReview(
ctx context.Context,
logger *slog.Logger,
speech speechEnqueuer,
adapter Adapter,
reviewRequests <-chan input.Command,
objectReview *review.Worker,
debounce time.Duration,
configuredPresentation ...presentation.Options,
) error {
return processEventsWithSettings(ctx, logger, speech, adapter, reviewRequests, objectReview, debounce, nil, configuredPresentation...)
}
func processEventsWithSettings(
ctx context.Context,
logger *slog.Logger,
speech speechEnqueuer,
adapter Adapter,
reviewRequests <-chan input.Command,
objectReview *review.Worker,
debounce time.Duration,
live *liveSettings,
configuredPresentation ...presentation.Options,
) error {
return processEventsWithInput(ctx, logger, speech, adapter, reviewRequests, objectReview, debounce, live, nil, nil, nil, configuredPresentation...)
}
func processEventsWithInput(
ctx context.Context,
logger *slog.Logger,
speech speechEnqueuer,
adapter Adapter,
reviewRequests <-chan input.Command,
objectReview *review.Worker,
debounce time.Duration,
live *liveSettings,
inputSource any,
focusGate *focusGate,
notifications *notification.Presenter,
configuredPresentation ...presentation.Options,
) error {
return processEventsWithOCR(ctx, logger, speech, adapter, reviewRequests, objectReview, debounce, live, inputSource, focusGate, notifications, nil, configuredPresentation...)
}
func processEventsWithOCR(
ctx context.Context,
logger *slog.Logger,
speech speechEnqueuer,
adapter Adapter,
reviewRequests <-chan input.Command,
objectReview *review.Worker,
debounce time.Duration,
live *liveSettings,
inputSource any,
focusGate *focusGate,
notifications *notification.Presenter,
ocrSession *ocrSession,
configuredPresentation ...presentation.Options,
) error {
return processEventsWithAttributes(ctx, logger, speech, adapter, reviewRequests, nil, objectReview, debounce, live, inputSource, focusGate, notifications, ocrSession, configuredPresentation...)
}
func processEventsWithAttributes(
ctx context.Context,
logger *slog.Logger,
speech speechEnqueuer,
adapter Adapter,
reviewRequests <-chan input.Command,
attributeRequests <-chan struct{},
objectReview *review.Worker,
debounce time.Duration,
live *liveSettings,
inputSource any,
focusGate *focusGate,
notifications *notification.Presenter,
ocrSession *ocrSession,
configuredPresentation ...presentation.Options,
) error {
attributes := startCharacterAttributeReader(ctx, adapter)
defer attributes.stop()
if notifications == nil {
notifications = notification.NewPresenter()
}
state := eventLoopState{}
state.presentationOptions = presentation.DefaultOptions()
if len(configuredPresentation) != 0 {
state.presentationOptions = configuredPresentation[0]
}
var brailleOutput brailleFrameEnqueuer
if queue, ok := speech.(outputQueue); ok {
brailleOutput = queue.braille
}
webScripts := &webScriptController{speech: speech, notifications: notifications, live: live, gate: focusGate}
webScripts.braille, _ = brailleOutput.(interface {
Flash(string, config.BrailleOptions) bool
})
defer webScripts.reset()
logger.Info("listening for focus events")
browser, _ := inputSource.(*browserModeController)
var modeResults <-chan browserModeResult
var modeToggles <-chan input.Command
if browser != nil {
modeResults = browser.results
modeToggles = browser.toggles
}
foreground, _ := adapter.(foregroundMonitor)
var foregroundChanges <-chan struct{}
if foreground != nil {
foregroundChanges = foreground.ForegroundChanges()
state.foregroundState = foreground.Foreground()
}
titles := &windowTitleController{state: &state, live: live, gate: focusGate, speech: speech, logger: logger, foreground: foreground, inputSource: inputSource}
titles.source, _ = adapter.(windowTitleSource)
var titleChanges <-chan struct{}
if titles.source != nil {
titleChanges = titles.source.TitleChanges()
titles.nativeChanged()
}
var wineTitles <-chan wineWindowTitle
if live != nil {
wineTitles = live.wineTitles
}
defer titles.reset()
defer state.pending.clear()
var objectReviewResults <-chan review.Result
if objectReview != nil {
objectReviewResults = objectReview.Results()
}
presenter := eventPresenter{
state: &state, logger: logger, speech: speech, foreground: foreground,
inputSource: inputSource, live: live, browser: browser,
}
speak := presenter.speak
flushTerminalDeletion := func() {
if state.pending.terminalDeletion.event.Type == "" {
return
}
event, _ := state.pending.terminalDeletion.take()
speak(event)
}
clearObjectFocus := func() {
ocrSession.Retire()
state.clearOwnedFocus(brailleOutput, browser, objectReview, speech)
}
clearOwnedFocus := func() {
titles.reset()
clearObjectFocus()
}
var wineReview *wineReviewFocusController
var wineReviewChanges <-chan struct{}
if live != nil && live.wineReview != nil {
// Wine control revisions reset object review, but a pending title and
// its duplicate history belong to the foreground window's lifetime.
// These notices carry metadata, not an instruction to stop speech.
// Foreground switches and user input already own speech cancellation.
clearWineReviewFocus := func() {
ocrSession.Retire()
state.clearObjectFocus(brailleOutput, browser, objectReview)
}
wineReview = &wineReviewFocusController{registry: live.wineReview, logger: logger, state: &state, foreground: foreground, titles: titles.source, gate: focusGate, worker: objectReview, clear: clearWineReviewFocus}
wineReviewChanges = live.wineReview.Changes()
wineReview.changed()
defer wineReview.retire()
}
syncForeground := func() {
state.syncForeground(foreground, logger, clearOwnedFocus)
if wineReview != nil {
wineReview.foregroundChanged()
}
}
reviewHandler := eventReviewHandler{
state: &state, logger: logger, speech: speech, objectReview: objectReview,
browser: browser, brailleOutput: brailleOutput, live: live,
focusGate: focusGate, syncForeground: syncForeground, speak: speak,
wineReview: wineReview,
}
eventHandler := accessibilityEventHandler{
state: &state, titles: titles, logger: logger, speech: speech, adapter: adapter,
objectReview: objectReview, browser: browser, inputSource: inputSource,
focusGate: focusGate, notifications: notifications, live: live,
brailleOutput: brailleOutput, foreground: foreground, debounce: debounce,
clearOwnedFocus: clearOwnedFocus, syncForeground: syncForeground,
flushTerminalDeletion: flushTerminalDeletion, speak: speak, webScripts: webScripts,
wineReview: wineReview,
}
var lockChanges <-chan struct{}
if focusGate != nil {
lockChanges = focusGate.lockChanges
}
for {
select {
case <-ctx.Done():
return nil
case <-lockChanges:
if wineReview != nil {
wineReview.retire()
}
webScripts.reset()
state.owner.clear()
clearOwnedFocus()
case <-wineReviewChanges:
syncForeground()
case <-webScripts.due:
webScripts.flush()
case <-foregroundChanges:
focusGate.retireTentacleOutput()
ocrSession.ForegroundChanged()
syncForeground()
if titles.source != nil {
titles.nativeChanged()
}
case <-titleChanges:
focusGate.retireTentacleOutput()
syncForeground()
titles.nativeChanged()
case update := <-wineTitles:
syncForeground()
titles.wineChanged(update)
case <-titles.due:
syncForeground()
titles.flush()
case result := <-modeResults:
focusGate.retireTentacleOutput()
if focusGate.Locked() {
continue
}
syncForeground()
if result.err != nil {
logger.Debug("browser mode outside web document", "reason", result.err)
}
browser.accept(result, speech)
case command := <-modeToggles:
focusGate.retireTentacleOutput()
if focusGate.Locked() {
continue
}
syncForeground()
switch command {
case input.StickyFocusMode:
browser.sticky(review.FocusMode, speech)
case input.StickyBrowseMode:
browser.sticky(review.BrowseMode, speech)
default:
browser.toggle(speech)
}
case event, ok := <-adapter.Events():
focusGate.retireTentacleOutput()
if !ok {
return nil
}
if !ocrSession.Observe(event) {
eventHandler.handle(event)
}
case <-state.pending.caret.ready:
if focusGate.Locked() {
state.pending.clear()
continue
}
event, intent := state.pending.caret.take()
speak(event, intent)
case <-state.pending.terminalFocus.ready:
if focusGate.Locked() {
state.pending.clear()
continue
}
event, _ := state.pending.terminalFocus.take()
speak(event)
case <-state.pending.terminalDeletion.ready:
if focusGate.Locked() {
state.pending.clear()
continue
}
event, _ := state.pending.terminalDeletion.take()
speak(event)
case command := <-reviewRequests:
if command == input.PresentCellFormula {
reviewHandler.presentCellFormula()
} else {
reviewHandler.whereAmI()
}
case <-attributeRequests:
if !focusGate.Locked() {
syncForeground()
attributes.request(state.current.ID(), recentInput(inputSource).Sequence)
}
case result := <-attributes.results:
if !focusGate.Locked() && result.object == state.current.ID() && result.object.Valid() && result.sequence == recentInput(inputSource).Sequence {
settings := live.Load()
presentCharacterAttributes(speech, result, settings.Reading.UseColorNames, settings.TextAttributes)
}
case result := <-objectReviewResults:
reviewHandler.handle(result)
}
}
}