Files
cthulhu/internal/app/event_review_results.go

282 lines
11 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 (
"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."
}
}