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(