From ddb03713409dd85db5b2ac0561c03490ef9bb7bc Mon Sep 17 00:00:00 2001 From: Nelson Djalo Date: Sat, 18 Jul 2026 01:43:27 +0100 Subject: [PATCH] Add "Preserve Line Breaks" teleprompter setting The teleprompter flattened every newline into a space (splitTextIntoWords did replacingOccurrences(of: "\n", with: " ")) and reflowed the words purely by container width, so the on-screen text never matched the line breaks and paragraph gaps the author typed in the editor. Add tokenizeText(), which tokenizes exactly as before but also records how many newlines preceded each word. That metadata (lineBreaks) is threaded through OverlayContent -> SpeechScrollView -> WordFlowLayout, which forces a line break at each hard newline and inserts blank lines for paragraph gaps. Expose it as a new Appearance setting, "Preserve Line Breaks" (on by default). When off, the prompter falls back to the previous width-only reflow. splitTextIntoWords is unchanged for callers that only need words (speech matching, char offsets), so tracking/highlighting is identical. Covers the notch, floating, fullscreen and external-display prompters. --- .../Textream/ExternalDisplayController.swift | 7 +- Textream/Textream/MarqueeTextView.swift | 156 ++++++++++++++---- .../Textream/NotchOverlayController.swift | 29 +++- Textream/Textream/NotchSettings.swift | 7 + Textream/Textream/SettingsView.swift | 18 ++ Textream/Textream/TextreamService.swift | 17 +- 6 files changed, 190 insertions(+), 44 deletions(-) diff --git a/Textream/Textream/ExternalDisplayController.swift b/Textream/Textream/ExternalDisplayController.swift index f239e63..2c40aeb 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], lineBreaks: [Int: Int] = [:], totalCharCount: Int, hasNextPage: Bool = false) { let settings = NotchSettings.shared guard settings.externalDisplayMode != .off else { return } guard let screen = targetScreen() else { return } @@ -37,6 +37,7 @@ class ExternalDisplayController { dismiss() overlayContent.words = words + overlayContent.lineBreaks = lineBreaks overlayContent.totalCharCount = totalCharCount overlayContent.hasNextPage = hasNextPage @@ -110,6 +111,9 @@ struct ExternalDisplayView: View { let mirrorAxis: MirrorAxis? private var words: [String] { content.words } + private var lineBreaks: [Int: Int] { + NotchSettings.shared.preserveLineBreaks ? content.lineBreaks : [:] + } private var totalCharCount: Int { content.totalCharCount } private var hasNextPage: Bool { content.hasNextPage } @@ -222,6 +226,7 @@ struct ExternalDisplayView: View { SpeechScrollView( words: words, + lineBreaks: lineBreaks, 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..39f6aa3 100644 --- a/Textream/Textream/MarqueeTextView.swift +++ b/Textream/Textream/MarqueeTextView.swift @@ -22,40 +22,92 @@ extension Unicode.Scalar { } } -/// Splits text into display-ready words. CJK characters (Chinese, Japanese, Korean) -/// are split into individual characters so the flow layout can wrap them properly. -func splitTextIntoWords(_ text: String) -> [String] { - let tokens = text.replacingOccurrences(of: "\n", with: " ") - .split(omittingEmptySubsequences: true, whereSeparator: { $0.isWhitespace }) - .map { String($0) } - +/// Splits a single whitespace-free token into display words. CJK characters +/// (Chinese, Japanese, Korean) are split into individual characters so the flow +/// layout can wrap them properly; runs of non-CJK characters stay grouped. +private func splitTokenCJK(_ token: String) -> [String] { + guard token.unicodeScalars.contains(where: { $0.isCJK }) else { + return [token] + } var result: [String] = [] - for token in tokens { - guard token.unicodeScalars.contains(where: { $0.isCJK }) else { - result.append(token) - continue - } - // Token contains CJK characters — split each CJK char individually; - // consecutive non-CJK chars (e.g. Latin letters, digits) stay grouped. - var buffer = "" - for char in token { - if char.unicodeScalars.first.map({ $0.isCJK }) == true { - if !buffer.isEmpty { - result.append(buffer) - buffer = "" - } - result.append(String(char)) - } else { - buffer.append(char) + var buffer = "" + for char in token { + if char.unicodeScalars.first.map({ $0.isCJK }) == true { + if !buffer.isEmpty { + result.append(buffer) + buffer = "" } + result.append(String(char)) + } else { + buffer.append(char) } - if !buffer.isEmpty { - result.append(buffer) - } + } + if !buffer.isEmpty { + result.append(buffer) } return result } +/// The result of tokenizing script text for the teleprompter: the flat word +/// array plus the hard line breaks that were present in the source. +struct TokenizedText { + /// Display-ready words (same tokens `splitTextIntoWords` produces). + let words: [String] + /// Maps a word index to the number of newlines that appeared immediately + /// before it in the source text. `1` means "start a new line here", `2+` + /// additionally inserts blank lines (a paragraph gap). Word 0 is never a key. + let breaksBefore: [Int: Int] +} + +/// Tokenizes script text while remembering the author's hard line breaks so the +/// teleprompter can lay the text out exactly as it appears in the editor. +/// +/// Word tokenization is identical to `splitTextIntoWords`: whitespace-separated, +/// with CJK characters split per-glyph. In addition, each run of whitespace +/// between two words is inspected for newlines, and their count is attached to +/// the word that follows. +func tokenizeText(_ text: String) -> TokenizedText { + var words: [String] = [] + var breaksBefore: [Int: Int] = [:] + + var current = "" // non-whitespace run being accumulated + var pendingNewlines = 0 // newlines seen in the gap before the next word + var sawWord = false // ignore leading whitespace/newlines + + func flush() { + guard !current.isEmpty else { return } + let subWords = splitTokenCJK(current) + if sawWord && pendingNewlines > 0 { + breaksBefore[words.count] = pendingNewlines + } + words.append(contentsOf: subWords) + sawWord = true + pendingNewlines = 0 + current = "" + } + + for char in text { + if char.isWhitespace { + flush() + if char == "\n" { pendingNewlines += 1 } + } else { + current.append(char) + } + } + flush() + + return TokenizedText(words: words, breaksBefore: breaksBefore) +} + +/// Splits text into display-ready words. CJK characters (Chinese, Japanese, Korean) +/// are split into individual characters so the flow layout can wrap them properly. +/// +/// Line breaks are flattened into spaces here; callers that need to preserve the +/// author's line breaks (the teleprompter layout) use `tokenizeText` instead. +func splitTextIntoWords(_ text: String) -> [String] { + tokenizeText(text).words +} + // MARK: - Data struct WordItem: Identifiable { @@ -78,6 +130,9 @@ struct WordYPreferenceKey: PreferenceKey { struct SpeechScrollView: View { let words: [String] + /// Hard line breaks from the source text (word index -> newline count), + /// forwarded to the flow layout so the teleprompter mirrors the editor. + var lineBreaks: [Int: Int] = [:] let highlightedCharCount: Int var font: NSFont = .systemFont(ofSize: 18, weight: .semibold) var highlightColor: Color = .white @@ -104,6 +159,7 @@ struct SpeechScrollView: View { GeometryReader { geo in WordFlowLayout( words: words, + lineBreaks: lineBreaks, highlightedCharCount: highlightedCharCount, font: font, highlightColor: highlightColor, @@ -331,6 +387,9 @@ struct SpeechScrollView: View { struct WordFlowLayout: View { let words: [String] + /// Hard line breaks from the source text: word index -> number of newlines + /// immediately before that word. Drives forced line breaks and blank lines. + var lineBreaks: [Int: Int] = [:] let highlightedCharCount: Int let font: NSFont var highlightColor: Color = .white @@ -352,13 +411,30 @@ struct WordFlowLayout: View { return ratio > 1.5 ? 2 : 8 } + // Height of one text line's content (excluding inter-line spacing). Used to + // give blank lines the same pitch as text lines. + private var emptyLineHeight: CGFloat { + ceil(font.ascender - font.descender + font.leading) + } + + // Order-independent signature of the hard line breaks so the layout cache + // invalidates when the break structure changes even if the words are identical + // (e.g. "a b" vs "a\nb" tokenize to the same words). + private var lineBreaksSignature: Int { + var hash = lineBreaks.count + for (k, v) in lineBreaks { + hash = hash &+ (k &* 100_003) &+ (v &* 31) + } + return hash + } + // Simple layout cache to avoid re-measuring words on every highlight update private static var _cacheKey: String = "" private static var _cachedItems: [WordItem] = [] 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))|\(lineBreaksSignature)" if key == Self._cacheKey { return (Self._cachedItems, Self._cachedLines) } @@ -404,10 +480,16 @@ struct WordFlowLayout: View { } ForEach(startLine.. 0 { + for _ in 0.. containerWidth && !lines[lines.count - 1].isEmpty { lines.append([]) diff --git a/Textream/Textream/NotchOverlayController.swift b/Textream/Textream/NotchOverlayController.swift index ca95e34..903a8ce 100644 --- a/Textream/Textream/NotchOverlayController.swift +++ b/Textream/Textream/NotchOverlayController.swift @@ -33,9 +33,21 @@ class NotchFrameTracker { @Observable class OverlayContent { var words: [String] = [] + /// Hard line breaks from the source text: word index -> number of newlines + /// immediately before that word. Lets the teleprompter mirror the editor's + /// line breaks and paragraph gaps instead of reflowing purely by width. + var lineBreaks: [Int: Int] = [:] var totalCharCount: Int = 0 var hasNextPage: Bool = false + /// Populate `words`, `lineBreaks`, and `totalCharCount` from raw script text. + func apply(text: String) { + let tokenized = tokenizeText(text) + words = tokenized.words + lineBreaks = tokenized.breaksBefore + totalCharCount = tokenized.words.joined(separator: " ").count + } + // Page picker var pageCount: Int = 1 var currentPageIndex: Int = 0 @@ -71,9 +83,7 @@ class NotchOverlayController: NSObject { observeDismiss() // Populate overlay content - let normalized = splitTextIntoWords(text) - overlayContent.words = normalized - overlayContent.totalCharCount = normalized.joined(separator: " ").count + overlayContent.apply(text: text) overlayContent.hasNextPage = hasNextPage let settings = NotchSettings.shared @@ -122,16 +132,13 @@ class NotchOverlayController: NSObject { } func updateContent(text: String, hasNextPage: Bool) { - let normalized = splitTextIntoWords(text) - // Fully reset speech state for new page speechRecognizer.recognizedCharCount = 0 speechRecognizer.shouldDismiss = false speechRecognizer.shouldAdvancePage = false speechRecognizer.lastSpokenText = "" - overlayContent.words = normalized - overlayContent.totalCharCount = normalized.joined(separator: " ").count + overlayContent.apply(text: text) overlayContent.hasNextPage = hasNextPage let settings = NotchSettings.shared @@ -638,6 +645,9 @@ struct NotchOverlayView: View { var frameTracker: NotchFrameTracker private var words: [String] { content.words } + private var lineBreaks: [Int: Int] { + NotchSettings.shared.preserveLineBreaks ? content.lineBreaks : [:] + } private var totalCharCount: Int { content.totalCharCount } private var hasNextPage: Bool { content.hasNextPage } @@ -875,6 +885,7 @@ struct NotchOverlayView: View { VStack(spacing: 0) { SpeechScrollView( words: words, + lineBreaks: lineBreaks, highlightedCharCount: effectiveCharCount, font: NotchSettings.shared.font, highlightColor: NotchSettings.shared.fontColorPreset.color, @@ -1211,6 +1222,9 @@ struct FloatingOverlayView: View { var followingCursor: Bool = false private var words: [String] { content.words } + private var lineBreaks: [Int: Int] { + NotchSettings.shared.preserveLineBreaks ? content.lineBreaks : [:] + } private var totalCharCount: Int { content.totalCharCount } private var hasNextPage: Bool { content.hasNextPage } @@ -1372,6 +1386,7 @@ struct FloatingOverlayView: View { VStack(spacing: 0) { SpeechScrollView( words: words, + lineBreaks: lineBreaks, highlightedCharCount: effectiveCharCount, font: NotchSettings.shared.font, highlightColor: NotchSettings.shared.fontColorPreset.color, diff --git a/Textream/Textream/NotchSettings.swift b/Textream/Textream/NotchSettings.swift index b136557..41354d9 100644 --- a/Textream/Textream/NotchSettings.swift +++ b/Textream/Textream/NotchSettings.swift @@ -411,6 +411,12 @@ class NotchSettings { didSet { UserDefaults.standard.set(showElapsedTime, forKey: "showElapsedTime") } } + /// When on, the teleprompter keeps the line breaks and paragraph gaps from + /// the script; when off, text is reflowed to fit the width (legacy behavior). + var preserveLineBreaks: Bool { + didSet { UserDefaults.standard.set(preserveLineBreaks, forKey: "preserveLineBreaks") } + } + var selectedMicUID: String { didSet { UserDefaults.standard.set(selectedMicUID, forKey: "selectedMicUID") } } @@ -496,6 +502,7 @@ class NotchSettings { self.scrollSpeed = savedSpeed > 0 ? savedSpeed : 3 self.hideFromScreenShare = UserDefaults.standard.object(forKey: "hideFromScreenShare") as? Bool ?? true self.showElapsedTime = UserDefaults.standard.object(forKey: "showElapsedTime") as? Bool ?? true + self.preserveLineBreaks = UserDefaults.standard.object(forKey: "preserveLineBreaks") as? Bool ?? true self.selectedMicUID = UserDefaults.standard.string(forKey: "selectedMicUID") ?? "" self.autoNextPage = UserDefaults.standard.object(forKey: "autoNextPage") as? Bool ?? false let savedDelay = UserDefaults.standard.integer(forKey: "autoNextPageDelay") diff --git a/Textream/Textream/SettingsView.swift b/Textream/Textream/SettingsView.swift index ad74631..d76d512 100644 --- a/Textream/Textream/SettingsView.swift +++ b/Textream/Textream/SettingsView.swift @@ -661,6 +661,23 @@ struct SettingsView: View { Divider() + // Text Layout + Text("Text Layout") + .font(.system(size: 13, weight: .medium)) + + Toggle(isOn: $settings.preserveLineBreaks) { + VStack(alignment: .leading, spacing: 2) { + Text("Preserve Line Breaks") + .font(.system(size: 13, weight: .medium)) + Text("Keep the line breaks and paragraph spacing from your script instead of reflowing the text to fit the width.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + } + .toggleStyle(.checkbox) + + Divider() + // Dimensions Text("Dimensions") .font(.system(size: 13, weight: .medium)) @@ -1419,6 +1436,7 @@ struct SettingsView: View { settings.listeningMode = .wordTracking settings.scrollSpeed = 3 settings.showElapsedTime = true + settings.preserveLineBreaks = true settings.selectedMicUID = "" settings.autoNextPage = false settings.autoNextPageDelay = 3 diff --git a/Textream/Textream/TextreamService.swift b/Textream/Textream/TextreamService.swift index fc46311..3446ad5 100644 --- a/Textream/Textream/TextreamService.swift +++ b/Textream/Textream/TextreamService.swift @@ -53,11 +53,13 @@ class TextreamService: NSObject, ObservableObject { updatePageInfo() // Also show on external display if configured (same parsing as overlay) - let words = splitTextIntoWords(trimmed) + let tokenized = tokenizeText(trimmed) + let words = tokenized.words let totalCharCount = words.joined(separator: " ").count externalDisplayController.show( speechRecognizer: overlayController.speechRecognizer, words: words, + lineBreaks: tokenized.breaksBefore, totalCharCount: totalCharCount, hasNextPage: hasNextPage ) @@ -113,8 +115,10 @@ class TextreamService: NSObject, ObservableObject { updatePageInfo() // Also update external display content in-place - let words = splitTextIntoWords(trimmed) + let tokenized = tokenizeText(trimmed) + let words = tokenized.words externalDisplayController.overlayContent.words = words + externalDisplayController.overlayContent.lineBreaks = tokenized.breaksBefore externalDisplayController.overlayContent.totalCharCount = words.joined(separator: " ").count externalDisplayController.overlayContent.hasNextPage = hasNextPage @@ -369,7 +373,8 @@ class TextreamService: NSObject, ObservableObject { } // Feed director server with speech recognizer - let words = splitTextIntoWords(trimmed) + let tokenized = tokenizeText(trimmed) + let words = tokenized.words let totalCharCount = words.joined(separator: " ").count directorServer.showContent( speechRecognizer: overlayController.speechRecognizer, @@ -381,6 +386,7 @@ class TextreamService: NSObject, ObservableObject { externalDisplayController.show( speechRecognizer: overlayController.speechRecognizer, words: words, + lineBreaks: tokenized.breaksBefore, totalCharCount: totalCharCount, hasNextPage: false ) @@ -404,11 +410,13 @@ class TextreamService: NSObject, ObservableObject { // Preserve read progress: only update unread portion let preservedCharCount = overlayController.speechRecognizer.recognizedCharCount - let words = splitTextIntoWords(trimmed) + let tokenized = tokenizeText(trimmed) + let words = tokenized.words let totalCharCount = words.joined(separator: " ").count // Update overlay content without resetting speech progress overlayController.overlayContent.words = words + overlayController.overlayContent.lineBreaks = tokenized.breaksBefore overlayController.overlayContent.totalCharCount = totalCharCount overlayController.overlayContent.hasNextPage = false @@ -420,6 +428,7 @@ class TextreamService: NSObject, ObservableObject { // Update external display & browser externalDisplayController.overlayContent.words = words + externalDisplayController.overlayContent.lineBreaks = tokenized.breaksBefore externalDisplayController.overlayContent.totalCharCount = totalCharCount if browserServer.isRunning { browserServer.updateContent(