Skip to content
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
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ final class RendererFactory {
return CodeBlockRenderer(factory: self, config: config)
case .blockquote:
return BlockquoteRenderer(factory: self, config: config)
case .unorderedList:
return ListRenderer(factory: self, config: config, isOrdered: false)
case .orderedList:
return ListRenderer(factory: self, config: config, isOrdered: true)
case .listItem:
return ListItemRenderer(factory: self, config: config)
default:
return nil
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import UIKit

final class ListItemRenderer: NodeRenderer {
private let factory: RendererFactory
private let config: MarkdownStyleConfig

init(factory: RendererFactory, config: MarkdownStyleConfig) {
self.factory = factory
self.config = config
}

func render(node: MarkdownASTNode, into output: NSMutableAttributedString, context: RenderContext) {
context.listItemNumber += 1
let currentPosition = context.listItemNumber
let currentDepth = context.listDepth
let nestingLevel = currentDepth - 1

let startLocation = output.length
factory.renderChildren(of: node, into: output, context: context)
ParagraphStyleHelpers.ensureTrailingNewline(in: output)

let itemRange = NSRange(location: startLocation, length: output.length - startLocation)
guard itemRange.length > 0 else { return }

let baseMarkerWidth = effectiveMarkerWidth(for: context.listType)
let gapWidth = max(config.list.gapWidth ?? 12, 4)
let marginLeft = config.list.marginLeft ?? 24
let totalIndent = baseMarkerWidth + gapWidth + (CGFloat(nestingLevel) * marginLeft)
let lineHeight = config.list.lineHeight ?? 0

let metadata: [NSAttributedString.Key: Any] = [
MarkdownAttribute.listDepth: nestingLevel,
MarkdownAttribute.listType: context.listType.rawValue,
MarkdownAttribute.listItemNumber: currentPosition
]

applyListItemStyling(
to: output,
itemRange: itemRange,
nestingLevel: nestingLevel,
metadata: metadata,
totalIndent: totalIndent,
lineHeight: lineHeight
)
}

private func effectiveMarkerWidth(for listType: ListType) -> CGFloat {
let minWidth = config.list.markerMinWidth ?? 0
switch listType {
case .ordered:
return max(minWidth, 20)
case .unordered:
return max(minWidth, config.list.bulletSize ?? 6)
}
}

private func applyListItemStyling(
to output: NSMutableAttributedString,
itemRange: NSRange,
nestingLevel: Int,
metadata: [NSAttributedString.Key: Any],
totalIndent: CGFloat,
lineHeight: CGFloat
) {
let string = output.string as NSString
var location = itemRange.location
let end = NSMaxRange(itemRange)

while location < end {
let paragraphRange = string.paragraphRange(for: NSRange(location: location, length: 0))
let applyRange = NSIntersectionRange(paragraphRange, itemRange)
guard applyRange.length > 0 else { break }

if shouldSkipListStyling(in: output, range: applyRange, nestingLevel: nestingLevel) {
location = NSMaxRange(applyRange)
continue
}

let style = NSMutableParagraphStyle()
style.firstLineHeadIndent = totalIndent
style.headIndent = totalIndent

if lineHeight > 0 {
style.minimumLineHeight = lineHeight
style.maximumLineHeight = lineHeight
}

var attributes = metadata
attributes[.paragraphStyle] = style
output.addAttributes(attributes, range: applyRange)

if lineHeight > 0 {
ParagraphStyleHelpers.applyBaselineOffset(to: output, range: applyRange)
}

location = NSMaxRange(applyRange)
}
}

private func shouldSkipListStyling(
in output: NSMutableAttributedString,
range: NSRange,
nestingLevel: Int
) -> Bool {
if let depth = MarkdownAttributeValue.intValue(
from: output.attribute(MarkdownAttribute.listDepth, at: range.location, effectiveRange: nil)
), depth > nestingLevel {
return true
}

if MarkdownAttributeValue.boolValue(
from: output.attribute(MarkdownAttribute.codeBlock, at: range.location, effectiveRange: nil)
) {
return true
}

return false
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import UIKit

final class ListRenderer: NodeRenderer {
private let factory: RendererFactory
private let config: MarkdownStyleConfig
private let isOrdered: Bool

init(factory: RendererFactory, config: MarkdownStyleConfig, isOrdered: Bool) {
self.factory = factory
self.config = config
self.isOrdered = isOrdered
}

func render(node: MarkdownASTNode, into output: NSMutableAttributedString, context: RenderContext) {
let prevDepth = context.listDepth
let prevType = context.listType
let prevNumber = context.listItemNumber
let startLocation = output.length

if prevDepth == 0 {
ParagraphStyleHelpers.ensureStartingOnNewLine(in: output)
_ = ParagraphStyleHelpers.applyBlockSpacingBefore(
to: output,
at: startLocation,
marginTop: config.list.marginTop ?? 0
)
} else if output.length > 0, !output.string.hasSuffix("\n") {
output.append(ParagraphStyleHelpers.newline)
}

context.listDepth = prevDepth + 1
context.listType = isOrdered ? .ordered : .unordered
context.listItemNumber = 0

let font = config.list.font ?? UIFont.preferredFont(forTextStyle: .body)
let color = config.list.foregroundColor ?? UIColor.label
context.setBlockStyle(
font: font,
color: color,
blockType: isOrdered ? .orderedList : .unorderedList
)

factory.renderChildren(of: node, into: output, context: context)

context.listDepth = prevDepth
context.listType = prevType
context.listItemNumber = prevNumber

if prevDepth == 0 {
context.clearBlockStyle()
if let marginBottom = config.list.marginBottom, marginBottom > 0 {
ParagraphStyleHelpers.applyBlockSpacingAfter(to: output, marginBottom: marginBottom)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,15 @@ enum DefaultMarkdownTheme {
.borderWidth(3)
.gapWidth(16)
.marginBottom(16)

List()
.font(.body)
.foregroundStyle(Semantic.primary)
.bulletColor(Semantic.secondary)
.markerColor(Semantic.secondary)
.gapWidth(12)
.marginLeft(24)
.marginBottom(16)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import SwiftUI

public struct List: MarkdownThemeElement {
public var fontSpec: ThemeFontSpec?
public var fontWeight: Font.Weight?
public var fontDesign: Font.Design?
public var foregroundColorSpec: ThemeColorSpec?
public var bulletColorSpec: ThemeColorSpec?
public var markerColorSpec: ThemeColorSpec?
public var marginTop: CGFloat?
public var marginBottom: CGFloat?
public var lineHeight: CGFloat?
public var textAlignment: TextAlignment?
public var marginLeft: CGFloat?
public var gapWidth: CGFloat?
public var bulletSize: CGFloat?
public var markerMinWidth: CGFloat?

public init() {}

public func bulletColor(_ color: Color) -> Self {
var copy = self
copy.bulletColorSpec = ThemeColorModifiers.spec(from: color)
return copy
}

public func bulletColor(_ semantic: ThemeColorSpec.SemanticColor) -> Self {
var copy = self
copy.bulletColorSpec = ThemeColorModifiers.spec(from: semantic)
return copy
}

public func markerColor(_ color: Color) -> Self {
var copy = self
copy.markerColorSpec = ThemeColorModifiers.spec(from: color)
return copy
}

public func markerColor(_ semantic: ThemeColorSpec.SemanticColor) -> Self {
var copy = self
copy.markerColorSpec = ThemeColorModifiers.spec(from: semantic)
return copy
}

public func marginLeft(_ value: CGFloat) -> Self {
var copy = self
copy.marginLeft = value
return copy
}

public func gapWidth(_ value: CGFloat) -> Self {
var copy = self
copy.gapWidth = value
return copy
}

public func bulletSize(_ value: CGFloat) -> Self {
var copy = self
copy.bulletSize = value
return copy
}

public func markerMinWidth(_ value: CGFloat) -> Self {
var copy = self
copy.markerMinWidth = value
return copy
}

public func apply(to config: inout MarkdownStyleConfig, traitCollection: UITraitCollection) {
applyElementStyle(to: &config.list, traitCollection: traitCollection)
if let bulletColorSpec {
config.list.bulletColor = bulletColorSpec.resolve(traitCollection: traitCollection)
}
if let markerColorSpec {
config.list.markerColor = markerColorSpec.resolve(traitCollection: traitCollection)
}
if let marginLeft { config.list.marginLeft = marginLeft }
if let gapWidth { config.list.gapWidth = gapWidth }
if let bulletSize { config.list.bulletSize = bulletSize }
if let markerMinWidth { config.list.markerMinWidth = markerMinWidth }
}

private func applyElementStyle(to style: inout ListStyle, traitCollection: UITraitCollection) {
if fontSpec != nil || fontWeight != nil || fontDesign != nil {
style.font = ThemeResolver.applyFont(
spec: fontSpec,
weight: fontWeight,
design: fontDesign,
to: style.font,
traitCollection: traitCollection
)
}
if let foregroundColorSpec {
style.foregroundColor = foregroundColorSpec.resolve(traitCollection: traitCollection)
}
if let marginTop { style.marginTop = marginTop }
if let marginBottom { style.marginBottom = marginBottom }
if let lineHeight { style.lineHeight = lineHeight }
}
}
Loading