Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion Textream/Textream/ExternalDisplayController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,15 @@ 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 }

dismiss()

overlayContent.words = words
overlayContent.lineBreaks = lineBreaks
overlayContent.totalCharCount = totalCharCount
overlayContent.hasNextPage = hasNextPage

Expand Down Expand Up @@ -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 }

Expand Down Expand Up @@ -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,
Expand Down
156 changes: 124 additions & 32 deletions Textream/Textream/MarqueeTextView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -104,6 +159,7 @@ struct SpeechScrollView: View {
GeometryReader { geo in
WordFlowLayout(
words: words,
lineBreaks: lineBreaks,
highlightedCharCount: highlightedCharCount,
font: font,
highlightColor: highlightColor,
Expand Down Expand Up @@ -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
Expand All @@ -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)
}
Expand Down Expand Up @@ -404,10 +480,16 @@ struct WordFlowLayout: View {
}

ForEach(startLine..<endLine, id: \.self) { lineIdx in
HStack(spacing: 0) {
ForEach(lines[lineIdx], id: \.id) { item in
wordView(for: item, isNextWord: item.id == nextIdx)
.id(item.id)
let line = lines[lineIdx]
if line.isEmpty {
// Blank line preserved from the source text (paragraph gap)
Color.clear.frame(height: emptyLineHeight)
} else {
HStack(spacing: 0) {
ForEach(line, id: \.id) { item in
wordView(for: item, isNextWord: item.id == nextIdx)
.id(item.id)
}
}
}
}
Expand Down Expand Up @@ -527,6 +609,16 @@ struct WordFlowLayout: View {
let spaceWidth = (" " as NSString).size(withAttributes: [.font: font]).width

for item in items {
// Honor the author's hard line breaks from the source text. One
// appended line per newline: the last becomes this word's fresh
// line; any extra ones stay blank (paragraph gaps).
if let newlines = lineBreaks[item.id], newlines > 0 {
for _ in 0..<newlines {
lines.append([])
}
currentLineWidth = 0
}

let wordWidth = (item.word as NSString).size(withAttributes: [.font: font]).width + spaceWidth
if currentLineWidth + wordWidth > containerWidth && !lines[lines.count - 1].isEmpty {
lines.append([])
Expand Down
29 changes: 22 additions & 7 deletions Textream/Textream/NotchOverlayController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 }

Expand Down Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions Textream/Textream/NotchSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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") }
}
Expand Down Expand Up @@ -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")
Expand Down
18 changes: 18 additions & 0 deletions Textream/Textream/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand Down
Loading