// Cthulhu screen reader. // Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "log/slog" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/braille" "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/presentation" "git.stormux.org/storm/cthulhu/internal/review" ) // eventReviewHandler consumes review results on the event-loop goroutine. type eventReviewHandler struct { state *eventLoopState logger *slog.Logger speech speechEnqueuer objectReview *review.Worker browser *browserModeController brailleOutput brailleFrameEnqueuer live *liveSettings focusGate *focusGate syncForeground func() speak func(atspi.Event, ...input.Intent) wineReview *wineReviewFocusController } func (handler *eventReviewHandler) whereAmI() { if handler.focusGate.Locked() { return } handler.syncForeground() handler.refreshPresentation() state := handler.state state.pending.terminalFocus.clear() state.pending.terminalDeletion.clear() // Python's Where Am I is speech-only. Do not suppress the role merely // because ordinary focus presentation replaces it with a sound cue. reviewOptions := state.presentationOptions reviewOptions.EnableSound = false spoken := presentation.FocusSpeechWithOptions(state.current, reviewOptions) if formula := presentation.LibreOfficeCellFormula(state.current); formula != "" { spoken = spoken.Append(formula, ", ") } text := spoken.Text role := state.current.Role if text == "" { text = "Could not find current location." role = "system" } handler.speech.Interrupt() handler.speech.Enqueue(speechRequest{Text: text, Chunks: spoken.WithText(text).Chunks, Role: role, Cause: "where-am-i"}) handler.logger.Debug("queued where am I presentation", "role", role) } func (handler *eventReviewHandler) handle(result review.Result) { handler.refreshPresentation() state := handler.state logger := handler.logger speech := handler.speech objectReview := handler.objectReview browser := handler.browser brailleOutput := handler.brailleOutput live := handler.live focusGate := handler.focusGate syncForeground := handler.syncForeground speak := handler.speak if focusGate.Locked() { return } if result.DocumentNativeCaret { logger.Debug("native document caret synchronized", "source_bus", result.DocumentFocus.BusName, "source_path", result.DocumentFocus.Path, "target_path", result.DocumentPosition.Object.ID().Path, "virtual_caret_offset", result.DocumentPosition.Offset, "error", result.Err) return } syncForeground() if !state.current.ID().Valid() { return } if result.ReadAllPosition { if objectReview.CurrentDocumentResult(result) && result.DocumentFocus == objectReview.Focus() { state.current = result.DocumentPosition.Snapshot state.focusedID = state.current.ID() } return } if result.ReadAllContext != nil { if objectReview.CurrentDocumentResult(result) && result.ReadAllContext.Err() == nil && result.DocumentFocus == objectReview.Focus() { presentReadAllResult(logger, speech, result, state.presentationOptions) } return } if (result.DocumentLine || result.DocumentTarget != 0 || result.DocumentCaretCommand != 0 || result.DocumentActivate || result.DocumentFragment) && !objectReview.CurrentDocumentResult(result) { return } if result.DocumentFragment && (!result.Moved || result.Err != nil) { if result.Err != nil { logger.Warn("document fragment navigation failed", "error", result.Err) } return } if result.DocumentActivate { if browser == nil || !browser.acceptsDocumentResult(result) { return } if result.Err != nil { logger.Debug("document activation failed", "error", result.Err) speech.Interrupt() speech.Enqueue(speechRequest{Text: "Could not activate current item.", Role: "system", Cause: "document-activate"}) } else { logger.Debug("document activation completed", "elapsed", result.Elapsed) } return } if result.DocumentLine { if result.DocumentLineRequest == 0 || result.DocumentLineRequest != state.documentLineRequest || !result.DocumentLineFocus.Valid() || result.DocumentLineFocus != state.documentLineFocus { return } event := state.documentLineEvent state.documentLineFocus = atspi.ObjectID{} state.documentLineRequest = 0 state.documentLineEvent = atspi.Event{} if result.Err != nil { logger.Debug("document caret line assembly failed", "error", result.Err) speak(event, input.LineIntent) return } spoken := presentation.DocumentLineSpeechWithOptions(result.DocumentLineData, state.presentationOptions) text := spoken.Text cues := presentation.DocumentLineSoundCues(result.DocumentLineData, state.presentationOptions) fallbackOptions := state.presentationOptions fallbackOptions.RoleSoundPresentation = "sound_and_speech" fallback := presentation.DocumentLineSpeechWithOptions(result.DocumentLineData, fallbackOptions) speech.Interrupt() if text == "" && len(cues) == 0 { return } speech.Enqueue(speechRequest{Text: text, Chunks: spoken.Chunks, FallbackText: fallback.Text, FallbackChunks: fallback.Chunks, Sound: cues, Role: "text", Cause: "document-caret-line", Line: document_speech_line(result.DocumentLineData), Context: result.DocumentLineData.Context, VoiceType: document_line_voice(result.DocumentLineData)}) return } if result.DocumentTarget != 0 || result.DocumentCaretCommand != 0 || result.DocumentFragment { if browser != nil { if !browser.acceptsDocumentResult(result) { return } } else if !documentResultMatchesCurrent(result, state.current) { return } // The native caret event which preceded this result can still be // waiting in the coalescing timer. Its old text must not replace // the structural result after the jump completes. state.pending.clear() if result.DocumentCaretCommand != 0 { if !result.DocumentBrailleRoute { presentDocumentCaretResult(logger, speech, result, state.presentationOptions) } } else { presentDocumentResult(logger, speech, result, state.presentationOptions) } if result.Moved && result.Err == nil && result.CaretErr == nil { if result.DocumentCaretCommand != review.CaretReadLine || result.DocumentBrailleRoute { state.documentSelection = textSelection{} } state.current = result.DocumentPosition.Snapshot if brailleOutput != nil { state.brailleSnapshot = state.current brailleOptions := config.DefaultSettings().Braille textAttributes := config.DefaultTextAttributes() if live != nil { settings := live.Load() brailleOptions, textAttributes = settings.Braille, settings.TextAttributes } brailleOutput.EnqueueFrame(brailleDocumentFrame(result, brailleOptions, textAttributes)) } state.focusedID = state.current.ID() // Browse navigation does not change native focus. Keep the // worker on its document anchor; dynamic pages can remove the // spoken leaf before the next review command. if result.DocumentCaretCommand == 0 && browser == nil { objectReview.SyncFocus(state.current.ID()) } if browser != nil { objectReview.SetFlatOrigin(state.current.ID(), result.DocumentPosition.Offset) browser.navigated(result.DocumentPosition, objectReview.Focus()) } } } else if result.FlatUnit != 0 { if reservedWineID(result.FlatFocus) && (handler.wineReview == nil || !handler.wineReview.resultCurrent(result)) { return } // The browse caret can be a leaf while native focus remains on // the document. Flat review belongs to that native review focus. if !flatReviewResultCurrent(objectReview, result) { logger.Debug("discarded stale flat review result", "result_focus", result.FlatFocus.Path, "current_focus", objectReview.Focus().Path, "document_epoch", result.DocumentEpoch) return } state.pending.terminalFocus.clear() state.pending.terminalDeletion.clear() presentFlatReviewResult(logger, speech, result) if reservedWineID(result.FlatFocus) && brailleOutput != nil && result.Err == nil && (result.Moved || result.FlatStep == 0 && result.FlatUnit != review.WordAbove && result.FlatUnit != review.WordBelow) { options := config.DefaultSettings().Braille if live != nil { options = live.Load().Braille } // Review is independent of the native caret. Display through the // existing queue with no native text routes or document navigation. brailleOutput.EnqueueFrame(braille.Frame{Text: result.FlatPosition.Text, Cursor: 0, WordWrap: options.WordWrap}) } } else { presentObjectReviewResult(logger, speech, result, state.presentationOptions) } } func (handler *eventReviewHandler) refreshPresentation() { if handler.live == nil { return } available := handler.state.presentationOptions.SoundCueAvailable handler.state.presentationOptions = presentationSettingsFromConfig(handler.live.Load(), true) handler.state.presentationOptions.SoundCueAvailable = available } func flatReviewResultCurrent(worker *review.Worker, result review.Result) bool { return worker != nil && result.FlatFocus.Valid() && worker.CurrentDocumentResult(result) && result.FlatFocus == worker.Focus() } func queueObjectReviewMove(logger *slog.Logger, worker *review.Worker, direction review.Direction) { logger.Info("object review command received", "direction", direction.String()) if err := worker.Move(direction); err != nil { logger.Warn("queue object review movement", "direction", direction.String(), "error", err) } } func presentObjectReviewResult(logger *slog.Logger, speech speechEnqueuer, result review.Result, options presentation.Options) { text := "" var spoken presentation.Speech role := "system" if result.Err != nil { logger.Warn("object review movement failed", "direction", result.Direction.String(), "error", result.Err) text = "Could not find current location." } else if !result.Moved { text = objectReviewBoundaryMessage(result.Direction) } else { options.EnableSound = false spoken = presentation.FocusSpeechWithOptions(result.Position.Current, options) text = spoken.Text role = result.Position.Current.Role if text == "" { text = "Could not find current location." role = "system" } } speech.Interrupt() speech.Enqueue(speechRequest{Text: text, Chunks: spoken.WithText(text).Chunks, Role: role, Cause: "object-review"}) logger.Debug("queued object review presentation", "direction", result.Direction.String(), "moved", result.Moved, "role", role) } func objectReviewBoundaryMessage(direction review.Direction) string { switch direction { case review.Parent: return "No parent." case review.FirstChild: return "No children." case review.PreviousSibling: return "No previous." case review.NextSibling: return "No next." default: return "Could not find current location." } }