From 7da1d7edf0fc8158ecadf3137d9282a50a69ed8a Mon Sep 17 00:00:00 2001 From: Justin Cone Date: Fri, 24 Jul 2026 11:59:10 -0500 Subject: [PATCH] Preserve script line breaks in teleprompter views The word splitter collapsed newlines into spaces, so scripts rendered as one wrapped paragraph. Line and paragraph break positions are now computed per word index (lineBreakIndices) and passed alongside the words array, which stays unchanged so char-offset-based speech tracking, tap-to-jump, and remote sync are unaffected. WordFlowLayout starts a new line at each break and renders paragraph breaks as one blank line of matching height to keep scroll culling uniform. Applies to notch, floating, fullscreen, and external display views, including director mode updates. Co-Authored-By: Claude Fable 5 --- .../Textream/ExternalDisplayController.swift | 6 +- Textream/Textream/MarqueeTextView.swift | 63 +++++++++++++++++-- .../Textream/NotchOverlayController.swift | 14 +++++ Textream/Textream/TextreamService.swift | 12 ++++ 4 files changed, 89 insertions(+), 6 deletions(-) diff --git a/Textream/Textream/ExternalDisplayController.swift b/Textream/Textream/ExternalDisplayController.swift index f239e63..be49b98 100644 --- a/Textream/Textream/ExternalDisplayController.swift +++ b/Textream/Textream/ExternalDisplayController.swift @@ -29,7 +29,7 @@ class ExternalDisplayController { return screens.first } - func show(speechRecognizer: SpeechRecognizer, words: [String], totalCharCount: Int, hasNextPage: Bool = false) { + func show(speechRecognizer: SpeechRecognizer, words: [String], lineBreaksAfter: Set = [], blankLineAfter: Set = [], totalCharCount: Int, hasNextPage: Bool = false) { let settings = NotchSettings.shared guard settings.externalDisplayMode != .off else { return } guard let screen = targetScreen() else { return } @@ -37,6 +37,8 @@ class ExternalDisplayController { dismiss() overlayContent.words = words + overlayContent.lineBreaksAfter = lineBreaksAfter + overlayContent.blankLineAfter = blankLineAfter overlayContent.totalCharCount = totalCharCount overlayContent.hasNextPage = hasNextPage @@ -222,6 +224,8 @@ struct ExternalDisplayView: View { SpeechScrollView( words: words, + lineBreaksAfter: content.lineBreaksAfter, + blankLineAfter: content.blankLineAfter, highlightedCharCount: effectiveCharCount, font: .systemFont(ofSize: fontSize, weight: .semibold), highlightColor: NotchSettings.shared.fontColorPreset.color, diff --git a/Textream/Textream/MarqueeTextView.swift b/Textream/Textream/MarqueeTextView.swift index 764a1d6..6c36efc 100644 --- a/Textream/Textream/MarqueeTextView.swift +++ b/Textream/Textream/MarqueeTextView.swift @@ -56,6 +56,35 @@ func splitTextIntoWords(_ text: String) -> [String] { return result } +/// Computes hard line breaks from the original text, keyed by word index. +/// Returns word indices (matching the array from `splitTextIntoWords`) after +/// which the layout should start a new line, and the subset also followed by +/// a blank line (paragraph break). The words array itself stays unchanged so +/// character offsets used for speech tracking are unaffected. +func lineBreakIndices(_ text: String) -> (breaksAfter: Set, blankLineAfter: Set) { + var breaksAfter: Set = [] + var blankLineAfter: Set = [] + var wordCount = 0 + var lastContentWordIndex: Int? = nil + var sawBlankLine = false + + for line in text.split(separator: "\n", omittingEmptySubsequences: false) { + let lineWords = splitTextIntoWords(String(line)) + if lineWords.isEmpty { + sawBlankLine = true + continue + } + if let idx = lastContentWordIndex { + breaksAfter.insert(idx) + if sawBlankLine { blankLineAfter.insert(idx) } + } + wordCount += lineWords.count + lastContentWordIndex = wordCount - 1 + sawBlankLine = false + } + return (breaksAfter, blankLineAfter) +} + // MARK: - Data struct WordItem: Identifiable { @@ -78,6 +107,10 @@ struct WordYPreferenceKey: PreferenceKey { struct SpeechScrollView: View { let words: [String] + /// Word indices after which the source text had a hard line break. + var lineBreaksAfter: Set = [] + /// Subset of `lineBreaksAfter` followed by a blank line (paragraph break). + var blankLineAfter: Set = [] let highlightedCharCount: Int var font: NSFont = .systemFont(ofSize: 18, weight: .semibold) var highlightColor: Color = .white @@ -104,6 +137,8 @@ struct SpeechScrollView: View { GeometryReader { geo in WordFlowLayout( words: words, + lineBreaksAfter: lineBreaksAfter, + blankLineAfter: blankLineAfter, highlightedCharCount: highlightedCharCount, font: font, highlightColor: highlightColor, @@ -331,6 +366,8 @@ struct SpeechScrollView: View { struct WordFlowLayout: View { let words: [String] + var lineBreaksAfter: Set = [] + var blankLineAfter: Set = [] let highlightedCharCount: Int let font: NSFont var highlightColor: Color = .white @@ -358,7 +395,7 @@ struct WordFlowLayout: View { private static var _cachedLines: [[WordItem]] = [] private func cachedLayout() -> ([WordItem], [[WordItem]]) { - let key = "\(words.count)|\(words.first ?? "")|\(words.last ?? "")|\(font.pointSize)|\(Int(containerWidth))" + let key = "\(words.count)|\(words.first ?? "")|\(words.last ?? "")|\(font.pointSize)|\(Int(containerWidth))|\(lineBreaksAfter.hashValue)|\(blankLineAfter.hashValue)" if key == Self._cacheKey { return (Self._cachedItems, Self._cachedLines) } @@ -404,10 +441,16 @@ struct WordFlowLayout: View { } ForEach(startLine.. = [] + /// Subset of `lineBreaksAfter` followed by a blank line (paragraph break) + var blankLineAfter: Set = [] var totalCharCount: Int = 0 var hasNextPage: Bool = false @@ -72,7 +76,10 @@ class NotchOverlayController: NSObject { // Populate overlay content let normalized = splitTextIntoWords(text) + let (breaksAfter, blankAfter) = lineBreakIndices(text) overlayContent.words = normalized + overlayContent.lineBreaksAfter = breaksAfter + overlayContent.blankLineAfter = blankAfter overlayContent.totalCharCount = normalized.joined(separator: " ").count overlayContent.hasNextPage = hasNextPage @@ -123,6 +130,7 @@ class NotchOverlayController: NSObject { func updateContent(text: String, hasNextPage: Bool) { let normalized = splitTextIntoWords(text) + let (breaksAfter, blankAfter) = lineBreakIndices(text) // Fully reset speech state for new page speechRecognizer.recognizedCharCount = 0 @@ -131,6 +139,8 @@ class NotchOverlayController: NSObject { speechRecognizer.lastSpokenText = "" overlayContent.words = normalized + overlayContent.lineBreaksAfter = breaksAfter + overlayContent.blankLineAfter = blankAfter overlayContent.totalCharCount = normalized.joined(separator: " ").count overlayContent.hasNextPage = hasNextPage @@ -875,6 +885,8 @@ struct NotchOverlayView: View { VStack(spacing: 0) { SpeechScrollView( words: words, + lineBreaksAfter: content.lineBreaksAfter, + blankLineAfter: content.blankLineAfter, highlightedCharCount: effectiveCharCount, font: NotchSettings.shared.font, highlightColor: NotchSettings.shared.fontColorPreset.color, @@ -1372,6 +1384,8 @@ struct FloatingOverlayView: View { VStack(spacing: 0) { SpeechScrollView( words: words, + lineBreaksAfter: content.lineBreaksAfter, + blankLineAfter: content.blankLineAfter, highlightedCharCount: effectiveCharCount, font: NotchSettings.shared.font, highlightColor: NotchSettings.shared.fontColorPreset.color, diff --git a/Textream/Textream/TextreamService.swift b/Textream/Textream/TextreamService.swift index fc46311..30d265e 100644 --- a/Textream/Textream/TextreamService.swift +++ b/Textream/Textream/TextreamService.swift @@ -54,10 +54,13 @@ class TextreamService: NSObject, ObservableObject { // Also show on external display if configured (same parsing as overlay) let words = splitTextIntoWords(trimmed) + let (breaksAfter, blankAfter) = lineBreakIndices(trimmed) let totalCharCount = words.joined(separator: " ").count externalDisplayController.show( speechRecognizer: overlayController.speechRecognizer, words: words, + lineBreaksAfter: breaksAfter, + blankLineAfter: blankAfter, totalCharCount: totalCharCount, hasNextPage: hasNextPage ) @@ -114,7 +117,10 @@ class TextreamService: NSObject, ObservableObject { // Also update external display content in-place let words = splitTextIntoWords(trimmed) + let (breaksAfter, blankAfter) = lineBreakIndices(trimmed) externalDisplayController.overlayContent.words = words + externalDisplayController.overlayContent.lineBreaksAfter = breaksAfter + externalDisplayController.overlayContent.blankLineAfter = blankAfter externalDisplayController.overlayContent.totalCharCount = words.joined(separator: " ").count externalDisplayController.overlayContent.hasNextPage = hasNextPage @@ -378,9 +384,12 @@ class TextreamService: NSObject, ObservableObject { ) // Also show on external display & browser if configured + let (breaksAfter, blankAfter) = lineBreakIndices(trimmed) externalDisplayController.show( speechRecognizer: overlayController.speechRecognizer, words: words, + lineBreaksAfter: breaksAfter, + blankLineAfter: blankAfter, totalCharCount: totalCharCount, hasNextPage: false ) @@ -408,7 +417,10 @@ class TextreamService: NSObject, ObservableObject { let totalCharCount = words.joined(separator: " ").count // Update overlay content without resetting speech progress + let (breaksAfter, blankAfter) = lineBreakIndices(trimmed) overlayController.overlayContent.words = words + overlayController.overlayContent.lineBreaksAfter = breaksAfter + overlayController.overlayContent.blankLineAfter = blankAfter overlayController.overlayContent.totalCharCount = totalCharCount overlayController.overlayContent.hasNextPage = false