// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/presentation" "git.stormux.org/storm/cthulhu/internal/review" ) func (worker *speechWorker) context_messages(ancestors []atspi.Snapshot) []string { return worker.context_messages_for(ancestors, false) } func (worker *speechWorker) context_messages_for(ancestors []atspi.Snapshot, sayAll bool) []string { var messages []string for _, message := range worker.context_announcements_for(ancestors, sayAll) { messages = append(messages, message.text) } return messages } type readingContextMessage struct { text string leaving bool } func (worker *speechWorker) context_announcements_for(ancestors []atspi.Snapshot, sayAll bool) []readingContextMessage { if ancestors == nil || worker.reading == nil { return nil } current := make([]atspi.Snapshot, 0, len(ancestors)) for _, ancestor := range ancestors { if presentation.ContextType(ancestor) != "" { current = append(current, ancestor) } } previous := worker.speechContext worker.speechContext = current // A new capture replaces both labels and their authority. Callers without // an admitted local request cannot authorize later cached exit labels. worker.speechContextObservation = nil settings := worker.reading() contains := func(values []atspi.Snapshot, candidate atspi.Snapshot) bool { for _, item := range values { if item.ID() == candidate.ID() && item.Role == candidate.Role && item.XMLRoles == candidate.XMLRoles { return true } } return false } var messages []readingContextMessage panelMessages := make(map[string]bool) appendMessage := func(item atspi.Snapshot, message string, leaving bool) { // Keep every container identity for later transitions, but say an // identical panel label only once in this transition. if presentation.ContextType(item) == "panel" { if panelMessages[message] { return } panelMessages[message] = true } messages = append(messages, readingContextMessage{text: message, leaving: leaving}) } enabled := presentation.ContextEnabled verbosity := 0 if worker.verbosity != nil { verbosity = worker.verbosity() } if sayAll { enabled = presentation.SayAllContextEnabled } for _, item := range previous { // Article labels can contain an entire chat message. Python announces // them on entry only; repeating the label on exit obscures the next one. if item.Role != "article" && !contains(current, item) && enabled(presentation.ContextType(item), settings) { appendMessage(item, "Leaving "+presentation.ContextLabel(item)+".", true) } } for index := len(current) - 1; index >= 0; index-- { item := current[index] if !contains(previous, item) && enabled(presentation.ContextType(item), settings) { appendMessage(item, presentation.ContextCountLabel(item, verbosity), false) } } return messages } func (worker *speechWorker) deliver_context(ctx context.Context, request speechRequest) error { if request.ResetContext { worker.speechContext = nil worker.speechContextObservation = nil return nil } return worker.deliver_context_for(ctx, request, request.Context, false) } func (browser *browserModeController) speech_context_enabled() bool { return browser != nil && browser.ready && browser.mode == review.BrowseMode && browser.document.Valid() } // Review results can also come from focus mode or native text. Capture the // mode on the event loop rather than reading browser state on speech delivery. type contextSpeechQueue struct { speechEnqueuer enabled bool } func (queue contextSpeechQueue) Enqueue(request speechRequest) { request.ResetContext = request.ResetContext || !queue.enabled queue.speechEnqueuer.Enqueue(request) } func (worker *speechWorker) deliver_context_for(ctx context.Context, request speechRequest, ancestors []atspi.Snapshot, sayAll bool) error { previousObservation := worker.speechContextObservation messages := worker.context_announcements_for(ancestors, sayAll) if ancestors != nil && worker.reading != nil { // Source classification precedes presentation filtering. Independently // check every retained context, including its unfiltered ancestry. eligible := exportable_speech(request) for _, ancestor := range ancestors { eligible = eligible && accessibility_speech_origin(ancestor) == speechOriginLocal } if eligible { worker.speechContextObservation = request.observation } } for _, message := range messages { delivery := request delivery.Role, delivery.Cause, delivery.Append = "system", "reading-context", true delivery.Character, delivery.CustomEcho = false, false delivery.observation = worker.speechContextObservation if message.leaving && delivery.observation != nil { // Cached labels retain their original lease. New consent may admit // current content, but cannot adopt labels from an earlier session. delivery.observation = previousObservation } worker.logDelivery(ctx, delivery, message.text, -1) worker.observe_speech(ctx, delivery, message.text, false) if voiced, ok := worker.speaker.(interface { SpeakVoice(context.Context, string, string) error }); ok { if err := voiced.SpeakVoice(ctx, message.text, "system"); err != nil { return err } } else if err := worker.speaker.Speak(ctx, message.text); err != nil { return err } } return nil } func document_line_voice(line atspi.DocumentLine) string { for _, content := range line.Contents { if content.Snapshot.Role == "link" { return "hyperlink" } } return "default" } func ancestor_voice(ancestors []atspi.Snapshot) string { for _, ancestor := range ancestors { if ancestor.Role == "link" { return "hyperlink" } } return "" }