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
6 changes: 5 additions & 1 deletion Textream/Textream/ExternalDisplayController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,16 @@ class ExternalDisplayController {
return screens.first
}

func show(speechRecognizer: SpeechRecognizer, words: [String], totalCharCount: Int, hasNextPage: Bool = false) {
func show(speechRecognizer: SpeechRecognizer, words: [String], lineBreaksAfter: Set<Int> = [], blankLineAfter: Set<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.lineBreaksAfter = lineBreaksAfter
overlayContent.blankLineAfter = blankLineAfter
overlayContent.totalCharCount = totalCharCount
overlayContent.hasNextPage = hasNextPage

Expand Down Expand Up @@ -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,
Expand Down
63 changes: 58 additions & 5 deletions Textream/Textream/MarqueeTextView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Int>, blankLineAfter: Set<Int>) {
var breaksAfter: Set<Int> = []
var blankLineAfter: Set<Int> = []
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 {
Expand All @@ -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<Int> = []
/// Subset of `lineBreaksAfter` followed by a blank line (paragraph break).
var blankLineAfter: Set<Int> = []
let highlightedCharCount: Int
var font: NSFont = .systemFont(ofSize: 18, weight: .semibold)
var highlightColor: Color = .white
Expand All @@ -104,6 +137,8 @@ struct SpeechScrollView: View {
GeometryReader { geo in
WordFlowLayout(
words: words,
lineBreaksAfter: lineBreaksAfter,
blankLineAfter: blankLineAfter,
highlightedCharCount: highlightedCharCount,
font: font,
highlightColor: highlightColor,
Expand Down Expand Up @@ -331,6 +366,8 @@ struct SpeechScrollView: View {

struct WordFlowLayout: View {
let words: [String]
var lineBreaksAfter: Set<Int> = []
var blankLineAfter: Set<Int> = []
let highlightedCharCount: Int
let font: NSFont
var highlightColor: Color = .white
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -404,10 +441,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)
if lines[lineIdx].isEmpty {
// Blank line (paragraph break) — same height as a text line
// so the visibility-culling math stays uniform
Color.clear.frame(height: lineH - lineSpacing)
} else {
HStack(spacing: 0) {
ForEach(lines[lineIdx], id: \.id) { item in
wordView(for: item, isNextWord: item.id == nextIdx)
.id(item.id)
}
}
}
}
Expand Down Expand Up @@ -534,6 +577,16 @@ struct WordFlowLayout: View {
}
lines[lines.count - 1].append(item)
currentLineWidth += wordWidth

// Hard break from the source text: start a new line, with an
// empty spacer line for paragraph breaks
if lineBreaksAfter.contains(item.id) {
if blankLineAfter.contains(item.id) {
lines.append([])
}
lines.append([])
currentLineWidth = 0
}
}
return lines
}
Expand Down
14 changes: 14 additions & 0 deletions Textream/Textream/NotchOverlayController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ class NotchFrameTracker {
@Observable
class OverlayContent {
var words: [String] = []
/// Word indices after which the source text had a hard line break
var lineBreaksAfter: Set<Int> = []
/// Subset of `lineBreaksAfter` followed by a blank line (paragraph break)
var blankLineAfter: Set<Int> = []
var totalCharCount: Int = 0
var hasNextPage: Bool = false

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

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

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions Textream/Textream/TextreamService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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

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

Expand Down