Add a textTransform hook to OrgView !9
4 files changed, +58 −3
Layout: unified · split
README.md +10
| @@ -108,6 +108,16 @@ OrgView(orgSource, options: options, | |||
| 108 | keywords: OrgKeywordStyle(todo: .gbOK, done: .gbDone)) | 108 | keywords: OrgKeywordStyle(todo: .gbOK, done: .gbDone)) |
| 109 | ``` | 109 | ``` |
| 110 | 110 | ||
| 111 | To add links or styling of your own to prose, pass a `textTransform`. It gets each | ||
| 112 | styled run of prose, never a code block; code and verbatim runs carry the `.code` | ||
| 113 | inline presentation intent and links carry `.link`: | ||
| 114 | |||
| 115 | ```swift | ||
| 116 | OrgView(orgSource, options: options, textTransform: { text in | ||
| 117 | // e.g. link issue references outside code and existing links | ||
| 118 | }) | ||
| 119 | ``` | ||
| 120 | |||
| 111 | ### Title and heading level | 121 | ### Title and heading level |
| 112 | 122 | ||
| 113 | Two presentation knobs, both defaulting to how Hutch rendered: | 123 | Two presentation knobs, both defaulting to how Hutch rendered: |
Sources/OrgSwiftUI/OrgLayoutBuilder.swift +4 −1
| @@ -12,6 +12,7 @@ struct OrgLayoutBuilder { | |||
| 12 | var options: OrgRenderOptions | 12 | var options: OrgRenderOptions |
| 13 | var styler: OrgCodeStyler | 13 | var styler: OrgCodeStyler |
| 14 | var keywords: OrgKeywordStyle | 14 | var keywords: OrgKeywordStyle |
| 15 | var textTransform: ((inout AttributedString) -> Void)? = nil | ||
| 15 | 16 | ||
| 16 | private var inline: OrgInlineStyler { OrgInlineStyler(options: options) } | 17 | private var inline: OrgInlineStyler { OrgInlineStyler(options: options) } |
| 17 | private var resolver: OrgURLResolver { OrgURLResolver(options: options) } | 18 | private var resolver: OrgURLResolver { OrgURLResolver(options: options) } |
| @@ -32,7 +33,9 @@ struct OrgLayoutBuilder { | |||
| 32 | _ objects: [OrgObject], _ notes: inout OrgFootnotes, | 33 | _ objects: [OrgObject], _ notes: inout OrgFootnotes, |
| 33 | style: Font.TextStyle = .body, bold: Bool = false | 34 | style: Font.TextStyle = .body, bold: Bool = false |
| 34 | ) -> AttributedString { | 35 | ) -> AttributedString { |
| 35 | inline.attributed(objects, ¬es, style: style, bold: bold) | 36 | var string = inline.attributed(objects, ¬es, style: style, bold: bold) |
| 37 | textTransform?(&string) | ||
| 38 | return string | ||
| 36 | } | 39 | } |
| 37 | 40 | ||
| 38 | /// One element to zero or more blocks. Zero for a footnote definition (it moves to the | 41 | /// One element to zero or more blocks. Zero for a footnote definition (it moves to the |
Sources/OrgSwiftUI/OrgView.swift +11 −2
| @@ -18,20 +18,28 @@ public struct OrgView: View { | |||
| 18 | private let options: OrgRenderOptions | 18 | private let options: OrgRenderOptions |
| 19 | private let styler: OrgCodeStyler | 19 | private let styler: OrgCodeStyler |
| 20 | private let keywords: OrgKeywordStyle | 20 | private let keywords: OrgKeywordStyle |
| 21 | private let textTransform: ((inout AttributedString) -> Void)? | ||
| 21 | 22 | ||
| 22 | @Environment(\.colorScheme) private var colorScheme | 23 | @Environment(\.colorScheme) private var colorScheme |
| 23 | @State private var layout = OrgLayout() | 24 | @State private var layout = OrgLayout() |
| 24 | 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. | ||
| 25 | public init( | 31 | public init( |
| 26 | _ source: String, | 32 | _ source: String, |
| 27 | options: OrgRenderOptions = .init(), | 33 | options: OrgRenderOptions = .init(), |
| 28 | styler: OrgCodeStyler = PlainOrgCodeStyler(), | 34 | styler: OrgCodeStyler = PlainOrgCodeStyler(), |
| 29 | keywords: OrgKeywordStyle = .init() | 35 | keywords: OrgKeywordStyle = .init(), |
| 36 | textTransform: ((inout AttributedString) -> Void)? = nil | ||
| 30 | ) { | 37 | ) { |
| 31 | self.source = source | 38 | self.source = source |
| 32 | self.options = options | 39 | self.options = options |
| 33 | self.styler = styler | 40 | self.styler = styler |
| 34 | self.keywords = keywords | 41 | self.keywords = keywords |
| 42 | self.textTransform = textTransform | ||
| 35 | } | 43 | } |
| 36 | 44 | ||
| 37 | public var body: some View { | 45 | public var body: some View { |
| @@ -44,7 +52,8 @@ public struct OrgView: View { | |||
| 44 | .frame(maxWidth: .infinity, alignment: .leading) | 52 | .frame(maxWidth: .infinity, alignment: .leading) |
| 45 | .textSelection(.enabled) | 53 | .textSelection(.enabled) |
| 46 | .task(id: LayoutKey(source: source, colorScheme: colorScheme)) { | 54 | .task(id: LayoutKey(source: source, colorScheme: colorScheme)) { |
| 47 | layout = OrgLayoutBuilder(options: options, styler: styler, keywords: keywords) | 55 | layout = OrgLayoutBuilder(options: options, styler: styler, keywords: keywords, |
| 56 | textTransform: textTransform) | ||
| 48 | .layout(OrgParser.parse(source)) | 57 | .layout(OrgParser.parse(source)) |
| 49 | } | 58 | } |
| 50 | } | 59 | } |
Tests/OrgSwiftUITests/OrgLayoutTests.swift +33
| @@ -343,4 +343,37 @@ struct OrgBadgeRowTests { | |||
| 343 | func anUnresolvableSourceIsNotABadgeRow() { | 343 | func anUnresolvableSourceIsNotABadgeRow() { |
| 344 | #expect(badges("[[https://example.com][file:badge.png]]") == nil) | 344 | #expect(badges("[[https://example.com][file:badge.png]]") == nil) |
| 345 | } | 345 | } |
| 346 | |||
| 347 | /// The transform sees each run of prose after styling, with code and links still marked, | ||
| 348 | /// and never sees a code block. | ||
| 349 | @Test | ||
| 350 | func textTransformRunsOnProseOnly() { | ||
| 351 | var seen: [String] = [] | ||
| 352 | let blocks = OrgLayoutBuilder( | ||
| 353 | options: .init(), styler: PlainOrgCodeStyler(), keywords: .init(), | ||
| 354 | textTransform: { text in | ||
| 355 | seen.append(String(text.characters)) | ||
| 356 | for run in text.runs where run.inlinePresentationIntent?.contains(.code) == true { | ||
| 357 | text[run.range].foregroundColor = .red | ||
| 358 | } | ||
| 359 | } | ||
| 360 | ).layout(OrgParser.parse(""" | ||
| 361 | * Title | ||
| 362 | |||
| 363 | Some =x= here. | ||
| 364 | |||
| 365 | | cell | | ||
| 366 | |||
| 367 | #+begin_src sh | ||
| 368 | echo hi | ||
| 369 | #+end_src | ||
| 370 | """)).blocks | ||
| 371 | |||
| 372 | #expect(seen == ["Title", "Some x here.", "cell"]) | ||
| 373 | guard case .paragraph(let text) = blocks[1] else { | ||
| 374 | Issue.record("expected a paragraph") | ||
| 375 | return | ||
| 376 | } | ||
| 377 | #expect(text.runs.contains { $0.foregroundColor == .red && String(text[$0.range].characters) == "x" }) | ||
| 378 | } | ||
| 346 | } | 379 | } |