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