Sources/OrgSwiftUI/OrgView.swift
99 lines · 4016 bytes
1import OrgSwift
2import SwiftUI
3
4/// Renders org-mode as native SwiftUI.
5///
6/// ```swift
7/// OrgView(source, options: OrgRenderOptions(host: "gitbay.org", owner: "krz",
8/// repositoryName: "org-swift"))
9/// ```
10///
11/// The parse, footnote numbering, URL resolution and syntax highlighting run once off
12/// `body`, in a task keyed to the source *and the color scheme*, so scrolling and Dynamic
13/// Type changes do not re-derive them — but switching to dark mode does, because a
14/// ``OrgCodeStyler`` bakes its theme's colors into the text it returns. Text is selectable,
15/// links open through the environment's `openURL`, and everything scales with Dynamic Type.
16public struct OrgView: View {
17 private let source: String
18 private let options: OrgRenderOptions
19 private let styler: OrgCodeStyler
20 private let keywords: OrgKeywordStyle
21 private let textTransform: ((inout AttributedString) -> Void)?
22 private let math: OrgMathRenderer?
23
24 @Environment(\.colorScheme) private var colorScheme
25 @State private var layout = OrgLayout()
26
27 /// - Parameter textTransform: runs on each run of prose after styling: paragraphs,
28 /// headings, list terms, table cells, verse, captions and footnotes, never a code
29 /// block. Code and verbatim runs carry the `.code` inline presentation intent and
30 /// links carry `.link`, so a transform can leave them alone. It is not part of the
31 /// layout key; changing it alone does not re-render. A math run carries
32 /// ``OrgMathAttribute`` over its source text.
33 /// - Parameter math: draws each LaTeX fragment and block; nil, or a nil result, shows
34 /// the source as written.
35 public init(
36 _ source: String,
37 options: OrgRenderOptions = .init(),
38 styler: OrgCodeStyler = PlainOrgCodeStyler(),
39 keywords: OrgKeywordStyle = .init(),
40 textTransform: ((inout AttributedString) -> Void)? = nil,
41 math: OrgMathRenderer? = nil
42 ) {
43 self.source = source
44 self.options = options
45 self.styler = styler
46 self.keywords = keywords
47 self.textTransform = textTransform
48 self.math = math
49 }
50
51 public var body: some View {
52 VStack(alignment: .leading, spacing: 12) {
53 OrgBlockListView(blocks: layout.blocks)
54 if !layout.footnotes.isEmpty {
55 OrgFootnotesView(footnotes: layout.footnotes)
56 }
57 }
58 .frame(maxWidth: .infinity, alignment: .leading)
59 .environment(\.orgMath, math)
60 .textSelection(.enabled)
61 .task(id: LayoutKey(source: source, colorScheme: colorScheme)) {
62 layout = OrgLayoutBuilder(options: options, styler: styler, keywords: keywords,
63 textTransform: textTransform)
64 .layout(OrgParser.parse(source))
65 }
66 }
67}
68
69/// What the prepared layout depends on. Highlighted code carries baked-in colors, so the
70/// scheme belongs in the key alongside the source.
71private struct LayoutKey: Equatable {
72 var source: String
73 var colorScheme: ColorScheme
74}
75
76/// The notes section org's exporter puts at the end of a document.
77struct OrgFootnotesView: View {
78 let footnotes: [OrgFootnote]
79 @Environment(\.orgMath) private var math
80
81 var body: some View {
82 VStack(alignment: .leading, spacing: 6) {
83 Divider()
84 ForEach(footnotes) { note in
85 HStack(alignment: .firstTextBaseline, spacing: 6) {
86 Text("\(note.number).")
87 .font(.footnote)
88 .monospacedDigit()
89 .foregroundStyle(.secondary)
90 orgText(note.text, math: math)
91 .fixedSize(horizontal: false, vertical: true)
92 .frame(maxWidth: .infinity, alignment: .leading)
93 }
94 }
95 }
96 .padding(.top, 8)
97 .accessibilityLabel("Footnotes")
98 }
99}