Commit dedfbafd51
Unsigned
Layout: unified · split
Package.swift +9 −2
| @@ -8,13 +8,20 @@ let package = Package( | |||
| 8 | .iOS(.v16) | 8 | .iOS(.v16) |
| 9 | ], | 9 | ], |
| 10 | products: [ | 10 | products: [ |
| 11 | .library(name: "OrgSwift", targets: ["OrgSwift"]) | 11 | .library(name: "OrgSwift", targets: ["OrgSwift"]), |
| 12 | .library(name: "OrgSwiftUI", targets: ["OrgSwiftUI"]) | ||
| 12 | ], | 13 | ], |
| 13 | targets: [ | 14 | targets: [ |
| 14 | .target( | 15 | .target( |
| 15 | name: "OrgSwift", | 16 | name: "OrgSwift", |
| 16 | swiftSettings: [.enableUpcomingFeature("BareSlashRegexLiterals")] | 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 | } | ||