Skip to content

Use FlowLayout for inline text #2

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
30 changes: 30 additions & 0 deletions Sources/MarkdownUI/Theme/Theme.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<BlockConfiguration> { $0.label },
count: 6
Expand Down Expand Up @@ -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<S: TextStyle>(@TextStyleBuilder emphasis: () -> S) -> Theme {
Expand Down Expand Up @@ -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
}
}
28 changes: 28 additions & 0 deletions Sources/MarkdownUI/Views/Inlines/InlineCodeView.swift
Original file line number Diff line number Diff line change
@@ -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)
)
}
}
}
}
267 changes: 254 additions & 13 deletions Sources/MarkdownUI/Views/Inlines/InlineText.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()) ?? [:]
Expand Down Expand Up @@ -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<S: Sequence>(_ 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
}
}
}