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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,26 @@ let package = Package(
.library(name: "EuforicAI", targets: ["EuforicAI"]),
],
dependencies: [
.package(url: "https://github.com/gonzalezreal/swift-markdown-ui", from: "2.4.1"),
.package(
url: "https://github.com/LiYanan2004/MarkdownView.git",
from: "3.0.0",
traits: []
),
],
targets: [
.target(name: "Streamdown"),
.target(
name: "StreamdownUI",
dependencies: [
"Streamdown",
.product(name: "MarkdownUI", package: "swift-markdown-ui"),
.product(name: "MarkdownView", package: "MarkdownView"),
]
),
.target(
name: "EuforicAI",
dependencies: [
"Streamdown",
"StreamdownUI",
.product(name: "MarkdownUI", package: "swift-markdown-ui"),
]
),
.testTarget(
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Add the package to your `Package.swift`:
Three targets are available:

- **Streamdown** — parsing only, no UI dependencies
- **StreamdownUI** — SwiftUI views, depends on [MarkdownUI](https://github.com/gonzalezreal/swift-markdown-ui)
- **StreamdownUI** — SwiftUI views, uses [MarkdownView](https://github.com/LiYanan2004/MarkdownView) for prose blocks
- **EuforicAI** — full chat UI component library (design system, message views, conversation views, panels), depends on Streamdown + StreamdownUI

```swift
Expand Down Expand Up @@ -271,7 +271,7 @@ Sources/
│ ├── StreamdownParsedBlock.swift # Block + character range
│ └── StreamdownTableData.swift # Export (CSV/TSV/Markdown)
│
└── StreamdownUI/ # SwiftUI views — depends on Streamdown + MarkdownUI
└── StreamdownUI/ # SwiftUI views — depends on Streamdown + MarkdownView
├── StreamdownView.swift # Main entry point
├── StreamdownControls.swift # Toolbar config
├── StreamdownTheme.swift # Theme + environment key
Expand All @@ -297,7 +297,7 @@ The test suite covers parsing (code fences, HTML blocks, tables, math blocks, mi
## Acknowledgments

- Inspired by [vercel/streamdown](https://github.com/nicepkg/streamdown)
- Markdown rendering powered by [MarkdownUI](https://github.com/gonzalezreal/swift-markdown-ui)
- Prose-block rendering powered by [MarkdownView](https://github.com/LiYanan2004/MarkdownView); Streamdown retains streaming parsing, block reuse, code, and table rendering

## License

Expand Down
15 changes: 0 additions & 15 deletions Sources/EuforicAI/Utilities/MarkdownTheme.swift

This file was deleted.

4 changes: 1 addition & 3 deletions Sources/StreamdownUI/CodeBlockRenderState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ import SwiftUI

@MainActor
final class CodeBlockRenderState: ObservableObject {
private static let defaultMaxDisplayedLines = 400

private struct DisplayWindow: Sendable {
let displayedLineStart: Int
let lineTexts: [String]
Expand Down Expand Up @@ -41,7 +39,7 @@ final class CodeBlockRenderState: ObservableObject {
init(
code: String,
language: String?,
maxDisplayedLines: Int = defaultMaxDisplayedLines,
maxDisplayedLines: Int = 400,
foreground: Color = Color(white: 0.93),
secondaryLabel: Color = Color(white: 0.55)
) {
Expand Down
3 changes: 2 additions & 1 deletion Sources/StreamdownUI/StreamdownLinkSafetyModal.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public struct StreamdownLinkSafetyModal: View {
let onOpen: () -> Void

@Environment(\.streamdownTheme) private var theme
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var copied = false

public init(
Expand Down Expand Up @@ -116,7 +117,7 @@ public struct StreamdownLinkSafetyModal: View {
.frame(maxWidth: 420)
}
.transition(.opacity)
.animation(.easeInOut(duration: 0.15), value: isPresented)
.animation(reduceMotion ? nil : .easeInOut(duration: 0.15), value: isPresented)
}

private func copyLink() {
Expand Down
4 changes: 1 addition & 3 deletions Sources/StreamdownUI/StreamdownRenderActor.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
@preconcurrency import MarkdownUI
import Foundation
import Streamdown
import SwiftUI
Expand Down Expand Up @@ -103,8 +102,7 @@ public actor StreamdownRenderActor {
return .markdown(
StreamdownMarkdownRenderBlock(
id: blockID,
source: source,
content: ParsedMarkdownContent(value: MarkdownContent(source))
source: source
)
)
case let .code(language, code, startLine, isIncomplete):
Expand Down
10 changes: 0 additions & 10 deletions Sources/StreamdownUI/StreamdownRenderTypes.swift
Original file line number Diff line number Diff line change
@@ -1,16 +1,7 @@
@preconcurrency import MarkdownUI
import Foundation
import Streamdown
import SwiftUI

struct ParsedMarkdownContent: @unchecked Sendable {
let value: MarkdownContent

var plainText: String {
value.renderPlainText()
}
}

struct StreamdownInlineContent: Sendable {
let attributed: AttributedString

Expand All @@ -22,7 +13,6 @@ struct StreamdownInlineContent: Sendable {
struct StreamdownMarkdownRenderBlock: Identifiable, Sendable {
let id: String
let source: String
let content: ParsedMarkdownContent
}

struct StreamdownCodeRenderBlock: Identifiable, Sendable {
Expand Down
43 changes: 21 additions & 22 deletions Sources/StreamdownUI/StreamdownView.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import SwiftUI
@preconcurrency import MarkdownUI
import MarkdownView
import Streamdown

#if canImport(UIKit)
Expand All @@ -8,22 +8,6 @@ import UIKit
import AppKit
#endif

// MARK: - MarkdownUI Theme Extension

extension Theme {
@MainActor static func streamdownHighlighted(theme: StreamdownTheme) -> Theme {
.gitHub.text {
ForegroundColor(.primary)
}
.codeBlock { configuration in
StreamdownCodeBlockContent(
language: configuration.language,
code: configuration.content
)
}
}
}

// MARK: - StreamdownView

public struct StreamdownAnimationConfig: Sendable {
Expand All @@ -50,6 +34,7 @@ public struct StreamdownView: View {
let animation: StreamdownAnimationConfig

@Environment(\.streamdownTheme) private var theme
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var modalURL: URL?
@State private var modalIsPresented: Bool = false
@StateObject private var renderModel = StreamdownRenderModel()
Expand Down Expand Up @@ -133,9 +118,18 @@ public struct StreamdownView: View {
tableBlock(table)
}
}
.opacity(visibleBlockIDs.contains(block.id) ? 1 : (animation.enabled && isStreaming ? 0 : 1))
.opacity(
visibleBlockIDs.contains(block.id)
? 1
: (animation.enabled && isStreaming && !reduceMotion ? 0 : 1)
)
.onAppear {
guard animation.enabled, isStreaming, !visibleBlockIDs.contains(block.id) else {
guard
animation.enabled,
isStreaming,
!reduceMotion,
!visibleBlockIDs.contains(block.id)
else {
visibleBlockIDs.insert(block.id)
return
}
Expand Down Expand Up @@ -168,12 +162,17 @@ public struct StreamdownView: View {
#endif
}
}
.animation(mode == .streaming ? nil : .easeInOut(duration: 0.2), value: renderModel.snapshot.blocks.map(\.id))
.animation(
mode == .streaming || !animation.enabled || reduceMotion
? nil
: .easeInOut(duration: 0.2),
value: renderModel.snapshot.blocks.map(\.id)
)
}

private func markdownBlock(_ block: StreamdownMarkdownRenderBlock) -> some View {
Markdown(block.content.value)
.markdownTheme(.streamdownHighlighted(theme: theme))
MarkdownView(block.source)
.foregroundStyle(theme.colors.foreground)
.textSelection(.enabled)
.environment(\.openURL, OpenURLAction { url in
systemOpenHandler(url)
Expand Down
37 changes: 37 additions & 0 deletions Tests/StreamdownUITests/StreamdownUIStateTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,43 @@ import Testing
#expect(model.snapshot.blocks.count == 1)
}

@Test func renderActorKeepsMarkdownSourceAndReusesStableBlocks() async {
let actor = StreamdownRenderActor()
let initialContent = """
Intro with **emphasis**.

```swift
let value = 1
```

Tail
"""
let initial = await actor.renderSnapshot(
content: initialContent,
mode: .streaming,
parseIncompleteMarkdown: true,
normalizeHtmlIndentation: false,
previous: nil
)

guard case let .markdown(intro) = initial.blocks.first else {
Issue.record("Expected the first rendered block to remain markdown")
return
}
#expect(intro.source == "Intro with **emphasis**.\n")

let updated = await actor.renderSnapshot(
content: initialContent + " extended",
mode: .streaming,
parseIncompleteMarkdown: true,
normalizeHtmlIndentation: false,
previous: initial
)

#expect(updated.reusedBlockCount == 2)
#expect(updated.reusedRenderedBlockCount == 2)
}

@MainActor
@Test func codeBlockStatePublishesUpdatedContent() async {
let state = CodeBlockRenderState(code: "let value = 1", language: "swift")
Expand Down
Loading