// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "log/slog" "strings" "unicode" "git.stormux.org/storm/cthulhu/internal/config" "git.stormux.org/storm/cthulhu/internal/presentation" "git.stormux.org/storm/cthulhu/internal/review" ) func presentReadAllResult(logger *slog.Logger, speech speechEnqueuer, result review.Result, options presentation.Options) { if result.ReadAllContext == nil || result.ReadAllContext.Err() != nil { return } text := "" var parts []readAllPart if result.Err != nil { logger.Warn("read all failed", "error", result.Err) text = "Could not read from current location." } else { // Keep semantic roles audible in continuous reading, without firing all // the page's sound cues before the speech has reached those objects. options.RoleSoundPresentation = "sound_and_speech" // Python reads each cell once in Say All rather than expanding every // cell into the same complete row. reading := config.DefaultSettings().Reading if options.Reading != nil { reading = *options.Reading } reading.ReadFullRowInDocumentTable, reading.ReadFullRowInSpreadSheet = false, false options.Reading = &reading options.ReadFullRowInGUITable = false for index, line := range result.ReadAllLines { if spoken := presentation.DocumentLineSpeechWithOptions(line, options); spoken.Text != "" { var began func() if result.ReadAllProgress != nil { began = func() { result.ReadAllProgress(index) } } values := spoken.Chunks if len(values) == 0 { values = []string{spoken.Text} } var chunks []readAllPart for _, value := range values { chunks = append(chunks, readAllLineParts(value, began, options.SayAllStyle)...) began = nil } chunks[0].Line = document_speech_line(line) for index := range chunks { chunks[index].VoiceType = document_line_voice(line) } chunks[0].Context = line.Context parts = append(parts, chunks...) } } } if text != "" || len(parts) != 0 { speech.Enqueue(speechRequest{Text: text, ReadAll: parts, Role: "system", Cause: "read-all", context: result.ReadAllContext}) } } // Keep each source line's first callback attached to its first spoken chunk. // The review worker tracks line positions, so sentence mode splits within a // line and never merges lines whose separate progress updates drive the caret. func readAllLineParts(text string, began func(), style string) []readAllPart { if style != "sentence" { return []readAllPart{{Text: text, Began: began}} } runes := []rune(text) var parts []readAllPart start := 0 for index, value := range runes { if value != '.' && value != '!' && value != '?' { continue } if index+1 < len(runes) && !unicode.IsSpace(runes[index+1]) { continue } chunk := strings.TrimSpace(string(runes[start : index+1])) if chunk != "" { parts = append(parts, readAllPart{Text: chunk, Began: began}) began = nil } start = index + 1 } if tail := strings.TrimSpace(string(runes[start:])); tail != "" { parts = append(parts, readAllPart{Text: tail, Began: began}) } return parts }