krz/org-swift

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

Sources/OrgSwiftUI/OrgView.swift

91 lines · 3620 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
23    @Environment(\.colorScheme) private var colorScheme
24    @State private var layout = OrgLayout()
25
26    /// - Parameter textTransform: runs on each run of prose after styling: paragraphs,
27    ///   headings, list terms, table cells, verse, captions and footnotes, never a code
28    ///   block. Code and verbatim runs carry the `.code` inline presentation intent and
29    ///   links carry `.link`, so a transform can leave them alone. It is not part of the
30    ///   layout key; changing it alone does not re-render.
31    public init(
32        _ source: String,
33        options: OrgRenderOptions = .init(),
34        styler: OrgCodeStyler = PlainOrgCodeStyler(),
35        keywords: OrgKeywordStyle = .init(),
36        textTransform: ((inout AttributedString) -> Void)? = nil
37    ) {
38        self.source = source
39        self.options = options
40        self.styler = styler
41        self.keywords = keywords
42        self.textTransform = textTransform
43    }
44
45    public var body: some View {
46        VStack(alignment: .leading, spacing: 12) {
47            OrgBlockListView(blocks: layout.blocks)
48            if !layout.footnotes.isEmpty {
49                OrgFootnotesView(footnotes: layout.footnotes)
50            }
51        }
52        .frame(maxWidth: .infinity, alignment: .leading)
53        .textSelection(.enabled)
54        .task(id: LayoutKey(source: source, colorScheme: colorScheme)) {
55            layout = OrgLayoutBuilder(options: options, styler: styler, keywords: keywords,
56                                      textTransform: textTransform)
57                .layout(OrgParser.parse(source))
58        }
59    }
60}
61
62/// What the prepared layout depends on. Highlighted code carries baked-in colors, so the
63/// scheme belongs in the key alongside the source.
64private struct LayoutKey: Equatable {
65    var source: String
66    var colorScheme: ColorScheme
67}
68
69/// The notes section org's exporter puts at the end of a document.
70struct OrgFootnotesView: View {
71    let footnotes: [OrgFootnote]
72
73    var body: some View {
74        VStack(alignment: .leading, spacing: 6) {
75            Divider()
76            ForEach(footnotes) { note in
77                HStack(alignment: .firstTextBaseline, spacing: 6) {
78                    Text("\(note.number).")
79                        .font(.footnote)
80                        .monospacedDigit()
81                        .foregroundStyle(.secondary)
82                    Text(note.text)
83                        .fixedSize(horizontal: false, vertical: true)
84                        .frame(maxWidth: .infinity, alignment: .leading)
85                }
86            }
87        }
88        .padding(.top, 8)
89        .accessibilityLabel("Footnotes")
90    }
91}