340 lines
10 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|