diff --git a/Sources/MarkdownUI/Theme/Theme.swift b/Sources/MarkdownUI/Theme/Theme.swift index b6d9ca9b..2f22e284 100644 --- a/Sources/MarkdownUI/Theme/Theme.swift +++ b/Sources/MarkdownUI/Theme/Theme.swift @@ -118,6 +118,12 @@ public struct Theme: Sendable { /// The link style. public var link: TextStyle = EmptyTextStyle() + /// The background color for inline code views. + public var codeBackgroundColor: Color? = nil + + /// The corner radius for inline code views. + public var codeCornerRadius: CGFloat = 0 + var headings = Array( repeating: BlockStyle { $0.label }, count: 6 @@ -216,6 +222,22 @@ extension Theme { return theme } + /// Sets the background color for inline code views. + /// - Parameter color: The background color to use. + public func codeBackgroundColor(_ color: Color?) -> Theme { + var theme = self + theme.codeBackgroundColor = color + return theme + } + + /// Sets the corner radius for inline code views. + /// - Parameter radius: The corner radius to use. + public func codeCornerRadius(_ radius: CGFloat) -> Theme { + var theme = self + theme.codeCornerRadius = radius + return theme + } + /// Adds an emphasis style to the theme. /// - Parameter emphasis: A text style builder that returns the emphasis style. public func emphasis(@TextStyleBuilder emphasis: () -> S) -> Theme { @@ -464,4 +486,12 @@ extension Theme { self.text._collectAttributes(in: &attributes) return attributes.backgroundColor } + + /// The inline code background color of the theme. + public var inlineCodeBackgroundColor: Color? { + if let color = self.codeBackgroundColor { return color } + var attributes = AttributeContainer() + self.code._collectAttributes(in: &attributes) + return attributes.backgroundColor + } } diff --git a/Sources/MarkdownUI/Views/Inlines/InlineCodeView.swift b/Sources/MarkdownUI/Views/Inlines/InlineCodeView.swift new file mode 100644 index 00000000..03341f0a --- /dev/null +++ b/Sources/MarkdownUI/Views/Inlines/InlineCodeView.swift @@ -0,0 +1,28 @@ +import SwiftUI + +struct InlineCodeView: View { + let text: String + let backgroundColor: Color? + let cornerRadius: CGFloat + let textStyle: TextStyle + + var body: some View { + TextStyleAttributesReader { attributes in + let merged = self.textStyle.mergingAttributes(attributes) + if self.cornerRadius == 0 && self.backgroundColor == nil { + Text(self.text, attributes: merged) + } else { + var attributes = merged + let bg = self.backgroundColor ?? attributes.backgroundColor + attributes.backgroundColor = nil + Text(self.text, attributes: attributes) + .padding(.horizontal, 4) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: self.cornerRadius, style: .continuous) + .fill(bg ?? Color.clear) + ) + } + } + } +} diff --git a/Sources/MarkdownUI/Views/Inlines/InlineText.swift b/Sources/MarkdownUI/Views/Inlines/InlineText.swift index 9da39404..fb3a88dd 100644 --- a/Sources/MarkdownUI/Views/Inlines/InlineText.swift +++ b/Sources/MarkdownUI/Views/Inlines/InlineText.swift @@ -17,19 +17,20 @@ struct InlineText: View { var body: some View { TextStyleAttributesReader { attributes in - self.inlines.renderText( - baseURL: self.baseURL, - textStyles: .init( - code: self.theme.code, - emphasis: self.theme.emphasis, - strong: self.theme.strong, - strikethrough: self.theme.strikethrough, - link: self.theme.link - ), - images: self.inlineImages, - softBreakMode: self.softBreakMode, - attributes: attributes - ) + let views = self.renderViews(attributes: attributes) + if #available(iOS 16.0, macOS 13.0, tvOS 16.0, watchOS 9.0, *) { + FlowLayout(horizontalSpacing: 0, verticalSpacing: 0) { + ForEach(Array(views.indices), id: \.self) { index in + views[index] + } + } + } else { + HStack(spacing: 0) { + ForEach(Array(views.indices), id: \.self) { index in + views[index] + } + } + } } .task(id: self.inlines) { self.inlineImages = (try? await self.loadInlineImages()) ?? [:] @@ -60,4 +61,244 @@ struct InlineText: View { return inlineImages } } + + private func renderViews(attributes: AttributeContainer) -> [AnyView] { + var renderer = InlineViewRenderer( + baseURL: self.baseURL, + textStyles: .init( + code: self.theme.code, + emphasis: self.theme.emphasis, + strong: self.theme.strong, + strikethrough: self.theme.strikethrough, + link: self.theme.link + ), + images: self.inlineImages, + softBreakMode: self.softBreakMode, + attributes: attributes, + theme: self.theme + ) + renderer.render(self.inlines) + return renderer.result + } +} + +private struct InlineViewRenderer { + var result: [AnyView] = [] + private var pendingText: Text? + + private let baseURL: URL? + private let textStyles: InlineTextStyles + private let images: [String: Image] + private let softBreakMode: SoftBreak.Mode + private var attributes: AttributeContainer + private let theme: Theme + private var shouldSkipNextWhitespace = false + + init( + baseURL: URL?, + textStyles: InlineTextStyles, + images: [String: Image], + softBreakMode: SoftBreak.Mode, + attributes: AttributeContainer, + theme: Theme + ) { + self.baseURL = baseURL + self.textStyles = textStyles + self.images = images + self.softBreakMode = softBreakMode + self.attributes = attributes + self.theme = theme + } + + mutating func render(_ inlines: S) where S.Element == InlineNode { + for inline in inlines { self.render(inline) } + self.flushText() + } + + private mutating func render(_ inline: InlineNode) { + switch inline { + case .text(let content): + self.renderText(content) + case .softBreak: + self.renderSoftBreak() + case .lineBreak: + self.renderLineBreak() + case .code(let content): + self.flushText() + self.renderCode(content) + case .html(let content): + self.renderHTML(content) + case .emphasis(let children): + self.renderEmphasis(children: children) + case .strong(let children): + self.renderStrong(children: children) + case .strikethrough(let children): + self.renderStrikethrough(children: children) + case .link(let destination, let children): + self.renderLink(destination: destination, children: children) + case .image(let source, _): + self.flushText() + self.renderImage(source) + } + } + + private mutating func renderText(_ text: String) { + var text = text + + if self.shouldSkipNextWhitespace { + self.shouldSkipNextWhitespace = false + text = text.replacingOccurrences(of: "^\\s+", with: "", options: .regularExpression) + } + + self.appendText( + Text( + InlineNode.text(text).renderAttributedString( + baseURL: self.baseURL, + textStyles: self.textStyles, + softBreakMode: self.softBreakMode, + attributes: self.attributes + ) + ) + ) + } + + private mutating func renderSoftBreak() { + switch self.softBreakMode { + case .space where self.shouldSkipNextWhitespace: + self.shouldSkipNextWhitespace = false + case .space: + self.appendText( + Text( + InlineNode.softBreak.renderAttributedString( + baseURL: self.baseURL, + textStyles: self.textStyles, + softBreakMode: self.softBreakMode, + attributes: self.attributes + ) + ) + ) + case .lineBreak: + self.shouldSkipNextWhitespace = true + self.appendText( + Text( + InlineNode.lineBreak.renderAttributedString( + baseURL: self.baseURL, + textStyles: self.textStyles, + softBreakMode: self.softBreakMode, + attributes: self.attributes + ) + ) + ) + } + } + + private mutating func renderLineBreak() { + self.appendText( + Text( + InlineNode.lineBreak.renderAttributedString( + baseURL: self.baseURL, + textStyles: self.textStyles, + softBreakMode: self.softBreakMode, + attributes: self.attributes + ) + ) + ) + } + + private mutating func renderCode(_ code: String) { + let view = InlineCodeView( + text: code, + backgroundColor: self.theme.codeBackgroundColor ?? self.attributes.backgroundColor, + cornerRadius: self.theme.codeCornerRadius, + textStyle: self.textStyles.code + ) + self.result.append(AnyView(view)) + } + + private mutating func renderHTML(_ html: String) { + let tag = HTMLTag(html) + + switch tag?.name.lowercased() { + case "br": + self.appendText( + Text( + InlineNode.lineBreak.renderAttributedString( + baseURL: self.baseURL, + textStyles: self.textStyles, + softBreakMode: self.softBreakMode, + attributes: self.attributes + ) + ) + ) + self.shouldSkipNextWhitespace = true + default: + self.renderText(html) + } + } + + private mutating func renderEmphasis(children: [InlineNode]) { + let savedAttributes = self.attributes + self.attributes = self.textStyles.emphasis.mergingAttributes(self.attributes) + + for child in children { + self.render(child) + } + + self.attributes = savedAttributes + } + + private mutating func renderStrong(children: [InlineNode]) { + let savedAttributes = self.attributes + self.attributes = self.textStyles.strong.mergingAttributes(self.attributes) + + for child in children { + self.render(child) + } + + self.attributes = savedAttributes + } + + private mutating func renderStrikethrough(children: [InlineNode]) { + let savedAttributes = self.attributes + self.attributes = self.textStyles.strikethrough.mergingAttributes(self.attributes) + + for child in children { + self.render(child) + } + + self.attributes = savedAttributes + } + + private mutating func renderLink(destination: String, children: [InlineNode]) { + let savedAttributes = self.attributes + self.attributes = self.textStyles.link.mergingAttributes(self.attributes) + self.attributes.link = URL(string: destination, relativeTo: self.baseURL) + + for child in children { + self.render(child) + } + + self.attributes = savedAttributes + } + + private mutating func renderImage(_ source: String) { + if let image = self.images[source] { + self.result.append(AnyView(image)) + } + } + + private mutating func appendText(_ text: Text) { + if let current = self.pendingText { + self.pendingText = current + text + } else { + self.pendingText = text + } + } + + private mutating func flushText() { + if let text = self.pendingText { + self.result.append(AnyView(text)) + self.pendingText = nil + } + } }