Commit dedfbafd51
Unsigned
Layout: unified · split
Package.swift +9 −2
| @@ -8,13 +8,20 @@ let package = Package( | ||
| 8 | 8 | .iOS(.v16) |
| 9 | 9 | ], |
| 10 | 10 | products: [ |
| 11 | .library(name: "OrgSwift", targets: ["OrgSwift"]) | |
| 11 | .library(name: "OrgSwift", targets: ["OrgSwift"]), | |
| 12 | .library(name: "OrgSwiftUI", targets: ["OrgSwiftUI"]) | |
| 12 | 13 | ], |
| 13 | 14 | targets: [ |
| 14 | 15 | .target( |
| 15 | 16 | name: "OrgSwift", |
| 16 | 17 | swiftSettings: [.enableUpcomingFeature("BareSlashRegexLiterals")] |
| 17 | 18 | ), |
| 18 | .testTarget(name: "OrgSwiftTests", dependencies: ["OrgSwift"]) | |
| 19 | .target( | |
| 20 | name: "OrgSwiftUI", | |
| 21 | dependencies: ["OrgSwift"], | |
| 22 | swiftSettings: [.enableUpcomingFeature("BareSlashRegexLiterals")] | |
| 23 | ), | |
| 24 | .testTarget(name: "OrgSwiftTests", dependencies: ["OrgSwift"]), | |
| 25 | .testTarget(name: "OrgSwiftUITests", dependencies: ["OrgSwiftUI"]) | |
| 19 | 26 | ] |
| 20 | 27 | ) |
Sources/OrgSwiftUI/OrgBlockView.swift added +108
| @@ -0,0 +1,108 @@ | ||
| 1 | import OrgSwift | |
| 2 | import SwiftUI | |
| 3 | ||
| 4 | /// Renders prepared blocks. All the org work happened in ``OrgLayoutBuilder``; this is | |
| 5 | /// layout only. | |
| 6 | struct OrgBlockListView: View { | |
| 7 | let blocks: [OrgBlock] | |
| 8 | ||
| 9 | var body: some View { | |
| 10 | VStack(alignment: .leading, spacing: 12) { | |
| 11 | ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in | |
| 12 | OrgBlockView(block: block) | |
| 13 | } | |
| 14 | } | |
| 15 | .frame(maxWidth: .infinity, alignment: .leading) | |
| 16 | } | |
| 17 | } | |
| 18 | ||
| 19 | struct OrgBlockView: View { | |
| 20 | let block: OrgBlock | |
| 21 | ||
| 22 | var body: some View { | |
| 23 | switch block { | |
| 24 | case .heading(let text, let level): | |
| 25 | Text(text) | |
| 26 | .padding(.top, level <= 2 ? 8 : 2) | |
| 27 | .fixedSize(horizontal: false, vertical: true) | |
| 28 | ||
| 29 | case .paragraph(let text): | |
| 30 | Text(text) | |
| 31 | .fixedSize(horizontal: false, vertical: true) | |
| 32 | ||
| 33 | case .code(let code): | |
| 34 | ScrollView(.horizontal) { | |
| 35 | Text(code) | |
| 36 | .textSelection(.enabled) | |
| 37 | .padding(10) | |
| 38 | } | |
| 39 | .background( | |
| 40 | Color.secondary.opacity(0.12), | |
| 41 | in: RoundedRectangle(cornerRadius: 4) | |
| 42 | ) | |
| 43 | ||
| 44 | case .quote(let blocks): | |
| 45 | HStack(spacing: 10) { | |
| 46 | RoundedRectangle(cornerRadius: 2) | |
| 47 | .fill(.tertiary) | |
| 48 | .frame(width: 3) | |
| 49 | OrgBlockListView(blocks: blocks) | |
| 50 | .foregroundStyle(.secondary) | |
| 51 | } | |
| 52 | .fixedSize(horizontal: false, vertical: true) | |
| 53 | ||
| 54 | case .center(let blocks): | |
| 55 | OrgBlockListView(blocks: blocks) | |
| 56 | .multilineTextAlignment(.center) | |
| 57 | .frame(maxWidth: .infinity, alignment: .center) | |
| 58 | ||
| 59 | case .verse(let lines): | |
| 60 | // Verse keeps its line breaks; that is the whole point of the block. | |
| 61 | VStack(alignment: .leading, spacing: 0) { | |
| 62 | ForEach(Array(lines.enumerated()), id: \.offset) { _, line in | |
| 63 | Text(line).fixedSize(horizontal: false, vertical: true) | |
| 64 | } | |
| 65 | } | |
| 66 | ||
| 67 | case .special(_, let blocks): | |
| 68 | OrgBlockListView(blocks: blocks) | |
| 69 | ||
| 70 | case .list(let list): | |
| 71 | OrgListView(list: list) | |
| 72 | ||
| 73 | case .table(let table): | |
| 74 | OrgTableView(table: table) | |
| 75 | ||
| 76 | case .image(let source, let alt, let caption): | |
| 77 | VStack(alignment: .leading, spacing: 4) { | |
| 78 | AsyncImage(url: source) { phase in | |
| 79 | switch phase { | |
| 80 | case .success(let image): | |
| 81 | image.resizable().scaledToFit() | |
| 82 | case .failure: | |
| 83 | Text(alt.isEmpty ? source.lastPathComponent : alt) | |
| 84 | .font(.footnote) | |
| 85 | .foregroundStyle(.secondary) | |
| 86 | default: | |
| 87 | ProgressView() | |
| 88 | } | |
| 89 | } | |
| 90 | .accessibilityLabel(alt) | |
| 91 | if let caption { | |
| 92 | Text(caption).foregroundStyle(.secondary) | |
| 93 | } | |
| 94 | } | |
| 95 | ||
| 96 | case .captioned(let caption, let blocks): | |
| 97 | VStack(alignment: .leading, spacing: 4) { | |
| 98 | OrgBlockListView(blocks: blocks) | |
| 99 | if let caption { | |
| 100 | Text(caption).foregroundStyle(.secondary) | |
| 101 | } | |
| 102 | } | |
| 103 | ||
| 104 | case .rule: | |
| 105 | Divider() | |
| 106 | } | |
| 107 | } | |
| 108 | } | |
Sources/OrgSwiftUI/OrgCodeStyler.swift added +20
| @@ -0,0 +1,20 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | /// Syntax highlighting for `#+begin_src` blocks, supplied by the app. | |
| 4 | /// | |
| 5 | /// The native counterpart to ``CodeHighlighter``: that one returns HTML for the HTML | |
| 6 | /// renderer, this returns an `AttributedString`. Keeping it a protocol is what lets | |
| 7 | /// OrgSwiftUI stay dependency-free — a highlighting engine is the app's business. | |
| 8 | /// | |
| 9 | /// Returning nil means "show it as plain monospaced text", which is a fine outcome for an | |
| 10 | /// unknown language, never an error. | |
| 11 | @MainActor | |
| 12 | public protocol OrgCodeStyler { | |
| 13 | func highlighted(code: String, language: String?) -> AttributedString? | |
| 14 | } | |
| 15 | ||
| 16 | /// The default: no highlighting. | |
| 17 | public struct PlainOrgCodeStyler: OrgCodeStyler { | |
| 18 | nonisolated public init() {} | |
| 19 | public func highlighted(code _: String, language _: String?) -> AttributedString? { nil } | |
| 20 | } | |
Sources/OrgSwiftUI/OrgInlineStyler.swift added +84
| @@ -0,0 +1,84 @@ | ||
| 1 | import OrgSwift | |
| 2 | import SwiftUI | |
| 3 | ||
| 4 | /// Turns a run of inline org objects into a styled `AttributedString`. | |
| 5 | /// | |
| 6 | /// The parse and the attribute intents come from ``OrgAttributedStringRenderer``; this adds | |
| 7 | /// only presentation. Everything is expressed in semantic `Font.TextStyle`s and semantic | |
| 8 | /// colors, so the result scales with Dynamic Type and follows light/dark on its own. | |
| 9 | struct OrgInlineStyler { | |
| 10 | var renderer: OrgAttributedStringRenderer | |
| 11 | ||
| 12 | init(options: OrgRenderOptions) { | |
| 13 | renderer = OrgAttributedStringRenderer(options: options) | |
| 14 | } | |
| 15 | ||
| 16 | /// - Parameter style: the text style of the block this run sits in, so emphasis inside a | |
| 17 | /// heading bolds the heading's font rather than the body font. | |
| 18 | func attributed( | |
| 19 | _ objects: [OrgObject], _ notes: inout OrgFootnotes, style: Font.TextStyle = .body | |
| 20 | ) -> AttributedString { | |
| 21 | var string = renderer.inline(objects, ¬es) | |
| 22 | ||
| 23 | for range in string.runs.map(\.range) { | |
| 24 | let intents = string[range].inlinePresentationIntent ?? [] | |
| 25 | let role = string[range][OrgRoleAttribute.self] | |
| 26 | ||
| 27 | var textStyle = style | |
| 28 | var monospaced = false | |
| 29 | var bold = false | |
| 30 | var italic = false | |
| 31 | ||
| 32 | if intents.contains(.stronglyEmphasized) { bold = true } | |
| 33 | if intents.contains(.emphasized) { italic = true } | |
| 34 | if intents.contains(.code) { | |
| 35 | monospaced = true | |
| 36 | string[range].backgroundColor = .secondary.opacity(0.12) | |
| 37 | } | |
| 38 | ||
| 39 | switch role { | |
| 40 | case .underline: | |
| 41 | string[range].underlineStyle = .single | |
| 42 | case .strikeThrough: | |
| 43 | string[range].strikethroughStyle = .single | |
| 44 | case .superscript, .footnoteReference: | |
| 45 | // No portable superscript attribute, so raise it and step the style down. | |
| 46 | textStyle = Self.smaller(textStyle) | |
| 47 | string[range].baselineOffset = 5 | |
| 48 | if role == .footnoteReference { string[range].foregroundColor = .accentColor } | |
| 49 | case .timestamp: | |
| 50 | string[range].foregroundColor = .secondary | |
| 51 | case .image: | |
| 52 | // An inline image inside a paragraph shows its alt text; the block-level | |
| 53 | // `.figure` case is where an image is actually loaded. | |
| 54 | italic = true | |
| 55 | string[range].foregroundColor = .secondary | |
| 56 | case nil: | |
| 57 | break | |
| 58 | } | |
| 59 | ||
| 60 | if bold || italic || monospaced || textStyle != style { | |
| 61 | var font = Font.system(textStyle, design: monospaced ? .monospaced : .default) | |
| 62 | if bold { font = font.bold() } | |
| 63 | if italic { font = font.italic() } | |
| 64 | string[range].font = font | |
| 65 | } | |
| 66 | } | |
| 67 | return string | |
| 68 | } | |
| 69 | ||
| 70 | /// One step down the semantic scale, for superscripts and footnote markers. | |
| 71 | private static func smaller(_ style: Font.TextStyle) -> Font.TextStyle { | |
| 72 | switch style { | |
| 73 | case .largeTitle: .title | |
| 74 | case .title: .title2 | |
| 75 | case .title2: .title3 | |
| 76 | case .title3: .headline | |
| 77 | case .headline, .body: .footnote | |
| 78 | case .callout: .caption | |
| 79 | case .subheadline: .caption | |
| 80 | case .footnote, .caption, .caption2: .caption2 | |
| 81 | @unknown default: .caption | |
| 82 | } | |
| 83 | } | |
| 84 | } | |
Sources/OrgSwiftUI/OrgLayout.swift added +77
| @@ -0,0 +1,77 @@ | ||
| 1 | import OrgSwift | |
| 2 | import SwiftUI | |
| 3 | ||
| 4 | /// A document prepared for display: inline runs already styled, footnotes already numbered, | |
| 5 | /// image URLs already resolved, code already highlighted. | |
| 6 | /// | |
| 7 | /// The walk happens once, ahead of `body`, because three things it does must not happen per | |
| 8 | /// layout pass — footnote numbering is sequential and document-wide, syntax highlighting is | |
| 9 | /// expensive, and both would otherwise re-run every time SwiftUI recomputes a view. | |
| 10 | struct OrgLayout { | |
| 11 | var blocks: [OrgBlock] = [] | |
| 12 | var footnotes: [OrgFootnote] = [] | |
| 13 | } | |
| 14 | ||
| 15 | struct OrgFootnote: Identifiable { | |
| 16 | var number: Int | |
| 17 | var text: AttributedString | |
| 18 | var id: Int { number } | |
| 19 | } | |
| 20 | ||
| 21 | /// A block ready to render. Mirrors ``OrgElement`` minus the cases that resolve away: | |
| 22 | /// footnote definitions move to ``OrgLayout/footnotes``, and an export block for another | |
| 23 | /// backend drops entirely. | |
| 24 | indirect enum OrgBlock: Identifiable { | |
| 25 | case heading(text: AttributedString, level: Int) | |
| 26 | case paragraph(AttributedString) | |
| 27 | case code(AttributedString) | |
| 28 | case quote([OrgBlock]) | |
| 29 | case center([OrgBlock]) | |
| 30 | case verse([AttributedString]) | |
| 31 | case special(name: String, blocks: [OrgBlock]) | |
| 32 | case list(OrgListLayout) | |
| 33 | case table(OrgTableLayout) | |
| 34 | case image(source: URL, alt: String, caption: AttributedString?) | |
| 35 | case captioned(caption: AttributedString?, blocks: [OrgBlock]) | |
| 36 | case rule | |
| 37 | ||
| 38 | /// Positional identity. Blocks are rebuilt wholesale when the source changes, so their | |
| 39 | /// index in the layout is stable for as long as the layout is. | |
| 40 | var id: String { String(describing: self).prefix(64).description } | |
| 41 | } | |
| 42 | ||
| 43 | struct OrgListLayout { | |
| 44 | var kind: OrgListKind | |
| 45 | var items: [Item] | |
| 46 | ||
| 47 | struct Item: Identifiable { | |
| 48 | var checkbox: OrgCheckbox? | |
| 49 | var term: AttributedString? | |
| 50 | var paragraphs: [AttributedString] | |
| 51 | var sublist: OrgListLayout? | |
| 52 | var id = UUID() | |
| 53 | } | |
| 54 | } | |
| 55 | ||
| 56 | struct OrgTableLayout { | |
| 57 | /// Cell rows only; org's `|---|` rule rows are structure, carried by ``headerRowCount``. | |
| 58 | var rows: [Row] | |
| 59 | var headerRowCount: Int | |
| 60 | var alignments: [OrgAlignment?] | |
| 61 | ||
| 62 | struct Row: Identifiable { | |
| 63 | var cells: [Cell] | |
| 64 | var id = UUID() | |
| 65 | } | |
| 66 | ||
| 67 | struct Cell: Identifiable { | |
| 68 | var text: AttributedString | |
| 69 | var id = UUID() | |
| 70 | } | |
| 71 | ||
| 72 | var columnCount: Int { rows.map(\.cells.count).max() ?? 0 } | |
| 73 | ||
| 74 | func alignment(column: Int) -> OrgAlignment? { | |
| 75 | column < alignments.count ? alignments[column] : nil | |
| 76 | } | |
| 77 | } | |
Sources/OrgSwiftUI/OrgLayoutBuilder.swift added +177
| @@ -0,0 +1,177 @@ | ||
| 1 | import OrgSwift | |
| 2 | import SwiftUI | |
| 3 | ||
| 4 | /// Walks an ``OrgDocument`` into an ``OrgLayout``. | |
| 5 | /// | |
| 6 | /// The third renderer over the shared tree, alongside HTML and `AttributedString`. It | |
| 7 | /// resolves URLs through the same ``OrgURLResolver`` and numbers footnotes through the same | |
| 8 | /// ``OrgFootnotes``, so a link that works in the HTML output works here and note 1 is note 1 | |
| 9 | /// in both. | |
| 10 | @MainActor | |
| 11 | struct OrgLayoutBuilder { | |
| 12 | var options: OrgRenderOptions | |
| 13 | var styler: OrgCodeStyler | |
| 14 | ||
| 15 | private var inline: OrgInlineStyler { OrgInlineStyler(options: options) } | |
| 16 | private var resolver: OrgURLResolver { OrgURLResolver(options: options) } | |
| 17 | ||
| 18 | func layout(_ document: OrgDocument) -> OrgLayout { | |
| 19 | var notes = OrgFootnotes(document: document) | |
| 20 | var layout = OrgLayout() | |
| 21 | layout.blocks = document.elements.flatMap { block($0, ¬es) } | |
| 22 | // Numbering is complete only after the whole body is walked, so the notes section is | |
| 23 | // built last — exactly as the HTML renderer does it. | |
| 24 | layout.footnotes = notes.notes.map { | |
| 25 | OrgFootnote(number: $0.number, text: text($0.content, ¬es, style: .footnote)) | |
| 26 | } | |
| 27 | return layout | |
| 28 | } | |
| 29 | ||
| 30 | private func text( | |
| 31 | _ objects: [OrgObject], _ notes: inout OrgFootnotes, style: Font.TextStyle = .body | |
| 32 | ) -> AttributedString { | |
| 33 | inline.attributed(objects, ¬es, style: style) | |
| 34 | } | |
| 35 | ||
| 36 | /// One element to zero or more blocks. Zero for a footnote definition (it moves to the | |
| 37 | /// notes section) and for an export block aimed at another backend. | |
| 38 | private func block(_ element: OrgElement, _ notes: inout OrgFootnotes) -> [OrgBlock] { | |
| 39 | switch element { | |
| 40 | case .heading(let heading): | |
| 41 | var title = AttributedString() | |
| 42 | if let todo = heading.todo { | |
| 43 | var keyword = AttributedString(todo + " ") | |
| 44 | keyword.foregroundColor = todo == "DONE" ? .green : .orange | |
| 45 | title += keyword | |
| 46 | } | |
| 47 | if let priority = heading.priority { | |
| 48 | var cookie = AttributedString("[#\(priority)] ") | |
| 49 | cookie.foregroundColor = .secondary | |
| 50 | title += cookie | |
| 51 | } | |
| 52 | let level = min(6, max(1, heading.level + options.headingLevelOffset)) | |
| 53 | title += text(heading.title, ¬es, style: Self.headingStyle(level)) | |
| 54 | for tag in heading.tags { | |
| 55 | var chip = AttributedString(" \(tag)") | |
| 56 | chip.foregroundColor = .secondary | |
| 57 | chip.font = .system(.caption, design: .monospaced) | |
| 58 | title += chip | |
| 59 | } | |
| 60 | return [.heading(text: title, level: level)] | |
| 61 | ||
| 62 | case .paragraph(let objects): | |
| 63 | return [.paragraph(text(objects, ¬es))] | |
| 64 | ||
| 65 | case .list(let list): | |
| 66 | return [.list(listLayout(list, ¬es))] | |
| 67 | ||
| 68 | case .table(let table): | |
| 69 | return [.table(tableLayout(table, ¬es))] | |
| 70 | ||
| 71 | case .srcBlock(let language, let code): | |
| 72 | return [.code(styled(code: code, language: language))] | |
| 73 | ||
| 74 | case .exampleBlock(let raw): | |
| 75 | return [.code(styled(code: raw, language: nil))] | |
| 76 | ||
| 77 | case .quoteBlock(let children): | |
| 78 | return [.quote(children.flatMap { block($0, ¬es) })] | |
| 79 | ||
| 80 | case .centerBlock(let children): | |
| 81 | return [.center(children.flatMap { block($0, ¬es) })] | |
| 82 | ||
| 83 | case .verseBlock(let lines): | |
| 84 | return [.verse(lines.map { text($0, ¬es) })] | |
| 85 | ||
| 86 | case .specialBlock(let name, let children): | |
| 87 | return [.special(name: name, blocks: children.flatMap { block($0, ¬es) })] | |
| 88 | ||
| 89 | case .exportBlock(let backend, let raw): | |
| 90 | // HTML passes through verbatim in the HTML renderer; natively there is nothing to | |
| 91 | // pass it to, so it shows as the literal block it is rather than vanishing. | |
| 92 | return backend == "html" ? [.code(styled(code: raw, language: nil))] : [] | |
| 93 | ||
| 94 | case .figure(let figure): | |
| 95 | return [imageBlock(figure, ¬es)] | |
| 96 | ||
| 97 | case .captioned(_, let caption, let content): | |
| 98 | return [.captioned( | |
| 99 | caption: caption.map { text($0, ¬es, style: .footnote) }, | |
| 100 | blocks: block(content, ¬es) | |
| 101 | )] | |
| 102 | ||
| 103 | case .horizontalRule: | |
| 104 | return [.rule] | |
| 105 | ||
| 106 | case .footnoteDefinition: | |
| 107 | return [] // collected into the notes section | |
| 108 | } | |
| 109 | } | |
| 110 | ||
| 111 | /// An image whose source will not resolve to a safe absolute URL degrades to its alt | |
| 112 | /// text, the same trade the HTML renderer makes rather than pointing at something unsafe. | |
| 113 | /// | |
| 114 | /// The absolute requirement is the one place this diverges from HTML, which may emit a | |
| 115 | /// relative `src` for a document base URL to resolve. A view has no base, so a relative | |
| 116 | /// source is unloadable and showing the text beats showing a broken image. | |
| 117 | private func imageBlock(_ figure: OrgFigure, _ notes: inout OrgFootnotes) -> OrgBlock { | |
| 118 | let caption = figure.caption.map { text($0, ¬es, style: .footnote) } | |
| 119 | guard let string = resolver.imageSource(figure.source), | |
| 120 | let url = URL(string: string), url.scheme != nil else { | |
| 121 | return .paragraph(AttributedString(figure.alt ?? figure.source)) | |
| 122 | } | |
| 123 | let alt = figure.alt ?? figure.caption.map { OrgParser.plain($0) } ?? "" | |
| 124 | return .image(source: url, alt: alt, caption: caption) | |
| 125 | } | |
| 126 | ||
| 127 | private func styled(code: String, language: String?) -> AttributedString { | |
| 128 | if let highlighted = styler.highlighted(code: code, language: language) { | |
| 129 | return highlighted | |
| 130 | } | |
| 131 | var plain = AttributedString(code) | |
| 132 | plain.font = .system(.footnote, design: .monospaced) | |
| 133 | return plain | |
| 134 | } | |
| 135 | ||
| 136 | private func listLayout(_ list: OrgList, _ notes: inout OrgFootnotes) -> OrgListLayout { | |
| 137 | var items: [OrgListLayout.Item] = [] | |
| 138 | for item in list.items { | |
| 139 | items.append(OrgListLayout.Item( | |
| 140 | checkbox: item.checkbox, | |
| 141 | term: item.term.map { term in | |
| 142 | var styled = text(term, ¬es) | |
| 143 | styled.font = .body.bold() | |
| 144 | return styled | |
| 145 | }, | |
| 146 | paragraphs: item.content.map { text($0, ¬es) }, | |
| 147 | sublist: item.sublist.map { listLayout($0, ¬es) } | |
| 148 | )) | |
| 149 | } | |
| 150 | return OrgListLayout(kind: list.kind, items: items) | |
| 151 | } | |
| 152 | ||
| 153 | private func tableLayout(_ table: OrgTable, _ notes: inout OrgFootnotes) -> OrgTableLayout { | |
| 154 | var rows: [OrgTableLayout.Row] = [] | |
| 155 | for row in table.rows { | |
| 156 | guard case .cells(let cells) = row else { continue } | |
| 157 | rows.append(OrgTableLayout.Row(cells: cells.map { | |
| 158 | OrgTableLayout.Cell(text: text($0, ¬es, style: .subheadline)) | |
| 159 | })) | |
| 160 | } | |
| 161 | return OrgTableLayout( | |
| 162 | rows: rows, | |
| 163 | headerRowCount: table.headerRowCount, | |
| 164 | alignments: table.alignments | |
| 165 | ) | |
| 166 | } | |
| 167 | ||
| 168 | private static func headingStyle(_ level: Int) -> Font.TextStyle { | |
| 169 | switch level { | |
| 170 | case 1: .title | |
| 171 | case 2: .title2 | |
| 172 | case 3: .title3 | |
| 173 | case 4: .headline | |
| 174 | default: .subheadline | |
| 175 | } | |
| 176 | } | |
| 177 | } | |
Sources/OrgSwiftUI/OrgListView.swift added +88
| @@ -0,0 +1,88 @@ | ||
| 1 | import OrgSwift | |
| 2 | import SwiftUI | |
| 3 | ||
| 4 | /// A list, recursing into its own sublists. | |
| 5 | /// | |
| 6 | /// SwiftUI has no list-layout primitive, so the marker sits in its own column and the content | |
| 7 | /// hangs beside it — wrapped lines line up under the text, not under the bullet. | |
| 8 | struct OrgListView: View { | |
| 9 | let list: OrgListLayout | |
| 10 | /// Ordered numbering restarts at each nesting level, as in org. | |
| 11 | var body: some View { | |
| 12 | VStack(alignment: .leading, spacing: 6) { | |
| 13 | ForEach(Array(list.items.enumerated()), id: \.element.id) { index, item in | |
| 14 | if list.kind == .description { | |
| 15 | descriptionItem(item) | |
| 16 | } else { | |
| 17 | markedItem(item, marker: marker(at: index)) | |
| 18 | } | |
| 19 | } | |
| 20 | } | |
| 21 | } | |
| 22 | ||
| 23 | private func marker(at index: Int) -> String { | |
| 24 | list.kind == .ordered ? "\(index + 1)." : "•" | |
| 25 | } | |
| 26 | ||
| 27 | private func markedItem(_ item: OrgListLayout.Item, marker: String) -> some View { | |
| 28 | HStack(alignment: .firstTextBaseline, spacing: 8) { | |
| 29 | Text(marker) | |
| 30 | .monospacedDigit() | |
| 31 | .foregroundStyle(.secondary) | |
| 32 | VStack(alignment: .leading, spacing: 6) { | |
| 33 | if let checkbox = item.checkbox { | |
| 34 | HStack(alignment: .firstTextBaseline, spacing: 6) { | |
| 35 | Image(systemName: Self.symbol(for: checkbox)) | |
| 36 | .foregroundStyle(checkbox == .on ? Color.accentColor : .secondary) | |
| 37 | .accessibilityLabel(Self.label(for: checkbox)) | |
| 38 | paragraphs(item) | |
| 39 | } | |
| 40 | } else { | |
| 41 | paragraphs(item) | |
| 42 | } | |
| 43 | if let sublist = item.sublist { | |
| 44 | OrgListView(list: sublist) | |
| 45 | } | |
| 46 | } | |
| 47 | } | |
| 48 | } | |
| 49 | ||
| 50 | /// `term :: definition` — the term above, the definition indented under it. | |
| 51 | private func descriptionItem(_ item: OrgListLayout.Item) -> some View { | |
| 52 | VStack(alignment: .leading, spacing: 2) { | |
| 53 | if let term = item.term { | |
| 54 | Text(term).fixedSize(horizontal: false, vertical: true) | |
| 55 | } | |
| 56 | paragraphs(item).padding(.leading, 16) | |
| 57 | if let sublist = item.sublist { | |
| 58 | OrgListView(list: sublist).padding(.leading, 16) | |
| 59 | } | |
| 60 | } | |
| 61 | } | |
| 62 | ||
| 63 | private func paragraphs(_ item: OrgListLayout.Item) -> some View { | |
| 64 | VStack(alignment: .leading, spacing: 6) { | |
| 65 | ForEach(Array(item.paragraphs.enumerated()), id: \.offset) { _, paragraph in | |
| 66 | Text(paragraph) | |
| 67 | .fixedSize(horizontal: false, vertical: true) | |
| 68 | .frame(maxWidth: .infinity, alignment: .leading) | |
| 69 | } | |
| 70 | } | |
| 71 | } | |
| 72 | ||
| 73 | private static func symbol(for checkbox: OrgCheckbox) -> String { | |
| 74 | switch checkbox { | |
| 75 | case .on: "checkmark.square.fill" | |
| 76 | case .off: "square" | |
| 77 | case .partial: "minus.square" | |
| 78 | } | |
| 79 | } | |
| 80 | ||
| 81 | private static func label(for checkbox: OrgCheckbox) -> String { | |
| 82 | switch checkbox { | |
| 83 | case .on: "Done" | |
| 84 | case .off: "Not done" | |
| 85 | case .partial: "Partly done" | |
| 86 | } | |
| 87 | } | |
| 88 | } | |
Sources/OrgSwiftUI/OrgTableView.swift added +56
| @@ -0,0 +1,56 @@ | ||
| 1 | import OrgSwift | |
| 2 | import SwiftUI | |
| 3 | ||
| 4 | /// A table, in a `Grid` that scrolls sideways when it is wider than the screen. | |
| 5 | /// | |
| 6 | /// Column alignment comes from org's `|---+:---:+---|` separator row. `Grid` takes it from | |
| 7 | /// one cell per column, so it is set on the first row and inherited down. | |
| 8 | struct OrgTableView: View { | |
| 9 | let table: OrgTableLayout | |
| 10 | ||
| 11 | var body: some View { | |
| 12 | ScrollView(.horizontal, showsIndicators: false) { | |
| 13 | Grid(alignment: .topLeading, horizontalSpacing: 14, verticalSpacing: 7) { | |
| 14 | ForEach(Array(table.rows.enumerated()), id: \.element.id) { index, row in | |
| 15 | GridRow { | |
| 16 | ForEach(0 ..< table.columnCount, id: \.self) { column in | |
| 17 | cell(row, column: column, isHeader: index < table.headerRowCount) | |
| 18 | } | |
| 19 | } | |
| 20 | // The rule row separating the header band from the body. | |
| 21 | if index + 1 == table.headerRowCount { | |
| 22 | Divider().gridCellColumns(table.columnCount) | |
| 23 | } | |
| 24 | } | |
| 25 | } | |
| 26 | .padding(.vertical, 2) | |
| 27 | } | |
| 28 | } | |
| 29 | ||
| 30 | @ViewBuilder | |
| 31 | private func cell(_ row: OrgTableLayout.Row, column: Int, isHeader: Bool) -> some View { | |
| 32 | // Org tolerates ragged rows; a missing cell is empty, not a crash. | |
| 33 | let text = column < row.cells.count ? row.cells[column].text : AttributedString() | |
| 34 | Text(text) | |
| 35 | .fontWeight(isHeader ? .semibold : .regular) | |
| 36 | .multilineTextAlignment(Self.textAlignment(table.alignment(column: column))) | |
| 37 | .fixedSize(horizontal: false, vertical: true) | |
| 38 | .gridColumnAlignment(Self.gridAlignment(table.alignment(column: column))) | |
| 39 | } | |
| 40 | ||
| 41 | private static func gridAlignment(_ alignment: OrgAlignment?) -> HorizontalAlignment { | |
| 42 | switch alignment { | |
| 43 | case .center: .center | |
| 44 | case .right: .trailing | |
| 45 | case .left, nil: .leading | |
| 46 | } | |
| 47 | } | |
| 48 | ||
| 49 | private static func textAlignment(_ alignment: OrgAlignment?) -> TextAlignment { | |
| 50 | switch alignment { | |
| 51 | case .center: .center | |
| 52 | case .right: .trailing | |
| 53 | case .left, nil: .leading | |
| 54 | } | |
| 55 | } | |
| 56 | } | |
Sources/OrgSwiftUI/OrgView.swift added +70
| @@ -0,0 +1,70 @@ | ||
| 1 | import OrgSwift | |
| 2 | import 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, so scrolling and Dynamic Type changes do not | |
| 13 | /// re-derive them. Text is selectable, links open through the environment's `openURL`, and | |
| 14 | /// everything scales with Dynamic Type. | |
| 15 | public struct OrgView: View { | |
| 16 | private let source: String | |
| 17 | private let options: OrgRenderOptions | |
| 18 | private let styler: OrgCodeStyler | |
| 19 | ||
| 20 | @State private var layout = OrgLayout() | |
| 21 | ||
| 22 | public init( | |
| 23 | _ source: String, | |
| 24 | options: OrgRenderOptions = .init(), | |
| 25 | styler: OrgCodeStyler = PlainOrgCodeStyler() | |
| 26 | ) { | |
| 27 | self.source = source | |
| 28 | self.options = options | |
| 29 | self.styler = styler | |
| 30 | } | |
| 31 | ||
| 32 | public var body: some View { | |
| 33 | VStack(alignment: .leading, spacing: 12) { | |
| 34 | OrgBlockListView(blocks: layout.blocks) | |
| 35 | if !layout.footnotes.isEmpty { | |
| 36 | OrgFootnotesView(footnotes: layout.footnotes) | |
| 37 | } | |
| 38 | } | |
| 39 | .frame(maxWidth: .infinity, alignment: .leading) | |
| 40 | .textSelection(.enabled) | |
| 41 | .task(id: source) { | |
| 42 | layout = OrgLayoutBuilder(options: options, styler: styler) | |
| 43 | .layout(OrgParser.parse(source)) | |
| 44 | } | |
| 45 | } | |
| 46 | } | |
| 47 | ||
| 48 | /// The notes section org's exporter puts at the end of a document. | |
| 49 | struct OrgFootnotesView: View { | |
| 50 | let footnotes: [OrgFootnote] | |
| 51 | ||
| 52 | var body: some View { | |
| 53 | VStack(alignment: .leading, spacing: 6) { | |
| 54 | Divider() | |
| 55 | ForEach(footnotes) { note in | |
| 56 | HStack(alignment: .firstTextBaseline, spacing: 6) { | |
| 57 | Text("\(note.number).") | |
| 58 | .font(.footnote) | |
| 59 | .monospacedDigit() | |
| 60 | .foregroundStyle(.secondary) | |
| 61 | Text(note.text) | |
| 62 | .fixedSize(horizontal: false, vertical: true) | |
| 63 | .frame(maxWidth: .infinity, alignment: .leading) | |
| 64 | } | |
| 65 | } | |
| 66 | } | |
| 67 | .padding(.top, 8) | |
| 68 | .accessibilityLabel("Footnotes") | |
| 69 | } | |
| 70 | } | |
Tests/OrgSwiftUITests/OrgLayoutTests.swift added +193
| @@ -0,0 +1,193 @@ | ||
| 1 | import Foundation | |
| 2 | import SwiftUI | |
| 3 | import Testing | |
| 4 | @testable import OrgSwift | |
| 5 | @testable import OrgSwiftUI | |
| 6 | ||
| 7 | /// Tests for the third renderer over the shared tree: `OrgDocument → OrgLayout`. | |
| 8 | /// | |
| 9 | /// The views themselves are layout over this model, so the model is where the org behaviour | |
| 10 | /// lives and where it is worth asserting. | |
| 11 | @MainActor | |
| 12 | struct OrgLayoutTests { | |
| 13 | ||
| 14 | private func layout( | |
| 15 | _ source: String, | |
| 16 | options: OrgRenderOptions = .init(), | |
| 17 | styler: OrgCodeStyler = PlainOrgCodeStyler() | |
| 18 | ) -> OrgLayout { | |
| 19 | OrgLayoutBuilder(options: options, styler: styler).layout(OrgParser.parse(source)) | |
| 20 | } | |
| 21 | ||
| 22 | private func plain(_ string: AttributedString) -> String { String(string.characters) } | |
| 23 | ||
| 24 | @Test | |
| 25 | func blocksCoverTheElementTree() { | |
| 26 | let blocks = layout(""" | |
| 27 | * Heading | |
| 28 | ||
| 29 | A paragraph. | |
| 30 | ||
| 31 | - one | |
| 32 | - two | |
| 33 | ||
| 34 | | a | b | | |
| 35 | |---+---| | |
| 36 | | 1 | 2 | | |
| 37 | ||
| 38 | #+begin_src swift | |
| 39 | let x = 1 | |
| 40 | #+end_src | |
| 41 | ||
| 42 | #+begin_quote | |
| 43 | quoted | |
| 44 | #+end_quote | |
| 45 | ||
| 46 | ----- | |
| 47 | """).blocks | |
| 48 | ||
| 49 | func has(_ predicate: (OrgBlock) -> Bool) -> Bool { blocks.contains(where: predicate) } | |
| 50 | #expect(has { if case .heading = $0 { true } else { false } }) | |
| 51 | #expect(has { if case .paragraph = $0 { true } else { false } }) | |
| 52 | #expect(has { if case .list = $0 { true } else { false } }) | |
| 53 | #expect(has { if case .table = $0 { true } else { false } }) | |
| 54 | #expect(has { if case .code = $0 { true } else { false } }) | |
| 55 | #expect(has { if case .quote = $0 { true } else { false } }) | |
| 56 | #expect(has { if case .rule = $0 { true } else { false } }) | |
| 57 | } | |
| 58 | ||
| 59 | /// Numbering is document-wide and the definitions leave the body, exactly as in HTML. | |
| 60 | @Test | |
| 61 | func footnotesAreNumberedAndMovedToTheSection() { | |
| 62 | let result = layout(""" | |
| 63 | First[fn:b] and second[fn:a]. | |
| 64 | ||
| 65 | [fn:a] note a | |
| 66 | [fn:b] note b | |
| 67 | """) | |
| 68 | ||
| 69 | #expect(result.footnotes.map(\.number) == [1, 2]) | |
| 70 | #expect(plain(result.footnotes[0].text) == "note b") | |
| 71 | #expect(plain(result.footnotes[1].text) == "note a") | |
| 72 | ||
| 73 | guard case .paragraph(let text) = result.blocks[0] else { | |
| 74 | Issue.record("expected a paragraph"); return | |
| 75 | } | |
| 76 | #expect(plain(text) == "First[1] and second[2].") | |
| 77 | // The definitions are not also blocks in the body. | |
| 78 | #expect(result.blocks.count == 1) | |
| 79 | } | |
| 80 | ||
| 81 | @Test | |
| 82 | func nestedListsRecurse() { | |
| 83 | guard case .list(let list) = layout(""" | |
| 84 | - outer | |
| 85 | - inner | |
| 86 | - deepest | |
| 87 | """).blocks.first else { | |
| 88 | Issue.record("expected a list"); return | |
| 89 | } | |
| 90 | #expect(list.kind == .unordered) | |
| 91 | #expect(plain(list.items[0].paragraphs[0]) == "outer") | |
| 92 | let inner = list.items[0].sublist | |
| 93 | #expect(plain(inner?.items[0].paragraphs[0] ?? "") == "inner") | |
| 94 | #expect(plain(inner?.items[0].sublist?.items[0].paragraphs[0] ?? "") == "deepest") | |
| 95 | } | |
| 96 | ||
| 97 | @Test | |
| 98 | func checkboxesAndDescriptionListsSurvive() { | |
| 99 | guard case .list(let tasks) = layout("- [X] done\n- [ ] todo").blocks.first else { | |
| 100 | Issue.record("expected a list"); return | |
| 101 | } | |
| 102 | #expect(tasks.items.map(\.checkbox) == [.on, .off]) | |
| 103 | ||
| 104 | guard case .list(let described) = layout("- term :: definition").blocks.first else { | |
| 105 | Issue.record("expected a description list"); return | |
| 106 | } | |
| 107 | #expect(described.kind == .description) | |
| 108 | #expect(plain(described.items[0].term ?? "") == "term") | |
| 109 | #expect(plain(described.items[0].paragraphs[0]) == "definition") | |
| 110 | } | |
| 111 | ||
| 112 | /// Rule rows are structure, not content: they set the header band and then disappear. | |
| 113 | @Test | |
| 114 | func tableDropsRuleRowsAndKeepsAlignments() { | |
| 115 | guard case .table(let table) = layout(""" | |
| 116 | | name | count | | |
| 117 | |------+------:| | |
| 118 | | a | 1 | | |
| 119 | | b | 2 | | |
| 120 | """).blocks.first else { | |
| 121 | Issue.record("expected a table"); return | |
| 122 | } | |
| 123 | #expect(table.rows.count == 3) | |
| 124 | #expect(table.headerRowCount == 1) | |
| 125 | #expect(table.columnCount == 2) | |
| 126 | #expect(table.alignment(column: 1) == .right) | |
| 127 | #expect(plain(table.rows[0].cells[0].text) == "name") | |
| 128 | #expect(plain(table.rows[2].cells[1].text) == "2") | |
| 129 | } | |
| 130 | ||
| 131 | @Test | |
| 132 | func imagesResolveRelativeSourcesAndDegradeWhenUnsafe() { | |
| 133 | let options = OrgRenderOptions( | |
| 134 | host: "gitbay.org", owner: "krz", repositoryName: "org-swift", | |
| 135 | ref: "HEAD", readmePath: "README.org", imagePathSegment: "raw" | |
| 136 | ) | |
| 137 | guard case .image(let source, _, _) = layout("[[file:docs/logo.png]]", options: options) | |
| 138 | .blocks.first else { | |
| 139 | Issue.record("expected an image"); return | |
| 140 | } | |
| 141 | #expect(source.absoluteString == "https://gitbay.org/krz/org-swift/raw/HEAD/docs/logo.png") | |
| 142 | ||
| 143 | // With no repository context the relative path cannot be made absolute, so it shows | |
| 144 | // as text rather than as a broken image. | |
| 145 | guard case .paragraph(let text) = layout("[[file:docs/logo.png]]").blocks.first else { | |
| 146 | Issue.record("expected a paragraph fallback"); return | |
| 147 | } | |
| 148 | #expect(plain(text) == "docs/logo.png") | |
| 149 | } | |
| 150 | ||
| 151 | @Test | |
| 152 | func codeStylerIsUsedWhenItAnswers() { | |
| 153 | struct Stub: OrgCodeStyler { | |
| 154 | func highlighted(code: String, language: String?) -> AttributedString? { | |
| 155 | language == "swift" ? AttributedString("STYLED:\(code.count)") : nil | |
| 156 | } | |
| 157 | } | |
| 158 | guard case .code(let styled) = layout( | |
| 159 | "#+begin_src swift\nlet x = 1\n#+end_src", styler: Stub() | |
| 160 | ).blocks.first else { | |
| 161 | Issue.record("expected a code block"); return | |
| 162 | } | |
| 163 | #expect(plain(styled).hasPrefix("STYLED:")) | |
| 164 | ||
| 165 | // An unknown language falls back to plain monospaced text, never to nothing. | |
| 166 | guard case .code(let plainCode) = layout( | |
| 167 | "#+begin_src brainfuck\n+++\n#+end_src", styler: Stub() | |
| 168 | ).blocks.first else { | |
| 169 | Issue.record("expected a code block"); return | |
| 170 | } | |
| 171 | #expect(plain(plainCode) == "+++") | |
| 172 | } | |
| 173 | ||
| 174 | /// HTML export has nowhere to go natively, so it shows as the literal block it is; other | |
| 175 | /// backends drop, as they do in HTML. | |
| 176 | @Test | |
| 177 | func exportBlocksShowHTMLLiterallyAndDropOtherBackends() { | |
| 178 | #expect(layout("#+begin_export html\n<b>hi</b>\n#+end_export").blocks.count == 1) | |
| 179 | #expect(layout("#+begin_export latex\n\\emph{hi}\n#+end_export").blocks.isEmpty) | |
| 180 | } | |
| 181 | ||
| 182 | /// Emphasis nests, and the styler turns intents into fonts rather than dropping them. | |
| 183 | @Test | |
| 184 | func inlineStylingCarriesNestedEmphasis() { | |
| 185 | guard case .paragraph(let text) = layout("*bold /italic/* and ~code~").blocks.first else { | |
| 186 | Issue.record("expected a paragraph"); return | |
| 187 | } | |
| 188 | #expect(plain(text) == "bold italic and code") | |
| 189 | // Every emphasised or code run got a concrete font; unstyled runs inherit the view's. | |
| 190 | let fonts = text.runs.compactMap(\.font) | |
| 191 | #expect(fonts.count >= 3) | |
| 192 | } | |
| 193 | } | |