* Add dynamic color extension

* Add a basic theme

* Add DocC theme

* Fix block spacing logic

* Update theme initializer

* Add DocC theme tests

* Add GitHub theme
This commit is contained in:
Guille Gonzalez
2022-12-12 21:05:32 +01:00
committed by GitHub
parent 0838208319
commit c91729dd98
52 changed files with 976 additions and 310 deletions

View File

@@ -0,0 +1,41 @@
import SwiftUI
extension Color {
public init(rgba: UInt32) {
self.init(
red: CGFloat((rgba & 0xff00_0000) >> 24) / 255.0,
green: CGFloat((rgba & 0x00ff_0000) >> 16) / 255.0,
blue: CGFloat((rgba & 0x0000_ff00) >> 8) / 255.0,
opacity: CGFloat(rgba & 0x0000_00ff) / 255.0
)
}
public init(light: @escaping @autoclosure () -> Color, dark: @escaping @autoclosure () -> Color) {
#if os(macOS)
self.init(
nsColor: .init(name: nil) { appearance in
if appearance.bestMatch(from: [.aqua, .darkAqua]) == .aqua {
return NSColor(light())
} else {
return NSColor(dark())
}
}
)
#elseif os(iOS) || os(tvOS)
self.init(
uiColor: .init { traitCollection in
switch traitCollection.userInterfaceStyle {
case .unspecified, .light:
return UIColor(light())
case .dark:
return UIColor(dark())
@unknown default:
return UIColor(light())
}
}
)
#elseif os(watchOS)
self.init(uiColor: dark())
#endif
}
}

View File

@@ -39,8 +39,16 @@ extension Size {
}
extension View {
public func frame(
width: Size? = nil,
height: Size? = nil,
alignment: Alignment = .center
) -> some View {
self.modifier(FrameModifier(width: width, height: height, alignment: alignment))
}
public func frame(minWidth: Size, alignment: Alignment = .center) -> some View {
self.modifier(FrameModifier(minWidth: minWidth, alignment: alignment))
self.modifier(FrameModifier2(minWidth: minWidth, alignment: alignment))
}
public func padding(_ edges: Edge.Set = .all, _ length: Size) -> some View {
@@ -55,6 +63,22 @@ extension View {
private struct FrameModifier: ViewModifier {
@Environment(\.fontStyle) private var fontStyle
let width: Size?
let height: Size?
let alignment: Alignment
func body(content: Content) -> some View {
content.frame(
width: self.width?.points(relativeTo: self.fontStyle),
height: self.height?.points(relativeTo: self.fontStyle),
alignment: self.alignment
)
}
}
private struct FrameModifier2: ViewModifier {
@Environment(\.fontStyle) private var fontStyle
let minWidth: Size
let alignment: Alignment

View File

@@ -19,90 +19,7 @@ public struct BlockStyle {
}
extension BlockStyle {
public static var defaultImage: BlockStyle {
self.default
}
public static func defaultImage(alignment: HorizontalAlignment) -> BlockStyle {
BlockStyle { label in
ZStack {
label
}
.frame(maxWidth: .infinity, alignment: .init(horizontal: alignment, vertical: .center))
}
}
public static var `default`: BlockStyle {
BlockStyle { label in
label
.lineSpacing(.em(0.15))
}
}
public static var defaultBlockquote: BlockStyle {
BlockStyle { label in
label
.markdownFontStyle {
$0.italic()
}
.padding(.leading, .em(2))
.padding(.trailing, .em(1))
}
}
public static var defaultCodeBlock: BlockStyle {
BlockStyle { label in
label.markdownFontStyle {
$0.monospaced()
}
.padding(.leading, .em(1))
.lineSpacing(.em(0.15))
}
}
public static var defaultHeading1: BlockStyle {
defaultHeading(level: 1)
}
public static var defaultHeading2: BlockStyle {
defaultHeading(level: 2)
}
public static var defaultHeading3: BlockStyle {
defaultHeading(level: 3)
}
public static var defaultHeading4: BlockStyle {
defaultHeading(level: 4)
}
public static var defaultHeading5: BlockStyle {
defaultHeading(level: 5)
}
public static var defaultHeading6: BlockStyle {
defaultHeading(level: 6)
}
private static func defaultHeading(level: Int) -> BlockStyle {
BlockStyle { label in
label.markdownFontStyle {
$0.bold()
.size(headingSizes[level - 1])
}
.markdownBlockSpacing(top: .rem(1.5))
}
}
public static var defaultThematicBreak: BlockStyle {
BlockStyle { _ in
Divider()
.markdownBlockSpacing(top: .rem(2), bottom: .rem(2))
}
public static var unit: BlockStyle {
BlockStyle { $0 }
}
}
private let headingSizes: [Size] = [
.em(2), .em(1.5), .em(1.17),
.em(1), .em(0.83), .em(0.67),
]

View File

@@ -56,7 +56,7 @@ extension FontStyle: Hashable {
}
extension FontStyle {
public static var `default`: FontStyle {
public static var body: FontStyle {
#if os(macOS)
return .system(size: 13)
#elseif os(iOS)

View File

@@ -1,31 +1,27 @@
import SwiftUI
public struct InlineStyle {
var update: (inout AttributeContainer) -> Void
var transform: (inout AttributeContainer) -> Void
public init(update: @escaping (inout AttributeContainer) -> Void) {
self.update = update
public init(transform: @escaping (_ attributes: inout AttributeContainer) -> Void) {
self.transform = transform
}
func updating(_ attributes: AttributeContainer) -> AttributeContainer {
func transforming(_ attributes: AttributeContainer) -> AttributeContainer {
var newAttributes = attributes
update(&newAttributes)
transform(&newAttributes)
return newAttributes
}
}
extension InlineStyle {
public static var `default`: Self {
public static var unit: Self {
.init { _ in }
}
public static var monospaced: Self {
.monospaced()
}
public static func monospaced(backgroundColor: Color? = nil) -> Self {
public static func monospaced(size: Size = .em(1), backgroundColor: Color? = nil) -> Self {
.init { attributes in
attributes.fontStyle = attributes.fontStyle?.monospaced()
attributes.fontStyle = attributes.fontStyle?.monospaced().size(size)
attributes.backgroundColor = backgroundColor
}
}
@@ -36,13 +32,6 @@ extension InlineStyle {
}
}
public static var italicUnderline: Self {
.init { attributes in
attributes.fontStyle = attributes.fontStyle?.italic()
attributes.underlineStyle = .single
}
}
public static var bold: Self {
.init { attributes in
attributes.fontStyle = attributes.fontStyle?.bold()
@@ -61,16 +50,9 @@ extension InlineStyle {
}
}
public static var redacted: Self {
public static func foregroundColor(_ color: Color) -> Self {
.init { attributes in
attributes.foregroundColor = .primary
attributes.backgroundColor = .primary
}
}
public static var underlineDot: Self {
.init { attributes in
attributes.underlineStyle = .init(pattern: .dot)
attributes.foregroundColor = color
}
}
}

View File

@@ -81,23 +81,23 @@ extension ListMarkerStyle where Configuration == ListItemConfiguration {
}
}
private struct Bullet: View {
public struct Bullet: View {
@Environment(\.fontStyle.size) private var fontSize
private let image: SwiftUI.Image
var body: some View {
public var body: some View {
image.font(.system(size: round(fontSize / 3)))
}
static var disc: Bullet {
public static var disc: Bullet {
.init(image: .init(systemName: "circle.fill"))
}
static var circle: Bullet {
public static var circle: Bullet {
.init(image: .init(systemName: "circle"))
}
static var square: Bullet {
public static var square: Bullet {
.init(image: .init(systemName: "square.fill"))
}
}
@@ -105,16 +105,3 @@ private struct Bullet: View {
public struct TaskListItemConfiguration {
public var isCompleted: Bool
}
extension ListMarkerStyle where Configuration == TaskListItemConfiguration {
// MARK: - Tasks
public static var checkmarkSquareFill: ListMarkerStyle {
ListMarkerStyle { configuration in
SwiftUI.Image(systemName: configuration.isCompleted ? "checkmark.square.fill" : "square")
.symbolRenderingMode(.hierarchical)
.imageScale(.small)
.frame(minWidth: .em(1.5), alignment: .trailing)
}
}
}

View File

@@ -2,22 +2,16 @@ import SwiftUI
public struct TableBorderStyle {
let border: TableBorder
let style: AnyShapeStyle
let color: Color
let strokeStyle: StrokeStyle
public init<S: ShapeStyle>(_ border: TableBorder = .all, style: S, strokeStyle: StrokeStyle) {
public init(_ border: TableBorder = .all, color: Color, strokeStyle: StrokeStyle) {
self.border = border
self.style = .init(style)
self.color = color
self.strokeStyle = strokeStyle
}
public init<S: ShapeStyle>(_ border: TableBorder = .all, style: S, width: CGFloat = 1) {
self.init(border, style: style, strokeStyle: .init(lineWidth: width))
}
}
extension TableBorderStyle {
public static var `default`: TableBorderStyle {
.init(style: Color.secondary)
public init(_ border: TableBorder = .all, color: Color, width: CGFloat = 1) {
self.init(border, color: color, strokeStyle: .init(lineWidth: width))
}
}

View File

@@ -14,7 +14,7 @@ public struct TableCellBackgroundStyle {
}
extension TableCellBackgroundStyle {
public static var `default`: TableCellBackgroundStyle {
public static var clear: TableCellBackgroundStyle {
TableCellBackgroundStyle { _ in
Color.clear
}

View File

@@ -21,16 +21,3 @@ public struct TableCellStyle {
self.makeBody = { AnyView(makeBody($0)) }
}
}
extension TableCellStyle {
public static var `default`: TableCellStyle {
TableCellStyle { configuration in
configuration.label
.markdownFontStyle { fontStyle in
configuration.row == 0 ? fontStyle.bold() : fontStyle
}
.padding(.horizontal, .em(0.72))
.padding(.vertical, .em(0.35))
}
}
}

View File

@@ -0,0 +1,74 @@
import SwiftUI
extension Theme {
public static let basic = Theme(
code: .monospaced(size: .em(0.94)),
heading1: BlockStyle { label in
label
.markdownBlockSpacing(top: .rem(1.5), bottom: .rem(1))
.markdownFontStyle { $0.bold().size(.em(2)) }
},
heading2: BlockStyle { label in
label
.markdownBlockSpacing(top: .rem(1.5), bottom: .rem(1))
.markdownFontStyle { $0.bold().size(.em(1.5)) }
},
heading3: BlockStyle { label in
label
.markdownBlockSpacing(top: .rem(1.5), bottom: .rem(1))
.markdownFontStyle { $0.bold().size(.em(1.17)) }
},
heading4: BlockStyle { label in
label
.markdownBlockSpacing(top: .rem(1.5), bottom: .rem(1))
.markdownFontStyle { $0.bold().size(.em(1)) }
},
heading5: BlockStyle { label in
label
.markdownBlockSpacing(top: .rem(1.5), bottom: .rem(1))
.markdownFontStyle { $0.bold().size(.em(0.83)) }
},
heading6: BlockStyle { label in
label
.markdownBlockSpacing(top: .rem(1.5), bottom: .rem(1))
.markdownFontStyle { $0.bold().size(.em(0.67)) }
},
paragraph: BlockStyle { label in
label
.lineSpacing(.em(0.15))
.markdownBlockSpacing(top: .zero, bottom: .em(1))
},
blockquote: BlockStyle { label in
label
.markdownFontStyle { $0.italic() }
.padding(.leading, .em(2))
.padding(.trailing, .em(1))
},
codeBlock: BlockStyle { label in
label.markdownFontStyle { $0.monospaced().size(.em(0.94)) }
.padding(.leading, .em(1))
.lineSpacing(.em(0.15))
.markdownBlockSpacing(top: .zero, bottom: .em(1))
},
taskListMarker: ListMarkerStyle { configuration in
SwiftUI.Image(systemName: configuration.isCompleted ? "checkmark.square.fill" : "square")
.symbolRenderingMode(.hierarchical)
.imageScale(.small)
.frame(minWidth: .em(1.5), alignment: .trailing)
},
table: BlockStyle { label in
label.markdownBlockSpacing(top: .zero, bottom: .em(1))
},
tableBorder: TableBorderStyle(color: .secondary),
tableCell: TableCellStyle { configuration in
configuration.label
.markdownFontStyle { configuration.row == 0 ? $0.bold() : $0 }
.lineSpacing(.em(0.15))
.padding(.horizontal, .em(0.72))
.padding(.vertical, .em(0.35))
},
thematicBreak: BlockStyle { _ in
Divider().markdownBlockSpacing(top: .em(2), bottom: .em(2))
}
)
}

View File

@@ -0,0 +1,132 @@
import SwiftUI
extension Theme {
public static let docC = Theme(
textColor: .text,
link: .foregroundColor(.link),
heading1: BlockStyle { label in
label
.markdownBlockSpacing(top: .em(0.8), bottom: .zero)
.markdownFontStyle { $0.bold().size(.em(2)) }
},
heading2: BlockStyle { label in
label
.lineSpacing(.em(0.0625))
.markdownBlockSpacing(top: .em(1.6), bottom: .zero)
.markdownFontStyle { $0.bold().size(.em(1.88235)) }
},
heading3: BlockStyle { label in
label
.lineSpacing(.em(0.07143))
.markdownBlockSpacing(top: .em(1.6), bottom: .zero)
.markdownFontStyle { $0.bold().size(.em(1.64706)) }
},
heading4: BlockStyle { label in
label
.lineSpacing(.em(0.083335))
.markdownBlockSpacing(top: .em(1.6), bottom: .zero)
.markdownFontStyle { $0.bold().size(.em(1.41176)) }
},
heading5: BlockStyle { label in
label
.lineSpacing(.em(0.09091))
.markdownBlockSpacing(top: .em(1.6), bottom: .zero)
.markdownFontStyle { $0.bold().size(.em(1.29412)) }
},
heading6: BlockStyle { label in
label
.lineSpacing(.em(0.235295))
.markdownBlockSpacing(top: .em(1.6), bottom: .zero)
.markdownFontStyle { $0.bold() }
},
paragraph: BlockStyle { label in
label
.lineSpacing(.em(0.235295))
.markdownBlockSpacing(top: .em(0.8), bottom: .zero)
},
blockquote: BlockStyle { label in
label
.padding(.all, .rem(0.94118))
.frame(maxWidth: .infinity, alignment: .leading)
.background {
ZStack {
RoundedRectangle.container
.fill(Color.asideNoteBackground)
RoundedRectangle.container
.strokeBorder(Color.asideNoteBorder)
}
}
.markdownBlockSpacing(top: .em(1.6), bottom: .zero)
},
codeBlock: BlockStyle { label in
ScrollView(.horizontal) {
label
.lineSpacing(.em(0.333335))
.markdownFontStyle { $0.monospaced().size(.rem(0.88235)) }
.padding(.vertical, 8)
.padding(.leading, 14)
}
.background(Color.codeBackground)
.clipShape(.container)
.markdownBlockSpacing(top: .em(0.8), bottom: .zero)
},
image: BlockStyle { label in
label
.frame(maxWidth: .infinity)
.markdownBlockSpacing(top: .em(1.6), bottom: .em(1.6))
},
listItem: BlockStyle { label in
label.markdownBlockSpacing(top: .em(0.8))
},
taskListMarker: ListMarkerStyle { _ in
// DocC renders task lists as bullet lists
Bullet.disc
.frame(minWidth: .em(1.5), alignment: .trailing)
},
table: BlockStyle { label in
label.markdownBlockSpacing(top: .em(1.6), bottom: .zero)
},
tableBorder: TableBorderStyle(.horizontalLines, color: .grid),
tableCell: TableCellStyle { configuration in
configuration.label
.markdownFontStyle { configuration.row == 0 ? $0.bold() : $0 }
.lineSpacing(.em(0.235295))
.padding(.all, .rem(0.58824))
},
thematicBreak: BlockStyle { _ in
Divider()
.overlay(Color.grid)
.markdownBlockSpacing(top: .em(2.35), bottom: .em(2.35))
}
)
}
extension Shape where Self == RoundedRectangle {
fileprivate static var container: Self {
.init(cornerRadius: 15, style: .continuous)
}
}
extension Color {
fileprivate static let text = Color(
light: Color(rgba: 0x1d1d_1fff), dark: Color(rgba: 0xf5f5_f7ff)
)
fileprivate static let secondaryLabel = Color(
light: Color(rgba: 0x6e6e_73ff), dark: Color(rgba: 0x8686_8bff)
)
fileprivate static let link = Color(
light: Color(rgba: 0x0066_ccff), dark: Color(rgba: 0x2997_ffff)
)
fileprivate static let asideNoteBackground = Color(
light: Color(rgba: 0xf5f5_f7ff), dark: Color(rgba: 0x3232_32ff)
)
fileprivate static let asideNoteBorder = Color(
light: Color(rgba: 0x6969_69ff), dark: Color(rgba: 0x9a9a_9eff)
)
fileprivate static let codeBackground = Color(
light: Color(rgba: 0xf5f5_f7ff), dark: Color(rgba: 0x3333_36ff)
)
fileprivate static let grid = Color(
light: Color(rgba: 0xd2d2_d7ff), dark: Color(rgba: 0x4242_45ff)
)
}

View File

@@ -0,0 +1,141 @@
import SwiftUI
extension Theme {
public static let gitHub = Theme(
textColor: .text,
backgroundColor: .background,
font: .system(size: 16),
code: .monospaced(size: .em(0.85), backgroundColor: .secondaryBackground),
strong: .weight(.semibold),
link: .foregroundColor(.link),
heading1: BlockStyle { label in
VStack(alignment: .leading, spacing: 0) {
label
.padding(.bottom, .em(0.3))
.lineSpacing(.em(0.125))
.markdownBlockSpacing(top: .points(24), bottom: .points(16))
.markdownFontStyle { $0.weight(.semibold).size(.em(2)) }
Divider().overlay(Color.divider)
}
},
heading2: BlockStyle { label in
VStack(alignment: .leading, spacing: 0) {
label
.padding(.bottom, .em(0.3))
.lineSpacing(.em(0.125))
.markdownBlockSpacing(top: .points(24), bottom: .points(16))
.markdownFontStyle { $0.weight(.semibold).size(.em(1.5)) }
Divider().overlay(Color.divider)
}
},
heading3: BlockStyle { label in
label
.lineSpacing(.em(0.125))
.markdownBlockSpacing(top: .points(24), bottom: .points(16))
.markdownFontStyle { $0.weight(.semibold).size(.em(1.25)) }
},
heading4: BlockStyle { label in
label
.lineSpacing(.em(0.125))
.markdownBlockSpacing(top: .points(24), bottom: .points(16))
.markdownFontStyle { $0.weight(.semibold) }
},
heading5: BlockStyle { label in
label
.lineSpacing(.em(0.125))
.markdownBlockSpacing(top: .points(24), bottom: .points(16))
.markdownFontStyle { $0.weight(.semibold).size(.em(0.875)) }
},
heading6: BlockStyle { label in
label
.lineSpacing(.em(0.125))
.foregroundColor(.tertiaryText)
.markdownBlockSpacing(top: .points(24), bottom: .points(16))
.markdownFontStyle { $0.weight(.semibold).size(.em(0.85)) }
},
paragraph: BlockStyle { label in
label
.lineSpacing(.em(0.25))
.markdownBlockSpacing(top: .zero, bottom: .points(16))
},
blockquote: BlockStyle { label in
HStack(spacing: 0) {
RoundedRectangle(cornerRadius: 6)
.fill(Color.border)
.frame(width: .em(0.2))
label
.foregroundColor(.secondaryText)
.padding(.horizontal, .em(1))
}
.fixedSize(horizontal: false, vertical: true)
},
codeBlock: BlockStyle { label in
ScrollView(.horizontal) {
label
.lineSpacing(.em(0.225))
.markdownFontStyle { $0.monospaced().size(.em(0.85)) }
.padding(16)
}
.background(Color.secondaryBackground)
.clipShape(RoundedRectangle(cornerRadius: 6))
.markdownBlockSpacing(top: .zero, bottom: .points(16))
},
listItem: BlockStyle { label in
label.markdownBlockSpacing(top: .em(0.25))
},
taskListMarker: ListMarkerStyle { configuration in
SwiftUI.Image(systemName: configuration.isCompleted ? "checkmark.square.fill" : "square")
.symbolRenderingMode(.hierarchical)
.foregroundStyle(Color.checkbox, Color.checkboxBackground)
.imageScale(.small)
.frame(minWidth: .em(1.5), alignment: .trailing)
},
table: BlockStyle { label in
label.markdownBlockSpacing(top: .zero, bottom: .points(16))
},
tableBorder: TableBorderStyle(color: .border),
tableCell: TableCellStyle { configuration in
configuration.label
.padding(.vertical, 6)
.padding(.horizontal, 13)
.lineSpacing(.em(0.25))
.markdownFontStyle { configuration.row == 0 ? $0.weight(.semibold) : $0 }
},
tableCellBackground: .alternatingRows(Color.background, Color.secondaryBackground),
thematicBreak: BlockStyle { _ in
Divider()
.frame(height: .em(0.25))
.overlay(Color.border)
.markdownBlockSpacing(top: .points(24), bottom: .points(24))
}
)
}
extension Color {
fileprivate static let text = Color(
light: Color(rgba: 0x0606_06ff), dark: Color(rgba: 0xfbfb_fcff)
)
fileprivate static let secondaryText = Color(
light: Color(rgba: 0x6b6e_7bff), dark: Color(rgba: 0x9294_a0ff)
)
fileprivate static let tertiaryText = Color(
light: Color(rgba: 0x6b6e_7bff), dark: Color(rgba: 0x6d70_7dff)
)
fileprivate static let background = Color(
light: .white, dark: Color(rgba: 0x1819_1dff)
)
fileprivate static let secondaryBackground = Color(
light: Color(rgba: 0xf7f7_f9ff), dark: Color(rgba: 0x2526_2aff)
)
fileprivate static let link = Color(
light: Color(rgba: 0x2c65_cfff), dark: Color(rgba: 0x4c8e_f8ff)
)
fileprivate static let border = Color(
light: Color(rgba: 0xe4e4_e8ff), dark: Color(rgba: 0x4244_4eff)
)
fileprivate static let divider = Color(
light: Color(rgba: 0xd0d0_d3ff), dark: Color(rgba: 0x3334_38ff)
)
fileprivate static let checkbox = Color(rgba: 0xb9b9_bbff)
fileprivate static let checkboxBackground = Color(rgba: 0xeeee_efff)
}

View File

@@ -1,6 +1,11 @@
import SwiftUI
public struct Theme {
// MARK: - Colors
public var textColor: Color
public var backgroundColor: Color
// MARK: - Inlines
public var font: FontStyle
@@ -12,20 +17,16 @@ public struct Theme {
// MARK: - Blocks
public var image: BlockStyle
public private(set) var headings: [BlockStyle]
public var paragraph: BlockStyle
public var blockquote: BlockStyle
public var codeBlock: BlockStyle
public var image: BlockStyle
public var list: BlockStyle
public var listItem: BlockStyle
public var taskListMarker: ListMarkerStyle<TaskListItemConfiguration>
public var bulletedListMarker: ListMarkerStyle<ListItemConfiguration>
public var numberedListMarker: ListMarkerStyle<ListItemConfiguration>
public var codeBlock: BlockStyle
public var paragraph: BlockStyle
public var headings: [BlockStyle] {
willSet {
precondition(newValue.count == 6, "A theme must have six heading styles.")
}
}
public var table: BlockStyle
public var tableBorder: TableBorderStyle
public var tableCell: TableCellStyle
@@ -33,45 +34,55 @@ public struct Theme {
public var thematicBreak: BlockStyle
public init(
font: FontStyle = .default,
code: InlineStyle,
emphasis: InlineStyle,
strong: InlineStyle,
strikethrough: InlineStyle,
link: InlineStyle,
image: BlockStyle,
textColor: Color = .primary,
backgroundColor: Color = .clear,
font: FontStyle = .body,
code: InlineStyle = .monospaced(),
emphasis: InlineStyle = .italic,
strong: InlineStyle = .bold,
strikethrough: InlineStyle = .strikethrough,
link: InlineStyle = .unit,
heading1: BlockStyle,
heading2: BlockStyle,
heading3: BlockStyle,
heading4: BlockStyle,
heading5: BlockStyle,
heading6: BlockStyle,
paragraph: BlockStyle,
blockquote: BlockStyle,
list: BlockStyle = .default,
listItem: BlockStyle = .default,
taskListMarker: ListMarkerStyle<TaskListItemConfiguration>,
bulletedListMarker: ListMarkerStyle<ListItemConfiguration>,
numberedListMarker: ListMarkerStyle<ListItemConfiguration>,
codeBlock: BlockStyle,
paragraph: BlockStyle = .default,
headings: [BlockStyle],
table: BlockStyle = .default,
tableBorder: TableBorderStyle = .default,
tableCell: TableCellStyle = .default,
tableCellBackground: TableCellBackgroundStyle = .default,
image: BlockStyle = .unit,
list: BlockStyle = .unit,
listItem: BlockStyle = .unit,
taskListMarker: ListMarkerStyle<TaskListItemConfiguration>,
bulletedListMarker: ListMarkerStyle<ListItemConfiguration> = .discCircleSquare,
numberedListMarker: ListMarkerStyle<ListItemConfiguration> = .decimal,
table: BlockStyle,
tableBorder: TableBorderStyle,
tableCell: TableCellStyle,
tableCellBackground: TableCellBackgroundStyle = .clear,
thematicBreak: BlockStyle
) {
self.textColor = textColor
self.backgroundColor = backgroundColor
self.font = font
self.code = code
self.emphasis = emphasis
self.strong = strong
self.strikethrough = strikethrough
self.link = link
self.image = image
self.headings = [heading1, heading2, heading3, heading4, heading5, heading6]
self.paragraph = paragraph
self.blockquote = blockquote
self.codeBlock = codeBlock
self.image = image
self.list = list
self.listItem = listItem
self.taskListMarker = taskListMarker
self.bulletedListMarker = bulletedListMarker
self.numberedListMarker = numberedListMarker
self.codeBlock = codeBlock
self.paragraph = paragraph
precondition(headings.count == 6, "A theme must have six heading styles.")
self.headings = headings
self.table = table
self.tableBorder = tableBorder
self.tableCell = tableCell
@@ -79,30 +90,3 @@ public struct Theme {
self.thematicBreak = thematicBreak
}
}
extension Theme {
public static var `default`: Self {
.init(
code: .monospaced,
emphasis: .italic,
strong: .bold,
strikethrough: .strikethrough,
link: .default,
image: .defaultImage,
blockquote: .defaultBlockquote,
taskListMarker: .checkmarkSquareFill,
bulletedListMarker: .discCircleSquare,
numberedListMarker: .decimal,
codeBlock: .defaultCodeBlock,
headings: [
.defaultHeading1,
.defaultHeading2,
.defaultHeading3,
.defaultHeading4,
.defaultHeading5,
.defaultHeading6,
],
thematicBreak: .defaultThematicBreak
)
}
}

View File

@@ -8,7 +8,6 @@ where
{
@Environment(\.multilineTextAlignment) private var textAlignment
@Environment(\.tightSpacingEnabled) private var tightSpacingEnabled
@Environment(\.fontStyle) private var fontStyle
@State private var blockSpacings: [Int: BlockSpacing] = [:]
@@ -27,7 +26,7 @@ where
VStack(alignment: self.textAlignment.alignment.horizontal, spacing: 0) {
ForEach(self.data, id: \.self) { element in
self.content(element.index, element.value)
.onBlockSpacingChange { value in
.onPreferenceChange(BlockSpacingPreference.self) { value in
self.blockSpacings[element.hashValue] = value
}
.padding(.top, self.topPaddingLength(for: element))
@@ -35,25 +34,19 @@ where
}
}
private func topPaddingLength(for element: Indexed<Data.Element>) -> CGFloat {
private func topPaddingLength(for element: Indexed<Data.Element>) -> CGFloat? {
guard element.index > 0 else {
return 0
}
let topSpacing = self.topSpacing(for: element)
let topSpacing = self.blockSpacings[element.hashValue]?.top
let predecessor = self.data[element.index - 1]
let predecessorBottomSpacing =
self.tightSpacingEnabled ? 0 : self.bottomSpacing(for: predecessor)
self.tightSpacingEnabled ? 0 : self.blockSpacings[predecessor.hashValue]?.bottom
return max(topSpacing, predecessorBottomSpacing)
}
private func topSpacing(for element: Indexed<Data.Element>) -> CGFloat {
(self.blockSpacings[element.hashValue] ?? .default).top.points(relativeTo: self.fontStyle)
}
private func bottomSpacing(for element: Indexed<Data.Element>) -> CGFloat {
(self.blockSpacings[element.hashValue] ?? .default).bottom.points(relativeTo: self.fontStyle)
return [topSpacing, predecessorBottomSpacing]
.compactMap { $0 }
.max()
}
}

View File

@@ -0,0 +1,44 @@
import SwiftUI
struct BlockSpacing: Equatable {
var top: CGFloat?
var bottom: CGFloat?
static let unspecified = BlockSpacing()
}
extension View {
public func markdownBlockSpacing(top: Size? = nil, bottom: Size? = nil) -> some View {
self.modifier(BlockSpacingModifier(top: top, bottom: bottom))
}
}
struct BlockSpacingPreference: PreferenceKey {
static let defaultValue: BlockSpacing = .unspecified
static func reduce(value: inout BlockSpacing, nextValue: () -> BlockSpacing) {
let newValue = nextValue()
value.top = [value.top, newValue.top].compactMap { $0 }.max()
value.bottom = [value.bottom, newValue.bottom].compactMap { $0 }.max()
}
}
private struct BlockSpacingModifier: ViewModifier {
@Environment(\.fontStyle) private var fontStyle
let top: Size?
let bottom: Size?
func body(content: Content) -> some View {
content.transformPreference(BlockSpacingPreference.self) { value in
let newValue = BlockSpacing(
top: self.top?.points(relativeTo: self.fontStyle),
bottom: self.bottom?.points(relativeTo: self.fontStyle)
)
value.top = [value.top, newValue.top].compactMap { $0 }.max()
value.bottom = [value.bottom, newValue.bottom].compactMap { $0 }.max()
}
}
}

View File

@@ -1,31 +0,0 @@
import SwiftUI
extension View {
public func markdownBlockSpacing(top: Size? = nil, bottom: Size? = nil) -> some View {
self.transformPreference(BlockSpacingPreference.self) { blockSpacing in
blockSpacing.top = top ?? blockSpacing.top
blockSpacing.bottom = bottom ?? blockSpacing.bottom
}
}
func onBlockSpacingChange(perform action: @escaping (BlockSpacing) -> Void) -> some View {
self.onPreferenceChange(BlockSpacingPreference.self, perform: action)
}
}
struct BlockSpacing: Equatable {
var top: Size
var bottom: Size
}
extension BlockSpacing {
static let `default` = BlockSpacing(top: .zero, bottom: .rem(1))
}
private struct BlockSpacingPreference: PreferenceKey {
static let defaultValue: BlockSpacing = .default
static func reduce(value: inout BlockSpacing, nextValue: () -> BlockSpacing) {
value.bottom = nextValue().bottom
}
}

View File

@@ -11,6 +11,21 @@ extension TableBorder {
}
}
public static var topBottomOutline: TableBorder {
TableBorder { tableBounds, borderWidth in
[
CGRect(
origin: .init(x: tableBounds.bounds.minX, y: tableBounds.bounds.minY),
size: .init(width: tableBounds.bounds.width, height: borderWidth)
),
CGRect(
origin: .init(x: tableBounds.bounds.minX, y: tableBounds.bounds.maxY - borderWidth),
size: .init(width: tableBounds.bounds.width, height: borderWidth)
),
]
}
}
public static var horizontalGridlines: TableBorder {
TableBorder { tableBounds, borderWidth in
(0..<tableBounds.rowCount - 1)
@@ -50,6 +65,13 @@ extension TableBorder {
}
}
public static var horizontalLines: TableBorder {
TableBorder { tableBounds, borderWidth in
Self.topBottomOutline.rectangles(tableBounds, borderWidth)
+ Self.horizontalGridlines.rectangles(tableBounds, borderWidth)
}
}
public static var all: TableBorder {
TableBorder { tableBounds, borderWidth in
Self.gridlines.rectangles(tableBounds, borderWidth)

View File

@@ -15,7 +15,7 @@ struct TableBorderView: View {
ForEach(0..<rectangles.count, id: \.self) {
let rectangle = rectangles[$0]
Rectangle()
.strokeBorder(self.tableBorder.style, style: self.tableBorder.strokeStyle)
.strokeBorder(self.tableBorder.color, style: self.tableBorder.strokeStyle)
.offset(x: rectangle.minX, y: rectangle.minY)
.frame(width: rectangle.width, height: rectangle.height)
}

View File

@@ -57,5 +57,5 @@ extension EnvironmentValues {
}
private struct FontStyleKey: EnvironmentKey {
static let defaultValue = FontStyle.default
static let defaultValue: FontStyle = .body
}

View File

@@ -20,5 +20,5 @@ extension EnvironmentValues {
}
private struct ThemeKey: EnvironmentKey {
static var defaultValue: Theme = .default
static var defaultValue: Theme = .basic
}

View File

@@ -26,29 +26,29 @@ extension AttributedString {
case .lineBreak:
self.init("\n", attributes: attributes)
case .code(let content):
self.init(content, attributes: environment.code.updating(attributes))
self.init(content, attributes: environment.code.transforming(attributes))
case .html(let content):
self.init(content, attributes: attributes)
case .emphasis(let children):
self.init(
inlines: children,
environment: environment,
attributes: environment.emphasis.updating(attributes)
attributes: environment.emphasis.transforming(attributes)
)
case .strong(let children):
self.init(
inlines: children,
environment: environment,
attributes: environment.strong.updating(attributes)
attributes: environment.strong.transforming(attributes)
)
case .strikethrough(let children):
self.init(
inlines: children,
environment: environment,
attributes: environment.strikethrough.updating(attributes)
attributes: environment.strikethrough.transforming(attributes)
)
case .link(let destination, let children):
var newAttributes = environment.link.updating(attributes)
var newAttributes = environment.link.transforming(attributes)
newAttributes.link = URL.init(string: destination)?.relativeTo(environment.baseURL)
self.init(inlines: children, environment: environment, attributes: newAttributes)
case .image:

View File

@@ -15,7 +15,10 @@ public struct Markdown: View {
}
}
@Environment(\.theme.textColor) private var textColor
@Environment(\.theme.backgroundColor) private var backgroundColor
@Environment(\.theme.font) private var font
@State private var blocks: [Block] = []
private let storage: Storage
@@ -38,6 +41,8 @@ public struct Markdown: View {
self.blocks = storage.markdownContent.blocks
}
.environment(\.markdownBaseURL, self.baseURL)
.foregroundColor(self.textColor)
.background(self.backgroundColor)
.fontStyle(self.font)
}
}

View File

@@ -92,24 +92,6 @@
assertSnapshot(matching: view, as: .image(layout: layout))
}
func testImageStyle() {
let view = Markdown {
#"""
A link that contains an image instead of text:
[![](asset:///237-100x150)](https://example.com)
― Photo by André Spieker
"""#
}
.border(Color.accentColor)
.padding()
.markdownImageLoader(.asset(in: .module), forURLScheme: "asset")
.markdownTheme(\.image, .defaultImage(alignment: .center))
assertSnapshot(matching: view, as: .image(layout: layout))
}
func testMultipleImages() {
let view = Markdown {
#"""
@@ -144,24 +126,5 @@
assertSnapshot(matching: view, as: .image(layout: layout))
}
func testImageStyleWithMultipleImages() {
let view = Markdown {
#"""
![](asset:///237-100x150)
![](asset:///237-125x75)
![](asset:///237-500x300)
![](asset:///237-100x150)
― Photo by André Spieker
"""#
}
.border(Color.accentColor)
.padding()
.markdownImageLoader(.asset(in: .module), forURLScheme: "asset")
.markdownTheme(\.image, .defaultImage(alignment: .center))
assertSnapshot(matching: view, as: .image(layout: layout))
}
}
#endif

View File

@@ -173,7 +173,7 @@
\.tableBorder,
.init(
.outline,
style: Color.mint,
color: Color.mint,
strokeStyle: .init(lineWidth: 2, lineJoin: .round, dash: [4])
)
)

View File

@@ -274,10 +274,27 @@
.border(Color.accentColor)
.padding()
.markdownTheme(\.code, .monospaced(backgroundColor: .yellow))
.markdownTheme(\.emphasis, .italicUnderline)
.markdownTheme(
\.emphasis,
InlineStyle { attributes in
attributes.fontStyle = attributes.fontStyle?.italic()
attributes.underlineStyle = .single
}
)
.markdownTheme(\.strong, .weight(.heavy))
.markdownTheme(\.strikethrough, .redacted)
.markdownTheme(\.link, .underlineDot)
.markdownTheme(
\.strikethrough,
InlineStyle { attributes in
attributes.foregroundColor = .primary
attributes.backgroundColor = .primary
}
)
.markdownTheme(
\.link,
InlineStyle { attributes in
attributes.underlineStyle = .init(pattern: .dot)
}
)
assertSnapshot(matching: view, as: .image(layout: layout))
}

View File

@@ -0,0 +1,174 @@
#if os(iOS)
import SnapshotTesting
import SwiftUI
import XCTest
import MarkdownUI
final class ThemeDocCTests: XCTestCase {
private let layout = SwiftUISnapshotLayout.device(config: .iPhone8)
func testInlines() {
let view = ThemePreview(theme: .docC) {
#"""
**This is bold text**
*This text is italicized*
~~This was mistaken text~~
**This text is _extremely_ important**
MarkdownUI is fully compliant with the [CommonMark Spec](https://spec.commonmark.org/current/).
Use `git status` to list all new or modified files that haven't yet been committed.
"""#
}
assertSnapshot(matching: view, as: .image(layout: layout))
}
func testHeadings() {
let view = ThemePreview(theme: .docC, colorScheme: .light) {
#"""
Paragraph.
# Heading 1
Paragraph.
## Heading 2
Paragraph.
### Heading 3
Paragraph.
#### Heading 4
Paragraph.
##### Heading 5
Paragraph.
###### Heading 6
Paragraph.
"""#
}
assertSnapshot(matching: view, as: .image(layout: layout))
}
func testParagraph() {
let view = ThemePreview(theme: .docC, colorScheme: .light) {
#"""
The sky above the port was the color of television, tuned to a dead channel.
It was a bright cold day in April, and the clocks were striking thirteen.
The sky above the port was the color of television, tuned to a dead channel.
It was a bright cold day in April, and the clocks were striking thirteen.
"""#
}
assertSnapshot(matching: view, as: .image(layout: layout))
}
func testBlockquote() {
let view = ThemePreview(theme: .docC) {
#"""
The sky above the port was the color of television, tuned to a dead channel.
> Outside of a dog, a book is man's best friend. Inside of a dog it's too dark to read.
It was a bright cold day in April, and the clocks were striking thirteen.
"""#
}
assertSnapshot(matching: view, as: .image(layout: layout))
}
func testCodeBlock() {
let view = ThemePreview(theme: .docC) {
#"""
The sky above the port was the color of television, tuned to a dead channel.
```swift
struct Sightseeing: Activity {
func perform(with sloth: inout Sloth) -> Speed {
sloth.energyLevel -= 10
return .slow
}
}
```
It was a bright cold day in April, and the clocks were striking thirteen.
"""#
}
assertSnapshot(matching: view, as: .image(layout: layout))
}
func testImage() {
let view = ThemePreview(theme: .docC, colorScheme: .light) {
#"""
The sky above the port was the color of television, tuned to a dead channel.
![](asset:///237-100x150)
It was a bright cold day in April, and the clocks were striking thirteen.
![](asset:///237-100x150)
![](asset:///237-125x75)
― Photo by André Spieker
"""#
}
.markdownImageLoader(.asset(in: .module), forURLScheme: "asset")
assertSnapshot(matching: view, as: .image(layout: layout))
}
func testList() {
let view = ThemePreview(theme: .docC, colorScheme: .light) {
#"""
The sky above the port was the color of television, tuned to a dead channel.
* Systems
* FFF units
* Great Underground Empire (Zork)
* Potrzebie
* Equals the thickness of Mad issue 26
* Developed by 19-year-old Donald E. Knuth
It was a bright cold day in April, and the clocks were striking thirteen.
10. Helmets
1. Hoods
1. Headbands, headscarves, wimples
The sky above the port was the color of television, tuned to a dead channel.
- [x] A finished task
- [ ] An unfinished task
"""#
}
assertSnapshot(matching: view, as: .image(layout: layout))
}
func testTable() {
let view = ThemePreview(theme: .docC) {
#"""
Add tables of data:
| Sloth speed | Description |
| ------------ | ------------------------------------- |
| `slow` | Moves slightly faster than a snail. |
| `medium` | Moves at an average speed. |
| `fast` | Moves faster than a hare. |
"""#
}
assertSnapshot(matching: view, as: .image(layout: layout))
}
func testThematicBreak() {
let view = ThemePreview(theme: .docC) {
#"""
The sky above the port was the color of television, tuned to a dead channel.
---
It was a bright cold day in April, and the clocks were striking thirteen.
"""#
}
assertSnapshot(matching: view, as: .image(layout: layout))
}
}
#endif

View File

@@ -0,0 +1,177 @@
#if os(iOS)
import SnapshotTesting
import SwiftUI
import XCTest
import MarkdownUI
final class ThemeGitHubTests: XCTestCase {
private let layout = SwiftUISnapshotLayout.device(config: .iPhone8)
func testInlines() {
let view = ThemePreview(theme: .gitHub) {
#"""
**This is bold text**
*This text is italicized*
~~This was mistaken text~~
**This text is _extremely_ important**
MarkdownUI is fully compliant with the [CommonMark Spec](https://spec.commonmark.org/current/).
Use `git status` to list all new or modified files that haven't yet been committed.
"""#
}
assertSnapshot(matching: view, as: .image(layout: layout))
}
func testHeadings() {
let view = ThemePreview(theme: .gitHub, colorScheme: .light) {
#"""
Paragraph.
# Heading 1
Paragraph.
## Heading 2
Paragraph.
### Heading 3
Paragraph.
#### Heading 4
Paragraph.
##### Heading 5
Paragraph.
###### Heading 6
Paragraph.
"""#
}
assertSnapshot(matching: view, as: .image(layout: layout))
}
func testHeadingsColors() {
let view = ThemePreview(theme: .gitHub) {
#"""
# Heading 1
Paragraph.
## Heading 2
Paragraph.
###### Heading 6
Paragraph.
"""#
}
assertSnapshot(matching: view, as: .image(layout: layout))
}
func testParagraph() {
let view = ThemePreview(theme: .gitHub, colorScheme: .light) {
#"""
The sky above the port was the color of television, tuned to a dead channel.
It was a bright cold day in April, and the clocks were striking thirteen.
The sky above the port was the color of television, tuned to a dead channel.
It was a bright cold day in April, and the clocks were striking thirteen.
"""#
}
assertSnapshot(matching: view, as: .image(layout: layout))
}
func testBlockquote() {
let view = ThemePreview(theme: .gitHub) {
#"""
The sky above the port was the color of television, tuned to a dead channel.
> Outside of a dog, a book is man's best friend. Inside of a dog it's too dark to read.
It was a bright cold day in April, and the clocks were striking thirteen.
"""#
}
assertSnapshot(matching: view, as: .image(layout: layout))
}
func testCodeBlock() {
let view = ThemePreview(theme: .gitHub) {
#"""
The sky above the port was the color of television, tuned to a dead channel.
```swift
struct Sightseeing: Activity {
func perform(with sloth: inout Sloth) -> Speed {
sloth.energyLevel -= 10
return .slow
}
}
```
It was a bright cold day in April, and the clocks were striking thirteen.
"""#
}
assertSnapshot(matching: view, as: .image(layout: layout))
}
func testTaskList() {
let view = ThemePreview(theme: .gitHub) {
#"""
The sky above the port was the color of television, tuned to a dead channel.
- [x] A finished task
- [ ] An unfinished task
"""#
}
assertSnapshot(matching: view, as: .image(layout: layout))
}
func testList() {
let view = ThemePreview(theme: .gitHub, colorScheme: .light) {
#"""
The sky above the port was the color of television, tuned to a dead channel.
* Systems
* FFF units
* Great Underground Empire (Zork)
* Potrzebie
* Equals the thickness of Mad issue 26
* Developed by 19-year-old Donald E. Knuth
It was a bright cold day in April, and the clocks were striking thirteen.
10. Helmets
1. Hoods
1. Headbands, headscarves, wimples
The sky above the port was the color of television, tuned to a dead channel.
"""#
}
assertSnapshot(matching: view, as: .image(layout: layout))
}
func testTable() {
let view = ThemePreview(theme: .gitHub) {
#"""
Add tables of data:
| Sloth speed | Description |
| ------------ | ------------------------------------- |
| `slow` | Moves slightly faster than a snail. |
| `medium` | Moves at an average speed. |
| `fast` | Moves faster than a hare. |
"""#
}
assertSnapshot(matching: view, as: .image(layout: layout))
}
func testThematicBreak() {
let view = ThemePreview(theme: .gitHub) {
#"""
The sky above the port was the color of television, tuned to a dead channel.
---
It was a bright cold day in April, and the clocks were striking thirteen.
"""#
}
assertSnapshot(matching: view, as: .image(layout: layout))
}
}
#endif

View File

@@ -0,0 +1,39 @@
import MarkdownUI
import SwiftUI
struct ThemePreview: View {
private let theme: Theme
private let colorSchemes: [ColorScheme]
private let content: () -> MarkdownContent
init(
theme: Theme,
colorSchemes: [ColorScheme] = ColorScheme.allCases,
@MarkdownContentBuilder content: @escaping () -> MarkdownContent
) {
self.theme = theme
self.colorSchemes = colorSchemes
self.content = content
}
init(
theme: Theme,
colorScheme: ColorScheme,
@MarkdownContentBuilder content: @escaping () -> MarkdownContent
) {
self.init(theme: theme, colorSchemes: [colorScheme], content: content)
}
var body: some View {
VStack {
ForEach(self.colorSchemes, id: \.self) { colorScheme in
Markdown(content: self.content)
.padding()
.background()
.colorScheme(colorScheme)
}
}
.markdownTheme(self.theme)
.padding()
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB