diff --git a/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/DemonstrationView.swift b/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/DemonstrationView.swift index 39dd6ac..09baceb 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 = SampleStreamingTextAnimation.characterStreaming 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(SampleStreamingTextAnimation.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..bbc269f 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: SampleStreamingTextAnimation + ) -> MarkdownRenderConfig { + theme.renderConfig( + for: self, + textAnimation: isStreaming ? streamingTextAnimation.renderAnimation : .none + ) } var automaticBackgroundColor: Color { 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 72c022d..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, isStreaming: Bool) -> MarkdownRenderConfig { + func renderConfig( + for demonstration: Demonstration, + textAnimation: MarkdownRenderConfig.TextAnimation + ) -> MarkdownRenderConfig { resolvedConfig(for: demonstration) .withTextContextMenu(value: demonstration.customContextMenu) - .withShouldAnimateText(value: isStreaming) + .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 adea41a..c2b79a0 100644 --- a/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/SampleSettings.swift +++ b/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/SampleSettings.swift @@ -4,11 +4,34 @@ // import SwiftUI +import SwiftStreamingMarkdown enum SampleSettings { static let preferStreamedMarkdownKey = "preferStreamedMarkdown" static let appearanceModeKey = "appearanceMode" static let markdownThemeKey = "markdownTheme" + static let streamingTextAnimationKey = "streamingTextAnimation" +} + +enum SampleStreamingTextAnimation: String, CaseIterable, Identifiable { + case characterStreaming + case standardFade + + var id: String { rawValue } + + var displayName: String { + switch self { + case .characterStreaming: "Character Streaming" + case .standardFade: "Standard Fade" + } + } + + var renderAnimation: MarkdownRenderConfig.TextAnimation { + switch self { + case .characterStreaming: .characterStreaming + case .standardFade: .fade + } + } } enum AppearanceMode: String, CaseIterable, Identifiable { diff --git a/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/SettingsView.swift b/Examples/SwiftStreamingMarkdownSample/SwiftStreamingMarkdownSample/SettingsView.swift index ac97ff6..74a904c 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 = SampleStreamingTextAnimation.characterStreaming var body: some View { Form { Toggle("Streamed", isOn: $preferStreamedMarkdown) + Picker("Streaming Text", selection: $streamingTextAnimation) { + ForEach(SampleStreamingTextAnimation.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/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/Models/MarkdownRenderConfig+Builders.swift b/Sources/MarkdownText/Models/MarkdownRenderConfig+Builders.swift index 019cc3f..9eb230b 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, @@ -41,14 +42,15 @@ extension MarkdownRenderConfig { codeBlockConfig: codeBlockConfig, blockSpacing: blockSpacing, textSelectionConfig: textSelectionConfig, - thematicBreakColor: thematicBreakColor + thematicBreakColor: thematicBreakColor, + imageConfig: imageConfig ) } /// Returns a copy with `headingStyle` replaced. public func withHeadingStyle(value: MarkdownHeadingTextStyle) -> MarkdownRenderConfig { MarkdownRenderConfig( - shouldAnimateText: shouldAnimateText, + textAnimation: textAnimation, blockQuoteStyle: blockQuoteStyle, headingStyle: value, orderedListStyle: orderedListStyle, @@ -60,14 +62,15 @@ extension MarkdownRenderConfig { codeBlockConfig: codeBlockConfig, blockSpacing: blockSpacing, textSelectionConfig: textSelectionConfig, - thematicBreakColor: thematicBreakColor + thematicBreakColor: thematicBreakColor, + imageConfig: imageConfig ) } /// Returns a copy with `orderedListStyle` replaced. public func withOrderedListStyle(value: MarkdownTextStyle) -> MarkdownRenderConfig { MarkdownRenderConfig( - shouldAnimateText: shouldAnimateText, + textAnimation: textAnimation, blockQuoteStyle: blockQuoteStyle, headingStyle: headingStyle, orderedListStyle: value, @@ -79,14 +82,15 @@ extension MarkdownRenderConfig { codeBlockConfig: codeBlockConfig, blockSpacing: blockSpacing, textSelectionConfig: textSelectionConfig, - thematicBreakColor: thematicBreakColor + thematicBreakColor: thematicBreakColor, + imageConfig: imageConfig ) } /// Returns a copy with `paragraphStyle` replaced. public func withParagraphStyle(value: MarkdownTextStyle) -> MarkdownRenderConfig { MarkdownRenderConfig( - shouldAnimateText: shouldAnimateText, + textAnimation: textAnimation, blockQuoteStyle: blockQuoteStyle, headingStyle: headingStyle, orderedListStyle: orderedListStyle, @@ -98,14 +102,15 @@ extension MarkdownRenderConfig { codeBlockConfig: codeBlockConfig, blockSpacing: blockSpacing, textSelectionConfig: textSelectionConfig, - thematicBreakColor: thematicBreakColor + thematicBreakColor: thematicBreakColor, + imageConfig: imageConfig ) } /// Returns a copy with `tableStyle` replaced. public func withTableStyle(value: MarkdownTableTextStyle) -> MarkdownRenderConfig { MarkdownRenderConfig( - shouldAnimateText: shouldAnimateText, + textAnimation: textAnimation, blockQuoteStyle: blockQuoteStyle, headingStyle: headingStyle, orderedListStyle: orderedListStyle, @@ -117,14 +122,15 @@ extension MarkdownRenderConfig { codeBlockConfig: codeBlockConfig, blockSpacing: blockSpacing, textSelectionConfig: textSelectionConfig, - thematicBreakColor: thematicBreakColor + thematicBreakColor: thematicBreakColor, + imageConfig: imageConfig ) } /// Returns a copy with `inlineStyle` replaced. public func withInlineStyle(value: MarkdownInlineTextStyle) -> MarkdownRenderConfig { MarkdownRenderConfig( - shouldAnimateText: shouldAnimateText, + textAnimation: textAnimation, blockQuoteStyle: blockQuoteStyle, headingStyle: headingStyle, orderedListStyle: orderedListStyle, @@ -136,7 +142,8 @@ extension MarkdownRenderConfig { codeBlockConfig: codeBlockConfig, blockSpacing: blockSpacing, textSelectionConfig: textSelectionConfig, - thematicBreakColor: thematicBreakColor + thematicBreakColor: thematicBreakColor, + imageConfig: imageConfig ) } @@ -144,7 +151,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, @@ -156,14 +163,15 @@ extension MarkdownRenderConfig { codeBlockConfig: codeBlockConfig, blockSpacing: blockSpacing, textSelectionConfig: textSelectionConfig, - thematicBreakColor: thematicBreakColor + thematicBreakColor: thematicBreakColor, + imageConfig: imageConfig ) } /// Returns a copy with `blockSpacing` replaced. public func withBlockSpacing(value: CGFloat) -> MarkdownRenderConfig { MarkdownRenderConfig( - shouldAnimateText: shouldAnimateText, + textAnimation: textAnimation, blockQuoteStyle: blockQuoteStyle, headingStyle: headingStyle, orderedListStyle: orderedListStyle, @@ -175,14 +183,15 @@ extension MarkdownRenderConfig { codeBlockConfig: codeBlockConfig, blockSpacing: value, textSelectionConfig: textSelectionConfig, - thematicBreakColor: thematicBreakColor + thematicBreakColor: thematicBreakColor, + imageConfig: imageConfig ) } /// Returns a copy with `codeBlockConfig` replaced. public func withCodeBlockConfig(value: CodeBlockConfig) -> MarkdownRenderConfig { MarkdownRenderConfig( - shouldAnimateText: shouldAnimateText, + textAnimation: textAnimation, blockQuoteStyle: blockQuoteStyle, headingStyle: headingStyle, orderedListStyle: orderedListStyle, @@ -194,7 +203,8 @@ extension MarkdownRenderConfig { codeBlockConfig: value, blockSpacing: blockSpacing, textSelectionConfig: textSelectionConfig, - thematicBreakColor: thematicBreakColor + thematicBreakColor: thematicBreakColor, + imageConfig: imageConfig ) } @@ -202,7 +212,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, @@ -214,14 +224,15 @@ extension MarkdownRenderConfig { codeBlockConfig: codeBlockConfig, blockSpacing: blockSpacing, textSelectionConfig: value, - thematicBreakColor: thematicBreakColor + thematicBreakColor: thematicBreakColor, + imageConfig: imageConfig ) } /// Returns a copy with `thematicBreakColor` replaced. public func withThematicBreakColor(value: Color) -> MarkdownRenderConfig { MarkdownRenderConfig( - shouldAnimateText: shouldAnimateText, + textAnimation: textAnimation, blockQuoteStyle: blockQuoteStyle, headingStyle: headingStyle, orderedListStyle: orderedListStyle, @@ -233,14 +244,15 @@ extension MarkdownRenderConfig { codeBlockConfig: codeBlockConfig, blockSpacing: blockSpacing, textSelectionConfig: textSelectionConfig, - thematicBreakColor: value + thematicBreakColor: value, + imageConfig: imageConfig ) } /// 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 7344464..79879ff 100644 --- a/Sources/MarkdownText/Models/MarkdownRenderConfig.swift +++ b/Sources/MarkdownText/Models/MarkdownRenderConfig.swift @@ -13,8 +13,18 @@ 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. - 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. @@ -253,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, @@ -268,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 @@ -286,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/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/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 bbce1d1..84b3b96 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,10 @@ 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 + /// 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 c4327bd..fcfd58d 100644 --- a/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphNSView.swift +++ b/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphNSView.swift @@ -17,28 +17,36 @@ 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 fadeAnimationDisplayLink: CADisplayLink? + private var finalAttributedText = NSAttributedString() + private var activeAnimation: FadeAnimationData? + private let characterStreamingState = CharacterStreamingState() + private var characterStreamingTimer: Timer? + 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,7 +61,8 @@ class ParagraphNSView: NSTextView { deinit { tearDownDisplayLink() - activeAnimations.removeAll() + characterStreamingTimer?.invalidate() + activeAnimation = nil } // MARK: - Appearance @@ -63,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 { @@ -114,56 +130,117 @@ class ParagraphNSView: NSTextView { // MARK: - Content Update - func setParagraphContents(_ newContents: NSMutableAttributedString, lineSpacing: CGFloat? = nil, animatedByWord: Bool) { + func setParagraphContents( + _ newContents: NSMutableAttributedString, + lineSpacing: CGFloat? = nil, + textAnimation: MarkdownRenderConfig.TextAnimation, + isStreamComplete: 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 finalString: NSMutableAttributedString if lineSpacing != nil { finalString = applyLineSpacing(to: newContents, lineSpacing: lineSpacing) } else { finalString = newContents } + let previousAttributedText = finalAttributedText + let previousText = previousAttributedText.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() + 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() - - 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 + switch textAnimation { + case .none: + stopCharacterStreaming() + activeAnimation = nil + tearDownDisplayLink() + textStorage?.setAttributedString(finalString) + case .fade: + stopCharacterStreaming() + guard contentsChanged || modeChanged else { + invalidateIntrinsicContentSize() + return + } + textStorage?.setAttributedString(finalString) + let revealPlan = contentsChanged + ? ParagraphRevealPlan.appendedText( + previousText: previousText, + newText: finalString.string ) - activeAnimations.append(animationData) + : nil + guard let revealPlan else { + activeAnimation = nil + tearDownDisplayLink() + invalidateIntrinsicContentSize() + return + } + let currentTime = CACurrentMediaTime() + let previousAnimation = modeChanged ? nil : activeAnimation + activeAnimation = FadeAnimationData( + plan: revealPlan, + startTime: currentTime, + previousAnimation: previousAnimation, + contentLength: finalString.length + ) + updateTextViewWithCurrentAnimations(at: currentTime) + 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() + } } + characterStreamingState.update( + target: finalString, + isComplete: isStreamComplete, + at: currentTime + ) + synchronizeCharacterStreamingText() + if characterStreamingTimer == nil { + releaseOneCharacter(at: currentTime) + } + } - updateTextViewWithCurrentAnimations() + invalidateIntrinsicContentSize() + } - if fadeAnimationDisplayLink == nil { - setUpDisplayLink() - } - } else { - activeAnimations.removeAll() + func finishTextAnimation() { + if let activeAnimation { + restoreFinalAttributes(in: activeAnimation.segments.map(\.range)) + self.activeAnimation = nil } + if textAnimation == .characterStreaming { + characterStreamingState.settle() + synchronizeCharacterStreamingText() + stopCharacterStreaming() + } + tearDownDisplayLink() } // MARK: - Line Spacing @@ -239,71 +316,148 @@ class ParagraphNSView: NSTextView { } } - // MARK: - Fade Animation + // MARK: - Text Animation - @objc private func updateFadeAnimation() { + @objc private func updateTextAnimation() { 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) - } - } - activeAnimations.removeAll { completedAnimations.contains($0.id) } - - if activeAnimations.isEmpty { + 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() + } } } - 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 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) + invalidateCachedSize() + invalidateIntrinsicContentSize() + } + + private func scheduleNextCharacterRelease() { + guard textAnimation == .characterStreaming, + characterStreamingState.hasPendingGrapheme, + characterStreamingTimer == nil else { + return + } + + let timer = Timer( + timeInterval: characterStreamingState.releaseDelay( + at: CACurrentMediaTime() + ), + 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 + characterStreamingLayoutManager?.updateAnimations( + animations, + at: currentTime + ) + } + + private func stopCharacterStreaming() { + characterStreamingTimer?.invalidate() + characterStreamingTimer = nil + characterStreamingLayoutManager?.clearAnimations() + } + + private var characterStreamingLayoutManager: CharacterStreamingLayoutManager? { + layoutManager as? CharacterStreamingLayoutManager + } + private func setUpDisplayLink() { + 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 befa77f..d5931b1 100644 --- a/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphView+macOS.swift +++ b/Sources/MarkdownText/UI/Paragraph/AppKit/ParagraphView+macOS.swift @@ -10,6 +10,9 @@ struct ParagraphView: NSViewRepresentable { @Environment(\.openURL) var openURL @Environment(\.markdownConfig) var config: MarkdownRenderConfig @Environment(\.markdownController) var markdownController: MarkdownController? + @Environment(\.accessibilityReduceMotion) var reduceMotion + @Environment(\.isMarkdownStreamComplete) var isStreamComplete + @Environment(\.isMarkdownStreamingTailBranch) var isStreamingTailBranch var contents: NSMutableAttributedString var lineSpacing: CGFloat? @@ -24,27 +27,37 @@ 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: config.textAnimation == .characterStreaming + ) view.onUrlTap = openUrlFunction - view.setParagraphContents(contents, lineSpacing: lineSpacing, animatedByWord: false) + view.setParagraphContents( + contents, + lineSpacing: lineSpacing, + textAnimation: resolvedAnimation, + isStreamComplete: paragraphStreamComplete + ) 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 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, + textAnimation: view.window == nil ? .none : resolvedAnimation, + isStreamComplete: paragraphStreamComplete + ) + } else { + view.setParagraphContents( + contents, + lineSpacing: lineSpacing, + textAnimation: resolvedAnimation, + isStreamComplete: paragraphStreamComplete + ) } view.setTextContextMenu(config.resolvedTextContextMenu) view.setMarkdownController(markdownController) @@ -61,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 @@ -74,15 +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 new file mode 100644 index 0000000..7599d5b --- /dev/null +++ b/Sources/MarkdownText/UI/Paragraph/CharacterStreamingLayoutManager.swift @@ -0,0 +1,552 @@ +// +// 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 CoreImage +import Foundation + +#if canImport(UIKit) +import UIKit +#elseif canImport(AppKit) +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 + ) + } +} + +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], + at time: CFTimeInterval + ) { + updateAnimationFrames(animations.map { + CharacterStreamingGlyphAnimationFrame( + range: $0.range, + transform: $0.transform(at: time), + startTime: $0.startTime + ) + }) + } + + func updateAnimationFrames( + _ frames: [CharacterStreamingGlyphAnimationFrame] + ) { + let invalidatedRange = Self.unionRange( + (animationFrames + frames).map(\.range) + ) + animationFrames = frames + 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() + glyphImageCache.removeAll() + if let invalidatedRange { + invalidateDisplay(forCharacterRange: invalidatedRange) + } + } + + override func drawGlyphs( + forGlyphRange glyphsToShow: NSRange, + at origin: CGPoint + ) { + guard !animationFrames.isEmpty else { + super.drawGlyphs(forGlyphRange: glyphsToShow, at: origin) + return + } + + let glyphFrames: [CharacterStreamingGlyphAnimationFrame] = animationFrames.compactMap { frame in + let frameGlyphRange = glyphRange( + forCharacterRange: frame.range, + actualCharacterRange: nil + ) + 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) + + for cluster in shapedClusters { + if nextGlyphLocation < cluster.range.location { + super.drawGlyphs( + forGlyphRange: NSRange( + location: nextGlyphLocation, + length: cluster.range.location - nextGlyphLocation + ), + at: origin + ) + } + + let transformedRange = NSIntersectionRange( + cluster.range, + NSRange( + location: nextGlyphLocation, + length: max(0, glyphEnd - nextGlyphLocation) + ) + ) + if transformedRange.length > 0 { + drawTransformedGlyphs( + in: transformedRange, + at: origin, + frame: cluster + ) + nextGlyphLocation = NSMaxRange(transformedRange) + } + } + + if nextGlyphLocation < glyphEnd { + super.drawGlyphs( + forGlyphRange: NSRange( + location: nextGlyphLocation, + length: glyphEnd - nextGlyphLocation + ), + at: origin + ) + } + } + + 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 + } + + 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, + frame: CharacterStreamingGlyphAnimationFrame + ) { + 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) + 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( + drawingFrame, + context: context, + alpha: blend.blurredAlpha, + blurRadius: blend.blurRadius + ) + } + if blend.sharpAlpha > 0 { + drawGlyphPass( + drawingFrame, + context: context, + alpha: blend.sharpAlpha, + blurRadius: nil + ) + } + } + + private func drawGlyphPass( + _ frame: CharacterStreamingGlyphDrawingFrame, + context: CGContext, + alpha: CGFloat, + blurRadius: CGFloat? + ) { + context.saveGState() + context.translateBy( + x: 0, + y: Self.baselineTranslation(frame.transform.baselineOffset) + ) + 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: frame.range, + at: frame.origin, + bounds: frame.bounds, + radius: blurRadius, + scale: frame.backingScale, + cacheID: frame.cacheID, + alpha: alpha + ) + } else { + context.setAlpha(alpha) + super.drawGlyphs(forGlyphRange: frame.range, at: frame.origin) + } + + context.restoreGState() + } + + private func drawBlurredGlyphs( + in glyphRange: NSRange, + at origin: CGPoint, + bounds: CGRect, + radius: CGFloat, + scale: CGFloat, + cacheID: CFTimeInterval, + alpha: CGFloat + ) { + let padding = ParagraphAnimationConstants.initialCharacterBlurRadius * 4 + let imageBounds = bounds.insetBy(dx: -padding, dy: -padding) + guard imageBounds.width > 0, + imageBounds.height > 0, + let sourceImage = cachedGlyphImage( + in: glyphRange, + at: origin, + bounds: imageBounds, + scale: scale, + cacheID: cacheID + ) else { + super.drawGlyphs(forGlyphRange: glyphRange, at: origin) + return + } + + 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 + } + + #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 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 + 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() + #elseif canImport(AppKit) + NSGraphicsContext.current?.cgContext + #endif + } + + static func baselineTranslation(_ offset: CGFloat) -> CGFloat { + offset + } +} +#endif diff --git a/Sources/MarkdownText/UI/Paragraph/ParagraphAnimation.swift b/Sources/MarkdownText/UI/Paragraph/ParagraphAnimation.swift index 525d2d3..6551e28 100644 --- a/Sources/MarkdownText/UI/Paragraph/ParagraphAnimation.swift +++ b/Sources/MarkdownText/UI/Paragraph/ParagraphAnimation.swift @@ -6,15 +6,369 @@ import Foundation enum ParagraphAnimationConstants { - static let fadeInDuration: CFTimeInterval = 0.5 - static let delayBetweenWordsRatio: Double = 0.1 + static let fadeInDuration: CFTimeInterval = 0.45 + 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 = 5 + static let initialCharacterBlurRadius: CGFloat = 2 +} + +struct ParagraphSizeCacheKey: Hashable { + let width: CGFloat + let visibleUTF16Length: Int +} + +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.fadeTargetSegmentLength))) + ) + let segmentCount = min( + ParagraphAnimationConstants.maximumFadeSegmentCount, + preferredSegmentCount + ) + let ranges = segmentRanges( + in: updated, + appendedRange: appendedRange, + segmentCount: segmentCount + ) + let delayStep = 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) + } + 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 id: UUID = UUID() + 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.maximumFadeSegmentCount) + ) + } + + var endTime: CFTimeInterval { + (segments.map(\.startTime).max() ?? 0) + ParagraphAnimationConstants.fadeInDuration + } +} + +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 - let duration: 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 nextReleaseDeadline: 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 releaseDelay(at time: CFTimeInterval) -> CFTimeInterval { + guard let nextReleaseDeadline else { return 0 } + return max(0, nextReleaseDeadline - time) + } + + func update( + target newTarget: NSAttributedString, + isComplete: Bool, + at time: CFTimeInterval + ) { + let oldString = target.string + let newString = newTarget.string + + 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) + self.isComplete = isComplete + releasedUTF16Length = min(releasedUTF16Length, target.length) + pruneAnimations(at: time) + recalculatePendingGraphemeCount() + } + + func releaseNext(at time: CFTimeInterval) -> CharacterStreamingRelease? { + guard pendingGraphemeCount > 0, + nextReleaseDeadline.map({ time >= $0 }) ?? true, + 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 + nextReleaseDeadline = time + nextReleaseInterval + 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() + nextReleaseDeadline = nil + } + + func reset() { + target = NSAttributedString() + releasedUTF16Length = 0 + pendingGraphemeCount = 0 + activeAnimations.removeAll() + isComplete = false + nextReleaseDeadline = 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 { + 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) + } + let sequence = utf16.rangeOfComposedCharacterSequence(at: offset) + return sequence.location < offset ? sequence.location : offset + } +} + +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 56f3f10..c822fcf 100644 --- a/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphUIView.swift +++ b/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphUIView.swift @@ -21,14 +21,20 @@ 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 fadeAnimationDisplayLink: CADisplayLink? + private var finalAttributedText = NSAttributedString() + private var activeAnimation: FadeAnimationData? + private let characterStreamingState = CharacterStreamingState() + private var characterStreamingTimer: Timer? + 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 +47,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,7 +66,8 @@ class ParagraphUIView: UITextView { deinit { tearDownDisplayLink() - activeAnimations.removeAll() + characterStreamingTimer?.invalidate() + activeAnimation = nil } override func willMove(toWindow newWindow: UIWindow?) { @@ -57,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() + } } } @@ -99,66 +120,159 @@ class ParagraphUIView: UITextView { invalidateIntrinsicContentSize() } - func setParagraphContents(_ newContents: NSMutableAttributedString, lineSpacing: CGFloat? = nil, animatedByWord: Bool) { + func setParagraphContents( + _ newContents: NSMutableAttributedString, + lineSpacing: CGFloat? = nil, + 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 } - self.paragraphContents = newContents - self.lineSpacing = lineSpacing - let oldAttributedString: NSAttributedString = attributedText let finalString: NSMutableAttributedString if lineSpacing != nil { finalString = applyLineSpacing(to: newContents, lineSpacing: lineSpacing) } else { finalString = newContents } - - guard finalString != oldAttributedString else { + let previousAttributedText = finalAttributedText + let previousText = previousAttributedText.string + let contentsChanged = paragraphContents != newContents + || self.lineSpacing != lineSpacing + let modeChanged = self.textAnimation != textAnimation + let completionChanged = self.isStreamComplete != isStreamComplete + guard contentsChanged || modeChanged || completionChanged else { return } - // Stop display link update before updating the attributed string - tearDownDisplayLink() + 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() - - 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 + switch textAnimation { + case .none: + break + case .fade: + stopCharacterStreaming() + guard contentsChanged || modeChanged else { + invalidateIntrinsicContentSize() + return + } + attributedText = finalString + let revealPlan = contentsChanged + ? ParagraphRevealPlan.appendedText( + previousText: previousText, + newText: finalString.string ) - activeAnimations.append(animationData) + : nil + guard let revealPlan else { + activeAnimation = nil + tearDownDisplayLink() + invalidateIntrinsicContentSize() + return } + let currentTime = CACurrentMediaTime() + let previousAnimation = modeChanged ? nil : activeAnimation + activeAnimation = FadeAnimationData( + plan: revealPlan, + startTime: currentTime, + previousAnimation: previousAnimation, + contentLength: finalString.length + ) + updateTextViewWithCurrentAnimations(at: currentTime) + 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() + } + } + characterStreamingState.update( + target: finalString, + isComplete: isStreamComplete, + at: currentTime + ) + synchronizeCharacterStreamingText() + if characterStreamingTimer == nil { + releaseOneCharacter(at: currentTime) + } + } - updateTextViewWithCurrentAnimations() + invalidateIntrinsicContentSize() + } - if fadeAnimationDisplayLink == nil { - setUpDisplayLink() - } - } else { - // If no animation needed anymore, clean up all existings animations if any. - activeAnimations.removeAll() + 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 + invalidateCachedSize() } private func applyLineSpacing(to attributedString: NSMutableAttributedString, lineSpacing: CGFloat?) -> NSMutableAttributedString { @@ -203,6 +317,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 @@ -261,69 +387,144 @@ class ParagraphUIView: UITextView { } } - @objc private func updateFadeAnimation() { + @objc private func updateTextAnimation() { let currentTime = CACurrentMediaTime() - var completedAnimations: [UUID] = [] + 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() + } + } + } - updateTextViewWithCurrentAnimations() + private func updateTextViewWithCurrentAnimations(at currentTime: CFTimeInterval = CACurrentMediaTime()) { + guard let activeAnimation else { return } - // Remove completed animations - for animation in activeAnimations { - let elapsed = currentTime - animation.startTime - let progress = elapsed / animation.duration + textStorage.beginEditing() + defer { textStorage.endEditing() } - if progress >= 1.0 { - completedAnimations.append(animation.id) + for segment in activeAnimation.segments { + guard NSMaxRange(segment.range) <= textStorage.length else { + continue } + let elapsed = currentTime - segment.startTime + let progress = min(max(elapsed / ParagraphAnimationConstants.fadeInDuration, 0), 1) + applyRevealProgress(paragraphEaseOut(progress), to: segment.range) } - activeAnimations.removeAll { completedAnimations.contains($0.id) } + } - if activeAnimations.isEmpty { - tearDownDisplayLink() + 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) } } - private func updateTextViewWithCurrentAnimations() { - let currentTime = CACurrentMediaTime() - + private func restoreFinalAttributes(in ranges: [NSRange]) { textStorage.beginEditing() defer { textStorage.endEditing() } - - for animation in activeAnimations { - guard animation.range.location + animation.range.length <= textStorage.length else { - continue + for range in ranges where NSMaxRange(range) <= finalAttributedText.length { + finalAttributedText.enumerateAttributes(in: range, options: []) { attributes, attributeRange, _ in + textStorage.setAttributes(attributes, range: attributeRange) } - let elapsed = currentTime - animation.startTime - let animatedAlpha: CGFloat + } + } - 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 releaseOneCharacter( + at currentTime: CFTimeInterval = CACurrentMediaTime() + ) { + guard textAnimation == .characterStreaming else { + return + } + if characterStreamingState.releaseNext(at: currentTime) != nil { + synchronizeCharacterStreamingText() + updateCharacterStreamingAnimations(at: currentTime) + setUpDisplayLink() + } + scheduleNextCharacterRelease() + } - // 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 synchronizeCharacterStreamingText() { + attributedText = characterStreamingState.visibleAttributedText + invalidateCachedSize() + invalidateIntrinsicContentSize() + } + + private func scheduleNextCharacterRelease() { + guard textAnimation == .characterStreaming, + characterStreamingState.hasPendingGrapheme, + characterStreamingTimer == nil else { + return + } + + let timer = Timer( + timeInterval: characterStreamingState.releaseDelay( + at: CACurrentMediaTime() + ), + 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 + characterStreamingLayoutManager?.updateAnimations( + animations, + at: currentTime + ) + } + + private func stopCharacterStreaming() { + characterStreamingTimer?.invalidate() + characterStreamingTimer = nil + if supportsCharacterStreaming { + characterStreamingLayoutManager?.clearAnimations() + } + } + + private var characterStreamingLayoutManager: CharacterStreamingLayoutManager? { + layoutManager as? CharacterStreamingLayoutManager } private func setUpDisplayLink() { - 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 c1fdd34..8ea4413 100644 --- a/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphView+iOS.swift +++ b/Sources/MarkdownText/UI/Paragraph/UIKit/ParagraphView+iOS.swift @@ -10,6 +10,9 @@ struct ParagraphView: UIViewRepresentable { @Environment(\.openURL) var openURL @Environment(\.markdownConfig) var config: MarkdownRenderConfig @Environment(\.markdownController) var markdownController: MarkdownController? + @Environment(\.accessibilityReduceMotion) var reduceMotion + @Environment(\.isMarkdownStreamComplete) var isStreamComplete + @Environment(\.isMarkdownStreamingTailBranch) var isStreamingTailBranch var contents: NSMutableAttributedString var lineSpacing: CGFloat? @@ -20,26 +23,40 @@ 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: config.textAnimation == .characterStreaming + ) + view.prepareForReuse() view.onUrlTap = openUrlFunction - view.setParagraphContents(contents, lineSpacing: lineSpacing, animatedByWord: false) + view.setParagraphContents( + contents, + lineSpacing: lineSpacing, + textAnimation: resolvedAnimation, + isStreamComplete: paragraphStreamComplete + ) 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 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, + textAnimation: view.window == nil ? .none : resolvedAnimation, + isStreamComplete: paragraphStreamComplete + ) + } else { + view.setParagraphContents( + contents, + lineSpacing: lineSpacing, + textAnimation: resolvedAnimation, + isStreamComplete: paragraphStreamComplete + ) } view.setTextContextMenu(config.resolvedTextContextMenu) view.setMarkdownController(markdownController) @@ -59,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] { @@ -78,15 +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/TableView.swift b/Sources/MarkdownText/UI/TableView.swift index 84579b0..9a0ea40 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,63 @@ 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 + )) + .environment( + \.markdownConfig, + config.withTextAnimation( + tableAttachmentTextAnimation(config.textAnimation) + ) + ) + } else if config.textAnimation == .fade { + 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) + } + } +} + +func tableAttachmentTextAnimation( + _ animation: MarkdownRenderConfig.TextAnimation +) -> MarkdownRenderConfig.TextAnimation { + animation == .characterStreaming ? .none : animation } #if DEBUG 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/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 - } -} 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/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 new file mode 100644 index 0000000..adb4bd1 --- /dev/null +++ b/Tests/MarkdownTextTests/ParagraphAnimationTests.swift @@ -0,0 +1,446 @@ +// +// 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("Character Streaming") +struct ParagraphAnimationTests { + @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 initialInterval = state.nextReleaseInterval + + for index in 0..<77 { + _ = try #require(state.releaseNext(at: Double(index + 1))) + } + + #expect(initialInterval < state.nextReleaseInterval) + #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() + 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 == 5) + #expect(transform.blurRadius == 2) + } + + @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("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)) + #expect( + CharacterStreamingLayoutManager.unionRange( + clusters.map(\.range) + ) == NSRange(location: 0, length: 5) + ) + } + + @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" + 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("Reduce Motion settles Character Streaming immediately") + func reduceMotion() { + #expect( + resolvedTextAnimation(.characterStreaming, reduceMotion: false) + == .characterStreaming + ) + #expect( + resolvedTextAnimation(.characterStreaming, reduceMotion: true) + == .none + ) + #expect(resolvedTextAnimation(.fade, reduceMotion: true) == .none) + } + + @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) + + 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("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() + 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( + state.visibleAttributedText.attribute( + styleKey, + at: 0, + effectiveRange: nil + ) as? String == "updated" + ) + } + + @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.018)) + #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") + 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( + state.visibleAttributedText.attribute( + styleKey, + at: 0, + effectiveRange: nil + ) as? String == "bold-link" + ) + } + + @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 + ) + + 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 + ) + } + + @Test("Public style selection is explicit and type safe") + func styleSelection() { + let characterStreaming = MarkdownRenderConfig( + textAnimation: .characterStreaming + ) + 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: previous, + newText: updated + ) + ) + let coveredRange = try #require(plan.coveredRange) + + #expect(coveredRange.location == (previous as NSString).length) + #expect(substring(updated, in: coveredRange) == " fades in") + #expect(plan.segments.first?.delay == 0) + #expect( + plan.segments.last?.delay + == 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 { + NSAttributedString(string: text) +} + +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.018)) + #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 { + 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..1719394 100644 --- a/Tests/MarkdownTextTests/ParagraphNSViewTests.swift +++ b/Tests/MarkdownTextTests/ParagraphNSViewTests.swift @@ -21,7 +21,11 @@ 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), + textAnimation: .none, + isStreamComplete: true + ) let narrow = view.measureSize(fittingWidth: 200) let wide = view.measureSize(fittingWidth: 1000) @@ -37,9 +41,298 @@ struct ParagraphNSViewTests { @Test("Empty content measures as zero") func measuresEmptyContentAsZero() { let view = ParagraphNSView() - view.setParagraphContents(NSMutableAttributedString(string: ""), animatedByWord: false) + view.setParagraphContents( + NSMutableAttributedString(string: ""), + 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) + #expect( + view.textStorage?.attribute( + .shadow, + at: 0, + effectiveRange: nil + ) == nil + ) + + view.finishTextAnimation() + + #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("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("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") + 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) + } + + @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 + ) + ]) + + 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 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 d1d932f..7760509 100644 --- a/Tests/MarkdownTextTests/ParagraphViewTests.swift +++ b/Tests/MarkdownTextTests/ParagraphViewTests.swift @@ -192,6 +192,262 @@ 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"), + textAnimation: .none, + isStreamComplete: true + ) + + #expect(view.accessibilityLabel == "Previous paragraph") + + view.prepareForReuse() + + #expect(view.attributedText.length == 0) + #expect(view.accessibilityLabel == nil) + #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( + .shadow, + at: 0, + effectiveRange: nil + ) == nil + ) + #expect( + view.attributedText.attribute( + .link, + at: 0, + effectiveRange: nil + ) as? URL == url + ) + + view.finishTextAnimation() + + #expect(view.attributedText.string == "AB") + #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("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("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() { + 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("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) @@ -287,5 +543,73 @@ 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 + ) + ]) + + 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) + ) + } } #endif 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