// 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) } } }