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