krz/org-swift

A dependency-free Swift library that renders org-mode to sanitized HTML. html library org-mode swift

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}