From 54dbe782312a1ea8b9102a7d7c79b3a3e6c71d32 Mon Sep 17 00:00:00 2001 From: Sam Gutentag <1404219+samgutentag@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:57:07 -0700 Subject: [PATCH] fix: unify scroll anchor and add smooth-mode lookahead MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recalcCenter() anchors the active word, and wordProgressAtCurrentOffset() reads the word back at a reference line — but the two disagreed. Smooth modes (classic/silence-paused) anchored near the bottom edge while the resume path read from the vertical center, so releasing a manual scroll snapped the text by ~half the window height. Both now share a single readingAnchorY() helper. The smooth-mode anchor also sat 20pt above the bottom, giving the speaker zero lookahead — any word past the timer was below the window. It now sits at 70% of viewport height so a couple of upcoming lines stay visible. Word-tracking mode is unchanged (active word still centered). This is the still-relevant half of the original #64. The other two fixes in that PR — preferring the word-level matcher on divergence, and isSpeaking hysteresis — were independently superseded upstream by the SpeechTextAlignment.bestOffset refactor and the VoiceActivityDetector. Co-Authored-By: Claude Fable 5 --- Textream/Textream/MarqueeTextView.swift | 27 ++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/Textream/Textream/MarqueeTextView.swift b/Textream/Textream/MarqueeTextView.swift index 764a1d6..b3f6ba0 100644 --- a/Textream/Textream/MarqueeTextView.swift +++ b/Textream/Textream/MarqueeTextView.swift @@ -246,24 +246,33 @@ struct SpeechScrollView: View { ) } + /// Y position in the viewport where the active word is anchored. + /// Smooth modes (classic/silence-paused) anchor in the lower third so read + /// text stays visible above while the next lines remain visible below — + /// anchoring at the very bottom leaves the speaker no lookahead. + /// wordProgressAtCurrentOffset must use the same anchor, otherwise + /// releasing a manual scroll snaps the text by the difference. + private func readingAnchorY(containerHeight: CGFloat) -> CGFloat { + smoothScroll ? containerHeight * 0.7 : containerHeight * 0.5 + } + private func recalcCenter(containerHeight: CGFloat) { - let center = containerHeight * 0.5 + let anchor = readingAnchorY(containerHeight: containerHeight) if smoothScroll { - // Classic/silence-paused: anchor active word near the bottom, scrolling up - let bottomAnchor = containerHeight - 20 + // Classic/silence-paused: continuous word progress, interpolated let wordIdx = Int(smoothWordProgress) let fraction = smoothWordProgress - Double(wordIdx) let clampedIdx = max(0, min(wordIdx, words.count - 1)) guard let wordY = wordYPositions[clampedIdx] else { return } let nextY = wordYPositions[clampedIdx + 1] ?? wordY let interpolatedY = wordY + (nextY - wordY) * CGFloat(fraction) - scrollOffset = bottomAnchor - interpolatedY + scrollOffset = anchor - interpolatedY } else { - // Word-tracking/voice-activated: active word at vertical center + // Word tracking: active word at vertical center let wordIdx = activeWordIndex() if let wordY = wordYPositions[wordIdx] { - let target = center - wordY + let target = anchor - wordY // Only update if it actually changed to avoid redundant animations if abs(scrollOffset - target) > 1 { scrollOffset = target @@ -274,9 +283,9 @@ struct SpeechScrollView: View { /// Find the word progress at the current visual position (scrollOffset + manualOffset) private func wordProgressAtCurrentOffset() -> Double { - let center = containerHeight * 0.5 - // The Y position currently at the center of the view - let targetY = center - (scrollOffset + manualOffset) + let anchor = readingAnchorY(containerHeight: containerHeight) + // The Y position currently at the reading anchor line + let targetY = anchor - (scrollOffset + manualOffset) // Find the closest word and interpolate let sorted = wordYPositions.sorted { $0.key < $1.key }