From 1270a8f56128984ddd9aa3daabb7e5662cedd146 Mon Sep 17 00:00:00 2001 From: "Mahyar (Mac) McDonald" <22130+theontho@users.noreply.github.com> Date: Fri, 17 Jul 2026 09:36:42 -0700 Subject: [PATCH 1/7] Add append-only streamed text reveal Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../DemonstrationView.swift | 19 ++- .../DemonstrationViewModel.swift | 4 +- .../Demonstrations.swift | 11 +- .../SampleMarkdownTheme.swift | 4 +- .../SampleSettings.swift | 15 ++ .../SettingsView.swift | 7 + .../Models/MarkdownRenderConfig.swift | 3 +- .../TextTransition/FadeInTextTransition.swift | 159 ------------------ .../FadeInTextTransitionViewModifier.swift | 86 ---------- .../UI/Paragraph/AppKit/ParagraphNSView.swift | 136 ++++++++------- .../AppKit/ParagraphView+macOS.swift | 32 ++-- .../UI/Paragraph/ParagraphAnimation.swift | 124 +++++++++++++- .../UI/Paragraph/UIKit/ParagraphUIView.swift | 157 ++++++++--------- .../Paragraph/UIKit/ParagraphView+iOS.swift | 32 ++-- Sources/MarkdownText/UI/TableView.swift | 142 ++++++++-------- .../ParagraphAnimationTests.swift | 141 ++++++++++++++++ .../ParagraphNSViewTests.swift | 10 +- .../ParagraphViewTests.swift | 18 ++ 18 files changed, 611 insertions(+), 489 deletions(-) delete mode 100644 Sources/MarkdownText/TextTransition/FadeInTextTransition.swift delete mode 100644 Sources/MarkdownText/TextTransition/FadeInTextTransitionViewModifier.swift create mode 100644 Tests/MarkdownTextTests/ParagraphAnimationTests.swift diff --git a/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/DemonstrationView.swift b/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/DemonstrationView.swift index 39dd6ac..df3aa73 100644 --- a/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/DemonstrationView.swift +++ b/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/DemonstrationView.swift @@ -10,6 +10,7 @@ struct DemonstrationView: View { @AppStorage(SampleSettings.preferStreamedMarkdownKey) private var preferStreamedMarkdown = true @AppStorage(SampleSettings.appearanceModeKey) private var appearanceMode = AppearanceMode.device @AppStorage(SampleSettings.markdownThemeKey) private var markdownTheme = SampleMarkdownTheme.automatic + @AppStorage(SampleSettings.streamingTextAnimationKey) private var streamingTextAnimation = StreamingTextAnimation.telegramReveal let demonstration: Demonstration let markdownText: String @@ -29,14 +30,22 @@ struct DemonstrationView: View { if preferStreamedMarkdown { StreamedMarkdownView( source: viewModel, - config: demonstration.renderConfig(theme: markdownTheme, isStreaming: true), + config: demonstration.renderConfig( + theme: markdownTheme, + isStreaming: true, + streamingTextAnimation: streamingTextAnimation + ), listener: listener ) .id(streamedContentID) } else { MarkdownView( text: markdownText, - config: demonstration.renderConfig(theme: markdownTheme, isStreaming: false), + config: demonstration.renderConfig( + theme: markdownTheme, + isStreaming: false, + streamingTextAnimation: streamingTextAnimation + ), listener: listener ) .id(staticContentID) @@ -106,6 +115,12 @@ struct DemonstrationView: View { Text(mode.displayName).tag(mode) } } + + Picker("Streaming Text", selection: $streamingTextAnimation) { + ForEach(StreamingTextAnimation.allCases) { animation in + Text(animation.displayName).tag(animation) + } + } } label: { Image(systemName: "circle.righthalf.filled") .accessibilityLabel("Appearance") diff --git a/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/DemonstrationViewModel.swift b/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/DemonstrationViewModel.swift index 2d6f530..099a340 100644 --- a/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/DemonstrationViewModel.swift +++ b/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/DemonstrationViewModel.swift @@ -50,8 +50,8 @@ final class DemonstrationViewModel: ObservableObject, StreamedMarkdownSource { init( text: String, - chunkSize: Int = 48, - chunkInterval: TimeInterval = 0.2 + chunkSize: Int = 24, + chunkInterval: TimeInterval = 0.15 ) { self.fullText = text self.chunkSize = max(1, chunkSize) diff --git a/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/Demonstrations.swift b/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/Demonstrations.swift index 04ace25..dbcc868 100644 --- a/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/Demonstrations.swift +++ b/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/Demonstrations.swift @@ -63,8 +63,15 @@ enum Demonstration: String, CaseIterable, Identifiable, Hashable { } } - func renderConfig(theme: SampleMarkdownTheme, isStreaming: Bool) -> MarkdownRenderConfig { - theme.renderConfig(for: self, isStreaming: isStreaming) + func renderConfig( + theme: SampleMarkdownTheme, + isStreaming: Bool, + streamingTextAnimation: StreamingTextAnimation + ) -> MarkdownRenderConfig { + theme.renderConfig( + for: self, + shouldAnimateText: isStreaming && streamingTextAnimation == .telegramReveal + ) } var automaticBackgroundColor: Color { diff --git a/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/SampleMarkdownTheme.swift b/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/SampleMarkdownTheme.swift index 72c022d..aabac68 100644 --- a/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/SampleMarkdownTheme.swift +++ b/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/SampleMarkdownTheme.swift @@ -49,10 +49,10 @@ enum SampleMarkdownTheme: String, CaseIterable, Identifiable { } } - func renderConfig(for demonstration: Demonstration, isStreaming: Bool) -> MarkdownRenderConfig { + func renderConfig(for demonstration: Demonstration, shouldAnimateText: Bool) -> MarkdownRenderConfig { resolvedConfig(for: demonstration) .withTextContextMenu(value: demonstration.customContextMenu) - .withShouldAnimateText(value: isStreaming) + .withShouldAnimateText(value: shouldAnimateText) .withImageConfig(ImageConfig( enabled: true, allowedImageTypes: [.remote(allowedDomains: ["markdownguide.org"]), .assetCatalog, .bundledResource] diff --git a/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/SampleSettings.swift b/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/SampleSettings.swift index adea41a..4e90f1b 100644 --- a/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/SampleSettings.swift +++ b/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/SampleSettings.swift @@ -9,6 +9,21 @@ enum SampleSettings { static let preferStreamedMarkdownKey = "preferStreamedMarkdown" static let appearanceModeKey = "appearanceMode" static let markdownThemeKey = "markdownTheme" + static let streamingTextAnimationKey = "streamingTextAnimation" +} + +enum StreamingTextAnimation: String, CaseIterable, Identifiable { + case telegramReveal + case standard + + var id: String { rawValue } + + var displayName: String { + switch self { + case .telegramReveal: "Telegram Reveal" + case .standard: "Standard Updates" + } + } } enum AppearanceMode: String, CaseIterable, Identifiable { diff --git a/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/SettingsView.swift b/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/SettingsView.swift index ac97ff6..6d1e923 100644 --- a/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/SettingsView.swift +++ b/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/SettingsView.swift @@ -9,10 +9,17 @@ struct SettingsView: View { @AppStorage(SampleSettings.preferStreamedMarkdownKey) private var preferStreamedMarkdown = true @AppStorage(SampleSettings.appearanceModeKey) private var appearanceMode = AppearanceMode.device @AppStorage(SampleSettings.markdownThemeKey) private var markdownTheme = SampleMarkdownTheme.automatic + @AppStorage(SampleSettings.streamingTextAnimationKey) private var streamingTextAnimation = StreamingTextAnimation.telegramReveal var body: some View { Form { Toggle("Streamed", isOn: $preferStreamedMarkdown) + Picker("Streaming Text", selection: $streamingTextAnimation) { + ForEach(StreamingTextAnimation.allCases) { animation in + Text(animation.displayName).tag(animation) + } + } + .pickerStyle(.menu) Picker("Markdown Theme", selection: $markdownTheme) { ForEach(SampleMarkdownTheme.allCases) { theme in Text(theme.displayName).tag(theme) diff --git a/Sources/MarkdownText/Models/MarkdownRenderConfig.swift b/Sources/MarkdownText/Models/MarkdownRenderConfig.swift index 7344464..63ad1e0 100644 --- a/Sources/MarkdownText/Models/MarkdownRenderConfig.swift +++ b/Sources/MarkdownText/Models/MarkdownRenderConfig.swift @@ -13,7 +13,8 @@ import SwiftUI /// Use `MarkdownRenderConfig.default` or the `withโ€ฆ` builders on the type for /// incremental overrides. public struct MarkdownRenderConfig: Hashable, Sendable { - /// When `true`, newly appended text fades in instead of appearing instantly. + /// When `true`, only newly appended text receives a bounded soft reveal. + /// Existing text remains stable, and Reduce Motion disables the effect. public let shouldAnimateText: Bool /// Styling applied to block-quote content. public let blockQuoteStyle: MarkdownTextStyle diff --git a/Sources/MarkdownText/TextTransition/FadeInTextTransition.swift b/Sources/MarkdownText/TextTransition/FadeInTextTransition.swift deleted file mode 100644 index f04ae45..0000000 --- a/Sources/MarkdownText/TextTransition/FadeInTextTransition.swift +++ /dev/null @@ -1,159 +0,0 @@ -// -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. See LICENSE in the project root for license information. -// - -import SwiftUI - -@available(iOS 18.0, macOS 15.0, *) -struct VariableDurationFadeInTextTransition: Transition { - - static var properties: TransitionProperties { - TransitionProperties(hasMotion: true) - } - - let totalGlyphs: Int - let glyphDelay: TimeInterval - let glyphDuration: TimeInterval - - let totalDuration: TimeInterval - - init(totalGlyphs: Int, glyphDelay: TimeInterval, glyphDuration: TimeInterval) { - self.totalGlyphs = totalGlyphs - self.glyphDelay = glyphDelay - self.glyphDuration = glyphDuration - self.totalDuration = max(0, Double(totalGlyphs - 1) * glyphDelay) + glyphDuration - } - - func body(content: Content, phase: TransitionPhase) -> some View { - let renderer = VariableDurationFadeInTextRenderer(elapsedTime: phase.isIdentity ? self.totalDuration : 0, glyphCount: totalGlyphs, glyphDelay: glyphDelay, glyphDuration: glyphDuration) - content.transaction { transaction in - if !transaction.disablesAnimations { - transaction.animation = .linear(duration: self.totalDuration) - } - } body: { view in - view.textRenderer(renderer) - } - } -} - -@available(iOS 18.0, macOS 15.0, *) -struct FixedDurationFadeInTextTransition: Transition { - static var properties: TransitionProperties { - TransitionProperties(hasMotion: true) - } - - let totalDuration: TimeInterval - let glyphDelay: TimeInterval - let glyphDuration: TimeInterval - - init(duration: TimeInterval, glyphDelay: TimeInterval, glyphDuration: TimeInterval) { - self.totalDuration = duration - self.glyphDelay = glyphDelay - self.glyphDuration = glyphDuration - } - - func body(content: Content, phase: TransitionPhase) -> some View { - let renderer = FixedDurationFadeInTextRenderer( - elapsedTime: phase.isIdentity ? self.totalDuration : 0, - duration: self.totalDuration, - delay: glyphDelay, - animationDuration: glyphDuration - ) - - content.transaction { transaction in - if !transaction.disablesAnimations { - transaction.animation = .linear(duration: self.totalDuration) - } - } body: { view in - view.textRenderer(renderer) - } - } -} - -@available(iOS 18.0, macOS 15.0, *) -struct VariableDurationFadeInTextRenderer: TextRenderer, Animatable { - - var elapsedTime: TimeInterval - - var animatableData: Double { - get { elapsedTime } - set { elapsedTime = newValue } - } - - let glyphCount: Int - let glyphDelay: TimeInterval - let glyphDuration: TimeInterval - - init(elapsedTime: TimeInterval, glyphCount: Int, glyphDelay: TimeInterval, glyphDuration: TimeInterval) { - self.elapsedTime = elapsedTime - self.glyphCount = glyphCount - self.glyphDelay = glyphDelay - self.glyphDuration = glyphDuration - } - - func draw(layout: Text.Layout, in ctx: inout GraphicsContext) { - for (index, slice) in layout.flattenedRunSlices.enumerated() { - let normalizedX = min(max(0, elapsedTime - Double(index) * glyphDelay) / glyphDuration, 1) - ctx.opacity = UnitCurve.easeOut.value(at: normalizedX) - ctx.draw(slice, options: .disablesSubpixelQuantization) - } - } -} - -@available(iOS 18.0, macOS 15.0, *) -struct FixedDurationFadeInTextRenderer: TextRenderer, Animatable { - var elapsedTime: TimeInterval - - let duration: TimeInterval - let delay: TimeInterval - let animationDuration: TimeInterval - - private func opacityForGlyph(groupIndex: Int, totalGroups: Int) -> Double { - let normalizedX = min(max(0, elapsedTime - Double(groupIndex) * delay) / animationDuration, 1) - return UnitCurve.easeOut.value(at: normalizedX) - } - - var animatableData: Double { - get { elapsedTime } - set { elapsedTime = newValue } - } - - init(elapsedTime: TimeInterval, duration: TimeInterval, delay: TimeInterval, animationDuration: TimeInterval) { - self.elapsedTime = elapsedTime - self.duration = duration - self.delay = delay - self.animationDuration = animationDuration - } - - func draw(layout: Text.Layout, in context: inout GraphicsContext) { - let numberOfGlyphs = layout.flattenedRunSlices.count - guard numberOfGlyphs > 0 else { - return - } - - let glyphGroups = Int(max(1, (duration - animationDuration) / delay).rounded(.up)) - - for (index, slice) in layout.flattenedRunSlices.enumerated() { - let groupIndex = index * glyphGroups / numberOfGlyphs - let opacity = opacityForGlyph(groupIndex: groupIndex, totalGroups: glyphGroups) - context.opacity = opacity - context.draw(slice, options: .disablesSubpixelQuantization) - } - } -} - -@available(iOS 18.0, macOS 15.0, *) -extension Text.Layout { - /// A helper function for easier access to all runs in a layout. - var flattenedRuns: some RandomAccessCollection { - self.flatMap { line in - line - } - } - - /// A helper function for easier access to all run slices in a layout. - var flattenedRunSlices: some RandomAccessCollection { - flattenedRuns.flatMap(\.self) - } -} diff --git a/Sources/MarkdownText/TextTransition/FadeInTextTransitionViewModifier.swift b/Sources/MarkdownText/TextTransition/FadeInTextTransitionViewModifier.swift deleted file mode 100644 index 5a97289..0000000 --- a/Sources/MarkdownText/TextTransition/FadeInTextTransitionViewModifier.swift +++ /dev/null @@ -1,86 +0,0 @@ -// -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. See LICENSE in the project root for license information. -// - -import Foundation -import SwiftUI - -struct FadeInTextTransitionViewModifier: ViewModifier { - - @State private var show = false - let config: FadeInTransitionConfig - - func body(content: Content) -> some View { - if #available(iOS 18.0, macOS 15.0, *) { - ZStack { - if show { - content - .transition(config.asTransition) - } - } - .onAppear { - show = true - } - } else { - content - .transition(.opacity) - } - } -} - -extension View { - func fadeInTextTransition(config: FadeInTransitionConfig = .fixedDuration(duration: 2.0, glyphDelay: 0.02, glyphDuration: 0.2)) -> some View { - modifier(FadeInTextTransitionViewModifier(config: config)) - } -} - -enum FadeInTransitionConfig { - case fixedDuration(duration: TimeInterval, glyphDelay: TimeInterval, glyphDuration: TimeInterval) - case variableDuration(glyphCount: Int, glyphDelay: TimeInterval, glyphDuration: TimeInterval) - - @available(iOS 18.0, macOS 15.0, *) - var asTransition: AnyTransition { - switch self { - case .fixedDuration(let duration, let glyphDelay, let glyphDuration): - AnyTransition(FixedDurationFadeInTextTransition(duration: duration, glyphDelay: glyphDelay, glyphDuration: glyphDuration)) - case .variableDuration(let glyphCount, let glyphDelay, let glyphDuration): - AnyTransition(VariableDurationFadeInTextTransition(totalGlyphs: glyphCount, glyphDelay: glyphDelay, glyphDuration: glyphDuration)) - } - } -} - -#if DEBUG - -struct WrapperView: View { - - @State var text: String = "Welcome to Copilot!" - @State var show: Bool = false - - var body: some View { - VStack { - if show { - Text(text) - .font(.largeTitle) - .fadeInTextTransition() - } - Spacer() - } - .task { - var count = 0 - while true { - do { - try await Task.sleep(ms: 4000) - } catch {} - show.toggle() - count += 1 - } - } - } -} - -#Preview("Text", body: { - WrapperView() -}) - -#endif diff --git a/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphNSView.swift b/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphNSView.swift index c4327bd..491ca4d 100644 --- a/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphNSView.swift +++ b/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphNSView.swift @@ -17,11 +17,11 @@ private struct CachedParagraphNSViewSize { class ParagraphNSView: NSTextView { private static let jsonEncoder = JSONEncoder() - static let animationDuration: CFTimeInterval = ParagraphAnimationConstants.fadeInDuration private(set) var paragraphContents: NSMutableAttributedString = NSMutableAttributedString() private(set) var lineSpacing: CGFloat? - private var activeAnimations: [FadeAnimationData] = [] + private var finalAttributedText = NSAttributedString() + private var activeAnimation: FadeAnimationData? private var fadeAnimationDisplayLink: CADisplayLink? private var cachedSize: CachedParagraphNSViewSize? @@ -53,7 +53,7 @@ class ParagraphNSView: NSTextView { deinit { tearDownDisplayLink() - activeAnimations.removeAll() + activeAnimation = nil } // MARK: - Appearance @@ -114,24 +114,36 @@ class ParagraphNSView: NSTextView { // MARK: - Content Update - func setParagraphContents(_ newContents: NSMutableAttributedString, lineSpacing: CGFloat? = nil, animatedByWord: Bool) { + func setParagraphContents( + _ newContents: NSMutableAttributedString, + lineSpacing: CGFloat? = nil, + revealAppendedText: Bool + ) { AppAppearance.update(appearance: effectiveAppearance) guard paragraphContents != newContents || self.lineSpacing != lineSpacing else { return } - self.paragraphContents = newContents - self.lineSpacing = lineSpacing - - let oldLength = textStorage?.length ?? 0 + let previousText = paragraphContents.string let finalString: NSMutableAttributedString if lineSpacing != nil { finalString = applyLineSpacing(to: newContents, lineSpacing: lineSpacing) } else { finalString = newContents } + let revealPlan = revealAppendedText + ? ParagraphRevealPlan.appendedText( + previousText: previousText, + newText: finalString.string + ) + : nil + let previousAnimation = activeAnimation tearDownDisplayLink() + activeAnimation = nil + self.paragraphContents = newContents + self.lineSpacing = lineSpacing + finalAttributedText = NSAttributedString(attributedString: finalString) invalidateCachedSize() textStorage?.setAttributedString(finalString) @@ -139,33 +151,26 @@ class ParagraphNSView: NSTextView { invalidateIntrinsicContentSize() - let newContentLength = (textStorage?.length ?? 0) - oldLength - - if animatedByWord, newContentLength > 0 { - let newContentRange = NSRange(location: oldLength, length: newContentLength) - let wordRanges = finalString.splitIntoWords(withIn: newContentRange) - let wordCount = wordRanges.count - let delayBetweenWords: Double = ParagraphAnimationConstants.delayBetweenWordsRatio / Double(max(wordCount, 1)) - let baseStartTime = CACurrentMediaTime() - for (index, wordRange) in wordRanges.enumerated() { - let animationData = FadeAnimationData( - startTime: baseStartTime + Double(index) * delayBetweenWords, - duration: Self.animationDuration, - range: wordRange - ) - activeAnimations.append(animationData) - } - - updateTextViewWithCurrentAnimations() - - if fadeAnimationDisplayLink == nil { - setUpDisplayLink() - } - } else { - activeAnimations.removeAll() + if let revealPlan { + let currentTime = CACurrentMediaTime() + activeAnimation = FadeAnimationData( + plan: revealPlan, + startTime: currentTime, + previousAnimation: previousAnimation, + contentLength: finalString.length + ) + updateTextViewWithCurrentAnimations(at: currentTime) + setUpDisplayLink() } } + func finishTextReveal() { + guard let activeAnimation else { return } + restoreFinalAttributes(in: activeAnimation.segments.map(\.range)) + self.activeAnimation = nil + tearDownDisplayLink() + } + // MARK: - Line Spacing private func applyLineSpacing(to attributedString: NSMutableAttributedString, lineSpacing: CGFloat?) -> NSMutableAttributedString { @@ -242,56 +247,61 @@ class ParagraphNSView: NSTextView { // MARK: - Fade Animation @objc private func updateFadeAnimation() { - let currentTime = CACurrentMediaTime() - var completedAnimations: [UUID] = [] - - updateTextViewWithCurrentAnimations() - - for animation in activeAnimations { - let elapsed = currentTime - animation.startTime - let progress = elapsed / animation.duration - if progress >= 1.0 { - completedAnimations.append(animation.id) - } + guard let activeAnimation else { + tearDownDisplayLink() + return } - activeAnimations.removeAll { completedAnimations.contains($0.id) } - - if activeAnimations.isEmpty { + let currentTime = CACurrentMediaTime() + updateTextViewWithCurrentAnimations(at: currentTime) + if currentTime >= activeAnimation.endTime { + self.activeAnimation = nil tearDownDisplayLink() } } - private func updateTextViewWithCurrentAnimations() { + private func updateTextViewWithCurrentAnimations(at currentTime: CFTimeInterval = CACurrentMediaTime()) { + guard let activeAnimation else { return } guard let textStorage else { return } - let currentTime = CACurrentMediaTime() textStorage.beginEditing() defer { textStorage.endEditing() } - for animation in activeAnimations { - guard animation.range.location + animation.range.length <= textStorage.length else { + for segment in activeAnimation.segments { + guard NSMaxRange(segment.range) <= textStorage.length else { continue } - let elapsed = currentTime - animation.startTime - let animatedAlpha: CGFloat + let elapsed = currentTime - segment.startTime + let progress = min(max(elapsed / ParagraphAnimationConstants.fadeInDuration, 0), 1) + applyRevealProgress(paragraphEaseOut(progress), to: segment.range) + } + } - if elapsed < 0 { - animatedAlpha = 0.0 - } else { - let progress = min(max(elapsed / animation.duration, 0.0), 1.0) - let easedProgress = paragraphEaseOut(progress) - animatedAlpha = easedProgress - } + private func applyRevealProgress(_ progress: CGFloat, to range: NSRange) { + guard let textStorage else { return } + let defaultColor = NSColor(Color.Theme.Foreground.Primary.Primary750) + finalAttributedText.enumerateAttributes(in: range, options: []) { attributes, attributeRange, _ in + var attributes = attributes + let baseColor = (attributes[.foregroundColor] as? NSColor) ?? defaultColor + attributes[.foregroundColor] = baseColor.withAlphaComponent( + baseColor.alphaComponent * progress + ) + textStorage.setAttributes(attributes, range: attributeRange) + } + } - let defaultColor = NSColor(Color.Theme.Foreground.Primary.Primary750) - textStorage.enumerateAttribute(.foregroundColor, in: animation.range, options: []) { value, range, _ in - let baseColor = (value as? NSColor) ?? defaultColor - textStorage.addAttribute(.foregroundColor, value: baseColor.withAlphaComponent(animatedAlpha), range: range) + private func restoreFinalAttributes(in ranges: [NSRange]) { + guard let textStorage else { return } + textStorage.beginEditing() + defer { textStorage.endEditing() } + for range in ranges where NSMaxRange(range) <= finalAttributedText.length { + finalAttributedText.enumerateAttributes(in: range, options: []) { attributes, attributeRange, _ in + textStorage.setAttributes(attributes, range: attributeRange) } } } private func setUpDisplayLink() { + tearDownDisplayLink() let link = displayLink( target: self, selector: #selector(updateFadeAnimation) diff --git a/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphView+macOS.swift b/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphView+macOS.swift index befa77f..41c7c4f 100644 --- a/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphView+macOS.swift +++ b/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphView+macOS.swift @@ -10,6 +10,7 @@ struct ParagraphView: NSViewRepresentable { @Environment(\.openURL) var openURL @Environment(\.markdownConfig) var config: MarkdownRenderConfig @Environment(\.markdownController) var markdownController: MarkdownController? + @Environment(\.accessibilityReduceMotion) var reduceMotion var contents: NSMutableAttributedString var lineSpacing: CGFloat? @@ -26,25 +27,27 @@ struct ParagraphView: NSViewRepresentable { // paragraph gets its own view instead. let view = ParagraphNSView() view.onUrlTap = openUrlFunction - view.setParagraphContents(contents, lineSpacing: lineSpacing, animatedByWord: false) + view.setParagraphContents( + contents, + lineSpacing: lineSpacing, + revealAppendedText: shouldRevealText + ) view.setTextContextMenu(config.resolvedTextContextMenu) view.setMarkdownController(markdownController) - if config.shouldAnimateText { - view.alphaValue = 0 - NSAnimationContext.runAnimationGroup { ctx in - ctx.duration = ParagraphNSView.animationDuration - view.animator().alphaValue = 1 - } - } - return view } func updateNSView(_ view: ParagraphNSView, context: Context) { + if !shouldRevealText { + view.finishTextReveal() + } if view.paragraphContents != contents || view.lineSpacing != lineSpacing { - let shouldAnimate = view.window != nil && config.shouldAnimateText - view.setParagraphContents(contents, lineSpacing: lineSpacing, animatedByWord: shouldAnimate) + view.setParagraphContents( + contents, + lineSpacing: lineSpacing, + revealAppendedText: view.window != nil && shouldRevealText + ) } view.setTextContextMenu(config.resolvedTextContextMenu) view.setMarkdownController(markdownController) @@ -78,6 +81,13 @@ struct ParagraphView: NSViewRepresentable { var lastContents: NSMutableAttributedString? var lastLineSpacing: CGFloat? } + + private var shouldRevealText: Bool { + shouldRevealAppendedText( + isConfigured: config.shouldAnimateText, + reduceMotion: reduceMotion + ) + } } extension ParagraphView: Equatable { diff --git a/Sources/MarkdownText/UI/Paragraph/ParagraphAnimation.swift b/Sources/MarkdownText/UI/Paragraph/ParagraphAnimation.swift index 525d2d3..fb95c59 100644 --- a/Sources/MarkdownText/UI/Paragraph/ParagraphAnimation.swift +++ b/Sources/MarkdownText/UI/Paragraph/ParagraphAnimation.swift @@ -6,15 +6,129 @@ import Foundation enum ParagraphAnimationConstants { - static let fadeInDuration: CFTimeInterval = 0.5 - static let delayBetweenWordsRatio: Double = 0.1 + static let fadeInDuration: CFTimeInterval = 0.45 + static let revealStaggerDuration: CFTimeInterval = 0.12 + static let targetSegmentLength = 8 + static let maximumSegmentCount = 24 } -struct FadeAnimationData { - let id: UUID = UUID() - let startTime: CFTimeInterval +struct ParagraphRevealSegment: Equatable { + let range: NSRange + let delay: CFTimeInterval +} + +struct ParagraphRevealPlan: Equatable { + let segments: [ParagraphRevealSegment] let duration: CFTimeInterval + + static func appendedText(previousText: String, newText: String) -> ParagraphRevealPlan? { + let previous = previousText as NSString + let updated = newText as NSString + + guard updated.length > previous.length, + updated.substring(with: NSRange(location: 0, length: previous.length)) == previousText else { + return nil + } + let firstAppendedCharacter = updated.rangeOfComposedCharacterSequence( + at: previous.length + ) + guard firstAppendedCharacter.location == previous.length else { + return nil + } + + let appendedRange = NSRange( + location: previous.length, + length: updated.length - previous.length + ) + let preferredSegmentCount = max( + 1, + Int(ceil(Double(appendedRange.length) / Double(ParagraphAnimationConstants.targetSegmentLength))) + ) + let segmentCount = min( + ParagraphAnimationConstants.maximumSegmentCount, + preferredSegmentCount + ) + let ranges = segmentRanges( + in: updated, + appendedRange: appendedRange, + segmentCount: segmentCount + ) + let delayStep = ranges.count > 1 + ? ParagraphAnimationConstants.revealStaggerDuration / Double(ranges.count - 1) + : 0 + let segments = ranges.enumerated().map { index, range in + ParagraphRevealSegment(range: range, delay: Double(index) * delayStep) + } + let duration = (segments.last?.delay ?? 0) + ParagraphAnimationConstants.fadeInDuration + return ParagraphRevealPlan(segments: segments, duration: duration) + } + + private static func segmentRanges( + in string: NSString, + appendedRange: NSRange, + segmentCount: Int + ) -> [NSRange] { + guard segmentCount > 1 else { + return [appendedRange] + } + + let end = NSMaxRange(appendedRange) + var segmentStart = appendedRange.location + var ranges: [NSRange] = [] + ranges.reserveCapacity(segmentCount) + + for index in 1.. segmentStart, boundary < end else { + continue + } + ranges.append(NSRange(location: segmentStart, length: boundary - segmentStart)) + segmentStart = boundary + } + + ranges.append(NSRange(location: segmentStart, length: end - segmentStart)) + return ranges + } +} + +struct FadeAnimationSegment { let range: NSRange + let startTime: CFTimeInterval +} + +struct FadeAnimationData { + let segments: [FadeAnimationSegment] + + init( + plan: ParagraphRevealPlan, + startTime: CFTimeInterval, + previousAnimation: FadeAnimationData? = nil, + contentLength: Int + ) { + let unfinishedSegments = previousAnimation?.segments.filter { + startTime < $0.startTime + ParagraphAnimationConstants.fadeInDuration + && NSMaxRange($0.range) <= contentLength + } ?? [] + let appendedSegments = plan.segments.map { + FadeAnimationSegment(range: $0.range, startTime: startTime + $0.delay) + } + segments = Array( + (unfinishedSegments + appendedSegments) + .suffix(ParagraphAnimationConstants.maximumSegmentCount) + ) + } + + var endTime: CFTimeInterval { + (segments.map(\.startTime).max() ?? 0) + ParagraphAnimationConstants.fadeInDuration + } +} + +func shouldRevealAppendedText(isConfigured: Bool, reduceMotion: Bool) -> Bool { + isConfigured && !reduceMotion } /// Cubic Bezier ease-out curve shared between iOS and macOS paragraph views. diff --git a/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphUIView.swift b/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphUIView.swift index 56f3f10..17115fb 100644 --- a/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphUIView.swift +++ b/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphUIView.swift @@ -21,11 +21,11 @@ private struct CachedParagraphUIViewSize { class ParagraphUIView: UITextView { private static let jsonEncoder = JSONEncoder() - static let animationDuration: CFTimeInterval = ParagraphAnimationConstants.fadeInDuration private(set) var paragraphContents: NSMutableAttributedString = NSMutableAttributedString() private(set) var lineSpacing: CGFloat? - private var activeAnimations: [FadeAnimationData] = [] + private var finalAttributedText = NSAttributedString() + private var activeAnimation: FadeAnimationData? private var fadeAnimationDisplayLink: CADisplayLink? private var cachedSize: CachedParagraphUIViewSize? @@ -49,7 +49,7 @@ class ParagraphUIView: UITextView { deinit { tearDownDisplayLink() - activeAnimations.removeAll() + activeAnimation = nil } override func willMove(toWindow newWindow: UIWindow?) { @@ -99,7 +99,11 @@ class ParagraphUIView: UITextView { invalidateIntrinsicContentSize() } - func setParagraphContents(_ newContents: NSMutableAttributedString, lineSpacing: CGFloat? = nil, animatedByWord: Bool) { + func setParagraphContents( + _ newContents: NSMutableAttributedString, + lineSpacing: CGFloat? = nil, + revealAppendedText: Bool + ) { // Keep the cached interface style up to date for citation preview rendering. // This runs on the main thread so it's safe to read traitCollection here. AppAppearance.update(style: traitCollection.userInterfaceStyle) @@ -107,23 +111,26 @@ class ParagraphUIView: UITextView { guard paragraphContents != newContents || self.lineSpacing != lineSpacing else { return } - self.paragraphContents = newContents - self.lineSpacing = lineSpacing - - let oldAttributedString: NSAttributedString = attributedText + let previousText = paragraphContents.string let finalString: NSMutableAttributedString if lineSpacing != nil { finalString = applyLineSpacing(to: newContents, lineSpacing: lineSpacing) } else { finalString = newContents } + let revealPlan = revealAppendedText + ? ParagraphRevealPlan.appendedText( + previousText: previousText, + newText: finalString.string + ) + : nil + let previousAnimation = activeAnimation - guard finalString != oldAttributedString else { - return - } - - // Stop display link update before updating the attributed string tearDownDisplayLink() + activeAnimation = nil + self.paragraphContents = newContents + self.lineSpacing = lineSpacing + finalAttributedText = NSAttributedString(attributedString: finalString) invalidateCachedSize() attributedText = finalString @@ -131,34 +138,36 @@ class ParagraphUIView: UITextView { invalidateIntrinsicContentSize() - let newContentLength = attributedText.length - oldAttributedString.length - - if animatedByWord, - newContentLength > 0 { - // Animate word by word - let newContentRange = NSRange(location: oldAttributedString.length, length: newContentLength) - let wordRanges = attributedText.splitIntoWords(withIn: newContentRange) - let wordCount = wordRanges.count - let delayBetweenWords: Double = ParagraphAnimationConstants.delayBetweenWordsRatio / Double(wordCount) - let baseStartTime = CACurrentMediaTime() - for (index, wordRange) in wordRanges.enumerated() { - let animationData = FadeAnimationData( - startTime: baseStartTime + Double(index) * delayBetweenWords, - duration: Self.animationDuration, - range: wordRange - ) - activeAnimations.append(animationData) - } + if let revealPlan { + let currentTime = CACurrentMediaTime() + activeAnimation = FadeAnimationData( + plan: revealPlan, + startTime: currentTime, + previousAnimation: previousAnimation, + contentLength: finalString.length + ) + updateTextViewWithCurrentAnimations(at: currentTime) + setUpDisplayLink() + } + } - updateTextViewWithCurrentAnimations() + func finishTextReveal() { + guard let activeAnimation else { return } + restoreFinalAttributes(in: activeAnimation.segments.map(\.range)) + self.activeAnimation = nil + tearDownDisplayLink() + } - if fadeAnimationDisplayLink == nil { - setUpDisplayLink() - } - } else { - // If no animation needed anymore, clean up all existings animations if any. - activeAnimations.removeAll() - } + func prepareForReuse() { + activeAnimation = nil + tearDownDisplayLink() + paragraphContents = NSMutableAttributedString() + lineSpacing = nil + finalAttributedText = NSAttributedString() + attributedText = NSAttributedString() + accessibilityLabel = nil + accessibilityCustomActions = nil + invalidateCachedSize() } private func applyLineSpacing(to attributedString: NSMutableAttributedString, lineSpacing: CGFloat?) -> NSMutableAttributedString { @@ -262,60 +271,58 @@ class ParagraphUIView: UITextView { } @objc private func updateFadeAnimation() { - let currentTime = CACurrentMediaTime() - var completedAnimations: [UUID] = [] - - updateTextViewWithCurrentAnimations() - - // Remove completed animations - for animation in activeAnimations { - let elapsed = currentTime - animation.startTime - let progress = elapsed / animation.duration - - if progress >= 1.0 { - completedAnimations.append(animation.id) - } + guard let activeAnimation else { + tearDownDisplayLink() + return } - activeAnimations.removeAll { completedAnimations.contains($0.id) } - - if activeAnimations.isEmpty { + let currentTime = CACurrentMediaTime() + updateTextViewWithCurrentAnimations(at: currentTime) + if currentTime >= activeAnimation.endTime { + self.activeAnimation = nil tearDownDisplayLink() } } - private func updateTextViewWithCurrentAnimations() { - let currentTime = CACurrentMediaTime() + private func updateTextViewWithCurrentAnimations(at currentTime: CFTimeInterval = CACurrentMediaTime()) { + guard let activeAnimation else { return } textStorage.beginEditing() defer { textStorage.endEditing() } - for animation in activeAnimations { - guard animation.range.location + animation.range.length <= textStorage.length else { + for segment in activeAnimation.segments { + guard NSMaxRange(segment.range) <= textStorage.length else { continue } - let elapsed = currentTime - animation.startTime - let animatedAlpha: CGFloat + let elapsed = currentTime - segment.startTime + let progress = min(max(elapsed / ParagraphAnimationConstants.fadeInDuration, 0), 1) + applyRevealProgress(paragraphEaseOut(progress), to: segment.range) + } + } - if elapsed < 0 { - animatedAlpha = 0.0 - } else { - let progress = min(max(elapsed / animation.duration, 0.0), 1.0) - let easedProgress = paragraphEaseOut(progress) - animatedAlpha = easedProgress - } + private func applyRevealProgress(_ progress: CGFloat, to range: NSRange) { + let defaultColor = UIColor(Color.Theme.Foreground.Primary.Primary750) + finalAttributedText.enumerateAttributes(in: range, options: []) { attributes, attributeRange, _ in + var attributes = attributes + let baseColor = (attributes[.foregroundColor] as? UIColor) ?? defaultColor + attributes[.foregroundColor] = baseColor.withAlphaComponent( + baseColor.cgColor.alpha * progress + ) + textStorage.setAttributes(attributes, range: attributeRange) + } + } - // Apply alpha to this animation's range, preserving each span's - // existing foreground color. Spans with no foreground color get a - // sensible default so they still fade in instead of disappearing. - let defaultColor = UIColor(Color.Theme.Foreground.Primary.Primary750) - textStorage.enumerateAttribute(.foregroundColor, in: animation.range, options: []) { value, range, _ in - let baseColor = (value as? UIColor) ?? defaultColor - textStorage.addAttribute(.foregroundColor, value: baseColor.withAlphaComponent(animatedAlpha), range: range) + private func restoreFinalAttributes(in ranges: [NSRange]) { + textStorage.beginEditing() + defer { textStorage.endEditing() } + for range in ranges where NSMaxRange(range) <= finalAttributedText.length { + finalAttributedText.enumerateAttributes(in: range, options: []) { attributes, attributeRange, _ in + textStorage.setAttributes(attributes, range: attributeRange) } } } private func setUpDisplayLink() { + tearDownDisplayLink() fadeAnimationDisplayLink = CADisplayLink(target: self, selector: #selector(updateFadeAnimation)) fadeAnimationDisplayLink?.preferredFramesPerSecond = 60 fadeAnimationDisplayLink?.add(to: .main, forMode: .common) diff --git a/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphView+iOS.swift b/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphView+iOS.swift index c1fdd34..14f89d7 100644 --- a/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphView+iOS.swift +++ b/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphView+iOS.swift @@ -10,6 +10,7 @@ struct ParagraphView: UIViewRepresentable { @Environment(\.openURL) var openURL @Environment(\.markdownConfig) var config: MarkdownRenderConfig @Environment(\.markdownController) var markdownController: MarkdownController? + @Environment(\.accessibilityReduceMotion) var reduceMotion var contents: NSMutableAttributedString var lineSpacing: CGFloat? @@ -21,25 +22,29 @@ struct ParagraphView: UIViewRepresentable { func makeUIView(context: Context) -> ParagraphUIView { let openUrlFunction = openURL.callAsFunction(_:) let view = ParagraphViewCache.shared.createOrReuseView(contents: contents, lineSpacing: lineSpacing) + view.prepareForReuse() view.onUrlTap = openUrlFunction - view.setParagraphContents(contents, lineSpacing: lineSpacing, animatedByWord: false) + view.setParagraphContents( + contents, + lineSpacing: lineSpacing, + revealAppendedText: shouldRevealText + ) view.setTextContextMenu(config.resolvedTextContextMenu) view.setMarkdownController(markdownController) - if config.shouldAnimateText { - view.alpha = 0 - UIView.animate(withDuration: ParagraphUIView.animationDuration) { - view.alpha = 1 - } - } - return view } func updateUIView(_ view: ParagraphUIView, context: Context) { + if !shouldRevealText { + view.finishTextReveal() + } if view.paragraphContents != contents || view.lineSpacing != lineSpacing { - let shouldAnimate = view.window != nil && config.shouldAnimateText // only animate when visible - view.setParagraphContents(contents, lineSpacing: lineSpacing, animatedByWord: shouldAnimate) + view.setParagraphContents( + contents, + lineSpacing: lineSpacing, + revealAppendedText: view.window != nil && shouldRevealText + ) } view.setTextContextMenu(config.resolvedTextContextMenu) view.setMarkdownController(markdownController) @@ -82,6 +87,13 @@ struct ParagraphView: UIViewRepresentable { var lastContents: NSMutableAttributedString? var lastLineSpacing: CGFloat? } + + private var shouldRevealText: Bool { + shouldRevealAppendedText( + isConfigured: config.shouldAnimateText, + reduceMotion: reduceMotion + ) + } } extension ParagraphView: Equatable { diff --git a/Sources/MarkdownText/UI/TableView.swift b/Sources/MarkdownText/UI/TableView.swift index 84579b0..e8d4685 100644 --- a/Sources/MarkdownText/UI/TableView.swift +++ b/Sources/MarkdownText/UI/TableView.swift @@ -11,17 +11,12 @@ import UIKit import AppKit #endif -enum RowContent: Equatable { - case text(string: AttributedString) - case containsAttachment(string: NSAttributedString) -} - struct TableView: View { @Environment(\.markdownConfig) var config: MarkdownRenderConfig @Environment(\.markdownController) var controller: MarkdownController? - let headings: [AttributedString] - let rows: [[RowContent]] + let headings: [NSMutableAttributedString] + let rows: [[NSMutableAttributedString]] let columnMaxWidths: [Int: CGFloat] private let defaultMaxColumnWidth: CGFloat = 200 @@ -33,15 +28,9 @@ struct TableView: View { private let rawMarkdown: String init(headings: [NSMutableAttributedString], rows: [[NSMutableAttributedString]], columnMaxWidths: [Int: CGFloat] = [:], rawMarkdown: String = "") { - self.headings = headings.map { AttributedString($0) } + self.headings = headings.map { NSMutableAttributedString(attributedString: $0) } self.rows = rows.map { row in - row.map { content in - if content.containsAttachments(in: NSRange(location: 0, length: content.length)) { - return .containsAttachment(string: content) - } else { - return .text(string: AttributedString(content)) - } - } + row.map { NSMutableAttributedString(attributedString: $0) } } self.columnMaxWidths = columnMaxWidths @@ -54,14 +43,11 @@ struct TableView: View { private func headerView(colIdx: Int) -> some View { HStack(spacing: 0) { - Text(headings[colIdx]) - .foregroundStyle(config.tableStyle.headerTextColor) - .lineLimit(nil) - .multilineTextAlignment(.leading) + tableText( + headings[colIdx], + color: config.tableStyle.headerTextColor + ) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) - .if(config.shouldAnimateText) { view in - view.fadeInTextTransition(attributedString: headings[colIdx]) - } .accessibilityValue(String.itemPositionInTable(rowIndex: 1, totalRow: numOfRows + 1, columnIndex: colIdx + 1, totalColumn: headings.count)) Spacer() } @@ -102,36 +88,19 @@ struct TableView: View { @ViewBuilder private func gridCellViewFor(rowIdx: Int, colIdx: Int) -> some View { let content = rows[rowIdx][colIdx] - switch content { - case .containsAttachment(let nsAttributedString): - HStack(spacing: 0) { - ParagraphView(contents: applyTypographyThemingAndGetContent(nsAttributedString)) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .accessibilityValue(String.itemPositionInTable(rowIndex: rowIdx + 2, totalRow: numOfRows + 1, columnIndex: colIdx + 1, totalColumn: headings.count)) - Spacer() - } - .frame(maxHeight: .infinity) - .padding(12) - .id("\(colIdx)-\(rowIdx)") - .applyCellBorder(colIndex: colIdx, colCount: headings.count, rowIndex: rowIdx, rowCount: numOfRows, color: config.tableStyle.borderColor) - case .text(let attributedString): - HStack(spacing: 0) { - Text(attributedString) - .foregroundStyle(config.tableStyle.regularTextColor) - .lineLimit(nil) - .multilineTextAlignment(.leading) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .if(config.shouldAnimateText) { view in - view.fadeInTextTransition(attributedString: attributedString) - } - .accessibilityValue(String.itemPositionInTable(rowIndex: rowIdx + 2, totalRow: numOfRows + 1, columnIndex: colIdx + 1, totalColumn: headings.count)) - Spacer() - } - .frame(maxHeight: .infinity) - .padding(12) - .id("\(colIdx)-\(rowIdx)") - .applyCellBorder(colIndex: colIdx, colCount: headings.count, rowIndex: rowIdx, rowCount: numOfRows, color: config.tableStyle.borderColor) + HStack(spacing: 0) { + tableText( + content, + color: config.tableStyle.regularTextColor + ) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .accessibilityValue(String.itemPositionInTable(rowIndex: rowIdx + 2, totalRow: numOfRows + 1, columnIndex: colIdx + 1, totalColumn: headings.count)) + Spacer() } + .frame(maxHeight: .infinity) + .padding(12) + .id("\(colIdx)-\(rowIdx)") + .applyCellBorder(colIndex: colIdx, colCount: headings.count, rowIndex: rowIdx, rowCount: numOfRows, color: config.tableStyle.borderColor) } var body: some View { @@ -256,13 +225,6 @@ extension View { return border(width: 1, edges: edges, color: color) } - @ViewBuilder - func fadeInTextTransition(attributedString: AttributedString) -> some View { - self.fadeInTextTransition(config: .variableDuration( - glyphCount: attributedString.characters.count, - glyphDelay: 0.02, - glyphDuration: 0.2)) - } } struct TableLayout: Layout { @@ -335,18 +297,15 @@ struct TableLayout: Layout { // MARK: - Helper Functions extension TableView { /// Apply typography theming and return themed content for use with ParagraphView - private func applyTypographyThemingAndGetContent(_ attributedString: NSAttributedString) -> NSMutableAttributedString { - // Apply typography theming for table cells - let mutableAttributedString = NSMutableAttributedString(attributedString: attributedString) + private func applyTypographyThemingAndGetContent( + _ attributedString: NSAttributedString, + color: Color + ) -> NSMutableAttributedString { + let mutableAttributedString = applyingForegroundColor( + color, + to: attributedString + ) let fullRange = NSRange(location: 0, length: mutableAttributedString.length) - let themeColor = MDColor(config.tableStyle.regularTextColor) - - // Apply theme color to text that doesn't already have a foreground color - mutableAttributedString.enumerateAttribute(.foregroundColor, in: fullRange, options: []) { existingColor, range, _ in - if existingColor == nil { - mutableAttributedString.addAttribute(.foregroundColor, value: themeColor, range: range) - } - } // Apply citation baseline offset for proper alignment // This is needed because table cells bypass Paragraph+ parsing where baseline offset is normally applied @@ -388,6 +347,51 @@ extension TableView { // we can return the themed string directly return mutableAttributedString } + + private func applyingForegroundColor( + _ color: Color, + to attributedString: NSAttributedString + ) -> NSMutableAttributedString { + let result = NSMutableAttributedString(attributedString: attributedString) + let fullRange = NSRange(location: 0, length: result.length) + let themeColor = MDColor(color) + result.enumerateAttribute(.foregroundColor, in: fullRange, options: []) { existingColor, range, _ in + if existingColor == nil { + result.addAttribute(.foregroundColor, value: themeColor, range: range) + } + } + return result + } + + @ViewBuilder + private func tableText( + _ content: NSMutableAttributedString, + color: Color + ) -> some View { + let containsAttachments = content.containsAttachments( + in: NSRange(location: 0, length: content.length) + ) + if containsAttachments { + ParagraphView(contents: applyTypographyThemingAndGetContent( + content, + color: color + )) + } else if config.shouldAnimateText { + Text(AttributedString(content)) + .foregroundStyle(color) + .lineLimit(nil) + .multilineTextAlignment(.leading) + .hidden() + .overlay(alignment: .topLeading) { + ParagraphView(contents: applyingForegroundColor(color, to: content)) + } + } else { + Text(AttributedString(content)) + .foregroundStyle(color) + .lineLimit(nil) + .multilineTextAlignment(.leading) + } + } } #if DEBUG diff --git a/Tests/MarkdownTextTests/ParagraphAnimationTests.swift b/Tests/MarkdownTextTests/ParagraphAnimationTests.swift new file mode 100644 index 0000000..e4596ed --- /dev/null +++ b/Tests/MarkdownTextTests/ParagraphAnimationTests.swift @@ -0,0 +1,141 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +// + +import Foundation +@testable import SwiftStreamingMarkdown +import Testing + +@Suite("Paragraph reveal planning") +struct ParagraphAnimationTests { + @Test("Reveals only the appended suffix") + func appendedSuffix() throws { + let previous = "Stable text" + let updated = "\(previous) fades in" + let plan = try #require( + ParagraphRevealPlan.appendedText(previousText: previous, newText: updated) + ) + let coveredRange = try #require(plan.coveredRange) + + #expect(coveredRange.location == (previous as NSString).length) + #expect((updated as NSString).substring(with: coveredRange) == " fades in") + #expect(plan.segments.first?.delay == 0) + #expect(plan.segments.last?.delay == ParagraphAnimationConstants.revealStaggerDuration) + } + + @Test("Does not animate replacements or style-only updates") + func nonAppendUpdates() { + #expect( + ParagraphRevealPlan.appendedText( + previousText: "Streaming *text", + newText: "Streaming text" + ) == nil + ) + #expect( + ParagraphRevealPlan.appendedText( + previousText: "Unchanged", + newText: "Unchanged" + ) == nil + ) + } + + @Test("Uses UTF-16 ranges without splitting composed characters") + func composedCharacters() throws { + let previous = "Hello ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ" + let suffix = " cafรฉ ๐Ÿงฎ" + let updated = previous + suffix + let plan = try #require( + ParagraphRevealPlan.appendedText(previousText: previous, newText: updated) + ) + let coveredRange = try #require(plan.coveredRange) + + #expect(coveredRange.location == (previous as NSString).length) + #expect((updated as NSString).substring(with: coveredRange) == suffix) + #expect(plan.segments.allSatisfy { segment in + let composedRange = (updated as NSString).rangeOfComposedCharacterSequences( + for: segment.range + ) + return composedRange == segment.range + }) + } + + @Test("Does not animate when an append extends the previous grapheme") + func extendedPreviousGrapheme() { + #expect( + ParagraphRevealPlan.appendedText( + previousText: "Cafe", + newText: "Cafe\u{301}" + ) == nil + ) + #expect( + ParagraphRevealPlan.appendedText( + previousText: "๐Ÿ‘จ", + newText: "๐Ÿ‘จโ€๐Ÿ‘ฉ" + ) == nil + ) + } + + @Test("Bounds work for very large chunks") + func boundedSegments() throws { + let suffix = String(repeating: "streaming ", count: 10_000) + let plan = try #require( + ParagraphRevealPlan.appendedText(previousText: "Start: ", newText: "Start: \(suffix)") + ) + + #expect(plan.segments.count <= ParagraphAnimationConstants.maximumSegmentCount) + #expect(plan.coveredRange?.length == (suffix as NSString).length) + } + + @Test("Carries unfinished segments across rapid updates with bounded work") + func rapidUpdates() throws { + let firstPlan = try #require( + ParagraphRevealPlan.appendedText(previousText: "", newText: "First streamed chunk") + ) + let firstAnimation = FadeAnimationData( + plan: firstPlan, + startTime: 0, + contentLength: ("First streamed chunk" as NSString).length + ) + let secondText = "First streamed chunk plus another streamed chunk" + let secondPlan = try #require( + ParagraphRevealPlan.appendedText( + previousText: "First streamed chunk", + newText: secondText + ) + ) + let secondAnimation = FadeAnimationData( + plan: secondPlan, + startTime: 0.15, + previousAnimation: firstAnimation, + contentLength: (secondText as NSString).length + ) + + #expect(secondAnimation.segments.contains { $0.range.location == 0 }) + #expect(secondAnimation.segments.contains { + $0.range.location >= ("First streamed chunk" as NSString).length + }) + #expect( + secondAnimation.segments.count <= ParagraphAnimationConstants.maximumSegmentCount + ) + } + + @Test("Reduce Motion disables the reveal") + func reduceMotion() { + #expect(shouldRevealAppendedText(isConfigured: true, reduceMotion: false)) + #expect(!shouldRevealAppendedText(isConfigured: true, reduceMotion: true)) + #expect(!shouldRevealAppendedText(isConfigured: false, reduceMotion: false)) + } +} + +private extension ParagraphRevealPlan { + var coveredRange: NSRange? { + guard let first = segments.first, let last = segments.last else { + return nil + } + return NSRange( + location: first.range.location, + length: NSMaxRange(last.range) - first.range.location + ) + } +} diff --git a/Tests/MarkdownTextTests/ParagraphNSViewTests.swift b/Tests/MarkdownTextTests/ParagraphNSViewTests.swift index ea01de6..d4b66ff 100644 --- a/Tests/MarkdownTextTests/ParagraphNSViewTests.swift +++ b/Tests/MarkdownTextTests/ParagraphNSViewTests.swift @@ -21,7 +21,10 @@ struct ParagraphNSViewTests { func measuresHeightWithoutFrame() { let view = ParagraphNSView() let longText = String(repeating: "word ", count: 200) - view.setParagraphContents(NSMutableAttributedString(string: longText), animatedByWord: false) + view.setParagraphContents( + NSMutableAttributedString(string: longText), + revealAppendedText: false + ) let narrow = view.measureSize(fittingWidth: 200) let wide = view.measureSize(fittingWidth: 1000) @@ -37,7 +40,10 @@ struct ParagraphNSViewTests { @Test("Empty content measures as zero") func measuresEmptyContentAsZero() { let view = ParagraphNSView() - view.setParagraphContents(NSMutableAttributedString(string: ""), animatedByWord: false) + view.setParagraphContents( + NSMutableAttributedString(string: ""), + revealAppendedText: false + ) #expect(view.measureSize(fittingWidth: 400) == .zero) } diff --git a/Tests/MarkdownTextTests/ParagraphViewTests.swift b/Tests/MarkdownTextTests/ParagraphViewTests.swift index d1d932f..76cb572 100644 --- a/Tests/MarkdownTextTests/ParagraphViewTests.swift +++ b/Tests/MarkdownTextTests/ParagraphViewTests.swift @@ -192,6 +192,24 @@ struct ParagraphViewTests { #expect(textContent.string == "", "Text content should be empty") } + @Test("Reused paragraph clears stale accessibility content") + @MainActor + func prepareForReuseClearsAccessibilityContent() { + let view = ParagraphUIView() + view.setParagraphContents( + NSMutableAttributedString(string: "Previous paragraph"), + revealAppendedText: false + ) + + #expect(view.accessibilityLabel == "Previous paragraph") + + view.prepareForReuse() + + #expect(view.attributedText.length == 0) + #expect(view.accessibilityLabel == nil) + #expect(view.accessibilityCustomActions == nil) + } + @Test("Long text overflow handling") func longTextOverflow() { let longText = String(repeating: "This is a very long text that should test overflow behavior. ", count: 20) From 760ccf8dbf411fc7bc5d991e937f0221f324acab Mon Sep 17 00:00:00 2001 From: "Mahyar (Mac) McDonald" <22130+theontho@users.noreply.github.com> Date: Fri, 17 Jul 2026 09:36:42 -0700 Subject: [PATCH 2/7] Rework character streaming animation Replace suffix fading with a one-grapheme attributed streaming pipeline, adaptive cadence, full platform transforms, selectable sample styles, and deterministic coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a649c2f7-fe53-4216-9f15-b5aa7237934a --- .../DemonstrationView.swift | 4 +- .../Demonstrations.swift | 4 +- .../RobotoTheme.swift | 2 +- .../SampleMarkdownTheme.swift | 9 +- .../SampleSettings.swift | 18 +- .../SettingsView.swift | 4 +- .../MarkdownRenderConfig+Builders.swift | 33 +- .../Models/MarkdownRenderConfig.swift | 21 +- .../MarkdownText/StreamedMarkdownView.swift | 13 + Sources/MarkdownText/UI/DocumentView.swift | 3 + .../UI/Paragraph/AppKit/ParagraphNSView.swift | 244 +++++++++++--- .../AppKit/ParagraphView+macOS.swift | 28 +- .../CharacterStreamingLayoutManager.swift | 151 +++++++++ .../UI/Paragraph/ParagraphAnimation.swift | 230 ++++++++++++- .../UI/Paragraph/ParagraphViewCache.swift | 25 +- .../UI/Paragraph/UIKit/ParagraphUIView.swift | 288 ++++++++++++++--- .../Paragraph/UIKit/ParagraphView+iOS.swift | 30 +- Sources/MarkdownText/UI/TableView.swift | 3 +- .../ParagraphAnimationTests.swift | 304 +++++++++++++----- .../ParagraphNSViewTests.swift | 23 +- .../ParagraphViewTests.swift | 38 ++- 21 files changed, 1236 insertions(+), 239 deletions(-) create mode 100644 Sources/MarkdownText/UI/Paragraph/CharacterStreamingLayoutManager.swift diff --git a/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/DemonstrationView.swift b/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/DemonstrationView.swift index df3aa73..09baceb 100644 --- a/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/DemonstrationView.swift +++ b/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/DemonstrationView.swift @@ -10,7 +10,7 @@ struct DemonstrationView: View { @AppStorage(SampleSettings.preferStreamedMarkdownKey) private var preferStreamedMarkdown = true @AppStorage(SampleSettings.appearanceModeKey) private var appearanceMode = AppearanceMode.device @AppStorage(SampleSettings.markdownThemeKey) private var markdownTheme = SampleMarkdownTheme.automatic - @AppStorage(SampleSettings.streamingTextAnimationKey) private var streamingTextAnimation = StreamingTextAnimation.telegramReveal + @AppStorage(SampleSettings.streamingTextAnimationKey) private var streamingTextAnimation = SampleStreamingTextAnimation.characterStreaming let demonstration: Demonstration let markdownText: String @@ -117,7 +117,7 @@ struct DemonstrationView: View { } Picker("Streaming Text", selection: $streamingTextAnimation) { - ForEach(StreamingTextAnimation.allCases) { animation in + ForEach(SampleStreamingTextAnimation.allCases) { animation in Text(animation.displayName).tag(animation) } } diff --git a/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/Demonstrations.swift b/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/Demonstrations.swift index dbcc868..bbc269f 100644 --- a/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/Demonstrations.swift +++ b/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/Demonstrations.swift @@ -66,11 +66,11 @@ enum Demonstration: String, CaseIterable, Identifiable, Hashable { func renderConfig( theme: SampleMarkdownTheme, isStreaming: Bool, - streamingTextAnimation: StreamingTextAnimation + streamingTextAnimation: SampleStreamingTextAnimation ) -> MarkdownRenderConfig { theme.renderConfig( for: self, - shouldAnimateText: isStreaming && streamingTextAnimation == .telegramReveal + textAnimation: isStreaming ? streamingTextAnimation.renderAnimation : .none ) } diff --git a/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/RobotoTheme.swift b/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/RobotoTheme.swift index 7f7fc03..60c97f9 100644 --- a/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/RobotoTheme.swift +++ b/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/RobotoTheme.swift @@ -77,7 +77,7 @@ enum RobotoTheme { // MARK: - Config static let renderConfig: MarkdownRenderConfig = MarkdownRenderConfig( - shouldAnimateText: false, + textAnimation: .none, blockQuoteStyle: .init( textFonts: textFonts(size: 16, lineHeight: 24), textColor: mutedForeground diff --git a/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/SampleMarkdownTheme.swift b/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/SampleMarkdownTheme.swift index aabac68..2fdecd6 100644 --- a/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/SampleMarkdownTheme.swift +++ b/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/SampleMarkdownTheme.swift @@ -49,10 +49,13 @@ enum SampleMarkdownTheme: String, CaseIterable, Identifiable { } } - func renderConfig(for demonstration: Demonstration, shouldAnimateText: Bool) -> MarkdownRenderConfig { + func renderConfig( + for demonstration: Demonstration, + textAnimation: MarkdownRenderConfig.TextAnimation + ) -> MarkdownRenderConfig { resolvedConfig(for: demonstration) .withTextContextMenu(value: demonstration.customContextMenu) - .withShouldAnimateText(value: shouldAnimateText) + .withTextAnimation(textAnimation) .withImageConfig(ImageConfig( enabled: true, allowedImageTypes: [.remote(allowedDomains: ["markdownguide.org"]), .assetCatalog, .bundledResource] @@ -88,7 +91,7 @@ enum SampleMarkdownTheme: String, CaseIterable, Identifiable { private static func paletteConfig(_ palette: Palette) -> MarkdownRenderConfig { MarkdownRenderConfig( - shouldAnimateText: false, + textAnimation: .none, blockQuoteStyle: .init( textFonts: MarkdownRenderConfig.defaultBlockQuoteStyle.textFonts, textColor: palette.secondaryForeground diff --git a/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/SampleSettings.swift b/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/SampleSettings.swift index 4e90f1b..c2b79a0 100644 --- a/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/SampleSettings.swift +++ b/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/SampleSettings.swift @@ -4,6 +4,7 @@ // import SwiftUI +import SwiftStreamingMarkdown enum SampleSettings { static let preferStreamedMarkdownKey = "preferStreamedMarkdown" @@ -12,16 +13,23 @@ enum SampleSettings { static let streamingTextAnimationKey = "streamingTextAnimation" } -enum StreamingTextAnimation: String, CaseIterable, Identifiable { - case telegramReveal - case standard +enum SampleStreamingTextAnimation: String, CaseIterable, Identifiable { + case characterStreaming + case standardFade var id: String { rawValue } var displayName: String { switch self { - case .telegramReveal: "Telegram Reveal" - case .standard: "Standard Updates" + case .characterStreaming: "Character Streaming" + case .standardFade: "Standard Fade" + } + } + + var renderAnimation: MarkdownRenderConfig.TextAnimation { + switch self { + case .characterStreaming: .characterStreaming + case .standardFade: .fade } } } diff --git a/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/SettingsView.swift b/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/SettingsView.swift index 6d1e923..74a904c 100644 --- a/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/SettingsView.swift +++ b/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/SettingsView.swift @@ -9,13 +9,13 @@ struct SettingsView: View { @AppStorage(SampleSettings.preferStreamedMarkdownKey) private var preferStreamedMarkdown = true @AppStorage(SampleSettings.appearanceModeKey) private var appearanceMode = AppearanceMode.device @AppStorage(SampleSettings.markdownThemeKey) private var markdownTheme = SampleMarkdownTheme.automatic - @AppStorage(SampleSettings.streamingTextAnimationKey) private var streamingTextAnimation = StreamingTextAnimation.telegramReveal + @AppStorage(SampleSettings.streamingTextAnimationKey) private var streamingTextAnimation = SampleStreamingTextAnimation.characterStreaming var body: some View { Form { Toggle("Streamed", isOn: $preferStreamedMarkdown) Picker("Streaming Text", selection: $streamingTextAnimation) { - ForEach(StreamingTextAnimation.allCases) { animation in + ForEach(SampleStreamingTextAnimation.allCases) { animation in Text(animation.displayName).tag(animation) } } diff --git a/Sources/MarkdownText/Models/MarkdownRenderConfig+Builders.swift b/Sources/MarkdownText/Models/MarkdownRenderConfig+Builders.swift index 019cc3f..85d6b06 100644 --- a/Sources/MarkdownText/Models/MarkdownRenderConfig+Builders.swift +++ b/Sources/MarkdownText/Models/MarkdownRenderConfig+Builders.swift @@ -7,10 +7,10 @@ import Foundation import SwiftUI extension MarkdownRenderConfig { - /// Returns a copy with `shouldAnimateText` replaced. - public func withShouldAnimateText(value: Bool) -> MarkdownRenderConfig { + /// Returns a copy with `textAnimation` replaced. + public func withTextAnimation(_ value: TextAnimation) -> MarkdownRenderConfig { MarkdownRenderConfig( - shouldAnimateText: value, + textAnimation: value, blockQuoteStyle: blockQuoteStyle, headingStyle: headingStyle, orderedListStyle: orderedListStyle, @@ -22,14 +22,15 @@ extension MarkdownRenderConfig { codeBlockConfig: codeBlockConfig, blockSpacing: blockSpacing, textSelectionConfig: textSelectionConfig, - thematicBreakColor: thematicBreakColor + thematicBreakColor: thematicBreakColor, + imageConfig: imageConfig ) } /// Returns a copy with `blockQuoteStyle` replaced. public func withBlockQuoteStyle(value: MarkdownTextStyle) -> MarkdownRenderConfig { MarkdownRenderConfig( - shouldAnimateText: shouldAnimateText, + textAnimation: textAnimation, blockQuoteStyle: value, headingStyle: headingStyle, orderedListStyle: orderedListStyle, @@ -48,7 +49,7 @@ extension MarkdownRenderConfig { /// Returns a copy with `headingStyle` replaced. public func withHeadingStyle(value: MarkdownHeadingTextStyle) -> MarkdownRenderConfig { MarkdownRenderConfig( - shouldAnimateText: shouldAnimateText, + textAnimation: textAnimation, blockQuoteStyle: blockQuoteStyle, headingStyle: value, orderedListStyle: orderedListStyle, @@ -67,7 +68,7 @@ extension MarkdownRenderConfig { /// Returns a copy with `orderedListStyle` replaced. public func withOrderedListStyle(value: MarkdownTextStyle) -> MarkdownRenderConfig { MarkdownRenderConfig( - shouldAnimateText: shouldAnimateText, + textAnimation: textAnimation, blockQuoteStyle: blockQuoteStyle, headingStyle: headingStyle, orderedListStyle: value, @@ -86,7 +87,7 @@ extension MarkdownRenderConfig { /// Returns a copy with `paragraphStyle` replaced. public func withParagraphStyle(value: MarkdownTextStyle) -> MarkdownRenderConfig { MarkdownRenderConfig( - shouldAnimateText: shouldAnimateText, + textAnimation: textAnimation, blockQuoteStyle: blockQuoteStyle, headingStyle: headingStyle, orderedListStyle: orderedListStyle, @@ -105,7 +106,7 @@ extension MarkdownRenderConfig { /// Returns a copy with `tableStyle` replaced. public func withTableStyle(value: MarkdownTableTextStyle) -> MarkdownRenderConfig { MarkdownRenderConfig( - shouldAnimateText: shouldAnimateText, + textAnimation: textAnimation, blockQuoteStyle: blockQuoteStyle, headingStyle: headingStyle, orderedListStyle: orderedListStyle, @@ -124,7 +125,7 @@ extension MarkdownRenderConfig { /// Returns a copy with `inlineStyle` replaced. public func withInlineStyle(value: MarkdownInlineTextStyle) -> MarkdownRenderConfig { MarkdownRenderConfig( - shouldAnimateText: shouldAnimateText, + textAnimation: textAnimation, blockQuoteStyle: blockQuoteStyle, headingStyle: headingStyle, orderedListStyle: orderedListStyle, @@ -144,7 +145,7 @@ extension MarkdownRenderConfig { /// custom context menu and fall back to the system menu. public func withTextContextMenu(value: TextContextMenu?) -> MarkdownRenderConfig { MarkdownRenderConfig( - shouldAnimateText: shouldAnimateText, + textAnimation: textAnimation, blockQuoteStyle: blockQuoteStyle, headingStyle: headingStyle, orderedListStyle: orderedListStyle, @@ -163,7 +164,7 @@ extension MarkdownRenderConfig { /// Returns a copy with `blockSpacing` replaced. public func withBlockSpacing(value: CGFloat) -> MarkdownRenderConfig { MarkdownRenderConfig( - shouldAnimateText: shouldAnimateText, + textAnimation: textAnimation, blockQuoteStyle: blockQuoteStyle, headingStyle: headingStyle, orderedListStyle: orderedListStyle, @@ -182,7 +183,7 @@ extension MarkdownRenderConfig { /// Returns a copy with `codeBlockConfig` replaced. public func withCodeBlockConfig(value: CodeBlockConfig) -> MarkdownRenderConfig { MarkdownRenderConfig( - shouldAnimateText: shouldAnimateText, + textAnimation: textAnimation, blockQuoteStyle: blockQuoteStyle, headingStyle: headingStyle, orderedListStyle: orderedListStyle, @@ -202,7 +203,7 @@ extension MarkdownRenderConfig { /// `isEnabled: false` to hide the built-in "Select more text" edit-menu action. public func withTextSelectionConfig(value: TextSelectionConfig) -> MarkdownRenderConfig { MarkdownRenderConfig( - shouldAnimateText: shouldAnimateText, + textAnimation: textAnimation, blockQuoteStyle: blockQuoteStyle, headingStyle: headingStyle, orderedListStyle: orderedListStyle, @@ -221,7 +222,7 @@ extension MarkdownRenderConfig { /// Returns a copy with `thematicBreakColor` replaced. public func withThematicBreakColor(value: Color) -> MarkdownRenderConfig { MarkdownRenderConfig( - shouldAnimateText: shouldAnimateText, + textAnimation: textAnimation, blockQuoteStyle: blockQuoteStyle, headingStyle: headingStyle, orderedListStyle: orderedListStyle, @@ -240,7 +241,7 @@ extension MarkdownRenderConfig { /// Returns a copy with `imageConfig` replaced. Image support is experimental. public func withImageConfig(_ value: ImageConfig) -> MarkdownRenderConfig { MarkdownRenderConfig( - shouldAnimateText: shouldAnimateText, + textAnimation: textAnimation, blockQuoteStyle: blockQuoteStyle, headingStyle: headingStyle, orderedListStyle: orderedListStyle, diff --git a/Sources/MarkdownText/Models/MarkdownRenderConfig.swift b/Sources/MarkdownText/Models/MarkdownRenderConfig.swift index 63ad1e0..79879ff 100644 --- a/Sources/MarkdownText/Models/MarkdownRenderConfig.swift +++ b/Sources/MarkdownText/Models/MarkdownRenderConfig.swift @@ -13,9 +13,18 @@ import SwiftUI /// Use `MarkdownRenderConfig.default` or the `withโ€ฆ` builders on the type for /// incremental overrides. public struct MarkdownRenderConfig: Hashable, Sendable { - /// When `true`, only newly appended text receives a bounded soft reveal. - /// Existing text remains stable, and Reduce Motion disables the effect. - public let shouldAnimateText: Bool + /// The animation applied as streamed text arrives. + public enum TextAnimation: Hashable, Sendable { + /// Display each render immediately. + case none + /// Fade newly appended text without changing its release cadence. + case fade + /// Buffer attributed text and release one composed character at a time. + case characterStreaming + } + + /// The animation applied as streamed text arrives. + public let textAnimation: TextAnimation /// Styling applied to block-quote content. public let blockQuoteStyle: MarkdownTextStyle /// Per-level heading styling. @@ -254,7 +263,7 @@ public struct MarkdownRenderConfig: Hashable, Sendable { /// matches the bundled `Typography`/`Color.Theme` palette, so callers can /// override only the fields they care about. public init( - shouldAnimateText: Bool = false, + textAnimation: TextAnimation = .none, blockQuoteStyle: MarkdownTextStyle = MarkdownRenderConfig.defaultBlockQuoteStyle, headingStyle: MarkdownHeadingTextStyle = MarkdownRenderConfig.defaultHeadingStyle, orderedListStyle: MarkdownTextStyle = MarkdownRenderConfig.defaultOrderedListStyle, @@ -269,7 +278,7 @@ public struct MarkdownRenderConfig: Hashable, Sendable { thematicBreakColor: Color = MarkdownRenderConfig.defaultThematicBreakColor, imageConfig: ImageConfig = .disabled ) { - self.shouldAnimateText = shouldAnimateText + self.textAnimation = textAnimation self.blockQuoteStyle = blockQuoteStyle self.headingStyle = headingStyle self.orderedListStyle = orderedListStyle @@ -287,7 +296,7 @@ public struct MarkdownRenderConfig: Hashable, Sendable { /// The default render config, equivalent to calling `init()` with no /// arguments. - public static let `default` = MarkdownRenderConfig(shouldAnimateText: false) + public static let `default` = MarkdownRenderConfig() /// The context menu actually rendered on text selection: the consumer-supplied /// `textContextMenu` with the built-in "Select more text" group prepended (so diff --git a/Sources/MarkdownText/StreamedMarkdownView.swift b/Sources/MarkdownText/StreamedMarkdownView.swift index f55e82c..84095ee 100644 --- a/Sources/MarkdownText/StreamedMarkdownView.swift +++ b/Sources/MarkdownText/StreamedMarkdownView.swift @@ -50,6 +50,7 @@ public struct StreamedMarkdownView: View { config: config, listener: controller.listener ) + .environment(\.isMarkdownStreamComplete, controller.isComplete) .task { await controller.start() } @@ -64,6 +65,7 @@ public struct StreamedMarkdownView: View { final class StreamedMarkdownController: ObservableObject { @Published var markdownToRender: RenderableDocument = .empty + @Published var isComplete = false let config: MarkdownRenderConfig let listener: MarkdownListener? @@ -83,6 +85,9 @@ final class StreamedMarkdownController: ObservableObject { func start() async { task?.cancel() + await MainActor.run { + isComplete = false + } task = Task { [weak self] in guard let self else { return } for await text in self.source.text { @@ -93,11 +98,19 @@ final class StreamedMarkdownController: ObservableObject { self.markdownToRender = renderable } } + if !Task.isCancelled { + await MainActor.run { + self.isComplete = true + } + } } } func end() async { task?.cancel() task = nil + await MainActor.run { + isComplete = true + } } } diff --git a/Sources/MarkdownText/UI/DocumentView.swift b/Sources/MarkdownText/UI/DocumentView.swift index bbce1d1..1521f3e 100644 --- a/Sources/MarkdownText/UI/DocumentView.swift +++ b/Sources/MarkdownText/UI/DocumentView.swift @@ -35,6 +35,7 @@ public struct DocumentView: View { public var body: some View { BlockView(renderables: renderableDocument.renderables) + .id(config.textAnimation) .environment(\.markdownConfig, config) .environment(\.markdownController, controller) .task { @@ -65,6 +66,8 @@ extension EnvironmentValues { /// The shared controller used by descendant Markdown views to route /// table/context-menu events to the configured `MarkdownListener`. @Entry public var markdownController: MarkdownController? + /// Whether the current streamed source has finished producing snapshots. + @Entry var isMarkdownStreamComplete = true } #if DEBUG diff --git a/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphNSView.swift b/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphNSView.swift index 491ca4d..89f8b0c 100644 --- a/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphNSView.swift +++ b/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphNSView.swift @@ -22,23 +22,32 @@ class ParagraphNSView: NSTextView { private(set) var lineSpacing: CGFloat? private var finalAttributedText = NSAttributedString() private var activeAnimation: FadeAnimationData? - private var fadeAnimationDisplayLink: CADisplayLink? + private let characterStreamingState = CharacterStreamingState() + private var characterStreamingTimer: Timer? + private var animatedCharacterRanges: [NSRange] = [] + private var textAnimationDisplayLink: CADisplayLink? + private var textAnimation: MarkdownRenderConfig.TextAnimation = .none + private var isStreamComplete = true private var cachedSize: CachedParagraphNSViewSize? + private(set) var supportsCharacterStreaming = false var textContextMenu: TextContextMenu? var markdownController: MarkdownController? var onUrlTap: (URL) -> Void = { NSWorkspace.shared.open($0) } - convenience init() { + convenience init(characterStreaming: Bool = false) { let textStorage = NSTextStorage() - let layoutManager = NSLayoutManager() + let layoutManager = characterStreaming + ? CharacterStreamingLayoutManager() + : NSLayoutManager() textStorage.addLayoutManager(layoutManager) let textContainer = NSTextContainer(containerSize: NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude)) textContainer.widthTracksTextView = true textContainer.heightTracksTextView = false layoutManager.addTextContainer(textContainer) self.init(frame: .zero, textContainer: textContainer) + supportsCharacterStreaming = characterStreaming } override init(frame frameRect: NSRect, textContainer container: NSTextContainer?) { @@ -53,6 +62,7 @@ class ParagraphNSView: NSTextView { deinit { tearDownDisplayLink() + characterStreamingTimer?.invalidate() activeAnimation = nil } @@ -117,42 +127,62 @@ class ParagraphNSView: NSTextView { func setParagraphContents( _ newContents: NSMutableAttributedString, lineSpacing: CGFloat? = nil, - revealAppendedText: Bool + textAnimation: MarkdownRenderConfig.TextAnimation, + isStreamComplete: Bool ) { AppAppearance.update(appearance: effectiveAppearance) - guard paragraphContents != newContents || self.lineSpacing != lineSpacing else { - return - } - let previousText = paragraphContents.string let finalString: NSMutableAttributedString if lineSpacing != nil { finalString = applyLineSpacing(to: newContents, lineSpacing: lineSpacing) } else { finalString = newContents } - let revealPlan = revealAppendedText - ? ParagraphRevealPlan.appendedText( - previousText: previousText, - newText: finalString.string - ) - : nil - let previousAnimation = activeAnimation + let previousText = finalAttributedText.string + let contentsChanged = paragraphContents != newContents + || self.lineSpacing != lineSpacing + let modeChanged = self.textAnimation != textAnimation + let completionChanged = self.isStreamComplete != isStreamComplete + guard contentsChanged || modeChanged || completionChanged else { + return + } - tearDownDisplayLink() - activeAnimation = nil + if modeChanged { + stopCharacterStreaming() + activeAnimation = nil + tearDownDisplayLink() + } self.paragraphContents = newContents self.lineSpacing = lineSpacing + self.textAnimation = textAnimation + self.isStreamComplete = isStreamComplete finalAttributedText = NSAttributedString(attributedString: finalString) invalidateCachedSize() - textStorage?.setAttributedString(finalString) - configureAccessibility(for: finalString) - invalidateIntrinsicContentSize() - - if let revealPlan { + switch textAnimation { + case .none: + stopCharacterStreaming() + activeAnimation = nil + tearDownDisplayLink() + textStorage?.setAttributedString(finalString) + case .fade: + stopCharacterStreaming() + textStorage?.setAttributedString(finalString) + let revealPlan = contentsChanged + ? ParagraphRevealPlan.appendedText( + previousText: previousText, + newText: finalString.string + ) + : nil + guard let revealPlan else { + activeAnimation = nil + tearDownDisplayLink() + invalidateIntrinsicContentSize() + return + } let currentTime = CACurrentMediaTime() + let previousAnimation = modeChanged ? nil : activeAnimation activeAnimation = FadeAnimationData( plan: revealPlan, startTime: currentTime, @@ -161,13 +191,34 @@ class ParagraphNSView: NSTextView { ) updateTextViewWithCurrentAnimations(at: currentTime) setUpDisplayLink() + case .characterStreaming: + activeAnimation = nil + if modeChanged { + characterStreamingState.reset() + } + let currentTime = CACurrentMediaTime() + characterStreamingState.update( + target: finalString, + isComplete: isStreamComplete, + at: currentTime + ) + synchronizeCharacterStreamingText() + releaseOneCharacter(at: currentTime) } + + invalidateIntrinsicContentSize() } - func finishTextReveal() { - guard let activeAnimation else { return } - restoreFinalAttributes(in: activeAnimation.segments.map(\.range)) - self.activeAnimation = nil + func finishTextAnimation() { + if let activeAnimation { + restoreFinalAttributes(in: activeAnimation.segments.map(\.range)) + self.activeAnimation = nil + } + if textAnimation == .characterStreaming { + characterStreamingState.settle() + synchronizeCharacterStreamingText() + stopCharacterStreaming() + } tearDownDisplayLink() } @@ -244,18 +295,28 @@ class ParagraphNSView: NSTextView { } } - // MARK: - Fade Animation + // MARK: - Text Animation - @objc private func updateFadeAnimation() { - guard let activeAnimation else { - tearDownDisplayLink() - return - } + @objc private func updateTextAnimation() { let currentTime = CACurrentMediaTime() - updateTextViewWithCurrentAnimations(at: currentTime) - if currentTime >= activeAnimation.endTime { - self.activeAnimation = nil + switch textAnimation { + case .none: tearDownDisplayLink() + case .fade: + guard let activeAnimation else { + tearDownDisplayLink() + return + } + updateTextViewWithCurrentAnimations(at: currentTime) + if currentTime >= activeAnimation.endTime { + self.activeAnimation = nil + tearDownDisplayLink() + } + case .characterStreaming: + updateCharacterStreamingAnimations(at: currentTime) + if characterStreamingState.activeAnimations.isEmpty { + tearDownDisplayLink() + } } } @@ -300,20 +361,123 @@ class ParagraphNSView: NSTextView { } } + private func releaseOneCharacter( + at currentTime: CFTimeInterval = CACurrentMediaTime() + ) { + guard textAnimation == .characterStreaming else { + return + } + if characterStreamingState.releaseNext(at: currentTime) != nil { + synchronizeCharacterStreamingText() + updateCharacterStreamingAnimations(at: currentTime) + setUpDisplayLink() + } + scheduleNextCharacterRelease() + } + + private func synchronizeCharacterStreamingText() { + textStorage?.setAttributedString(characterStreamingState.visibleAttributedText) + animatedCharacterRanges.removeAll() + invalidateCachedSize() + invalidateIntrinsicContentSize() + } + + private func scheduleNextCharacterRelease() { + characterStreamingTimer?.invalidate() + characterStreamingTimer = nil + guard textAnimation == .characterStreaming, + characterStreamingState.hasPendingGrapheme else { + return + } + + let timer = Timer( + timeInterval: characterStreamingState.nextReleaseInterval, + repeats: false + ) { [weak self] _ in + guard let self else { return } + self.characterStreamingTimer = nil + self.releaseOneCharacter() + } + RunLoop.main.add(timer, forMode: .common) + characterStreamingTimer = timer + } + + private func updateCharacterStreamingAnimations(at currentTime: CFTimeInterval) { + guard let textStorage else { return } + characterStreamingState.pruneAnimations(at: currentTime) + let animations = characterStreamingState.activeAnimations + let rangesToRestore = animatedCharacterRanges.filter { + NSMaxRange($0) <= textStorage.length + } + + textStorage.beginEditing() + restoreFinalAttributesWithoutEditing(in: rangesToRestore) + for animation in animations where NSMaxRange(animation.range) <= textStorage.length { + let transform = animation.transform(at: currentTime) + guard transform.blurRadius > 0 else { continue } + finalAttributedText.enumerateAttributes( + in: animation.range, + options: [] + ) { attributes, attributeRange, _ in + var attributes = attributes + let color = (attributes[.foregroundColor] as? NSColor) + ?? NSColor(Color.Theme.Foreground.Primary.Primary750) + let shadow = NSShadow() + shadow.shadowOffset = .zero + shadow.shadowBlurRadius = transform.blurRadius + shadow.shadowColor = color.withAlphaComponent(color.alphaComponent) + attributes[.shadow] = shadow + textStorage.setAttributes(attributes, range: attributeRange) + } + } + textStorage.endEditing() + + animatedCharacterRanges = animations.map(\.range) + characterStreamingLayoutManager?.updateAnimations( + animations, + at: currentTime + ) + } + + private func restoreFinalAttributesWithoutEditing(in ranges: [NSRange]) { + guard let textStorage else { return } + for range in ranges where NSMaxRange(range) <= finalAttributedText.length { + finalAttributedText.enumerateAttributes( + in: range, + options: [] + ) { attributes, attributeRange, _ in + textStorage.setAttributes(attributes, range: attributeRange) + } + } + } + + private func stopCharacterStreaming() { + characterStreamingTimer?.invalidate() + characterStreamingTimer = nil + animatedCharacterRanges.removeAll() + characterStreamingLayoutManager?.clearAnimations() + } + + private var characterStreamingLayoutManager: CharacterStreamingLayoutManager? { + layoutManager as? CharacterStreamingLayoutManager + } + private func setUpDisplayLink() { - tearDownDisplayLink() + guard textAnimationDisplayLink == nil else { + return + } let link = displayLink( target: self, - selector: #selector(updateFadeAnimation) + selector: #selector(updateTextAnimation) ) link.preferredFrameRateRange = CAFrameRateRange(minimum: 30, maximum: 60, preferred: 60) link.add(to: .main, forMode: .common) - fadeAnimationDisplayLink = link + textAnimationDisplayLink = link } private func tearDownDisplayLink() { - fadeAnimationDisplayLink?.invalidate() - fadeAnimationDisplayLink = nil + textAnimationDisplayLink?.invalidate() + textAnimationDisplayLink = nil } private func invalidateCachedSize() { diff --git a/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphView+macOS.swift b/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphView+macOS.swift index 41c7c4f..649c45f 100644 --- a/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphView+macOS.swift +++ b/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphView+macOS.swift @@ -11,6 +11,7 @@ struct ParagraphView: NSViewRepresentable { @Environment(\.markdownConfig) var config: MarkdownRenderConfig @Environment(\.markdownController) var markdownController: MarkdownController? @Environment(\.accessibilityReduceMotion) var reduceMotion + @Environment(\.isMarkdownStreamComplete) var isStreamComplete var contents: NSMutableAttributedString var lineSpacing: CGFloat? @@ -25,12 +26,15 @@ struct ParagraphView: NSViewRepresentable { // stale attachment subviews (e.g. LaTeX views vended by LatexViewProvider) from a // previously displayed document, which then render at the wrong positions. Each // paragraph gets its own view instead. - let view = ParagraphNSView() + let view = ParagraphNSView( + characterStreaming: resolvedAnimation == .characterStreaming + ) view.onUrlTap = openUrlFunction view.setParagraphContents( contents, lineSpacing: lineSpacing, - revealAppendedText: shouldRevealText + textAnimation: resolvedAnimation, + isStreamComplete: isStreamComplete ) view.setTextContextMenu(config.resolvedTextContextMenu) view.setMarkdownController(markdownController) @@ -39,14 +43,19 @@ struct ParagraphView: NSViewRepresentable { } func updateNSView(_ view: ParagraphNSView, context: Context) { - if !shouldRevealText { - view.finishTextReveal() - } if view.paragraphContents != contents || view.lineSpacing != lineSpacing { view.setParagraphContents( contents, lineSpacing: lineSpacing, - revealAppendedText: view.window != nil && shouldRevealText + textAnimation: view.window == nil ? .none : resolvedAnimation, + isStreamComplete: isStreamComplete + ) + } else { + view.setParagraphContents( + contents, + lineSpacing: lineSpacing, + textAnimation: resolvedAnimation, + isStreamComplete: isStreamComplete ) } view.setTextContextMenu(config.resolvedTextContextMenu) @@ -82,11 +91,8 @@ struct ParagraphView: NSViewRepresentable { var lastLineSpacing: CGFloat? } - private var shouldRevealText: Bool { - shouldRevealAppendedText( - isConfigured: config.shouldAnimateText, - reduceMotion: reduceMotion - ) + private var resolvedAnimation: MarkdownRenderConfig.TextAnimation { + resolvedTextAnimation(config.textAnimation, reduceMotion: reduceMotion) } } diff --git a/Sources/MarkdownText/UI/Paragraph/CharacterStreamingLayoutManager.swift b/Sources/MarkdownText/UI/Paragraph/CharacterStreamingLayoutManager.swift new file mode 100644 index 0000000..c91378e --- /dev/null +++ b/Sources/MarkdownText/UI/Paragraph/CharacterStreamingLayoutManager.swift @@ -0,0 +1,151 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +// + +#if canImport(UIKit) || canImport(AppKit) +import Foundation + +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +import AppKit +#endif + +final class CharacterStreamingLayoutManager: NSLayoutManager { + private var animationFrames: [(range: NSRange, transform: CharacterStreamingTransform)] = [] + + func updateAnimations( + _ animations: [CharacterStreamingAnimation], + at time: CFTimeInterval + ) { + animationFrames = animations.map { + (range: $0.range, transform: $0.transform(at: time)) + } + invalidateDisplay(forCharacterRange: NSRange( + location: 0, + length: textStorage?.length ?? 0 + )) + } + + func clearAnimations() { + animationFrames.removeAll() + invalidateDisplay(forCharacterRange: NSRange( + location: 0, + length: textStorage?.length ?? 0 + )) + } + + override func drawGlyphs( + forGlyphRange glyphsToShow: NSRange, + at origin: CGPoint + ) { + guard !animationFrames.isEmpty else { + super.drawGlyphs(forGlyphRange: glyphsToShow, at: origin) + return + } + + let sortedFrames = animationFrames.sorted { + $0.range.location < $1.range.location + } + var nextGlyphLocation = glyphsToShow.location + let glyphEnd = NSMaxRange(glyphsToShow) + + for frame in sortedFrames { + let frameGlyphRange = glyphRange( + forCharacterRange: frame.range, + actualCharacterRange: nil + ) + let visibleFrameRange = NSIntersectionRange(frameGlyphRange, glyphsToShow) + guard visibleFrameRange.length > 0 else { + continue + } + + if nextGlyphLocation < visibleFrameRange.location { + super.drawGlyphs( + forGlyphRange: NSRange( + location: nextGlyphLocation, + length: visibleFrameRange.location - nextGlyphLocation + ), + at: origin + ) + } + + let transformedRange = NSIntersectionRange( + visibleFrameRange, + NSRange( + location: nextGlyphLocation, + length: max(0, glyphEnd - nextGlyphLocation) + ) + ) + if transformedRange.length > 0 { + drawTransformedGlyphs( + in: transformedRange, + at: origin, + transform: frame.transform + ) + nextGlyphLocation = NSMaxRange(transformedRange) + } + } + + if nextGlyphLocation < glyphEnd { + super.drawGlyphs( + forGlyphRange: NSRange( + location: nextGlyphLocation, + length: glyphEnd - nextGlyphLocation + ), + at: origin + ) + } + } + + private func drawTransformedGlyphs( + in glyphRange: NSRange, + at origin: CGPoint, + transform: CharacterStreamingTransform + ) { + guard let context = currentGraphicsContext(), + let textContainer = textContainer( + forGlyphAt: glyphRange.location, + effectiveRange: nil + ) else { + super.drawGlyphs(forGlyphRange: glyphRange, at: origin) + return + } + + let bounds = boundingRect( + forGlyphRange: glyphRange, + in: textContainer + ).offsetBy(dx: origin.x, dy: origin.y) + let anchor = CGPoint(x: bounds.midX, y: bounds.maxY) + + context.saveGState() + context.setAlpha(transform.opacity) + context.translateBy( + x: 0, + y: platformBaselineTranslation(transform.baselineOffset) + ) + context.translateBy(x: anchor.x, y: anchor.y) + context.scaleBy(x: transform.scale, y: transform.scale) + context.translateBy(x: -anchor.x, y: -anchor.y) + super.drawGlyphs(forGlyphRange: glyphRange, at: origin) + context.restoreGState() + } + + private func currentGraphicsContext() -> CGContext? { + #if canImport(UIKit) + UIGraphicsGetCurrentContext() + #elseif canImport(AppKit) + NSGraphicsContext.current?.cgContext + #endif + } + + private func platformBaselineTranslation(_ offset: CGFloat) -> CGFloat { + #if canImport(UIKit) + offset + #elseif canImport(AppKit) + -offset + #endif + } +} +#endif diff --git a/Sources/MarkdownText/UI/Paragraph/ParagraphAnimation.swift b/Sources/MarkdownText/UI/Paragraph/ParagraphAnimation.swift index fb95c59..85622d6 100644 --- a/Sources/MarkdownText/UI/Paragraph/ParagraphAnimation.swift +++ b/Sources/MarkdownText/UI/Paragraph/ParagraphAnimation.swift @@ -7,9 +7,21 @@ import Foundation enum ParagraphAnimationConstants { static let fadeInDuration: CFTimeInterval = 0.45 - static let revealStaggerDuration: CFTimeInterval = 0.12 - static let targetSegmentLength = 8 - static let maximumSegmentCount = 24 + static let fadeStaggerDuration: CFTimeInterval = 0.12 + static let fadeTargetSegmentLength = 8 + static let maximumFadeSegmentCount = 24 + + static let characterAnimationDuration: CFTimeInterval = 0.26 + static let characterReleaseInterval: CFTimeInterval = 0.018 + static let maximumCharacterReleaseSpeed = 4.0 + static let lowBacklogGraphemeCount = 3 + static let maximumAccelerationBacklog = 64 + static let maximumActiveCharacterAnimations = 64 + + static let initialCharacterOpacity: CGFloat = 0.08 + static let initialCharacterScale: CGFloat = 0.82 + static let initialCharacterBaselineOffset: CGFloat = 3 + static let initialCharacterBlurRadius: CGFloat = 3 } struct ParagraphRevealSegment: Equatable { @@ -42,10 +54,10 @@ struct ParagraphRevealPlan: Equatable { ) let preferredSegmentCount = max( 1, - Int(ceil(Double(appendedRange.length) / Double(ParagraphAnimationConstants.targetSegmentLength))) + Int(ceil(Double(appendedRange.length) / Double(ParagraphAnimationConstants.fadeTargetSegmentLength))) ) let segmentCount = min( - ParagraphAnimationConstants.maximumSegmentCount, + ParagraphAnimationConstants.maximumFadeSegmentCount, preferredSegmentCount ) let ranges = segmentRanges( @@ -54,7 +66,7 @@ struct ParagraphRevealPlan: Equatable { segmentCount: segmentCount ) let delayStep = ranges.count > 1 - ? ParagraphAnimationConstants.revealStaggerDuration / Double(ranges.count - 1) + ? ParagraphAnimationConstants.fadeStaggerDuration / Double(ranges.count - 1) : 0 let segments = ranges.enumerated().map { index, range in ParagraphRevealSegment(range: range, delay: Double(index) * delayStep) @@ -118,7 +130,7 @@ struct FadeAnimationData { } segments = Array( (unfinishedSegments + appendedSegments) - .suffix(ParagraphAnimationConstants.maximumSegmentCount) + .suffix(ParagraphAnimationConstants.maximumFadeSegmentCount) ) } @@ -127,8 +139,208 @@ struct FadeAnimationData { } } -func shouldRevealAppendedText(isConfigured: Bool, reduceMotion: Bool) -> Bool { - isConfigured && !reduceMotion +struct CharacterStreamingTransform: Equatable { + let opacity: CGFloat + let scale: CGFloat + let baselineOffset: CGFloat + let blurRadius: CGFloat + + static func value(at progress: CGFloat) -> CharacterStreamingTransform { + let easedProgress = paragraphEaseOut(min(max(progress, 0), 1)) + let remaining = 1 - easedProgress + return CharacterStreamingTransform( + opacity: ParagraphAnimationConstants.initialCharacterOpacity + + (1 - ParagraphAnimationConstants.initialCharacterOpacity) * easedProgress, + scale: ParagraphAnimationConstants.initialCharacterScale + + (1 - ParagraphAnimationConstants.initialCharacterScale) * easedProgress, + baselineOffset: ParagraphAnimationConstants.initialCharacterBaselineOffset * remaining, + blurRadius: ParagraphAnimationConstants.initialCharacterBlurRadius * remaining + ) + } +} + +struct CharacterStreamingAnimation: Equatable { + let range: NSRange + let startTime: CFTimeInterval + + func transform(at time: CFTimeInterval) -> CharacterStreamingTransform { + let progress = CGFloat( + min(max((time - startTime) / ParagraphAnimationConstants.characterAnimationDuration, 0), 1) + ) + return .value(at: progress) + } + + func isFinished(at time: CFTimeInterval) -> Bool { + time >= startTime + ParagraphAnimationConstants.characterAnimationDuration + } +} + +struct CharacterStreamingRelease: Equatable { + let range: NSRange + let time: CFTimeInterval +} + +final class CharacterStreamingState { + private(set) var target = NSAttributedString() + private(set) var releasedUTF16Length = 0 + private(set) var pendingGraphemeCount = 0 + private(set) var activeAnimations: [CharacterStreamingAnimation] = [] + private(set) var isComplete = false + + private var lastReleaseTime: CFTimeInterval? + + var visibleAttributedText: NSAttributedString { + target.attributedSubstring( + from: NSRange(location: 0, length: releasedUTF16Length) + ) + } + + var hasPendingGrapheme: Bool { + pendingGraphemeCount > 0 + } + + var nextReleaseInterval: CFTimeInterval { + Self.releaseInterval(forBacklog: pendingGraphemeCount) + } + + func update( + target newTarget: NSAttributedString, + isComplete: Bool, + at time: CFTimeInterval + ) { + let oldString = target.string + let newString = newTarget.string + + if oldString != newString && !newString.hasPrefix(oldString) { + releasedUTF16Length = min( + releasedUTF16Length, + Self.commonPrefixUTF16Length(oldString, newString) + ) + activeAnimations.removeAll { + NSMaxRange($0.range) > releasedUTF16Length + } + } + + target = NSAttributedString(attributedString: newTarget) + self.isComplete = isComplete + releasedUTF16Length = min(releasedUTF16Length, target.length) + pruneAnimations(at: time) + recalculatePendingGraphemeCount() + } + + func releaseNext(at time: CFTimeInterval) -> CharacterStreamingRelease? { + guard pendingGraphemeCount > 0, + lastReleaseTime != time, + releasedUTF16Length < releasableUTF16Length else { + return nil + } + + let range = (target.string as NSString).rangeOfComposedCharacterSequence( + at: releasedUTF16Length + ) + guard range.location == releasedUTF16Length, + NSMaxRange(range) <= releasableUTF16Length else { + return nil + } + + releasedUTF16Length = NSMaxRange(range) + pendingGraphemeCount -= 1 + lastReleaseTime = time + pruneAnimations(at: time) + activeAnimations.append(CharacterStreamingAnimation(range: range, startTime: time)) + if activeAnimations.count > ParagraphAnimationConstants.maximumActiveCharacterAnimations { + activeAnimations.removeFirst( + activeAnimations.count - ParagraphAnimationConstants.maximumActiveCharacterAnimations + ) + } + return CharacterStreamingRelease(range: range, time: time) + } + + func pruneAnimations(at time: CFTimeInterval) { + activeAnimations.removeAll { $0.isFinished(at: time) } + } + + func settle() { + releasedUTF16Length = target.length + pendingGraphemeCount = 0 + activeAnimations.removeAll() + lastReleaseTime = nil + } + + func reset() { + target = NSAttributedString() + releasedUTF16Length = 0 + pendingGraphemeCount = 0 + activeAnimations.removeAll() + isComplete = false + lastReleaseTime = nil + } + + static func releaseInterval(forBacklog backlog: Int) -> CFTimeInterval { + guard backlog > ParagraphAnimationConstants.lowBacklogGraphemeCount else { + return ParagraphAnimationConstants.characterReleaseInterval + } + + let accelerationRange = ParagraphAnimationConstants.maximumAccelerationBacklog + - ParagraphAnimationConstants.lowBacklogGraphemeCount + let normalizedBacklog = min( + 1, + Double(backlog - ParagraphAnimationConstants.lowBacklogGraphemeCount) + / Double(accelerationRange) + ) + let smoothedBacklog = normalizedBacklog * normalizedBacklog + * (3 - 2 * normalizedBacklog) + let speed = 1 + (ParagraphAnimationConstants.maximumCharacterReleaseSpeed - 1) + * smoothedBacklog + return ParagraphAnimationConstants.characterReleaseInterval / speed + } + + private var releasableUTF16Length: Int { + guard !isComplete, target.length > 0 else { + return target.length + } + return (target.string as NSString).rangeOfComposedCharacterSequence( + at: target.length - 1 + ).location + } + + private func recalculatePendingGraphemeCount() { + let string = target.string as NSString + let end = releasableUTF16Length + var location = releasedUTF16Length + var count = 0 + while location < end { + let range = string.rangeOfComposedCharacterSequence(at: location) + guard range.location == location, NSMaxRange(range) <= end else { + break + } + count += 1 + location = NSMaxRange(range) + } + pendingGraphemeCount = count + } + + private static func commonPrefixUTF16Length( + _ first: String, + _ second: String + ) -> Int { + var firstIndex = first.startIndex + var secondIndex = second.startIndex + while firstIndex < first.endIndex, + secondIndex < second.endIndex, + first[firstIndex] == second[secondIndex] { + first.formIndex(after: &firstIndex) + second.formIndex(after: &secondIndex) + } + return firstIndex.utf16Offset(in: first) + } +} + +func resolvedTextAnimation( + _ animation: MarkdownRenderConfig.TextAnimation, + reduceMotion: Bool +) -> MarkdownRenderConfig.TextAnimation { + reduceMotion ? .none : animation } /// Cubic Bezier ease-out curve shared between iOS and macOS paragraph views. diff --git a/Sources/MarkdownText/UI/Paragraph/ParagraphViewCache.swift b/Sources/MarkdownText/UI/Paragraph/ParagraphViewCache.swift index 6d357dc..24c5074 100644 --- a/Sources/MarkdownText/UI/Paragraph/ParagraphViewCache.swift +++ b/Sources/MarkdownText/UI/Paragraph/ParagraphViewCache.swift @@ -14,11 +14,22 @@ class ParagraphViewCache { static let shared: ParagraphViewCache = .init() - func createOrReuseView(contents: NSMutableAttributedString, lineSpacing: CGFloat?) -> MDParagraphView { - if let availableView = findAvailableCachedView() { + func createOrReuseView( + contents: NSMutableAttributedString, + lineSpacing: CGFloat?, + characterStreaming: Bool + ) -> MDParagraphView { + if let availableView = findAvailableCachedView( + characterStreaming: characterStreaming + ) { return availableView } - let newView = MDParagraphView() + let newView: MDParagraphView + if characterStreaming { + newView = MDParagraphView(characterStreaming: true) + } else { + newView = MDParagraphView() + } if $cachedViews.read(closure: { $0.count }) < maxCacheSize { $cachedViews.mutate { $0.append(newView) } } @@ -29,10 +40,14 @@ class ParagraphViewCache { $cachedViews.mutate { $0.removeAll() } } - private func findAvailableCachedView() -> MDParagraphView? { + private func findAvailableCachedView( + characterStreaming: Bool + ) -> MDParagraphView? { $cachedViews.read(closure: { cachedView in cachedView.first { view in - view.superview == nil && view.window == nil + view.superview == nil + && view.window == nil + && view.supportsCharacterStreaming == characterStreaming } }) } diff --git a/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphUIView.swift b/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphUIView.swift index 17115fb..6d8d4b9 100644 --- a/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphUIView.swift +++ b/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphUIView.swift @@ -26,9 +26,16 @@ class ParagraphUIView: UITextView { private(set) var lineSpacing: CGFloat? private var finalAttributedText = NSAttributedString() private var activeAnimation: FadeAnimationData? - private var fadeAnimationDisplayLink: CADisplayLink? + private let characterStreamingState = CharacterStreamingState() + private var characterStreamingTimer: Timer? + private var animatedCharacterRanges: [NSRange] = [] + private var textAnimationDisplayLink: CADisplayLink? + private var textAnimation: MarkdownRenderConfig.TextAnimation = .none + private var isStreamComplete = true + private var retainedTextStorage: NSTextStorage? private var cachedSize: CachedParagraphUIViewSize? + private(set) var supportsCharacterStreaming = false var textContextMenu: TextContextMenu? var markdownController: MarkdownController? @@ -41,6 +48,17 @@ class ParagraphUIView: UITextView { setupView() } + convenience init(characterStreaming: Bool) { + guard characterStreaming else { + self.init(frame: .zero, textContainer: nil) + return + } + let textSystem = Self.makeTextSystem() + self.init(frame: .zero, textContainer: textSystem.container) + retainedTextStorage = textSystem.storage + supportsCharacterStreaming = true + } + required init?(coder: NSCoder) { super.init(coder: coder) delegate = self @@ -49,6 +67,7 @@ class ParagraphUIView: UITextView { deinit { tearDownDisplayLink() + characterStreamingTimer?.invalidate() activeAnimation = nil } @@ -102,44 +121,88 @@ class ParagraphUIView: UITextView { func setParagraphContents( _ newContents: NSMutableAttributedString, lineSpacing: CGFloat? = nil, - revealAppendedText: Bool + textAnimation: MarkdownRenderConfig.TextAnimation, + isStreamComplete: Bool ) { // Keep the cached interface style up to date for citation preview rendering. // This runs on the main thread so it's safe to read traitCollection here. AppAppearance.update(style: traitCollection.userInterfaceStyle) - guard paragraphContents != newContents || self.lineSpacing != lineSpacing else { + if textAnimation == .none { + guard paragraphContents != newContents + || self.lineSpacing != lineSpacing + || self.textAnimation != .none + || self.isStreamComplete != isStreamComplete else { + return + } + stopCharacterStreaming() + activeAnimation = nil + tearDownDisplayLink() + self.paragraphContents = newContents + self.lineSpacing = lineSpacing + self.textAnimation = .none + self.isStreamComplete = isStreamComplete + let settledString = lineSpacing != nil + ? applyLineSpacing(to: newContents, lineSpacing: lineSpacing) + : newContents + finalAttributedText = NSAttributedString( + attributedString: settledString + ) + invalidateCachedSize() + attributedText = settledString + configureAccessibility(for: settledString) + invalidateIntrinsicContentSize() return } - let previousText = paragraphContents.string + let finalString: NSMutableAttributedString if lineSpacing != nil { finalString = applyLineSpacing(to: newContents, lineSpacing: lineSpacing) } else { finalString = newContents } - let revealPlan = revealAppendedText - ? ParagraphRevealPlan.appendedText( - previousText: previousText, - newText: finalString.string - ) - : nil - let previousAnimation = activeAnimation + let previousText = finalAttributedText.string + let contentsChanged = paragraphContents != newContents + || self.lineSpacing != lineSpacing + let modeChanged = self.textAnimation != textAnimation + let completionChanged = self.isStreamComplete != isStreamComplete + guard contentsChanged || modeChanged || completionChanged else { + return + } - tearDownDisplayLink() - activeAnimation = nil + if modeChanged { + stopCharacterStreaming() + activeAnimation = nil + tearDownDisplayLink() + } self.paragraphContents = newContents self.lineSpacing = lineSpacing + self.textAnimation = textAnimation + self.isStreamComplete = isStreamComplete finalAttributedText = NSAttributedString(attributedString: finalString) invalidateCachedSize() - attributedText = finalString - configureAccessibility(for: finalString) - invalidateIntrinsicContentSize() - - if let revealPlan { + switch textAnimation { + case .none: + break + case .fade: + stopCharacterStreaming() + attributedText = finalString + let revealPlan = contentsChanged + ? ParagraphRevealPlan.appendedText( + previousText: previousText, + newText: finalString.string + ) + : nil + guard let revealPlan else { + activeAnimation = nil + tearDownDisplayLink() + invalidateIntrinsicContentSize() + return + } let currentTime = CACurrentMediaTime() + let previousAnimation = modeChanged ? nil : activeAnimation activeAnimation = FadeAnimationData( plan: revealPlan, startTime: currentTime, @@ -148,22 +211,47 @@ class ParagraphUIView: UITextView { ) updateTextViewWithCurrentAnimations(at: currentTime) setUpDisplayLink() + case .characterStreaming: + activeAnimation = nil + if modeChanged { + characterStreamingState.reset() + } + let currentTime = CACurrentMediaTime() + characterStreamingState.update( + target: finalString, + isComplete: isStreamComplete, + at: currentTime + ) + synchronizeCharacterStreamingText() + releaseOneCharacter(at: currentTime) } + + invalidateIntrinsicContentSize() } - func finishTextReveal() { - guard let activeAnimation else { return } - restoreFinalAttributes(in: activeAnimation.segments.map(\.range)) - self.activeAnimation = nil + func finishTextAnimation() { + if let activeAnimation { + restoreFinalAttributes(in: activeAnimation.segments.map(\.range)) + self.activeAnimation = nil + } + if textAnimation == .characterStreaming { + characterStreamingState.settle() + synchronizeCharacterStreamingText() + stopCharacterStreaming() + } tearDownDisplayLink() } func prepareForReuse() { activeAnimation = nil + stopCharacterStreaming() + characterStreamingState.reset() tearDownDisplayLink() paragraphContents = NSMutableAttributedString() lineSpacing = nil finalAttributedText = NSAttributedString() + textAnimation = .none + isStreamComplete = true attributedText = NSAttributedString() accessibilityLabel = nil accessibilityCustomActions = nil @@ -212,6 +300,18 @@ class ParagraphUIView: UITextView { textDragInteraction?.isEnabled = false } + private static func makeTextSystem() -> ( + storage: NSTextStorage, + container: NSTextContainer + ) { + let textStorage = NSTextStorage() + let layoutManager = CharacterStreamingLayoutManager() + let textContainer = NSTextContainer(size: .zero) + textStorage.addLayoutManager(layoutManager) + layoutManager.addTextContainer(textContainer) + return (textStorage, textContainer) + } + /// Creates a custom accessibility action that forwards activation to `onUrlTap`. private func makeAccessibilityAction(name: String, url: URL) -> UIAccessibilityCustomAction { return UIAccessibilityCustomAction(name: name) { [weak self] _ in @@ -270,16 +370,26 @@ class ParagraphUIView: UITextView { } } - @objc private func updateFadeAnimation() { - guard let activeAnimation else { - tearDownDisplayLink() - return - } + @objc private func updateTextAnimation() { let currentTime = CACurrentMediaTime() - updateTextViewWithCurrentAnimations(at: currentTime) - if currentTime >= activeAnimation.endTime { - self.activeAnimation = nil + switch textAnimation { + case .none: tearDownDisplayLink() + case .fade: + guard let activeAnimation else { + tearDownDisplayLink() + return + } + updateTextViewWithCurrentAnimations(at: currentTime) + if currentTime >= activeAnimation.endTime { + self.activeAnimation = nil + tearDownDisplayLink() + } + case .characterStreaming: + updateCharacterStreamingAnimations(at: currentTime) + if characterStreamingState.activeAnimations.isEmpty { + tearDownDisplayLink() + } } } @@ -321,16 +431,122 @@ class ParagraphUIView: UITextView { } } + private func releaseOneCharacter( + at currentTime: CFTimeInterval = CACurrentMediaTime() + ) { + guard textAnimation == .characterStreaming else { + return + } + if characterStreamingState.releaseNext(at: currentTime) != nil { + synchronizeCharacterStreamingText() + updateCharacterStreamingAnimations(at: currentTime) + setUpDisplayLink() + } + scheduleNextCharacterRelease() + } + + private func synchronizeCharacterStreamingText() { + attributedText = characterStreamingState.visibleAttributedText + animatedCharacterRanges.removeAll() + invalidateCachedSize() + invalidateIntrinsicContentSize() + } + + private func scheduleNextCharacterRelease() { + characterStreamingTimer?.invalidate() + characterStreamingTimer = nil + guard textAnimation == .characterStreaming, + characterStreamingState.hasPendingGrapheme else { + return + } + + let timer = Timer( + timeInterval: characterStreamingState.nextReleaseInterval, + repeats: false + ) { [weak self] _ in + guard let self else { return } + self.characterStreamingTimer = nil + self.releaseOneCharacter() + } + RunLoop.main.add(timer, forMode: .common) + characterStreamingTimer = timer + } + + private func updateCharacterStreamingAnimations(at currentTime: CFTimeInterval) { + characterStreamingState.pruneAnimations(at: currentTime) + let animations = characterStreamingState.activeAnimations + let rangesToRestore = animatedCharacterRanges.filter { + NSMaxRange($0) <= textStorage.length + } + + textStorage.beginEditing() + restoreFinalAttributesWithoutEditing(in: rangesToRestore) + for animation in animations where NSMaxRange(animation.range) <= textStorage.length { + let transform = animation.transform(at: currentTime) + guard transform.blurRadius > 0 else { continue } + finalAttributedText.enumerateAttributes( + in: animation.range, + options: [] + ) { attributes, attributeRange, _ in + var attributes = attributes + let color = (attributes[.foregroundColor] as? UIColor) + ?? UIColor(Color.Theme.Foreground.Primary.Primary750) + let shadow = NSShadow() + shadow.shadowOffset = .zero + shadow.shadowBlurRadius = transform.blurRadius + shadow.shadowColor = color.withAlphaComponent(color.cgColor.alpha) + attributes[.shadow] = shadow + textStorage.setAttributes(attributes, range: attributeRange) + } + } + textStorage.endEditing() + + animatedCharacterRanges = animations.map(\.range) + characterStreamingLayoutManager?.updateAnimations( + animations, + at: currentTime + ) + } + + private func restoreFinalAttributesWithoutEditing(in ranges: [NSRange]) { + for range in ranges where NSMaxRange(range) <= finalAttributedText.length { + finalAttributedText.enumerateAttributes( + in: range, + options: [] + ) { attributes, attributeRange, _ in + textStorage.setAttributes(attributes, range: attributeRange) + } + } + } + + private func stopCharacterStreaming() { + characterStreamingTimer?.invalidate() + characterStreamingTimer = nil + animatedCharacterRanges.removeAll() + if supportsCharacterStreaming { + characterStreamingLayoutManager?.clearAnimations() + } + } + + private var characterStreamingLayoutManager: CharacterStreamingLayoutManager? { + layoutManager as? CharacterStreamingLayoutManager + } + private func setUpDisplayLink() { - tearDownDisplayLink() - fadeAnimationDisplayLink = CADisplayLink(target: self, selector: #selector(updateFadeAnimation)) - fadeAnimationDisplayLink?.preferredFramesPerSecond = 60 - fadeAnimationDisplayLink?.add(to: .main, forMode: .common) + guard textAnimationDisplayLink == nil else { + return + } + textAnimationDisplayLink = CADisplayLink( + target: self, + selector: #selector(updateTextAnimation) + ) + textAnimationDisplayLink?.preferredFramesPerSecond = 60 + textAnimationDisplayLink?.add(to: .main, forMode: .common) } private func tearDownDisplayLink() { - fadeAnimationDisplayLink?.remove(from: .main, forMode: .common) - fadeAnimationDisplayLink = nil + textAnimationDisplayLink?.remove(from: .main, forMode: .common) + textAnimationDisplayLink = nil } private func invalidateCachedSize() { diff --git a/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphView+iOS.swift b/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphView+iOS.swift index 14f89d7..c2f502f 100644 --- a/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphView+iOS.swift +++ b/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphView+iOS.swift @@ -11,6 +11,7 @@ struct ParagraphView: UIViewRepresentable { @Environment(\.markdownConfig) var config: MarkdownRenderConfig @Environment(\.markdownController) var markdownController: MarkdownController? @Environment(\.accessibilityReduceMotion) var reduceMotion + @Environment(\.isMarkdownStreamComplete) var isStreamComplete var contents: NSMutableAttributedString var lineSpacing: CGFloat? @@ -21,13 +22,18 @@ struct ParagraphView: UIViewRepresentable { func makeUIView(context: Context) -> ParagraphUIView { let openUrlFunction = openURL.callAsFunction(_:) - let view = ParagraphViewCache.shared.createOrReuseView(contents: contents, lineSpacing: lineSpacing) + let view = ParagraphViewCache.shared.createOrReuseView( + contents: contents, + lineSpacing: lineSpacing, + characterStreaming: resolvedAnimation == .characterStreaming + ) view.prepareForReuse() view.onUrlTap = openUrlFunction view.setParagraphContents( contents, lineSpacing: lineSpacing, - revealAppendedText: shouldRevealText + textAnimation: resolvedAnimation, + isStreamComplete: isStreamComplete ) view.setTextContextMenu(config.resolvedTextContextMenu) view.setMarkdownController(markdownController) @@ -36,14 +42,19 @@ struct ParagraphView: UIViewRepresentable { } func updateUIView(_ view: ParagraphUIView, context: Context) { - if !shouldRevealText { - view.finishTextReveal() - } if view.paragraphContents != contents || view.lineSpacing != lineSpacing { view.setParagraphContents( contents, lineSpacing: lineSpacing, - revealAppendedText: view.window != nil && shouldRevealText + textAnimation: view.window == nil ? .none : resolvedAnimation, + isStreamComplete: isStreamComplete + ) + } else { + view.setParagraphContents( + contents, + lineSpacing: lineSpacing, + textAnimation: resolvedAnimation, + isStreamComplete: isStreamComplete ) } view.setTextContextMenu(config.resolvedTextContextMenu) @@ -88,11 +99,8 @@ struct ParagraphView: UIViewRepresentable { var lastLineSpacing: CGFloat? } - private var shouldRevealText: Bool { - shouldRevealAppendedText( - isConfigured: config.shouldAnimateText, - reduceMotion: reduceMotion - ) + private var resolvedAnimation: MarkdownRenderConfig.TextAnimation { + resolvedTextAnimation(config.textAnimation, reduceMotion: reduceMotion) } } diff --git a/Sources/MarkdownText/UI/TableView.swift b/Sources/MarkdownText/UI/TableView.swift index e8d4685..3df02d3 100644 --- a/Sources/MarkdownText/UI/TableView.swift +++ b/Sources/MarkdownText/UI/TableView.swift @@ -376,7 +376,8 @@ extension TableView { content, color: color )) - } else if config.shouldAnimateText { + .environment(\.markdownConfig, config.withTextAnimation(.none)) + } else if config.textAnimation == .fade { Text(AttributedString(content)) .foregroundStyle(color) .lineLimit(nil) diff --git a/Tests/MarkdownTextTests/ParagraphAnimationTests.swift b/Tests/MarkdownTextTests/ParagraphAnimationTests.swift index e4596ed..4a3d01b 100644 --- a/Tests/MarkdownTextTests/ParagraphAnimationTests.swift +++ b/Tests/MarkdownTextTests/ParagraphAnimationTests.swift @@ -7,125 +7,257 @@ import Foundation @testable import SwiftStreamingMarkdown import Testing -@Suite("Paragraph reveal planning") +@Suite("Character Streaming") struct ParagraphAnimationTests { - @Test("Reveals only the appended suffix") - func appendedSuffix() throws { - let previous = "Stable text" - let updated = "\(previous) fades in" - let plan = try #require( - ParagraphRevealPlan.appendedText(previousText: previous, newText: updated) + @Test("Releases exactly one grapheme and never batches at one timestamp") + func releasesOneAtATime() throws { + let state = CharacterStreamingState() + state.update(target: attributed("abcd"), isComplete: false, at: 0) + + let first = try #require(state.releaseNext(at: 0)) + #expect(substring("abcd", in: first.range) == "a") + #expect(state.visibleAttributedText.string == "a") + #expect(state.releaseNext(at: 0) == nil) + #expect(state.visibleAttributedText.string == "a") + + let second = try #require(state.releaseNext(at: 0.018)) + #expect(substring("abcd", in: second.range) == "b") + #expect(state.visibleAttributedText.string == "ab") + } + + @Test("Uses 18ms at low backlog and smoothly accelerates up to four times") + func adaptiveCadence() { + let low = CharacterStreamingState.releaseInterval(forBacklog: 1) + let medium = CharacterStreamingState.releaseInterval(forBacklog: 32) + let high = CharacterStreamingState.releaseInterval(forBacklog: 64) + + #expect(low == 0.018) + #expect(medium < low) + #expect(medium > high) + #expect(abs(high - 0.0045) < 0.000_001) + } + + @Test("Cadence returns toward 18ms while backlog drains") + func cadenceSlowsWhileDraining() throws { + let state = CharacterStreamingState() + state.update( + target: attributed(String(repeating: "a", count: 80)), + isComplete: true, + at: 0 ) - let coveredRange = try #require(plan.coveredRange) + let initialInterval = state.nextReleaseInterval - #expect(coveredRange.location == (previous as NSString).length) - #expect((updated as NSString).substring(with: coveredRange) == " fades in") - #expect(plan.segments.first?.delay == 0) - #expect(plan.segments.last?.delay == ParagraphAnimationConstants.revealStaggerDuration) + for index in 0..<77 { + _ = try #require(state.releaseNext(at: Double(index + 1))) + } + + #expect(initialInterval < state.nextReleaseInterval) + #expect(state.nextReleaseInterval == 0.018) + } + + @Test("Withholds a terminal grapheme across chunks that extend it") + func crossChunkContinuity() throws { + let state = CharacterStreamingState() + state.update(target: attributed("Cafe"), isComplete: false, at: 0) + for time in [0.0, 0.018, 0.036] { + _ = try #require(state.releaseNext(at: time)) + } + #expect(state.visibleAttributedText.string == "Caf") + #expect(!state.hasPendingGrapheme) + + let continued = "Cafe\u{301} " + state.update(target: attributed(continued), isComplete: false, at: 0.05) + let release = try #require(state.releaseNext(at: 0.054)) + + #expect(substring(continued, in: release.range) == "e\u{301}") + #expect(state.visibleAttributedText.string == "Cafe\u{301}") + #expect(!state.hasPendingGrapheme) + } + + @Test("Starts with the exact rise, grow, sharpen, and fade transform") + func exactInitialTransform() { + let transform = CharacterStreamingTransform.value(at: 0) + + #expect(transform.opacity == 0.08) + #expect(transform.scale == 0.82) + #expect(transform.baselineOffset == 3) + #expect(transform.blurRadius == 3) + } + + @Test("Settles exactly to the final transform after 260ms") + func exactFinalTransform() { + let animation = CharacterStreamingAnimation( + range: NSRange(location: 0, length: 1), + startTime: 1 + ) + let transform = animation.transform( + at: 1 + ParagraphAnimationConstants.characterAnimationDuration + ) + + #expect(transform.opacity == 1) + #expect(transform.scale == 1) + #expect(transform.baselineOffset == 0) + #expect(transform.blurRadius == 0) + #expect(animation.isFinished(at: 1.26)) + } + + @Test("Releases Unicode composed character sequences intact") + func unicodeComposedGraphemes() throws { + let text = "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆe\u{301}๐Ÿ‡บ๐Ÿ‡ธX" + let state = CharacterStreamingState() + state.update(target: attributed(text), isComplete: false, at: 0) + + let family = try #require(state.releaseNext(at: 0)) + let accented = try #require(state.releaseNext(at: 0.018)) + let flag = try #require(state.releaseNext(at: 0.036)) + + #expect(substring(text, in: family.range) == "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ") + #expect(substring(text, in: accented.range) == "e\u{301}") + #expect(substring(text, in: flag.range) == "๐Ÿ‡บ๐Ÿ‡ธ") + #expect(state.visibleAttributedText.string == "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆe\u{301}๐Ÿ‡บ๐Ÿ‡ธ") + #expect(!state.hasPendingGrapheme) } - @Test("Does not animate replacements or style-only updates") - func nonAppendUpdates() { + @Test("Reduce Motion settles Character Streaming immediately") + func reduceMotion() { #expect( - ParagraphRevealPlan.appendedText( - previousText: "Streaming *text", - newText: "Streaming text" - ) == nil + resolvedTextAnimation(.characterStreaming, reduceMotion: false) + == .characterStreaming ) #expect( - ParagraphRevealPlan.appendedText( - previousText: "Unchanged", - newText: "Unchanged" - ) == nil + resolvedTextAnimation(.characterStreaming, reduceMotion: true) + == .none ) + #expect(resolvedTextAnimation(.fade, reduceMotion: true) == .none) } - @Test("Uses UTF-16 ranges without splitting composed characters") - func composedCharacters() throws { - let previous = "Hello ๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ" - let suffix = " cafรฉ ๐Ÿงฎ" - let updated = previous + suffix - let plan = try #require( - ParagraphRevealPlan.appendedText(previousText: previous, newText: updated) - ) - let coveredRange = try #require(plan.coveredRange) + @Test("Completion drains the withheld terminal grapheme") + func completionDrain() throws { + let state = CharacterStreamingState() + state.update(target: attributed("A"), isComplete: false, at: 0) + #expect(!state.hasPendingGrapheme) + #expect(state.releaseNext(at: 0) == nil) - #expect(coveredRange.location == (previous as NSString).length) - #expect((updated as NSString).substring(with: coveredRange) == suffix) - #expect(plan.segments.allSatisfy { segment in - let composedRange = (updated as NSString).rangeOfComposedCharacterSequences( - for: segment.range - ) - return composedRange == segment.range - }) + state.update(target: attributed("A"), isComplete: true, at: 0.1) + #expect(state.hasPendingGrapheme) + _ = try #require(state.releaseNext(at: 0.1)) + #expect(state.visibleAttributedText.string == "A") + #expect(!state.hasPendingGrapheme) } - @Test("Does not animate when an append extends the previous grapheme") - func extendedPreviousGrapheme() { - #expect( - ParagraphRevealPlan.appendedText( - previousText: "Cafe", - newText: "Cafe\u{301}" - ) == nil + @Test("Replacement rewinds to a composed prefix and restyling is retained") + func replacementAndRestyle() throws { + let state = CharacterStreamingState() + state.update(target: attributed("abcX"), isComplete: false, at: 0) + for time in [0.0, 0.018, 0.036] { + _ = try #require(state.releaseNext(at: time)) + } + #expect(state.visibleAttributedText.string == "abc") + + state.update(target: attributed("abZ!"), isComplete: false, at: 0.05) + #expect(state.visibleAttributedText.string == "ab") + _ = try #require(state.releaseNext(at: 0.054)) + #expect(state.visibleAttributedText.string == "abZ") + + let styleKey = NSAttributedString.Key("CharacterStreamingTests.style") + let restyled = NSMutableAttributedString(string: "abZ!") + restyled.addAttribute( + styleKey, + value: "updated", + range: NSRange(location: 0, length: restyled.length) ) + state.update(target: restyled, isComplete: false, at: 0.06) + #expect( - ParagraphRevealPlan.appendedText( - previousText: "๐Ÿ‘จ", - newText: "๐Ÿ‘จโ€๐Ÿ‘ฉ" - ) == nil + state.visibleAttributedText.attribute( + styleKey, + at: 0, + effectiveRange: nil + ) as? String == "updated" ) } - @Test("Bounds work for very large chunks") - func boundedSegments() throws { - let suffix = String(repeating: "streaming ", count: 10_000) - let plan = try #require( - ParagraphRevealPlan.appendedText(previousText: "Start: ", newText: "Start: \(suffix)") + @Test("Preserves attributed Markdown runs in released content") + func attributedContent() throws { + let styleKey = NSAttributedString.Key("CharacterStreamingTests.typography") + let target = NSMutableAttributedString(string: "ab") + target.addAttribute( + styleKey, + value: "bold-link", + range: NSRange(location: 0, length: 1) ) + let state = CharacterStreamingState() + state.update(target: target, isComplete: false, at: 0) + _ = try #require(state.releaseNext(at: 0)) - #expect(plan.segments.count <= ParagraphAnimationConstants.maximumSegmentCount) - #expect(plan.coveredRange?.length == (suffix as NSString).length) + #expect( + state.visibleAttributedText.attribute( + styleKey, + at: 0, + effectiveRange: nil + ) as? String == "bold-link" + ) } - @Test("Carries unfinished segments across rapid updates with bounded work") - func rapidUpdates() throws { - let firstPlan = try #require( - ParagraphRevealPlan.appendedText(previousText: "", newText: "First streamed chunk") + @Test("Bounds active animation state under sustained backlog") + func boundedAnimationState() throws { + let state = CharacterStreamingState() + state.update( + target: attributed(String(repeating: "a", count: 100)), + isComplete: true, + at: 0 + ) + + for index in 0..<100 { + _ = try #require(state.releaseNext(at: Double(index) / 1_000)) + } + + #expect( + state.activeAnimations.count + == ParagraphAnimationConstants.maximumActiveCharacterAnimations ) - let firstAnimation = FadeAnimationData( - plan: firstPlan, - startTime: 0, - contentLength: ("First streamed chunk" as NSString).length + } + + @Test("Public style selection is explicit and type safe") + func styleSelection() { + let characterStreaming = MarkdownRenderConfig( + textAnimation: .characterStreaming ) - let secondText = "First streamed chunk plus another streamed chunk" - let secondPlan = try #require( + let fade = characterStreaming.withTextAnimation(.fade) + + #expect(MarkdownRenderConfig.default.textAnimation == .none) + #expect(characterStreaming.textAnimation == .characterStreaming) + #expect(fade.textAnimation == .fade) + } + + @Test("Standard fade still targets only appended content") + func standardFadeAppend() throws { + let previous = "Stable text" + let updated = "\(previous) fades in" + let plan = try #require( ParagraphRevealPlan.appendedText( - previousText: "First streamed chunk", - newText: secondText + previousText: previous, + newText: updated ) ) - let secondAnimation = FadeAnimationData( - plan: secondPlan, - startTime: 0.15, - previousAnimation: firstAnimation, - contentLength: (secondText as NSString).length - ) + let coveredRange = try #require(plan.coveredRange) - #expect(secondAnimation.segments.contains { $0.range.location == 0 }) - #expect(secondAnimation.segments.contains { - $0.range.location >= ("First streamed chunk" as NSString).length - }) + #expect(coveredRange.location == (previous as NSString).length) + #expect(substring(updated, in: coveredRange) == " fades in") + #expect(plan.segments.first?.delay == 0) #expect( - secondAnimation.segments.count <= ParagraphAnimationConstants.maximumSegmentCount + plan.segments.last?.delay + == ParagraphAnimationConstants.fadeStaggerDuration ) } +} - @Test("Reduce Motion disables the reveal") - func reduceMotion() { - #expect(shouldRevealAppendedText(isConfigured: true, reduceMotion: false)) - #expect(!shouldRevealAppendedText(isConfigured: true, reduceMotion: true)) - #expect(!shouldRevealAppendedText(isConfigured: false, reduceMotion: false)) - } +private func attributed(_ text: String) -> NSAttributedString { + NSAttributedString(string: text) +} + +private func substring(_ text: String, in range: NSRange) -> String { + (text as NSString).substring(with: range) } private extension ParagraphRevealPlan { diff --git a/Tests/MarkdownTextTests/ParagraphNSViewTests.swift b/Tests/MarkdownTextTests/ParagraphNSViewTests.swift index d4b66ff..d245c9d 100644 --- a/Tests/MarkdownTextTests/ParagraphNSViewTests.swift +++ b/Tests/MarkdownTextTests/ParagraphNSViewTests.swift @@ -23,7 +23,8 @@ struct ParagraphNSViewTests { let longText = String(repeating: "word ", count: 200) view.setParagraphContents( NSMutableAttributedString(string: longText), - revealAppendedText: false + textAnimation: .none, + isStreamComplete: true ) let narrow = view.measureSize(fittingWidth: 200) @@ -42,10 +43,28 @@ struct ParagraphNSViewTests { let view = ParagraphNSView() view.setParagraphContents( NSMutableAttributedString(string: ""), - revealAppendedText: false + textAnimation: .none, + isStreamComplete: true ) #expect(view.measureSize(fittingWidth: 400) == .zero) } + + @Test("Character Streaming uses transformed TextKit rendering") + func characterStreamingParagraphIntegration() { + let view = ParagraphNSView(characterStreaming: true) + view.setParagraphContents( + NSMutableAttributedString(string: "AB"), + textAnimation: .characterStreaming, + isStreamComplete: false + ) + + #expect(view.string == "A") + #expect(view.layoutManager is CharacterStreamingLayoutManager) + + view.finishTextAnimation() + + #expect(view.string == "AB") + } } #endif diff --git a/Tests/MarkdownTextTests/ParagraphViewTests.swift b/Tests/MarkdownTextTests/ParagraphViewTests.swift index 76cb572..ad87c2a 100644 --- a/Tests/MarkdownTextTests/ParagraphViewTests.swift +++ b/Tests/MarkdownTextTests/ParagraphViewTests.swift @@ -198,7 +198,8 @@ struct ParagraphViewTests { let view = ParagraphUIView() view.setParagraphContents( NSMutableAttributedString(string: "Previous paragraph"), - revealAppendedText: false + textAnimation: .none, + isStreamComplete: true ) #expect(view.accessibilityLabel == "Previous paragraph") @@ -210,6 +211,41 @@ struct ParagraphViewTests { #expect(view.accessibilityCustomActions == nil) } + @Test("Character Streaming keeps one attributed paragraph and full accessibility") + @MainActor + func characterStreamingParagraphIntegration() throws { + let url = try #require(URL(string: "https://example.com")) + let contents = NSMutableAttributedString(string: "AB") + contents.addAttribute( + .link, + value: url, + range: NSRange(location: 0, length: 1) + ) + let view = ParagraphUIView(characterStreaming: true) + + view.setParagraphContents( + contents, + textAnimation: .characterStreaming, + isStreamComplete: false + ) + + #expect(view.attributedText.string == "A") + #expect(view.accessibilityLabel == "AB") + #expect(view.layoutManager is CharacterStreamingLayoutManager) + #expect( + view.attributedText.attribute( + .link, + at: 0, + effectiveRange: nil + ) as? URL == url + ) + + view.finishTextAnimation() + + #expect(view.attributedText.string == "AB") + #expect(view.accessibilityLabel == "AB") + } + @Test("Long text overflow handling") func longTextOverflow() { let longText = String(repeating: "This is a very long text that should test overflow behavior. ", count: 20) From d40d4d26be9ec305215cbab2d7ed7f1ccc6b4f66 Mon Sep 17 00:00:00 2001 From: "Mahyar (Mac) McDonald" <22130+theontho@users.noreply.github.com> Date: Fri, 17 Jul 2026 09:36:42 -0700 Subject: [PATCH 3/7] Fix character streaming regressions Scope terminal withholding to the structural tail paragraph, preserve scheduler deadlines and fade completion, keep streaming measurement bounded, support live Reduce Motion changes, and align UIKit/AppKit transforms. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a649c2f7-fe53-4216-9f15-b5aa7237934a --- Sources/MarkdownText/UI/BlockView.swift | 21 ++- Sources/MarkdownText/UI/DocumentView.swift | 2 + Sources/MarkdownText/UI/OrderedListView.swift | 26 ++++ .../UI/Paragraph/AppKit/ParagraphNSView.swift | 26 +++- .../AppKit/ParagraphView+macOS.swift | 33 ++++- .../CharacterStreamingLayoutManager.swift | 8 +- .../UI/Paragraph/ParagraphAnimation.swift | 9 +- .../UI/Paragraph/UIKit/ParagraphUIView.swift | 26 +++- .../Paragraph/UIKit/ParagraphView+iOS.swift | 33 ++++- .../MarkdownText/UI/UnorderedListView.swift | 26 ++++ .../ParagraphAnimationTests.swift | 49 ++++++- .../ParagraphNSViewTests.swift | 124 ++++++++++++++++ .../ParagraphViewTests.swift | 133 ++++++++++++++++++ 13 files changed, 478 insertions(+), 38 deletions(-) diff --git a/Sources/MarkdownText/UI/BlockView.swift b/Sources/MarkdownText/UI/BlockView.swift index 653f1f8..aebe73a 100644 --- a/Sources/MarkdownText/UI/BlockView.swift +++ b/Sources/MarkdownText/UI/BlockView.swift @@ -9,6 +9,7 @@ import SwiftUI struct BlockView: View { @Environment(\.markdownConfig) var config: MarkdownRenderConfig + @Environment(\.isMarkdownStreamingTailBranch) var isStreamingTailBranch let renderables: [MarkdownRenderable] @@ -18,13 +19,29 @@ struct BlockView: View { var body: some View { VStack(alignment: .leading, spacing: config.blockSpacing) { - ForEach(renderables) { renderable in - SingleBlockView(renderable: renderable) + ForEach(renderables.indices, id: \.self) { index in + SingleBlockView(renderable: renderables[index]) + .environment( + \.isMarkdownStreamingTailBranch, + isTrailingStreamingElement( + at: index, + count: renderables.count, + parentIsTrailing: isStreamingTailBranch + ) + ) } } } } +func isTrailingStreamingElement( + at index: Int, + count: Int, + parentIsTrailing: Bool +) -> Bool { + parentIsTrailing && index == count - 1 +} + struct SingleBlockView: View { @Environment(\.markdownConfig) var config: MarkdownRenderConfig diff --git a/Sources/MarkdownText/UI/DocumentView.swift b/Sources/MarkdownText/UI/DocumentView.swift index 1521f3e..84b3b96 100644 --- a/Sources/MarkdownText/UI/DocumentView.swift +++ b/Sources/MarkdownText/UI/DocumentView.swift @@ -68,6 +68,8 @@ extension EnvironmentValues { @Entry public var markdownController: MarkdownController? /// Whether the current streamed source has finished producing snapshots. @Entry var isMarkdownStreamComplete = true + /// Whether this branch ends at the only paragraph whose final grapheme may still grow. + @Entry var isMarkdownStreamingTailBranch = true } #if DEBUG diff --git a/Sources/MarkdownText/UI/OrderedListView.swift b/Sources/MarkdownText/UI/OrderedListView.swift index 16b27fe..4bf0fe4 100644 --- a/Sources/MarkdownText/UI/OrderedListView.swift +++ b/Sources/MarkdownText/UI/OrderedListView.swift @@ -10,6 +10,7 @@ struct OrderedListView: View { let items: [MarkdownListItem] @Environment(\.markdownConfig) var config: MarkdownRenderConfig + @Environment(\.isMarkdownStreamingTailBranch) var isStreamingTailBranch var body: some View { VStack(alignment: .leading, spacing: 8, content: { @@ -20,20 +21,45 @@ struct OrderedListView: View { .foregroundStyle(config.orderedListStyle.textColor) .transition(.opacity) if let firstChild = items[idx].children.first { + let firstChildIsTail = isTrailingStreamingElement( + at: 0, + count: items[idx].children.count, + parentIsTrailing: isTrailingStreamingElement( + at: idx, + count: items.count, + parentIsTrailing: isStreamingTailBranch + ) + ) if case .paragraph(_, let contents) = firstChild { // Wrap the SingleBlockView to provide proper baseline alignment. This is to fix the mis-alignment when the view is rendered off-screen, e.g. snapshot. ListItemContentWrapper(paragraphContents: contents) { SingleBlockView(renderable: firstChild) + .environment( + \.isMarkdownStreamingTailBranch, + firstChildIsTail + ) } .accessibilityLabel(Text(markdownListAccessibilityLabel(for: contents.string, at: idx, length: items.count))) } else { SingleBlockView(renderable: firstChild) + .environment( + \.isMarkdownStreamingTailBranch, + firstChildIsTail + ) } } Spacer() } if items[idx].children.count > 1 { BlockView(renderables: Array(items[idx].children.dropFirst())) + .environment( + \.isMarkdownStreamingTailBranch, + isTrailingStreamingElement( + at: idx, + count: items.count, + parentIsTrailing: isStreamingTailBranch + ) + ) .padding([.leading], 0) } } diff --git a/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphNSView.swift b/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphNSView.swift index 89f8b0c..e594b71 100644 --- a/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphNSView.swift +++ b/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphNSView.swift @@ -138,7 +138,8 @@ class ParagraphNSView: NSTextView { } else { finalString = newContents } - let previousText = finalAttributedText.string + let previousAttributedText = finalAttributedText + let previousText = previousAttributedText.string let contentsChanged = paragraphContents != newContents || self.lineSpacing != lineSpacing let modeChanged = self.textAnimation != textAnimation @@ -168,6 +169,10 @@ class ParagraphNSView: NSTextView { textStorage?.setAttributedString(finalString) case .fade: stopCharacterStreaming() + guard contentsChanged || modeChanged else { + invalidateIntrinsicContentSize() + return + } textStorage?.setAttributedString(finalString) let revealPlan = contentsChanged ? ParagraphRevealPlan.appendedText( @@ -193,17 +198,27 @@ class ParagraphNSView: NSTextView { setUpDisplayLink() case .characterStreaming: activeAnimation = nil + let currentTime = CACurrentMediaTime() if modeChanged { characterStreamingState.reset() + if previousAttributedText.length > 0 { + characterStreamingState.update( + target: previousAttributedText, + isComplete: true, + at: currentTime + ) + characterStreamingState.settle() + } } - let currentTime = CACurrentMediaTime() characterStreamingState.update( target: finalString, isComplete: isStreamComplete, at: currentTime ) synchronizeCharacterStreamingText() - releaseOneCharacter(at: currentTime) + if characterStreamingTimer == nil { + releaseOneCharacter(at: currentTime) + } } invalidateIntrinsicContentSize() @@ -383,10 +398,9 @@ class ParagraphNSView: NSTextView { } private func scheduleNextCharacterRelease() { - characterStreamingTimer?.invalidate() - characterStreamingTimer = nil guard textAnimation == .characterStreaming, - characterStreamingState.hasPendingGrapheme else { + characterStreamingState.hasPendingGrapheme, + characterStreamingTimer == nil else { return } diff --git a/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphView+macOS.swift b/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphView+macOS.swift index 649c45f..d5931b1 100644 --- a/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphView+macOS.swift +++ b/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphView+macOS.swift @@ -12,6 +12,7 @@ struct ParagraphView: NSViewRepresentable { @Environment(\.markdownController) var markdownController: MarkdownController? @Environment(\.accessibilityReduceMotion) var reduceMotion @Environment(\.isMarkdownStreamComplete) var isStreamComplete + @Environment(\.isMarkdownStreamingTailBranch) var isStreamingTailBranch var contents: NSMutableAttributedString var lineSpacing: CGFloat? @@ -27,14 +28,14 @@ struct ParagraphView: NSViewRepresentable { // previously displayed document, which then render at the wrong positions. Each // paragraph gets its own view instead. let view = ParagraphNSView( - characterStreaming: resolvedAnimation == .characterStreaming + characterStreaming: config.textAnimation == .characterStreaming ) view.onUrlTap = openUrlFunction view.setParagraphContents( contents, lineSpacing: lineSpacing, textAnimation: resolvedAnimation, - isStreamComplete: isStreamComplete + isStreamComplete: paragraphStreamComplete ) view.setTextContextMenu(config.resolvedTextContextMenu) view.setMarkdownController(markdownController) @@ -48,14 +49,14 @@ struct ParagraphView: NSViewRepresentable { contents, lineSpacing: lineSpacing, textAnimation: view.window == nil ? .none : resolvedAnimation, - isStreamComplete: isStreamComplete + isStreamComplete: paragraphStreamComplete ) } else { view.setParagraphContents( contents, lineSpacing: lineSpacing, textAnimation: resolvedAnimation, - isStreamComplete: isStreamComplete + isStreamComplete: paragraphStreamComplete ) } view.setTextContextMenu(config.resolvedTextContextMenu) @@ -73,7 +74,13 @@ struct ParagraphView: NSViewRepresentable { context.coordinator.lastLineSpacing = lineSpacing } - let cacheKey = (width * 10).rounded() / 10 + let cacheKey = ParagraphSizeCacheKey( + width: (width * 10).rounded() / 10, + visibleUTF16Length: nsView.textStorage?.length ?? 0 + ) + context.coordinator.updateVisibleUTF16Length( + nsView.textStorage?.length ?? 0 + ) if let cachedSize = context.coordinator.sizeCache[cacheKey] { return cachedSize @@ -86,19 +93,31 @@ struct ParagraphView: NSViewRepresentable { } class Coordinator { - var sizeCache: [CGFloat: CGSize] = [:] + var sizeCache: [ParagraphSizeCacheKey: CGSize] = [:] var lastContents: NSMutableAttributedString? var lastLineSpacing: CGFloat? + private(set) var lastVisibleUTF16Length: Int? + + func updateVisibleUTF16Length(_ length: Int) { + guard lastVisibleUTF16Length != length else { return } + sizeCache.removeAll() + lastVisibleUTF16Length = length + } } private var resolvedAnimation: MarkdownRenderConfig.TextAnimation { resolvedTextAnimation(config.textAnimation, reduceMotion: reduceMotion) } + + private var paragraphStreamComplete: Bool { + isStreamComplete || !isStreamingTailBranch + } } extension ParagraphView: Equatable { static func == (lhs: ParagraphView, rhs: ParagraphView) -> Bool { - lhs.contents.isEqual(to: rhs.contents) && lhs.lineSpacing == rhs.lineSpacing + lhs.contents.isEqual(to: rhs.contents) + && lhs.lineSpacing == rhs.lineSpacing } } #endif diff --git a/Sources/MarkdownText/UI/Paragraph/CharacterStreamingLayoutManager.swift b/Sources/MarkdownText/UI/Paragraph/CharacterStreamingLayoutManager.swift index c91378e..f4c6172 100644 --- a/Sources/MarkdownText/UI/Paragraph/CharacterStreamingLayoutManager.swift +++ b/Sources/MarkdownText/UI/Paragraph/CharacterStreamingLayoutManager.swift @@ -123,7 +123,7 @@ final class CharacterStreamingLayoutManager: NSLayoutManager { context.setAlpha(transform.opacity) context.translateBy( x: 0, - y: platformBaselineTranslation(transform.baselineOffset) + y: Self.baselineTranslation(transform.baselineOffset) ) context.translateBy(x: anchor.x, y: anchor.y) context.scaleBy(x: transform.scale, y: transform.scale) @@ -140,12 +140,8 @@ final class CharacterStreamingLayoutManager: NSLayoutManager { #endif } - private func platformBaselineTranslation(_ offset: CGFloat) -> CGFloat { - #if canImport(UIKit) + static func baselineTranslation(_ offset: CGFloat) -> CGFloat { offset - #elseif canImport(AppKit) - -offset - #endif } } #endif diff --git a/Sources/MarkdownText/UI/Paragraph/ParagraphAnimation.swift b/Sources/MarkdownText/UI/Paragraph/ParagraphAnimation.swift index 85622d6..6e30de7 100644 --- a/Sources/MarkdownText/UI/Paragraph/ParagraphAnimation.swift +++ b/Sources/MarkdownText/UI/Paragraph/ParagraphAnimation.swift @@ -20,8 +20,13 @@ enum ParagraphAnimationConstants { static let initialCharacterOpacity: CGFloat = 0.08 static let initialCharacterScale: CGFloat = 0.82 - static let initialCharacterBaselineOffset: CGFloat = 3 - static let initialCharacterBlurRadius: CGFloat = 3 + static let initialCharacterBaselineOffset: CGFloat = 5 + static let initialCharacterBlurRadius: CGFloat = 2 +} + +struct ParagraphSizeCacheKey: Hashable { + let width: CGFloat + let visibleUTF16Length: Int } struct ParagraphRevealSegment: Equatable { diff --git a/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphUIView.swift b/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphUIView.swift index 6d8d4b9..dbdaf7b 100644 --- a/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphUIView.swift +++ b/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphUIView.swift @@ -161,7 +161,8 @@ class ParagraphUIView: UITextView { } else { finalString = newContents } - let previousText = finalAttributedText.string + let previousAttributedText = finalAttributedText + let previousText = previousAttributedText.string let contentsChanged = paragraphContents != newContents || self.lineSpacing != lineSpacing let modeChanged = self.textAnimation != textAnimation @@ -188,6 +189,10 @@ class ParagraphUIView: UITextView { break case .fade: stopCharacterStreaming() + guard contentsChanged || modeChanged else { + invalidateIntrinsicContentSize() + return + } attributedText = finalString let revealPlan = contentsChanged ? ParagraphRevealPlan.appendedText( @@ -213,17 +218,27 @@ class ParagraphUIView: UITextView { setUpDisplayLink() case .characterStreaming: activeAnimation = nil + let currentTime = CACurrentMediaTime() if modeChanged { characterStreamingState.reset() + if previousAttributedText.length > 0 { + characterStreamingState.update( + target: previousAttributedText, + isComplete: true, + at: currentTime + ) + characterStreamingState.settle() + } } - let currentTime = CACurrentMediaTime() characterStreamingState.update( target: finalString, isComplete: isStreamComplete, at: currentTime ) synchronizeCharacterStreamingText() - releaseOneCharacter(at: currentTime) + if characterStreamingTimer == nil { + releaseOneCharacter(at: currentTime) + } } invalidateIntrinsicContentSize() @@ -453,10 +468,9 @@ class ParagraphUIView: UITextView { } private func scheduleNextCharacterRelease() { - characterStreamingTimer?.invalidate() - characterStreamingTimer = nil guard textAnimation == .characterStreaming, - characterStreamingState.hasPendingGrapheme else { + characterStreamingState.hasPendingGrapheme, + characterStreamingTimer == nil else { return } diff --git a/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphView+iOS.swift b/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphView+iOS.swift index c2f502f..8ea4413 100644 --- a/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphView+iOS.swift +++ b/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphView+iOS.swift @@ -12,6 +12,7 @@ struct ParagraphView: UIViewRepresentable { @Environment(\.markdownController) var markdownController: MarkdownController? @Environment(\.accessibilityReduceMotion) var reduceMotion @Environment(\.isMarkdownStreamComplete) var isStreamComplete + @Environment(\.isMarkdownStreamingTailBranch) var isStreamingTailBranch var contents: NSMutableAttributedString var lineSpacing: CGFloat? @@ -25,7 +26,7 @@ struct ParagraphView: UIViewRepresentable { let view = ParagraphViewCache.shared.createOrReuseView( contents: contents, lineSpacing: lineSpacing, - characterStreaming: resolvedAnimation == .characterStreaming + characterStreaming: config.textAnimation == .characterStreaming ) view.prepareForReuse() view.onUrlTap = openUrlFunction @@ -33,7 +34,7 @@ struct ParagraphView: UIViewRepresentable { contents, lineSpacing: lineSpacing, textAnimation: resolvedAnimation, - isStreamComplete: isStreamComplete + isStreamComplete: paragraphStreamComplete ) view.setTextContextMenu(config.resolvedTextContextMenu) view.setMarkdownController(markdownController) @@ -47,14 +48,14 @@ struct ParagraphView: UIViewRepresentable { contents, lineSpacing: lineSpacing, textAnimation: view.window == nil ? .none : resolvedAnimation, - isStreamComplete: isStreamComplete + isStreamComplete: paragraphStreamComplete ) } else { view.setParagraphContents( contents, lineSpacing: lineSpacing, textAnimation: resolvedAnimation, - isStreamComplete: isStreamComplete + isStreamComplete: paragraphStreamComplete ) } view.setTextContextMenu(config.resolvedTextContextMenu) @@ -75,7 +76,13 @@ struct ParagraphView: UIViewRepresentable { } // Round width to avoid cache misses from floating point precision issues - let cacheKey = (width * 10).rounded() / 10 // Round to 1 decimal place + let cacheKey = ParagraphSizeCacheKey( + width: (width * 10).rounded() / 10, + visibleUTF16Length: uiView.attributedText.length + ) + context.coordinator.updateVisibleUTF16Length( + uiView.attributedText.length + ) // Check if we have a cached size for this width if let cachedSize = context.coordinator.sizeCache[cacheKey] { @@ -94,19 +101,31 @@ struct ParagraphView: UIViewRepresentable { class Coordinator { // Cache all calculated sizes keyed by width - var sizeCache: [CGFloat: CGSize] = [:] + var sizeCache: [ParagraphSizeCacheKey: CGSize] = [:] var lastContents: NSMutableAttributedString? var lastLineSpacing: CGFloat? + private(set) var lastVisibleUTF16Length: Int? + + func updateVisibleUTF16Length(_ length: Int) { + guard lastVisibleUTF16Length != length else { return } + sizeCache.removeAll() + lastVisibleUTF16Length = length + } } private var resolvedAnimation: MarkdownRenderConfig.TextAnimation { resolvedTextAnimation(config.textAnimation, reduceMotion: reduceMotion) } + + private var paragraphStreamComplete: Bool { + isStreamComplete || !isStreamingTailBranch + } } extension ParagraphView: Equatable { static func == (lhs: ParagraphView, rhs: ParagraphView) -> Bool { - lhs.contents == rhs.contents && lhs.lineSpacing == rhs.lineSpacing + lhs.contents == rhs.contents + && lhs.lineSpacing == rhs.lineSpacing } } #endif diff --git a/Sources/MarkdownText/UI/UnorderedListView.swift b/Sources/MarkdownText/UI/UnorderedListView.swift index 8b65e25..8a1e8cc 100644 --- a/Sources/MarkdownText/UI/UnorderedListView.swift +++ b/Sources/MarkdownText/UI/UnorderedListView.swift @@ -10,6 +10,7 @@ struct UnorderedListView: View { let items: [MarkdownListItem] let nestedLevel: Int + @Environment(\.isMarkdownStreamingTailBranch) var isStreamingTailBranch var body: some View { VStack(alignment: .leading, spacing: 8, content: { @@ -17,20 +18,45 @@ struct UnorderedListView: View { HStack(alignment: .centerOfFirstLine, spacing: 1) { bulletView(forListItem: items[idx]) if let firstChild = items[idx].children.first { + let firstChildIsTail = isTrailingStreamingElement( + at: 0, + count: items[idx].children.count, + parentIsTrailing: isTrailingStreamingElement( + at: idx, + count: items.count, + parentIsTrailing: isStreamingTailBranch + ) + ) if case .paragraph(_, let contents) = firstChild { // Wrap the SingleBlockView to provide proper baseline alignment ListItemContentWrapper(paragraphContents: contents) { SingleBlockView(renderable: firstChild) + .environment( + \.isMarkdownStreamingTailBranch, + firstChildIsTail + ) } .accessibilityLabel(Text(listItemAccessibilityLabel(for: contents.string, at: idx, checkbox: items[idx].checkbox))) } else { SingleBlockView(renderable: firstChild) + .environment( + \.isMarkdownStreamingTailBranch, + firstChildIsTail + ) } } Spacer() } if items[idx].children.count > 1 { BlockView(renderables: Array(items[idx].children.dropFirst())) + .environment( + \.isMarkdownStreamingTailBranch, + isTrailingStreamingElement( + at: idx, + count: items.count, + parentIsTrailing: isStreamingTailBranch + ) + ) .padding([.leading], 0) } } diff --git a/Tests/MarkdownTextTests/ParagraphAnimationTests.swift b/Tests/MarkdownTextTests/ParagraphAnimationTests.swift index 4a3d01b..19b0845 100644 --- a/Tests/MarkdownTextTests/ParagraphAnimationTests.swift +++ b/Tests/MarkdownTextTests/ParagraphAnimationTests.swift @@ -80,8 +80,8 @@ struct ParagraphAnimationTests { #expect(transform.opacity == 0.08) #expect(transform.scale == 0.82) - #expect(transform.baselineOffset == 3) - #expect(transform.blurRadius == 3) + #expect(transform.baselineOffset == 5) + #expect(transform.blurRadius == 2) } @Test("Settles exactly to the final transform after 260ms") @@ -145,6 +145,43 @@ struct ParagraphAnimationTests { #expect(!state.hasPendingGrapheme) } + @Test("Parsed lists select their trailing paragraph structurally") + @MainActor + func parsedListTailOwnership() async throws { + let document = await MarkdownParserImpl().parse( + text: "1. First item\n2. Second item", + config: .default + ) + let renderable = try #require(document.renderables.last) + guard case .orderedList(_, let items) = renderable else { + Issue.record("Expected an ordered list") + return + } + + #expect(items.count == 2) + #expect( + !isTrailingStreamingElement( + at: 0, + count: items.count, + parentIsTrailing: true + ) + ) + #expect( + isTrailingStreamingElement( + at: 1, + count: items.count, + parentIsTrailing: true + ) + ) + #expect( + !isTrailingStreamingElement( + at: 1, + count: items.count, + parentIsTrailing: false + ) + ) + } + @Test("Replacement rewinds to a composed prefix and restyling is retained") func replacementAndRestyle() throws { let state = CharacterStreamingState() @@ -250,6 +287,14 @@ struct ParagraphAnimationTests { == ParagraphAnimationConstants.fadeStaggerDuration ) } + + @Test("Visible prefix length participates in paragraph size caching") + func streamingSizeCacheKey() { + let initial = ParagraphSizeCacheKey(width: 120, visibleUTF16Length: 1) + let wrapped = ParagraphSizeCacheKey(width: 120, visibleUTF16Length: 80) + + #expect(initial != wrapped) + } } private func attributed(_ text: String) -> NSAttributedString { diff --git a/Tests/MarkdownTextTests/ParagraphNSViewTests.swift b/Tests/MarkdownTextTests/ParagraphNSViewTests.swift index d245c9d..c424a42 100644 --- a/Tests/MarkdownTextTests/ParagraphNSViewTests.swift +++ b/Tests/MarkdownTextTests/ParagraphNSViewTests.swift @@ -66,5 +66,129 @@ struct ParagraphNSViewTests { #expect(view.string == "AB") } + + @Test("Rapid snapshots preserve the pending Character Streaming deadline") + func characterStreamingRapidSnapshots() { + let view = ParagraphNSView(characterStreaming: true) + view.setParagraphContents( + NSMutableAttributedString(string: "ABCDE"), + textAnimation: .characterStreaming, + isStreamComplete: false + ) + #expect(view.string == "A") + + view.setParagraphContents( + NSMutableAttributedString(string: "ABCDEF"), + textAnimation: .characterStreaming, + isStreamComplete: false + ) + + #expect(view.string == "A") + view.finishTextAnimation() + } + + @Test("Character Streaming remains settled when Reduce Motion turns off") + func characterStreamingReduceMotionToggle() { + let contents = NSMutableAttributedString(string: "Already visible") + let view = ParagraphNSView(characterStreaming: true) + view.setParagraphContents( + contents, + textAnimation: .none, + isStreamComplete: false + ) + + view.setParagraphContents( + contents, + textAnimation: .characterStreaming, + isStreamComplete: false + ) + + #expect(view.string == contents.string) + #expect(view.layoutManager is CharacterStreamingLayoutManager) + view.finishTextAnimation() + } + + @Test("Completion-only updates preserve an active Fade") + func fadeCompletionPreservesAnimation() throws { + let view = ParagraphNSView() + let initial = NSMutableAttributedString( + string: "A", + attributes: [.foregroundColor: NSColor.black] + ) + view.setParagraphContents( + initial, + textAnimation: .none, + isStreamComplete: false + ) + + let updated = NSMutableAttributedString( + string: "AB", + attributes: [.foregroundColor: NSColor.black] + ) + view.setParagraphContents( + updated, + textAnimation: .fade, + isStreamComplete: false + ) + let before = try #require( + view.textStorage?.attribute( + .foregroundColor, + at: 1, + effectiveRange: nil + ) as? NSColor + ).alphaComponent + + view.setParagraphContents( + updated, + textAnimation: .fade, + isStreamComplete: true + ) + let after = try #require( + view.textStorage?.attribute( + .foregroundColor, + at: 1, + effectiveRange: nil + ) as? NSColor + ).alphaComponent + + #expect(before < 1) + #expect(after == before) + view.finishTextAnimation() + } + + @Test("Character Streaming wrapped size grows with its visible prefix") + func characterStreamingWrappedMeasurement() { + let view = ParagraphNSView(characterStreaming: true) + view.setParagraphContents( + NSMutableAttributedString( + string: "This paragraph grows across several narrow wrapped lines." + ), + textAnimation: .characterStreaming, + isStreamComplete: true + ) + let initial = view.measureSize(fittingWidth: 70) + + view.finishTextAnimation() + let settled = view.measureSize(fittingWidth: 70) + + #expect(settled.height > initial.height) + } + + @Test("Streaming size cache evicts prior visible prefixes") + func characterStreamingSizeCacheIsBounded() { + let coordinator = ParagraphView.Coordinator() + let key = ParagraphSizeCacheKey(width: 70, visibleUTF16Length: 1) + coordinator.sizeCache[key] = CGSize(width: 70, height: 20) + + coordinator.updateVisibleUTF16Length(2) + + #expect(coordinator.sizeCache.isEmpty) + #expect(coordinator.lastVisibleUTF16Length == 2) + } + + @Test("AppKit Character Streaming translates positive offsets below baseline") + func characterStreamingBaselineDirection() { + #expect(CharacterStreamingLayoutManager.baselineTranslation(5) == 5) + } } #endif diff --git a/Tests/MarkdownTextTests/ParagraphViewTests.swift b/Tests/MarkdownTextTests/ParagraphViewTests.swift index ad87c2a..8be93b4 100644 --- a/Tests/MarkdownTextTests/ParagraphViewTests.swift +++ b/Tests/MarkdownTextTests/ParagraphViewTests.swift @@ -246,6 +246,139 @@ struct ParagraphViewTests { #expect(view.accessibilityLabel == "AB") } + @Test("Rapid snapshots preserve the pending Character Streaming deadline") + @MainActor + func characterStreamingRapidSnapshots() { + let view = ParagraphUIView(characterStreaming: true) + view.setParagraphContents( + NSMutableAttributedString(string: "ABCDE"), + textAnimation: .characterStreaming, + isStreamComplete: false + ) + #expect(view.attributedText.string == "A") + + view.setParagraphContents( + NSMutableAttributedString(string: "ABCDEF"), + textAnimation: .characterStreaming, + isStreamComplete: false + ) + + #expect(view.attributedText.string == "A") + view.finishTextAnimation() + } + + @Test("Character Streaming remains settled when Reduce Motion turns off") + @MainActor + func characterStreamingReduceMotionToggle() { + let contents = NSMutableAttributedString(string: "Already visible") + let view = ParagraphUIView(characterStreaming: true) + view.setParagraphContents( + contents, + textAnimation: .none, + isStreamComplete: false + ) + + view.setParagraphContents( + contents, + textAnimation: .characterStreaming, + isStreamComplete: false + ) + + #expect(view.attributedText.string == contents.string) + #expect(view.layoutManager is CharacterStreamingLayoutManager) + view.finishTextAnimation() + } + + @Test("Completion-only updates preserve an active Fade") + @MainActor + func fadeCompletionPreservesAnimation() throws { + let view = ParagraphUIView() + let initial = NSMutableAttributedString( + string: "A", + attributes: [.foregroundColor: UIColor.black] + ) + view.setParagraphContents( + initial, + textAnimation: .none, + isStreamComplete: false + ) + + let updated = NSMutableAttributedString( + string: "AB", + attributes: [.foregroundColor: UIColor.black] + ) + view.setParagraphContents( + updated, + textAnimation: .fade, + isStreamComplete: false + ) + let before = try #require( + view.attributedText.attribute( + .foregroundColor, + at: 1, + effectiveRange: nil + ) as? UIColor + ).cgColor.alpha + + view.setParagraphContents( + updated, + textAnimation: .fade, + isStreamComplete: true + ) + let after = try #require( + view.attributedText.attribute( + .foregroundColor, + at: 1, + effectiveRange: nil + ) as? UIColor + ).cgColor.alpha + + #expect(before < 1) + #expect(after == before) + view.finishTextAnimation() + } + + @Test("Character Streaming wrapped size grows with its visible prefix") + @MainActor + func characterStreamingWrappedMeasurement() { + let view = ParagraphUIView(characterStreaming: true) + view.setParagraphContents( + NSMutableAttributedString( + string: "This paragraph grows across several narrow wrapped lines." + ), + textAnimation: .characterStreaming, + isStreamComplete: true + ) + let initial = view.sizeThatFits( + CGSize(width: 70, height: CGFloat.greatestFiniteMagnitude) + ) + + view.finishTextAnimation() + let settled = view.sizeThatFits( + CGSize(width: 70, height: CGFloat.greatestFiniteMagnitude) + ) + + #expect(settled.height > initial.height) + } + + @Test("Streaming size cache evicts prior visible prefixes") + @MainActor + func characterStreamingSizeCacheIsBounded() { + let coordinator = ParagraphView.Coordinator() + let key = ParagraphSizeCacheKey(width: 70, visibleUTF16Length: 1) + coordinator.sizeCache[key] = CGSize(width: 70, height: 20) + + coordinator.updateVisibleUTF16Length(2) + + #expect(coordinator.sizeCache.isEmpty) + #expect(coordinator.lastVisibleUTF16Length == 2) + } + + @Test("UIKit Character Streaming translates positive offsets below baseline") + func characterStreamingBaselineDirection() { + #expect(CharacterStreamingLayoutManager.baselineTranslation(5) == 5) + } + @Test("Long text overflow handling") func longTextOverflow() { let longText = String(repeating: "This is a very long text that should test overflow behavior. ", count: 20) From ec52a97910371bc29c8c8359c2539758f95b6829 Mon Sep 17 00:00:00 2001 From: "Mahyar (Mac) McDonald" <22130+theontho@users.noreply.github.com> Date: Fri, 17 Jul 2026 09:36:42 -0700 Subject: [PATCH 4/7] Harden character streaming updates Compare streamed replacements by exact UTF-16, clamp released offsets to grapheme boundaries, preserve Fade in attachment table cells, and update the public animation example. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a649c2f7-fe53-4216-9f15-b5aa7237934a --- README.md | 2 +- .../UI/Paragraph/ParagraphAnimation.swift | 50 +++++++++++++------ Sources/MarkdownText/UI/TableView.swift | 13 ++++- .../ParagraphAnimationTests.swift | 49 ++++++++++++++++++ Tests/MarkdownTextTests/TableViewTests.swift | 9 ++++ 5 files changed, 105 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index efae63a..fe9c0e2 100644 --- a/README.md +++ b/README.md @@ -251,7 +251,7 @@ by composing the `withXxx` helpers on `.default`: ```swift let config = MarkdownRenderConfig.default - .withShouldAnimateText(value: true) + .withTextAnimation(.characterStreaming) .withHeadingStyle(value: MarkdownRenderConfig.defaultHeadingStyle) .withParagraphStyle(value: MarkdownRenderConfig.defaultParagraphStyle) ``` diff --git a/Sources/MarkdownText/UI/Paragraph/ParagraphAnimation.swift b/Sources/MarkdownText/UI/Paragraph/ParagraphAnimation.swift index 6e30de7..393fcc8 100644 --- a/Sources/MarkdownText/UI/Paragraph/ParagraphAnimation.swift +++ b/Sources/MarkdownText/UI/Paragraph/ParagraphAnimation.swift @@ -216,14 +216,20 @@ final class CharacterStreamingState { let oldString = target.string let newString = newTarget.string - if oldString != newString && !newString.hasPrefix(oldString) { - releasedUTF16Length = min( - releasedUTF16Length, - Self.commonPrefixUTF16Length(oldString, newString) - ) - activeAnimations.removeAll { - NSMaxRange($0.range) > releasedUTF16Length - } + let exactPrefixLength = Self.commonPrefixUTF16Length( + oldString, + newString + ) + var retainedPrefixLength = releasedUTF16Length + if exactPrefixLength != oldString.utf16.count { + retainedPrefixLength = min(retainedPrefixLength, exactPrefixLength) + } + releasedUTF16Length = Self.composedSequenceBoundary( + atOrBefore: retainedPrefixLength, + in: newString + ) + activeAnimations.removeAll { + NSMaxRange($0.range) > releasedUTF16Length } target = NSAttributedString(attributedString: newTarget) @@ -329,15 +335,27 @@ final class CharacterStreamingState { _ first: String, _ second: String ) -> Int { - var firstIndex = first.startIndex - var secondIndex = second.startIndex - while firstIndex < first.endIndex, - secondIndex < second.endIndex, - first[firstIndex] == second[secondIndex] { - first.formIndex(after: &firstIndex) - second.formIndex(after: &secondIndex) + let firstUTF16 = first as NSString + let secondUTF16 = second as NSString + let maximumLength = min(firstUTF16.length, secondUTF16.length) + var length = 0 + while length < maximumLength, + firstUTF16.character(at: length) == secondUTF16.character(at: length) { + length += 1 + } + return length + } + + private static func composedSequenceBoundary( + atOrBefore offset: Int, + in string: String + ) -> Int { + let utf16 = string as NSString + guard offset > 0, offset < utf16.length else { + return min(offset, utf16.length) } - return firstIndex.utf16Offset(in: first) + let sequence = utf16.rangeOfComposedCharacterSequence(at: offset) + return sequence.location < offset ? sequence.location : offset } } diff --git a/Sources/MarkdownText/UI/TableView.swift b/Sources/MarkdownText/UI/TableView.swift index 3df02d3..9a0ea40 100644 --- a/Sources/MarkdownText/UI/TableView.swift +++ b/Sources/MarkdownText/UI/TableView.swift @@ -376,7 +376,12 @@ extension TableView { content, color: color )) - .environment(\.markdownConfig, config.withTextAnimation(.none)) + .environment( + \.markdownConfig, + config.withTextAnimation( + tableAttachmentTextAnimation(config.textAnimation) + ) + ) } else if config.textAnimation == .fade { Text(AttributedString(content)) .foregroundStyle(color) @@ -395,6 +400,12 @@ extension TableView { } } +func tableAttachmentTextAnimation( + _ animation: MarkdownRenderConfig.TextAnimation +) -> MarkdownRenderConfig.TextAnimation { + animation == .characterStreaming ? .none : animation +} + #if DEBUG let tableViewHeadingMock: [NSMutableAttributedString] = [ diff --git a/Tests/MarkdownTextTests/ParagraphAnimationTests.swift b/Tests/MarkdownTextTests/ParagraphAnimationTests.swift index 19b0845..226883a 100644 --- a/Tests/MarkdownTextTests/ParagraphAnimationTests.swift +++ b/Tests/MarkdownTextTests/ParagraphAnimationTests.swift @@ -189,6 +189,7 @@ struct ParagraphAnimationTests { for time in [0.0, 0.018, 0.036] { _ = try #require(state.releaseNext(at: time)) } + #expect(state.visibleAttributedText.string == "abc") state.update(target: attributed("abZ!"), isComplete: false, at: 0.05) @@ -214,6 +215,36 @@ struct ParagraphAnimationTests { ) } + @Test("Normalization changes rewind to composed UTF-16 boundaries") + func normalizationSafeReplacement() throws { + try assertNormalizationReplacement( + from: "\u{00E9}X", + to: "e\u{301}X", + expected: "e\u{301}" + ) + try assertNormalizationReplacement( + from: "e\u{301}X", + to: "\u{00E9}X", + expected: "\u{00E9}" + ) + + let extendedPrefix = CharacterStreamingState() + extendedPrefix.update( + target: attributed("e"), + isComplete: true, + at: 0 + ) + _ = try #require(extendedPrefix.releaseNext(at: 0)) + extendedPrefix.update( + target: attributed("e\u{301}X"), + isComplete: false, + at: 0.01 + ) + #expect(extendedPrefix.visibleAttributedText.string.isEmpty) + let release = try #require(extendedPrefix.releaseNext(at: 0.01)) + #expect(substring("e\u{301}X", in: release.range) == "e\u{301}") + } + @Test("Preserves attributed Markdown runs in released content") func attributedContent() throws { let styleKey = NSAttributedString.Key("CharacterStreamingTests.typography") @@ -305,6 +336,24 @@ private func substring(_ text: String, in range: NSRange) -> String { (text as NSString).substring(with: range) } +private func assertNormalizationReplacement( + from original: String, + to replacement: String, + expected: String +) throws { + let state = CharacterStreamingState() + state.update(target: attributed(original), isComplete: false, at: 0) + _ = try #require(state.releaseNext(at: 0)) + #expect(!state.visibleAttributedText.string.isEmpty) + + state.update(target: attributed(replacement), isComplete: false, at: 0.01) + #expect(state.visibleAttributedText.string.isEmpty) + + let release = try #require(state.releaseNext(at: 0.01)) + #expect(substring(replacement, in: release.range) == expected) + #expect(state.visibleAttributedText.string == expected) +} + private extension ParagraphRevealPlan { var coveredRange: NSRange? { guard let first = segments.first, let last = segments.last else { diff --git a/Tests/MarkdownTextTests/TableViewTests.swift b/Tests/MarkdownTextTests/TableViewTests.swift index cdd6039..bdc93d1 100644 --- a/Tests/MarkdownTextTests/TableViewTests.swift +++ b/Tests/MarkdownTextTests/TableViewTests.swift @@ -73,6 +73,15 @@ final class TableViewTests: SnapshotTestCase { assert(view) } + func testAttachmentCellAnimationModeMapping() { + XCTAssertEqual(tableAttachmentTextAnimation(.none), .none) + XCTAssertEqual(tableAttachmentTextAnimation(.fade), .fade) + XCTAssertEqual( + tableAttachmentTextAnimation(.characterStreaming), + .none + ) + } + // MARK: - Helpers @ViewBuilder From 70e6aefe62ecdd1dda02f8fc24dce824dca614d3 Mon Sep 17 00:00:00 2001 From: "Mahyar (Mac) McDonald" <22130+theontho@users.noreply.github.com> Date: Fri, 17 Jul 2026 09:36:42 -0700 Subject: [PATCH 5/7] Fix character streaming rendering edge cases Preserve release cadence across drained queues, coalesce contextual glyph clusters, and render true bitmap blur before the sharp crossfade on UIKit and AppKit. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 78f1a116-c298-4c20-8669-dcded414d291 --- .../UI/Paragraph/AppKit/ParagraphNSView.swift | 47 +-- .../CharacterStreamingLayoutManager.swift | 281 ++++++++++++++++-- .../UI/Paragraph/ParagraphAnimation.swift | 15 +- .../UI/Paragraph/UIKit/ParagraphUIView.swift | 45 +-- .../CharacterStreamingRenderTestSupport.swift | 66 ++++ .../ParagraphAnimationTests.swift | 84 +++++- .../ParagraphNSViewTests.swift | 102 +++++++ .../ParagraphViewTests.swift | 93 ++++++ 8 files changed, 616 insertions(+), 117 deletions(-) create mode 100644 Tests/MarkdownTextTests/CharacterStreamingRenderTestSupport.swift diff --git a/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphNSView.swift b/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphNSView.swift index e594b71..6fa3b90 100644 --- a/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphNSView.swift +++ b/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphNSView.swift @@ -24,7 +24,6 @@ class ParagraphNSView: NSTextView { private var activeAnimation: FadeAnimationData? private let characterStreamingState = CharacterStreamingState() private var characterStreamingTimer: Timer? - private var animatedCharacterRanges: [NSRange] = [] private var textAnimationDisplayLink: CADisplayLink? private var textAnimation: MarkdownRenderConfig.TextAnimation = .none private var isStreamComplete = true @@ -392,7 +391,6 @@ class ParagraphNSView: NSTextView { private func synchronizeCharacterStreamingText() { textStorage?.setAttributedString(characterStreamingState.visibleAttributedText) - animatedCharacterRanges.removeAll() invalidateCachedSize() invalidateIntrinsicContentSize() } @@ -405,7 +403,9 @@ class ParagraphNSView: NSTextView { } let timer = Timer( - timeInterval: characterStreamingState.nextReleaseInterval, + timeInterval: characterStreamingState.releaseDelay( + at: CACurrentMediaTime() + ), repeats: false ) { [weak self] _ in guard let self else { return } @@ -417,58 +417,17 @@ class ParagraphNSView: NSTextView { } private func updateCharacterStreamingAnimations(at currentTime: CFTimeInterval) { - guard let textStorage else { return } characterStreamingState.pruneAnimations(at: currentTime) let animations = characterStreamingState.activeAnimations - let rangesToRestore = animatedCharacterRanges.filter { - NSMaxRange($0) <= textStorage.length - } - - textStorage.beginEditing() - restoreFinalAttributesWithoutEditing(in: rangesToRestore) - for animation in animations where NSMaxRange(animation.range) <= textStorage.length { - let transform = animation.transform(at: currentTime) - guard transform.blurRadius > 0 else { continue } - finalAttributedText.enumerateAttributes( - in: animation.range, - options: [] - ) { attributes, attributeRange, _ in - var attributes = attributes - let color = (attributes[.foregroundColor] as? NSColor) - ?? NSColor(Color.Theme.Foreground.Primary.Primary750) - let shadow = NSShadow() - shadow.shadowOffset = .zero - shadow.shadowBlurRadius = transform.blurRadius - shadow.shadowColor = color.withAlphaComponent(color.alphaComponent) - attributes[.shadow] = shadow - textStorage.setAttributes(attributes, range: attributeRange) - } - } - textStorage.endEditing() - - animatedCharacterRanges = animations.map(\.range) characterStreamingLayoutManager?.updateAnimations( animations, at: currentTime ) } - private func restoreFinalAttributesWithoutEditing(in ranges: [NSRange]) { - guard let textStorage else { return } - for range in ranges where NSMaxRange(range) <= finalAttributedText.length { - finalAttributedText.enumerateAttributes( - in: range, - options: [] - ) { attributes, attributeRange, _ in - textStorage.setAttributes(attributes, range: attributeRange) - } - } - } - private func stopCharacterStreaming() { characterStreamingTimer?.invalidate() characterStreamingTimer = nil - animatedCharacterRanges.removeAll() characterStreamingLayoutManager?.clearAnimations() } diff --git a/Sources/MarkdownText/UI/Paragraph/CharacterStreamingLayoutManager.swift b/Sources/MarkdownText/UI/Paragraph/CharacterStreamingLayoutManager.swift index f4c6172..6de27c3 100644 --- a/Sources/MarkdownText/UI/Paragraph/CharacterStreamingLayoutManager.swift +++ b/Sources/MarkdownText/UI/Paragraph/CharacterStreamingLayoutManager.swift @@ -4,6 +4,7 @@ // #if canImport(UIKit) || canImport(AppKit) +import CoreImage import Foundation #if canImport(UIKit) @@ -12,16 +13,59 @@ import UIKit import AppKit #endif +struct CharacterStreamingGlyphAnimationFrame: Equatable { + let range: NSRange + let transform: CharacterStreamingTransform + let startTime: CFTimeInterval +} + +struct CharacterStreamingGlyphBlend: Equatable { + let blurredAlpha: CGFloat + let sharpAlpha: CGFloat + let blurRadius: CGFloat + + static func value( + for transform: CharacterStreamingTransform + ) -> CharacterStreamingGlyphBlend { + let blurFraction = min( + max( + transform.blurRadius + / ParagraphAnimationConstants.initialCharacterBlurRadius, + 0 + ), + 1 + ) + return CharacterStreamingGlyphBlend( + blurredAlpha: transform.opacity * blurFraction, + sharpAlpha: transform.opacity * (1 - blurFraction), + blurRadius: transform.blurRadius + ) + } +} + final class CharacterStreamingLayoutManager: NSLayoutManager { - private var animationFrames: [(range: NSRange, transform: CharacterStreamingTransform)] = [] + private static let blurContext = CIContext( + options: [.cacheIntermediates: false] + ) + private var animationFrames: [CharacterStreamingGlyphAnimationFrame] = [] func updateAnimations( _ animations: [CharacterStreamingAnimation], at time: CFTimeInterval ) { - animationFrames = animations.map { - (range: $0.range, transform: $0.transform(at: time)) - } + updateAnimationFrames(animations.map { + CharacterStreamingGlyphAnimationFrame( + range: $0.range, + transform: $0.transform(at: time), + startTime: $0.startTime + ) + }) + } + + func updateAnimationFrames( + _ frames: [CharacterStreamingGlyphAnimationFrame] + ) { + animationFrames = frames invalidateDisplay(forCharacterRange: NSRange( location: 0, length: textStorage?.length ?? 0 @@ -45,34 +89,36 @@ final class CharacterStreamingLayoutManager: NSLayoutManager { return } - let sortedFrames = animationFrames.sorted { - $0.range.location < $1.range.location - } - var nextGlyphLocation = glyphsToShow.location - let glyphEnd = NSMaxRange(glyphsToShow) - - for frame in sortedFrames { + let glyphFrames: [CharacterStreamingGlyphAnimationFrame] = animationFrames.compactMap { frame in let frameGlyphRange = glyphRange( forCharacterRange: frame.range, actualCharacterRange: nil ) - let visibleFrameRange = NSIntersectionRange(frameGlyphRange, glyphsToShow) - guard visibleFrameRange.length > 0 else { - continue - } + let visibleRange = NSIntersectionRange(frameGlyphRange, glyphsToShow) + guard visibleRange.length > 0 else { return nil } + return CharacterStreamingGlyphAnimationFrame( + range: visibleRange, + transform: frame.transform, + startTime: frame.startTime + ) + } + let shapedClusters = Self.coalescedGlyphFrames(glyphFrames) + var nextGlyphLocation = glyphsToShow.location + let glyphEnd = NSMaxRange(glyphsToShow) - if nextGlyphLocation < visibleFrameRange.location { + for cluster in shapedClusters { + if nextGlyphLocation < cluster.range.location { super.drawGlyphs( forGlyphRange: NSRange( location: nextGlyphLocation, - length: visibleFrameRange.location - nextGlyphLocation + length: cluster.range.location - nextGlyphLocation ), at: origin ) } let transformedRange = NSIntersectionRange( - visibleFrameRange, + cluster.range, NSRange( location: nextGlyphLocation, length: max(0, glyphEnd - nextGlyphLocation) @@ -82,7 +128,7 @@ final class CharacterStreamingLayoutManager: NSLayoutManager { drawTransformedGlyphs( in: transformedRange, at: origin, - transform: frame.transform + transform: cluster.transform ) nextGlyphLocation = NSMaxRange(transformedRange) } @@ -99,6 +145,33 @@ final class CharacterStreamingLayoutManager: NSLayoutManager { } } + static func coalescedGlyphFrames( + _ frames: [CharacterStreamingGlyphAnimationFrame] + ) -> [CharacterStreamingGlyphAnimationFrame] { + let sortedFrames = frames.sorted { + if $0.range.location == $1.range.location { + $0.startTime < $1.startTime + } else { + $0.range.location < $1.range.location + } + } + var clusters: [CharacterStreamingGlyphAnimationFrame] = [] + for frame in sortedFrames { + guard let last = clusters.last, + NSIntersectionRange(last.range, frame.range).length > 0 else { + clusters.append(frame) + continue + } + let newest = frame.startTime >= last.startTime ? frame : last + clusters[clusters.count - 1] = CharacterStreamingGlyphAnimationFrame( + range: NSUnionRange(last.range, frame.range), + transform: newest.transform, + startTime: newest.startTime + ) + } + return clusters + } + private func drawTransformedGlyphs( in glyphRange: NSRange, at origin: CGPoint, @@ -118,9 +191,45 @@ final class CharacterStreamingLayoutManager: NSLayoutManager { in: textContainer ).offsetBy(dx: origin.x, dy: origin.y) let anchor = CGPoint(x: bounds.midX, y: bounds.maxY) + let blend = CharacterStreamingGlyphBlend.value(for: transform) + if blend.blurredAlpha > 0, blend.blurRadius > 0 { + drawGlyphPass( + in: glyphRange, + at: origin, + context: context, + bounds: bounds, + anchor: anchor, + transform: transform, + alpha: blend.blurredAlpha, + blurRadius: blend.blurRadius + ) + } + if blend.sharpAlpha > 0 { + drawGlyphPass( + in: glyphRange, + at: origin, + context: context, + bounds: bounds, + anchor: anchor, + transform: transform, + alpha: blend.sharpAlpha, + blurRadius: nil + ) + } + } + + private func drawGlyphPass( + in glyphRange: NSRange, + at origin: CGPoint, + context: CGContext, + bounds: CGRect, + anchor: CGPoint, + transform: CharacterStreamingTransform, + alpha: CGFloat, + blurRadius: CGFloat? + ) { context.saveGState() - context.setAlpha(transform.opacity) context.translateBy( x: 0, y: Self.baselineTranslation(transform.baselineOffset) @@ -128,10 +237,140 @@ final class CharacterStreamingLayoutManager: NSLayoutManager { context.translateBy(x: anchor.x, y: anchor.y) context.scaleBy(x: transform.scale, y: transform.scale) context.translateBy(x: -anchor.x, y: -anchor.y) - super.drawGlyphs(forGlyphRange: glyphRange, at: origin) + + if let blurRadius, blurRadius > 0 { + drawBlurredGlyphs( + in: glyphRange, + at: origin, + bounds: bounds, + radius: blurRadius, + scale: Self.backingScale(for: context), + alpha: alpha + ) + } else { + context.setAlpha(alpha) + super.drawGlyphs(forGlyphRange: glyphRange, at: origin) + } + context.restoreGState() } + private func drawBlurredGlyphs( + in glyphRange: NSRange, + at origin: CGPoint, + bounds: CGRect, + radius: CGFloat, + scale: CGFloat, + alpha: CGFloat + ) { + let padding = radius * 4 + let imageBounds = bounds.insetBy(dx: -padding, dy: -padding) + guard imageBounds.width > 0, + imageBounds.height > 0, + let sourceImage = glyphImage( + in: glyphRange, + at: origin, + bounds: imageBounds, + scale: scale + ) else { + super.drawGlyphs(forGlyphRange: glyphRange, at: origin) + return + } + + let inputImage = CIImage(cgImage: sourceImage) + guard let filter = CIFilter(name: "CIGaussianBlur") else { + super.drawGlyphs(forGlyphRange: glyphRange, at: origin) + return + } + filter.setValue(inputImage, forKey: kCIInputImageKey) + filter.setValue(radius * scale, forKey: kCIInputRadiusKey) + guard let outputImage = filter.outputImage?.cropped(to: inputImage.extent), + let blurredImage = Self.blurContext.createCGImage( + outputImage, + from: inputImage.extent + ) else { + super.drawGlyphs(forGlyphRange: glyphRange, at: origin) + return + } + + #if canImport(UIKit) + UIImage( + cgImage: blurredImage, + scale: scale, + orientation: .up + ).draw( + in: imageBounds, + blendMode: .normal, + alpha: alpha + ) + #elseif canImport(AppKit) + NSImage( + cgImage: blurredImage, + size: imageBounds.size + ).draw( + in: imageBounds, + from: .zero, + operation: .sourceOver, + fraction: alpha, + respectFlipped: true, + hints: nil + ) + #endif + } + + private func glyphImage( + in glyphRange: NSRange, + at origin: CGPoint, + bounds: CGRect, + scale: CGFloat + ) -> CGImage? { + #if canImport(UIKit) + let format = UIGraphicsImageRendererFormat() + format.opaque = false + format.scale = scale + let image = UIGraphicsImageRenderer( + size: bounds.size, + format: format + ).image { rendererContext in + rendererContext.cgContext.translateBy( + x: -bounds.minX, + y: -bounds.minY + ) + drawSourceGlyphs(in: glyphRange, at: origin) + } + return image.cgImage + #elseif canImport(AppKit) + let image = NSImage(size: bounds.size, flipped: true) { _ in + guard let context = NSGraphicsContext.current?.cgContext else { + return false + } + context.translateBy(x: -bounds.minX, y: -bounds.minY) + self.drawSourceGlyphs(in: glyphRange, at: origin) + return true + } + var proposedRect = CGRect(origin: .zero, size: bounds.size) + return image.cgImage( + forProposedRect: &proposedRect, + context: nil, + hints: nil + ) + #endif + } + + private func drawSourceGlyphs( + in glyphRange: NSRange, + at origin: CGPoint + ) { + super.drawGlyphs(forGlyphRange: glyphRange, at: origin) + } + + private static func backingScale(for context: CGContext) -> CGFloat { + let transform = context.ctm + let xScale = hypot(transform.a, transform.c) + let yScale = hypot(transform.b, transform.d) + return max(1, max(xScale, yScale)) + } + private func currentGraphicsContext() -> CGContext? { #if canImport(UIKit) UIGraphicsGetCurrentContext() diff --git a/Sources/MarkdownText/UI/Paragraph/ParagraphAnimation.swift b/Sources/MarkdownText/UI/Paragraph/ParagraphAnimation.swift index 393fcc8..6551e28 100644 --- a/Sources/MarkdownText/UI/Paragraph/ParagraphAnimation.swift +++ b/Sources/MarkdownText/UI/Paragraph/ParagraphAnimation.swift @@ -192,7 +192,7 @@ final class CharacterStreamingState { private(set) var activeAnimations: [CharacterStreamingAnimation] = [] private(set) var isComplete = false - private var lastReleaseTime: CFTimeInterval? + private var nextReleaseDeadline: CFTimeInterval? var visibleAttributedText: NSAttributedString { target.attributedSubstring( @@ -208,6 +208,11 @@ final class CharacterStreamingState { Self.releaseInterval(forBacklog: pendingGraphemeCount) } + func releaseDelay(at time: CFTimeInterval) -> CFTimeInterval { + guard let nextReleaseDeadline else { return 0 } + return max(0, nextReleaseDeadline - time) + } + func update( target newTarget: NSAttributedString, isComplete: Bool, @@ -241,7 +246,7 @@ final class CharacterStreamingState { func releaseNext(at time: CFTimeInterval) -> CharacterStreamingRelease? { guard pendingGraphemeCount > 0, - lastReleaseTime != time, + nextReleaseDeadline.map({ time >= $0 }) ?? true, releasedUTF16Length < releasableUTF16Length else { return nil } @@ -256,7 +261,7 @@ final class CharacterStreamingState { releasedUTF16Length = NSMaxRange(range) pendingGraphemeCount -= 1 - lastReleaseTime = time + nextReleaseDeadline = time + nextReleaseInterval pruneAnimations(at: time) activeAnimations.append(CharacterStreamingAnimation(range: range, startTime: time)) if activeAnimations.count > ParagraphAnimationConstants.maximumActiveCharacterAnimations { @@ -275,7 +280,7 @@ final class CharacterStreamingState { releasedUTF16Length = target.length pendingGraphemeCount = 0 activeAnimations.removeAll() - lastReleaseTime = nil + nextReleaseDeadline = nil } func reset() { @@ -284,7 +289,7 @@ final class CharacterStreamingState { pendingGraphemeCount = 0 activeAnimations.removeAll() isComplete = false - lastReleaseTime = nil + nextReleaseDeadline = nil } static func releaseInterval(forBacklog backlog: Int) -> CFTimeInterval { diff --git a/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphUIView.swift b/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphUIView.swift index dbdaf7b..99fa00f 100644 --- a/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphUIView.swift +++ b/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphUIView.swift @@ -28,7 +28,6 @@ class ParagraphUIView: UITextView { private var activeAnimation: FadeAnimationData? private let characterStreamingState = CharacterStreamingState() private var characterStreamingTimer: Timer? - private var animatedCharacterRanges: [NSRange] = [] private var textAnimationDisplayLink: CADisplayLink? private var textAnimation: MarkdownRenderConfig.TextAnimation = .none private var isStreamComplete = true @@ -462,7 +461,6 @@ class ParagraphUIView: UITextView { private func synchronizeCharacterStreamingText() { attributedText = characterStreamingState.visibleAttributedText - animatedCharacterRanges.removeAll() invalidateCachedSize() invalidateIntrinsicContentSize() } @@ -475,7 +473,9 @@ class ParagraphUIView: UITextView { } let timer = Timer( - timeInterval: characterStreamingState.nextReleaseInterval, + timeInterval: characterStreamingState.releaseDelay( + at: CACurrentMediaTime() + ), repeats: false ) { [weak self] _ in guard let self else { return } @@ -489,54 +489,15 @@ class ParagraphUIView: UITextView { private func updateCharacterStreamingAnimations(at currentTime: CFTimeInterval) { characterStreamingState.pruneAnimations(at: currentTime) let animations = characterStreamingState.activeAnimations - let rangesToRestore = animatedCharacterRanges.filter { - NSMaxRange($0) <= textStorage.length - } - - textStorage.beginEditing() - restoreFinalAttributesWithoutEditing(in: rangesToRestore) - for animation in animations where NSMaxRange(animation.range) <= textStorage.length { - let transform = animation.transform(at: currentTime) - guard transform.blurRadius > 0 else { continue } - finalAttributedText.enumerateAttributes( - in: animation.range, - options: [] - ) { attributes, attributeRange, _ in - var attributes = attributes - let color = (attributes[.foregroundColor] as? UIColor) - ?? UIColor(Color.Theme.Foreground.Primary.Primary750) - let shadow = NSShadow() - shadow.shadowOffset = .zero - shadow.shadowBlurRadius = transform.blurRadius - shadow.shadowColor = color.withAlphaComponent(color.cgColor.alpha) - attributes[.shadow] = shadow - textStorage.setAttributes(attributes, range: attributeRange) - } - } - textStorage.endEditing() - - animatedCharacterRanges = animations.map(\.range) characterStreamingLayoutManager?.updateAnimations( animations, at: currentTime ) } - private func restoreFinalAttributesWithoutEditing(in ranges: [NSRange]) { - for range in ranges where NSMaxRange(range) <= finalAttributedText.length { - finalAttributedText.enumerateAttributes( - in: range, - options: [] - ) { attributes, attributeRange, _ in - textStorage.setAttributes(attributes, range: attributeRange) - } - } - } - private func stopCharacterStreaming() { characterStreamingTimer?.invalidate() characterStreamingTimer = nil - animatedCharacterRanges.removeAll() if supportsCharacterStreaming { characterStreamingLayoutManager?.clearAnimations() } diff --git a/Tests/MarkdownTextTests/CharacterStreamingRenderTestSupport.swift b/Tests/MarkdownTextTests/CharacterStreamingRenderTestSupport.swift new file mode 100644 index 0000000..dc2bf03 --- /dev/null +++ b/Tests/MarkdownTextTests/CharacterStreamingRenderTestSupport.swift @@ -0,0 +1,66 @@ +// +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. +// + +#if canImport(UIKit) || canImport(AppKit) +import CoreGraphics + +struct CharacterStreamingGlyphImageMetrics { + let maximumAlpha: UInt8 + let faintPixelCount: Int + let opaquePixelCount: Int + let redPixelCount: Int +} + +func characterStreamingGlyphImageMetrics( + for image: CGImage +) -> CharacterStreamingGlyphImageMetrics { + let width = image.width + let height = image.height + var pixels = [UInt8](repeating: 0, count: width * height * 4) + pixels.withUnsafeMutableBytes { buffer in + let context = CGContext( + data: buffer.baseAddress, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width * 4, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) + context?.draw( + image, + in: CGRect(x: 0, y: 0, width: width, height: height) + ) + } + + var maximumAlpha: UInt8 = 0 + var faintPixelCount = 0 + var opaquePixelCount = 0 + var redPixelCount = 0 + for index in stride(from: 0, to: pixels.count, by: 4) { + let red = pixels[index] + let green = pixels[index + 1] + let blue = pixels[index + 2] + let alpha = pixels[index + 3] + maximumAlpha = max(maximumAlpha, alpha) + if alpha > 0, alpha < 64 { + faintPixelCount += 1 + } + if alpha > 192 { + opaquePixelCount += 1 + } + if alpha > 0, red > green, red > blue { + redPixelCount += 1 + } + } + + return CharacterStreamingGlyphImageMetrics( + maximumAlpha: maximumAlpha, + faintPixelCount: faintPixelCount, + opaquePixelCount: opaquePixelCount, + redPixelCount: redPixelCount + ) +} +#endif diff --git a/Tests/MarkdownTextTests/ParagraphAnimationTests.swift b/Tests/MarkdownTextTests/ParagraphAnimationTests.swift index 226883a..bd4dca0 100644 --- a/Tests/MarkdownTextTests/ParagraphAnimationTests.swift +++ b/Tests/MarkdownTextTests/ParagraphAnimationTests.swift @@ -55,6 +55,22 @@ struct ParagraphAnimationTests { #expect(state.nextReleaseInterval == 0.018) } + @Test("Idle queues retain the next release deadline") + func idleQueueRetainsDeadline() throws { + let state = CharacterStreamingState() + state.update(target: attributed("A"), isComplete: true, at: 0) + _ = try #require(state.releaseNext(at: 0)) + #expect(!state.hasPendingGrapheme) + + state.update(target: attributed("AB"), isComplete: true, at: 0.001) + #expect(state.releaseNext(at: 0.001) == nil) + #expect(state.releaseNext(at: 0.004_499) == nil) + #expect(abs(state.releaseDelay(at: 0.001) - 0.017) < 0.000_001) + + let release = try #require(state.releaseNext(at: 0.018)) + #expect(substring("AB", in: release.range) == "B") + } + @Test("Withholds a terminal grapheme across chunks that extend it") func crossChunkContinuity() throws { let state = CharacterStreamingState() @@ -101,6 +117,61 @@ struct ParagraphAnimationTests { #expect(animation.isFinished(at: 1.26)) } + @Test("Overlapping shaped glyph ranges animate once using the newest release") + func overlappingShapedGlyphClusters() { + let olderTransform = CharacterStreamingTransform.value(at: 0.75) + let newerTransform = CharacterStreamingTransform.value(at: 0.25) + let settledNeighborTransform = CharacterStreamingTransform.value(at: 0.5) + let clusters = CharacterStreamingLayoutManager.coalescedGlyphFrames([ + CharacterStreamingGlyphAnimationFrame( + range: NSRange(location: 0, length: 2), + transform: olderTransform, + startTime: 1 + ), + CharacterStreamingGlyphAnimationFrame( + range: NSRange(location: 1, length: 2), + transform: newerTransform, + startTime: 2 + ), + CharacterStreamingGlyphAnimationFrame( + range: NSRange(location: 4, length: 1), + transform: settledNeighborTransform, + startTime: 1.5 + ) + ]) + + #expect(clusters.count == 2) + #expect(clusters[0].range == NSRange(location: 0, length: 3)) + #expect(clusters[0].transform == newerTransform) + #expect(clusters[0].startTime == 2) + #expect(clusters[1].range == NSRange(location: 4, length: 1)) + } + + @Test("Glyph blur crossfades a blurred-only pass to the sharp pass") + func genuineGlyphBlurBlend() { + let initial = CharacterStreamingGlyphBlend.value( + for: .value(at: 0) + ) + #expect(initial.blurredAlpha == 0.08) + #expect(initial.sharpAlpha == 0) + #expect(initial.blurRadius == 2) + + let intermediate = CharacterStreamingGlyphBlend.value( + for: .value(at: 0.5) + ) + #expect(intermediate.blurredAlpha > 0) + #expect(intermediate.sharpAlpha > 0) + #expect(intermediate.blurRadius > 0) + #expect(intermediate.blurRadius < 2) + + let settled = CharacterStreamingGlyphBlend.value( + for: .value(at: 1) + ) + #expect(settled.blurredAlpha == 0) + #expect(settled.sharpAlpha == 1) + #expect(settled.blurRadius == 0) + } + @Test("Releases Unicode composed character sequences intact") func unicodeComposedGraphemes() throws { let text = "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆe\u{301}๐Ÿ‡บ๐Ÿ‡ธX" @@ -241,7 +312,7 @@ struct ParagraphAnimationTests { at: 0.01 ) #expect(extendedPrefix.visibleAttributedText.string.isEmpty) - let release = try #require(extendedPrefix.releaseNext(at: 0.01)) + let release = try #require(extendedPrefix.releaseNext(at: 0.018)) #expect(substring("e\u{301}X", in: release.range) == "e\u{301}") } @@ -276,13 +347,16 @@ struct ParagraphAnimationTests { at: 0 ) - for index in 0..<100 { - _ = try #require(state.releaseNext(at: Double(index) / 1_000)) + var time: CFTimeInterval = 0 + for _ in 0..<100 { + _ = try #require(state.releaseNext(at: time)) + time += state.nextReleaseInterval } + #expect(!state.activeAnimations.isEmpty) #expect( state.activeAnimations.count - == ParagraphAnimationConstants.maximumActiveCharacterAnimations + <= ParagraphAnimationConstants.maximumActiveCharacterAnimations ) } @@ -349,7 +423,7 @@ private func assertNormalizationReplacement( state.update(target: attributed(replacement), isComplete: false, at: 0.01) #expect(state.visibleAttributedText.string.isEmpty) - let release = try #require(state.releaseNext(at: 0.01)) + let release = try #require(state.releaseNext(at: 0.018)) #expect(substring(replacement, in: release.range) == expected) #expect(state.visibleAttributedText.string == expected) } diff --git a/Tests/MarkdownTextTests/ParagraphNSViewTests.swift b/Tests/MarkdownTextTests/ParagraphNSViewTests.swift index c424a42..ccdf3cf 100644 --- a/Tests/MarkdownTextTests/ParagraphNSViewTests.swift +++ b/Tests/MarkdownTextTests/ParagraphNSViewTests.swift @@ -61,6 +61,13 @@ struct ParagraphNSViewTests { #expect(view.string == "A") #expect(view.layoutManager is CharacterStreamingLayoutManager) + #expect( + view.textStorage?.attribute( + .shadow, + at: 0, + effectiveRange: nil + ) == nil + ) view.finishTextAnimation() @@ -87,6 +94,26 @@ struct ParagraphNSViewTests { view.finishTextAnimation() } + @Test("A drained queue still enforces Character Streaming cadence") + func characterStreamingDrainedQueueCadence() { + let view = ParagraphNSView(characterStreaming: true) + view.setParagraphContents( + NSMutableAttributedString(string: "A"), + textAnimation: .characterStreaming, + isStreamComplete: true + ) + #expect(view.string == "A") + + view.setParagraphContents( + NSMutableAttributedString(string: "AB"), + textAnimation: .characterStreaming, + isStreamComplete: true + ) + + #expect(view.string == "A") + view.finishTextAnimation() + } + @Test("Character Streaming remains settled when Reduce Motion turns off") func characterStreamingReduceMotionToggle() { let contents = NSMutableAttributedString(string: "Already visible") @@ -190,5 +217,80 @@ struct ParagraphNSViewTests { func characterStreamingBaselineDirection() { #expect(CharacterStreamingLayoutManager.baselineTranslation(5) == 5) } + + @Test("AppKit renders blurred glyph pixels before crossfading to sharp") + func characterStreamingBitmapBlur() throws { + let initial = try renderedGlyphMetrics( + for: .value(at: 0) + ) + let intermediate = try renderedGlyphMetrics( + for: .value(at: 0.5) + ) + let settled = try renderedGlyphMetrics( + for: .value(at: 1) + ) + + #expect(initial.maximumAlpha > 0) + #expect(initial.maximumAlpha < intermediate.maximumAlpha) + #expect(intermediate.maximumAlpha < settled.maximumAlpha) + #expect(initial.faintPixelCount > 0) + #expect(initial.opaquePixelCount == 0) + #expect(settled.opaquePixelCount > 0) + #expect(initial.redPixelCount > 0) + #expect(intermediate.redPixelCount > 0) + } + + private func renderedGlyphMetrics( + for transform: CharacterStreamingTransform + ) throws -> CharacterStreamingGlyphImageMetrics { + let textStorage = NSTextStorage( + attributedString: NSAttributedString( + string: "A", + attributes: [ + .font: NSFont.systemFont(ofSize: 40), + .foregroundColor: NSColor.red + ] + ) + ) + let layoutManager = CharacterStreamingLayoutManager() + let textContainer = NSTextContainer( + size: CGSize(width: 100, height: 100) + ) + textContainer.lineFragmentPadding = 0 + layoutManager.addTextContainer(textContainer) + textStorage.addLayoutManager(layoutManager) + let glyphRange = layoutManager.glyphRange(for: textContainer) + layoutManager.updateAnimationFrames([ + CharacterStreamingGlyphAnimationFrame( + range: NSRange(location: 0, length: 1), + transform: transform, + startTime: 0 + ) + ]) + + let image = NSImage( + size: CGSize(width: 100, height: 100), + flipped: true + ) { _ in + layoutManager.drawGlyphs( + forGlyphRange: glyphRange, + at: CGPoint(x: 20, y: 20) + ) + return true + } + var proposedRect = CGRect( + origin: .zero, + size: image.size + ) + return characterStreamingGlyphImageMetrics( + for: try #require( + image.cgImage( + forProposedRect: &proposedRect, + context: nil, + hints: nil + ) + ) + ) + } } #endif diff --git a/Tests/MarkdownTextTests/ParagraphViewTests.swift b/Tests/MarkdownTextTests/ParagraphViewTests.swift index 8be93b4..444a559 100644 --- a/Tests/MarkdownTextTests/ParagraphViewTests.swift +++ b/Tests/MarkdownTextTests/ParagraphViewTests.swift @@ -232,6 +232,13 @@ struct ParagraphViewTests { #expect(view.attributedText.string == "A") #expect(view.accessibilityLabel == "AB") #expect(view.layoutManager is CharacterStreamingLayoutManager) + #expect( + view.attributedText.attribute( + .shadow, + at: 0, + effectiveRange: nil + ) == nil + ) #expect( view.attributedText.attribute( .link, @@ -267,6 +274,27 @@ struct ParagraphViewTests { view.finishTextAnimation() } + @Test("A drained queue still enforces Character Streaming cadence") + @MainActor + func characterStreamingDrainedQueueCadence() { + let view = ParagraphUIView(characterStreaming: true) + view.setParagraphContents( + NSMutableAttributedString(string: "A"), + textAnimation: .characterStreaming, + isStreamComplete: true + ) + #expect(view.attributedText.string == "A") + + view.setParagraphContents( + NSMutableAttributedString(string: "AB"), + textAnimation: .characterStreaming, + isStreamComplete: true + ) + + #expect(view.attributedText.string == "A") + view.finishTextAnimation() + } + @Test("Character Streaming remains settled when Reduce Motion turns off") @MainActor func characterStreamingReduceMotionToggle() { @@ -379,6 +407,29 @@ struct ParagraphViewTests { #expect(CharacterStreamingLayoutManager.baselineTranslation(5) == 5) } + @Test("UIKit renders blurred glyph pixels before crossfading to sharp") + @MainActor + func characterStreamingBitmapBlur() throws { + let initial = try renderedGlyphMetrics( + for: .value(at: 0) + ) + let intermediate = try renderedGlyphMetrics( + for: .value(at: 0.5) + ) + let settled = try renderedGlyphMetrics( + for: .value(at: 1) + ) + + #expect(initial.maximumAlpha > 0) + #expect(initial.maximumAlpha < intermediate.maximumAlpha) + #expect(intermediate.maximumAlpha < settled.maximumAlpha) + #expect(initial.faintPixelCount > 0) + #expect(initial.opaquePixelCount == 0) + #expect(settled.opaquePixelCount > 0) + #expect(initial.redPixelCount > 0) + #expect(intermediate.redPixelCount > 0) + } + @Test("Long text overflow handling") func longTextOverflow() { let longText = String(repeating: "This is a very long text that should test overflow behavior. ", count: 20) @@ -474,5 +525,47 @@ struct ParagraphViewTests { #expect(citationData?.accessibilityLabel == "Test Source", "Should preserve accessibility label") #expect(citationData?.url != nil, "Should have valid URL") } + + @MainActor + private func renderedGlyphMetrics( + for transform: CharacterStreamingTransform + ) throws -> CharacterStreamingGlyphImageMetrics { + let textStorage = NSTextStorage( + attributedString: NSAttributedString( + string: "A", + attributes: [ + .font: UIFont.systemFont(ofSize: 40), + .foregroundColor: UIColor.red + ] + ) + ) + let layoutManager = CharacterStreamingLayoutManager() + let textContainer = NSTextContainer( + size: CGSize(width: 100, height: 100) + ) + textContainer.lineFragmentPadding = 0 + layoutManager.addTextContainer(textContainer) + textStorage.addLayoutManager(layoutManager) + let glyphRange = layoutManager.glyphRange(for: textContainer) + layoutManager.updateAnimationFrames([ + CharacterStreamingGlyphAnimationFrame( + range: NSRange(location: 0, length: 1), + transform: transform, + startTime: 0 + ) + ]) + + let image = UIGraphicsImageRenderer( + size: CGSize(width: 100, height: 100) + ).image { _ in + layoutManager.drawGlyphs( + forGlyphRange: glyphRange, + at: CGPoint(x: 20, y: 20) + ) + } + return characterStreamingGlyphImageMetrics( + for: try #require(image.cgImage) + ) + } } #endif From c33963656da4a8b8ed214bab0639112a87a199eb Mon Sep 17 00:00:00 2001 From: "Mahyar (Mac) McDonald" <22130+theontho@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:40:50 -0700 Subject: [PATCH 6/7] Address character streaming review findings Settle detached paragraph animations, preserve image configuration across builders, and bound glyph blur rendering work with coherent per-release caches. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 78f1a116-c298-4c20-8669-dcded414d291 --- .../MarkdownRenderConfig+Builders.swift | 33 ++- .../UI/Paragraph/AppKit/ParagraphNSView.swift | 7 + .../CharacterStreamingLayoutManager.swift | 264 ++++++++++++++---- .../UI/Paragraph/UIKit/ParagraphUIView.swift | 3 + .../MarkdownTextTests/ImageConfigTests.swift | 27 ++ .../ParagraphAnimationTests.swift | 5 + .../ParagraphNSViewTests.swift | 74 +++-- .../ParagraphViewTests.swift | 56 +++- 8 files changed, 387 insertions(+), 82 deletions(-) diff --git a/Sources/MarkdownText/Models/MarkdownRenderConfig+Builders.swift b/Sources/MarkdownText/Models/MarkdownRenderConfig+Builders.swift index 85d6b06..9eb230b 100644 --- a/Sources/MarkdownText/Models/MarkdownRenderConfig+Builders.swift +++ b/Sources/MarkdownText/Models/MarkdownRenderConfig+Builders.swift @@ -42,7 +42,8 @@ extension MarkdownRenderConfig { codeBlockConfig: codeBlockConfig, blockSpacing: blockSpacing, textSelectionConfig: textSelectionConfig, - thematicBreakColor: thematicBreakColor + thematicBreakColor: thematicBreakColor, + imageConfig: imageConfig ) } @@ -61,7 +62,8 @@ extension MarkdownRenderConfig { codeBlockConfig: codeBlockConfig, blockSpacing: blockSpacing, textSelectionConfig: textSelectionConfig, - thematicBreakColor: thematicBreakColor + thematicBreakColor: thematicBreakColor, + imageConfig: imageConfig ) } @@ -80,7 +82,8 @@ extension MarkdownRenderConfig { codeBlockConfig: codeBlockConfig, blockSpacing: blockSpacing, textSelectionConfig: textSelectionConfig, - thematicBreakColor: thematicBreakColor + thematicBreakColor: thematicBreakColor, + imageConfig: imageConfig ) } @@ -99,7 +102,8 @@ extension MarkdownRenderConfig { codeBlockConfig: codeBlockConfig, blockSpacing: blockSpacing, textSelectionConfig: textSelectionConfig, - thematicBreakColor: thematicBreakColor + thematicBreakColor: thematicBreakColor, + imageConfig: imageConfig ) } @@ -118,7 +122,8 @@ extension MarkdownRenderConfig { codeBlockConfig: codeBlockConfig, blockSpacing: blockSpacing, textSelectionConfig: textSelectionConfig, - thematicBreakColor: thematicBreakColor + thematicBreakColor: thematicBreakColor, + imageConfig: imageConfig ) } @@ -137,7 +142,8 @@ extension MarkdownRenderConfig { codeBlockConfig: codeBlockConfig, blockSpacing: blockSpacing, textSelectionConfig: textSelectionConfig, - thematicBreakColor: thematicBreakColor + thematicBreakColor: thematicBreakColor, + imageConfig: imageConfig ) } @@ -157,7 +163,8 @@ extension MarkdownRenderConfig { codeBlockConfig: codeBlockConfig, blockSpacing: blockSpacing, textSelectionConfig: textSelectionConfig, - thematicBreakColor: thematicBreakColor + thematicBreakColor: thematicBreakColor, + imageConfig: imageConfig ) } @@ -176,7 +183,8 @@ extension MarkdownRenderConfig { codeBlockConfig: codeBlockConfig, blockSpacing: value, textSelectionConfig: textSelectionConfig, - thematicBreakColor: thematicBreakColor + thematicBreakColor: thematicBreakColor, + imageConfig: imageConfig ) } @@ -195,7 +203,8 @@ extension MarkdownRenderConfig { codeBlockConfig: value, blockSpacing: blockSpacing, textSelectionConfig: textSelectionConfig, - thematicBreakColor: thematicBreakColor + thematicBreakColor: thematicBreakColor, + imageConfig: imageConfig ) } @@ -215,7 +224,8 @@ extension MarkdownRenderConfig { codeBlockConfig: codeBlockConfig, blockSpacing: blockSpacing, textSelectionConfig: value, - thematicBreakColor: thematicBreakColor + thematicBreakColor: thematicBreakColor, + imageConfig: imageConfig ) } @@ -234,7 +244,8 @@ extension MarkdownRenderConfig { codeBlockConfig: codeBlockConfig, blockSpacing: blockSpacing, textSelectionConfig: textSelectionConfig, - thematicBreakColor: value + thematicBreakColor: value, + imageConfig: imageConfig ) } diff --git a/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphNSView.swift b/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphNSView.swift index 6fa3b90..fcfd58d 100644 --- a/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphNSView.swift +++ b/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphNSView.swift @@ -72,6 +72,13 @@ class ParagraphNSView: NSTextView { AppAppearance.update(appearance: effectiveAppearance) } + override func viewWillMove(toWindow newWindow: NSWindow?) { + super.viewWillMove(toWindow: newWindow) + if newWindow == nil, textAnimation == .characterStreaming { + finishTextAnimation() + } + } + // MARK: - Intrinsic Content Size override var intrinsicContentSize: NSSize { diff --git a/Sources/MarkdownText/UI/Paragraph/CharacterStreamingLayoutManager.swift b/Sources/MarkdownText/UI/Paragraph/CharacterStreamingLayoutManager.swift index 6de27c3..7599d5b 100644 --- a/Sources/MarkdownText/UI/Paragraph/CharacterStreamingLayoutManager.swift +++ b/Sources/MarkdownText/UI/Paragraph/CharacterStreamingLayoutManager.swift @@ -43,11 +43,70 @@ struct CharacterStreamingGlyphBlend: Equatable { } } +private struct CharacterStreamingGlyphImageSignature: Equatable { + let range: NSRange + let bounds: CGRect + let scale: CGFloat + let glyphs: [UInt32] + let attributedString: NSAttributedString? + + static func == ( + lhs: CharacterStreamingGlyphImageSignature, + rhs: CharacterStreamingGlyphImageSignature + ) -> Bool { + guard lhs.range == rhs.range, + lhs.bounds == rhs.bounds, + lhs.scale == rhs.scale, + lhs.glyphs == rhs.glyphs else { + return false + } + switch (lhs.attributedString, rhs.attributedString) { + case (nil, nil): + return true + case let (lhs?, rhs?): + return lhs.isEqual(to: rhs) + default: + return false + } + } +} + +private struct CharacterStreamingGlyphImageCacheEntry { + let signature: CharacterStreamingGlyphImageSignature + let sourceImage: CGImage + var blurredImages: [Int: CGImage] = [:] +} + +private struct CharacterStreamingGlyphDrawingFrame { + let range: NSRange + let origin: CGPoint + let bounds: CGRect + let anchor: CGPoint + let transform: CharacterStreamingTransform + let backingScale: CGFloat + let cacheID: CFTimeInterval +} + final class CharacterStreamingLayoutManager: NSLayoutManager { private static let blurContext = CIContext( options: [.cacheIntermediates: false] ) + private static let blurRadiusStep: CGFloat = 0.25 private var animationFrames: [CharacterStreamingGlyphAnimationFrame] = [] + private var glyphImageCache: [ + CFTimeInterval: CharacterStreamingGlyphImageCacheEntry + ] = [:] + private(set) var renderedGlyphImageCount = 0 + + var cachedGlyphImageCount: Int { + glyphImageCache.count + } + + var cachedBlurredImageCount: Int { + glyphImageCache.values.reduce(0) { + $0 + $1.blurredImages.count + } + } func updateAnimations( _ animations: [CharacterStreamingAnimation], @@ -65,19 +124,26 @@ final class CharacterStreamingLayoutManager: NSLayoutManager { func updateAnimationFrames( _ frames: [CharacterStreamingGlyphAnimationFrame] ) { + let invalidatedRange = Self.unionRange( + (animationFrames + frames).map(\.range) + ) animationFrames = frames - invalidateDisplay(forCharacterRange: NSRange( - location: 0, - length: textStorage?.length ?? 0 - )) + let activeStartTimes = Set(frames.map(\.startTime)) + glyphImageCache = glyphImageCache.filter { + activeStartTimes.contains($0.key) + } + if let invalidatedRange { + invalidateDisplay(forCharacterRange: invalidatedRange) + } } func clearAnimations() { + let invalidatedRange = Self.unionRange(animationFrames.map(\.range)) animationFrames.removeAll() - invalidateDisplay(forCharacterRange: NSRange( - location: 0, - length: textStorage?.length ?? 0 - )) + glyphImageCache.removeAll() + if let invalidatedRange { + invalidateDisplay(forCharacterRange: invalidatedRange) + } } override func drawGlyphs( @@ -128,7 +194,7 @@ final class CharacterStreamingLayoutManager: NSLayoutManager { drawTransformedGlyphs( in: transformedRange, at: origin, - transform: cluster.transform + frame: cluster ) nextGlyphLocation = NSMaxRange(transformedRange) } @@ -172,10 +238,16 @@ final class CharacterStreamingLayoutManager: NSLayoutManager { return clusters } + static func unionRange(_ ranges: [NSRange]) -> NSRange? { + ranges.reduce(nil) { result, range in + result.map { NSUnionRange($0, range) } ?? range + } + } + private func drawTransformedGlyphs( in glyphRange: NSRange, at origin: CGPoint, - transform: CharacterStreamingTransform + frame: CharacterStreamingGlyphAnimationFrame ) { guard let context = currentGraphicsContext(), let textContainer = textContainer( @@ -191,28 +263,30 @@ final class CharacterStreamingLayoutManager: NSLayoutManager { in: textContainer ).offsetBy(dx: origin.x, dy: origin.y) let anchor = CGPoint(x: bounds.midX, y: bounds.maxY) + let transform = frame.transform let blend = CharacterStreamingGlyphBlend.value(for: transform) + let drawingFrame = CharacterStreamingGlyphDrawingFrame( + range: glyphRange, + origin: origin, + bounds: bounds, + anchor: anchor, + transform: transform, + backingScale: Self.backingScale(for: context), + cacheID: frame.startTime + ) if blend.blurredAlpha > 0, blend.blurRadius > 0 { drawGlyphPass( - in: glyphRange, - at: origin, + drawingFrame, context: context, - bounds: bounds, - anchor: anchor, - transform: transform, alpha: blend.blurredAlpha, blurRadius: blend.blurRadius ) } if blend.sharpAlpha > 0 { drawGlyphPass( - in: glyphRange, - at: origin, + drawingFrame, context: context, - bounds: bounds, - anchor: anchor, - transform: transform, alpha: blend.sharpAlpha, blurRadius: nil ) @@ -220,36 +294,33 @@ final class CharacterStreamingLayoutManager: NSLayoutManager { } private func drawGlyphPass( - in glyphRange: NSRange, - at origin: CGPoint, + _ frame: CharacterStreamingGlyphDrawingFrame, context: CGContext, - bounds: CGRect, - anchor: CGPoint, - transform: CharacterStreamingTransform, alpha: CGFloat, blurRadius: CGFloat? ) { context.saveGState() context.translateBy( x: 0, - y: Self.baselineTranslation(transform.baselineOffset) + y: Self.baselineTranslation(frame.transform.baselineOffset) ) - context.translateBy(x: anchor.x, y: anchor.y) - context.scaleBy(x: transform.scale, y: transform.scale) - context.translateBy(x: -anchor.x, y: -anchor.y) + context.translateBy(x: frame.anchor.x, y: frame.anchor.y) + context.scaleBy(x: frame.transform.scale, y: frame.transform.scale) + context.translateBy(x: -frame.anchor.x, y: -frame.anchor.y) if let blurRadius, blurRadius > 0 { drawBlurredGlyphs( - in: glyphRange, - at: origin, - bounds: bounds, + in: frame.range, + at: frame.origin, + bounds: frame.bounds, radius: blurRadius, - scale: Self.backingScale(for: context), + scale: frame.backingScale, + cacheID: frame.cacheID, alpha: alpha ) } else { context.setAlpha(alpha) - super.drawGlyphs(forGlyphRange: glyphRange, at: origin) + super.drawGlyphs(forGlyphRange: frame.range, at: frame.origin) } context.restoreGState() @@ -261,34 +332,39 @@ final class CharacterStreamingLayoutManager: NSLayoutManager { bounds: CGRect, radius: CGFloat, scale: CGFloat, + cacheID: CFTimeInterval, alpha: CGFloat ) { - let padding = radius * 4 + let padding = ParagraphAnimationConstants.initialCharacterBlurRadius * 4 let imageBounds = bounds.insetBy(dx: -padding, dy: -padding) guard imageBounds.width > 0, imageBounds.height > 0, - let sourceImage = glyphImage( + let sourceImage = cachedGlyphImage( in: glyphRange, at: origin, bounds: imageBounds, - scale: scale + scale: scale, + cacheID: cacheID ) else { super.drawGlyphs(forGlyphRange: glyphRange, at: origin) return } - let inputImage = CIImage(cgImage: sourceImage) - guard let filter = CIFilter(name: "CIGaussianBlur") else { - super.drawGlyphs(forGlyphRange: glyphRange, at: origin) - return - } - filter.setValue(inputImage, forKey: kCIInputImageKey) - filter.setValue(radius * scale, forKey: kCIInputRadiusKey) - guard let outputImage = filter.outputImage?.cropped(to: inputImage.extent), - let blurredImage = Self.blurContext.createCGImage( - outputImage, - from: inputImage.extent - ) else { + let blurIndex = max( + 1, + Int(ceil(radius / Self.blurRadiusStep)) + ) + let quantizedRadius = min( + ParagraphAnimationConstants.initialCharacterBlurRadius, + CGFloat(blurIndex) * Self.blurRadiusStep + ) + guard let blurredImage = cachedBlurredImage( + for: sourceImage, + radius: quantizedRadius, + scale: scale, + cacheID: cacheID, + blurIndex: blurIndex + ) else { super.drawGlyphs(forGlyphRange: glyphRange, at: origin) return } @@ -318,12 +394,102 @@ final class CharacterStreamingLayoutManager: NSLayoutManager { #endif } + private func cachedGlyphImage( + in glyphRange: NSRange, + at origin: CGPoint, + bounds: CGRect, + scale: CGFloat, + cacheID: CFTimeInterval + ) -> CGImage? { + let signature = glyphImageSignature( + in: glyphRange, + bounds: bounds, + scale: scale + ) + if let entry = glyphImageCache[cacheID], + entry.signature == signature { + return entry.sourceImage + } + guard let sourceImage = glyphImage( + in: glyphRange, + at: origin, + bounds: bounds, + scale: scale + ) else { + return nil + } + glyphImageCache[cacheID] = CharacterStreamingGlyphImageCacheEntry( + signature: signature, + sourceImage: sourceImage + ) + return sourceImage + } + + private func cachedBlurredImage( + for sourceImage: CGImage, + radius: CGFloat, + scale: CGFloat, + cacheID: CFTimeInterval, + blurIndex: Int + ) -> CGImage? { + if let blurredImage = glyphImageCache[cacheID]? + .blurredImages[blurIndex] { + return blurredImage + } + + let inputImage = CIImage(cgImage: sourceImage) + guard let filter = CIFilter(name: "CIGaussianBlur") else { + return nil + } + filter.setValue(inputImage, forKey: kCIInputImageKey) + filter.setValue(radius * scale, forKey: kCIInputRadiusKey) + guard let outputImage = filter.outputImage?.cropped(to: inputImage.extent), + let blurredImage = Self.blurContext.createCGImage( + outputImage, + from: inputImage.extent + ) else { + return nil + } + glyphImageCache[cacheID]?.blurredImages[blurIndex] = blurredImage + return blurredImage + } + + private func glyphImageSignature( + in glyphRange: NSRange, + bounds: CGRect, + scale: CGFloat + ) -> CharacterStreamingGlyphImageSignature { + let glyphs = (glyphRange.location.. CGImage? { + renderedGlyphImageCount += 1 #if canImport(UIKit) let format = UIGraphicsImageRendererFormat() format.opaque = false diff --git a/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphUIView.swift b/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphUIView.swift index 99fa00f..c822fcf 100644 --- a/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphUIView.swift +++ b/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphUIView.swift @@ -75,6 +75,9 @@ class ParagraphUIView: UITextView { // Fix for crash: "UIPreviewTarget requires that the container view is in a window". When the view is removed from the window (e.g. scrolled out in LazyVStack), we should clear the selection to prevent any pending menu or drag interactions from trying to reference the detached view. if newWindow == nil { selectedTextRange = nil + if textAnimation == .characterStreaming { + finishTextAnimation() + } } } diff --git a/Tests/MarkdownTextTests/ImageConfigTests.swift b/Tests/MarkdownTextTests/ImageConfigTests.swift index b88a027..0e37448 100644 --- a/Tests/MarkdownTextTests/ImageConfigTests.swift +++ b/Tests/MarkdownTextTests/ImageConfigTests.swift @@ -93,4 +93,31 @@ final class ImageConfigTests: XCTestCase { let off = ImageConfig(enabled: true, allowedImageTypes: [.assetCatalog], fullscreenViewerEnabled: false) XCTAssertNotEqual(on, off) } + + func test_builders_preserve_image_config() { + let imageConfig = ImageConfig( + enabled: true, + allowedImageTypes: [.assetCatalog], + fullscreenViewerEnabled: false + ) + let config = MarkdownRenderConfig(imageConfig: imageConfig) + let results = [ + config.withTextAnimation(.characterStreaming), + config.withBlockQuoteStyle(value: config.blockQuoteStyle), + config.withHeadingStyle(value: config.headingStyle), + config.withOrderedListStyle(value: config.orderedListStyle), + config.withParagraphStyle(value: config.paragraphStyle), + config.withTableStyle(value: config.tableStyle), + config.withInlineStyle(value: config.inlineStyle), + config.withTextContextMenu(value: config.textContextMenu), + config.withBlockSpacing(value: config.blockSpacing), + config.withCodeBlockConfig(value: config.codeBlockConfig), + config.withTextSelectionConfig(value: config.textSelectionConfig), + config.withThematicBreakColor(value: config.thematicBreakColor) + ] + + for result in results { + XCTAssertEqual(result.imageConfig, imageConfig) + } + } } diff --git a/Tests/MarkdownTextTests/ParagraphAnimationTests.swift b/Tests/MarkdownTextTests/ParagraphAnimationTests.swift index bd4dca0..adb4bd1 100644 --- a/Tests/MarkdownTextTests/ParagraphAnimationTests.swift +++ b/Tests/MarkdownTextTests/ParagraphAnimationTests.swift @@ -145,6 +145,11 @@ struct ParagraphAnimationTests { #expect(clusters[0].transform == newerTransform) #expect(clusters[0].startTime == 2) #expect(clusters[1].range == NSRange(location: 4, length: 1)) + #expect( + CharacterStreamingLayoutManager.unionRange( + clusters.map(\.range) + ) == NSRange(location: 0, length: 5) + ) } @Test("Glyph blur crossfades a blurred-only pass to the sharp pass") diff --git a/Tests/MarkdownTextTests/ParagraphNSViewTests.swift b/Tests/MarkdownTextTests/ParagraphNSViewTests.swift index ccdf3cf..1719394 100644 --- a/Tests/MarkdownTextTests/ParagraphNSViewTests.swift +++ b/Tests/MarkdownTextTests/ParagraphNSViewTests.swift @@ -114,6 +114,23 @@ struct ParagraphNSViewTests { view.finishTextAnimation() } + @Test("Detaching settles Character Streaming and stops scheduled work") + func characterStreamingSettlesWhenDetached() { + let view = ParagraphNSView(characterStreaming: true) + view.setParagraphContents( + NSMutableAttributedString(string: "AB"), + textAnimation: .characterStreaming, + isStreamComplete: true + ) + #expect(view.string == "A") + + let window = NSWindow() + window.contentView?.addSubview(view) + view.removeFromSuperview() + + #expect(view.string == "AB") + } + @Test("Character Streaming remains settled when Reduce Motion turns off") func characterStreamingReduceMotionToggle() { let contents = NSMutableAttributedString(string: "Already visible") @@ -268,29 +285,54 @@ struct ParagraphNSViewTests { ) ]) - let image = NSImage( - size: CGSize(width: 100, height: 100), - flipped: true - ) { _ in - layoutManager.drawGlyphs( - forGlyphRange: glyphRange, - at: CGPoint(x: 20, y: 20) + func renderImage() throws -> CGImage { + let image = NSImage( + size: CGSize(width: 100, height: 100), + flipped: true + ) { _ in + layoutManager.drawGlyphs( + forGlyphRange: glyphRange, + at: CGPoint(x: 20, y: 20) + ) + return true + } + var proposedRect = CGRect( + origin: .zero, + size: image.size ) - return true - } - var proposedRect = CGRect( - origin: .zero, - size: image.size - ) - return characterStreamingGlyphImageMetrics( - for: try #require( + return try #require( image.cgImage( forProposedRect: &proposedRect, context: nil, hints: nil ) ) - ) + } + + let image = try renderImage() + if transform.blurRadius > 0 { + let sourceCount = layoutManager.cachedGlyphImageCount + let blurredCount = layoutManager.cachedBlurredImageCount + _ = try renderImage() + #expect(sourceCount == 1) + #expect(blurredCount == 1) + #expect(layoutManager.cachedGlyphImageCount == sourceCount) + #expect(layoutManager.cachedBlurredImageCount == blurredCount) + #expect(layoutManager.renderedGlyphImageCount == 1) + textStorage.addAttribute( + .foregroundColor, + value: NSColor.blue, + range: NSRange(location: 0, length: textStorage.length) + ) + _ = try renderImage() + #expect(layoutManager.renderedGlyphImageCount == 2) + #expect(layoutManager.cachedGlyphImageCount == 1) + #expect(layoutManager.cachedBlurredImageCount == 1) + layoutManager.clearAnimations() + #expect(layoutManager.cachedGlyphImageCount == 0) + #expect(layoutManager.cachedBlurredImageCount == 0) + } + return characterStreamingGlyphImageMetrics(for: image) } } #endif diff --git a/Tests/MarkdownTextTests/ParagraphViewTests.swift b/Tests/MarkdownTextTests/ParagraphViewTests.swift index 444a559..7760509 100644 --- a/Tests/MarkdownTextTests/ParagraphViewTests.swift +++ b/Tests/MarkdownTextTests/ParagraphViewTests.swift @@ -295,6 +295,24 @@ struct ParagraphViewTests { view.finishTextAnimation() } + @Test("Detaching settles Character Streaming and stops scheduled work") + @MainActor + func characterStreamingSettlesWhenDetached() { + let view = ParagraphUIView(characterStreaming: true) + view.setParagraphContents( + NSMutableAttributedString(string: "AB"), + textAnimation: .characterStreaming, + isStreamComplete: true + ) + #expect(view.attributedText.string == "A") + + let window = UIWindow() + window.addSubview(view) + view.removeFromSuperview() + + #expect(view.attributedText.string == "AB") + } + @Test("Character Streaming remains settled when Reduce Motion turns off") @MainActor func characterStreamingReduceMotionToggle() { @@ -555,13 +573,39 @@ struct ParagraphViewTests { ) ]) - let image = UIGraphicsImageRenderer( - size: CGSize(width: 100, height: 100) - ).image { _ in - layoutManager.drawGlyphs( - forGlyphRange: glyphRange, - at: CGPoint(x: 20, y: 20) + func renderImage() -> UIImage { + UIGraphicsImageRenderer( + size: CGSize(width: 100, height: 100) + ).image { _ in + layoutManager.drawGlyphs( + forGlyphRange: glyphRange, + at: CGPoint(x: 20, y: 20) + ) + } + } + + let image = renderImage() + if transform.blurRadius > 0 { + let sourceCount = layoutManager.cachedGlyphImageCount + let blurredCount = layoutManager.cachedBlurredImageCount + _ = renderImage() + #expect(sourceCount == 1) + #expect(blurredCount == 1) + #expect(layoutManager.cachedGlyphImageCount == sourceCount) + #expect(layoutManager.cachedBlurredImageCount == blurredCount) + #expect(layoutManager.renderedGlyphImageCount == 1) + textStorage.addAttribute( + .foregroundColor, + value: UIColor.blue, + range: NSRange(location: 0, length: textStorage.length) ) + _ = renderImage() + #expect(layoutManager.renderedGlyphImageCount == 2) + #expect(layoutManager.cachedGlyphImageCount == 1) + #expect(layoutManager.cachedBlurredImageCount == 1) + layoutManager.clearAnimations() + #expect(layoutManager.cachedGlyphImageCount == 0) + #expect(layoutManager.cachedBlurredImageCount == 0) } return characterStreamingGlyphImageMetrics( for: try #require(image.cgImage) From 4b0f3830e3923693f3c3df34b5e17b990824ef1d Mon Sep 17 00:00:00 2001 From: "Mahyar (Mac) McDonald" <22130+theontho@users.noreply.github.com> Date: Sun, 19 Jul 2026 15:17:58 -0700 Subject: [PATCH 7/7] Remove obsolete word splitting helper Character Streaming no longer uses the per-word animation utility, leaving it unreachable. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 78f1a116-c298-4c20-8669-dcded414d291 --- .../Utilities/NSAttributedString+.swift | 69 ------------------- 1 file changed, 69 deletions(-) delete mode 100644 Sources/MarkdownText/Utilities/NSAttributedString+.swift diff --git a/Sources/MarkdownText/Utilities/NSAttributedString+.swift b/Sources/MarkdownText/Utilities/NSAttributedString+.swift deleted file mode 100644 index d3af12e..0000000 --- a/Sources/MarkdownText/Utilities/NSAttributedString+.swift +++ /dev/null @@ -1,69 +0,0 @@ -// -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. See LICENSE in the project root for license information. -// - -import Foundation -#if canImport(UIKit) -import UIKit -#elseif canImport(AppKit) -import AppKit -#endif - -extension NSAttributedString { - func splitIntoWords(withIn range: NSRange) -> [NSRange] { - var words: [NSRange] = [] - let string = self.string as NSString - - guard range.location != NSNotFound, - range.location >= 0, - NSMaxRange(range) <= string.length else { - return words - } - - string.enumerateSubstrings( - in: range, - options: [.byWords, .localized, .substringNotRequired] - ) { (_, substringRange, _, _) in - - // Add any separator/whitespace before this word - if let lastWord = words.last { - let gapStart = NSMaxRange(lastWord) - let gapLength = substringRange.location - gapStart - - if gapLength > 0 { - let gapRange = NSRange(location: gapStart, length: gapLength) - words.append(gapRange) - } - } else { - // Handle any leading separators/whitespace - let leadingGapLength = substringRange.location - range.location - if leadingGapLength > 0 { - let leadingGapRange = NSRange(location: range.location, length: leadingGapLength) - words.append(leadingGapRange) - } - } - - // Add the word range - words.append(substringRange) - } - - // Handle any trailing separators/whitespace - if let lastWord = words.last { - let trailingStart = NSMaxRange(lastWord) - let trailingLength = NSMaxRange(range) - trailingStart - - if trailingLength > 0 { - let trailingRange = NSRange(location: trailingStart, length: trailingLength) - words.append(trailingRange) - } - } else { - // If no words were found, return entire range - if range.length > 0 { - words.append(range) - } - } - - return words - } -}