Commit 1873fef702
1873fef70216e46ea03a1e72575fd83dd8b40cf2
parent: 77bb69d3d0
Unsigned
cmc <hello@cleberg.net> · 2026-08-31 18:32 UTC
Take TODO/DONE colors from the caller
The renderer hardcoded green for DONE and orange for TODO, which match no
embedding app's palette — everything else it draws is semantic and inherits
the app's appearance. OrgKeywordStyle carries the two colors and defaults
to semantic, so the package invents no hues of its own.
OrgRenderOptions stays Foundation-only, so this lives in OrgSwiftUI.
Layout: unified · split
ARCHITECTURE.md
+6
| @@ -25,6 +25,7 @@ Sources/OrgSwiftUI/ depends on OrgSwift |
| 25 | OrgInlineStyler.swift [OrgObject] → styled AttributedString |
25 | OrgInlineStyler.swift [OrgObject] → styled AttributedString |
| 26 | OrgBlockView.swift, OrgListView.swift, OrgTableView.swift |
26 | OrgBlockView.swift, OrgListView.swift, OrgTableView.swift |
| 27 | OrgCodeStyler.swift the app's syntax highlighter, as a protocol |
27 | OrgCodeStyler.swift the app's syntax highlighter, as a protocol |
| |
28 | OrgKeywordStyle.swift the app's colors for TODO/DONE |
| 28 | ``` |
29 | ``` |
| 29 | |
30 | |
| 30 | `OrgRenderer.renderToHTML` is a thin façade over `OrgParser.parse` + `OrgHTMLTreeRenderer`. |
31 | `OrgRenderer.renderToHTML` is a thin façade over `OrgParser.parse` + `OrgHTMLTreeRenderer`. |
| @@ -104,6 +105,11 @@ Syntax highlighting arrives through `OrgCodeStyler`, the native counterpart to |
| 104 | `CodeHighlighter` — returning `AttributedString` where the other returns HTML. Keeping it a |
105 | `CodeHighlighter` — returning `AttributedString` where the other returns HTML. Keeping it a |
| 105 | protocol is what lets the package stay dependency-free. |
106 | protocol is what lets the package stay dependency-free. |
| 106 | |
107 | |
| |
108 | Color follows the same principle. Everything the renderer draws uses a semantic color, so it |
| |
109 | inherits the embedding app's appearance; the one place org carries status rather than prose — |
| |
110 | `TODO`/`DONE` — is configurable through `OrgKeywordStyle` and defaults to semantic too. A |
| |
111 | package that picks concrete hues puts colors on screen that match nothing around them. |
| |
112 | |
| 107 | One behaviour deliberately differs from HTML. An image source that will not resolve to an |
113 | One behaviour deliberately differs from HTML. An image source that will not resolve to an |
| 108 | **absolute** URL degrades to its alt text: HTML may emit a relative `src` for a document base |
114 | **absolute** URL degrades to its alt text: HTML may emit a relative `src` for a document base |
| 109 | URL to resolve, and a view has no base, so a relative source is unloadable. |
115 | URL to resolve, and a view has no base, so a relative source is unloadable. |
README.md
+8
| @@ -89,6 +89,14 @@ struct Highlighter: OrgCodeStyler { |
| 89 | OrgView(orgSource, options: options, styler: Highlighter()) |
89 | OrgView(orgSource, options: options, styler: Highlighter()) |
| 90 | ``` |
90 | ``` |
| 91 | |
91 | |
| |
92 | Org's `TODO`/`DONE` keywords default to semantic colors, because a package |
| |
93 | cannot know your palette. Pass your own: |
| |
94 | |
| |
95 | ```swift |
| |
96 | OrgView(orgSource, options: options, |
| |
97 | keywords: OrgKeywordStyle(todo: .gbOK, done: .gbDone)) |
| |
98 | ``` |
| |
99 | |
| 92 | ### Title and heading level |
100 | ### Title and heading level |
| 93 | |
101 | |
| 94 | Two presentation knobs, both defaulting to how Hutch rendered: |
102 | Two presentation knobs, both defaulting to how Hutch rendered: |
Sources/OrgSwiftUI/OrgKeywordStyle.swift
added
+23
| @@ -0,0 +1,23 @@ |
| |
1 | import SwiftUI |
| |
2 | |
| |
3 | /// Colors for the part of a heading that is status rather than prose: org's `TODO` and |
| |
4 | /// `DONE` keywords. |
| |
5 | /// |
| |
6 | /// The defaults are deliberately semantic rather than literal. A package cannot know the |
| |
7 | /// palette of the app embedding it, and picking concrete hues — green for done, orange for |
| |
8 | /// todo — puts colors on screen that belong to no design system and match nothing around |
| |
9 | /// them. Apps pass their own. |
| |
10 | public struct OrgKeywordStyle: Sendable { |
| |
11 | public var todo: Color |
| |
12 | public var done: Color |
| |
13 | |
| |
14 | public init(todo: Color = .secondary, done: Color = .secondary) { |
| |
15 | self.todo = todo |
| |
16 | self.done = done |
| |
17 | } |
| |
18 | |
| |
19 | /// The keyword's color, or nil for a keyword that is neither. |
| |
20 | func color(for keyword: String) -> Color { |
| |
21 | keyword == "DONE" ? done : todo |
| |
22 | } |
| |
23 | } |
Sources/OrgSwiftUI/OrgLayoutBuilder.swift
+2 −1
| @@ -11,6 +11,7 @@ import SwiftUI |
| 11 | struct OrgLayoutBuilder { |
11 | struct OrgLayoutBuilder { |
| 12 | var options: OrgRenderOptions |
12 | var options: OrgRenderOptions |
| 13 | var styler: OrgCodeStyler |
13 | var styler: OrgCodeStyler |
| |
14 | var keywords: OrgKeywordStyle |
| 14 | |
15 | |
| 15 | private var inline: OrgInlineStyler { OrgInlineStyler(options: options) } |
16 | private var inline: OrgInlineStyler { OrgInlineStyler(options: options) } |
| 16 | private var resolver: OrgURLResolver { OrgURLResolver(options: options) } |
17 | private var resolver: OrgURLResolver { OrgURLResolver(options: options) } |
| @@ -42,7 +43,7 @@ struct OrgLayoutBuilder { |
| 42 | var title = AttributedString() |
43 | var title = AttributedString() |
| 43 | if let todo = heading.todo { |
44 | if let todo = heading.todo { |
| 44 | var keyword = AttributedString(todo + " ") |
45 | var keyword = AttributedString(todo + " ") |
| 45 | keyword.foregroundColor = todo == "DONE" ? .green : .orange |
46 | keyword.foregroundColor = keywords.color(for: todo) |
| 46 | title += keyword |
47 | title += keyword |
| 47 | } |
48 | } |
| 48 | if let priority = heading.priority { |
49 | if let priority = heading.priority { |
Sources/OrgSwiftUI/OrgView.swift
+5 −2
| @@ -17,6 +17,7 @@ public struct OrgView: View { |
| 17 | private let source: String |
17 | private let source: String |
| 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 | |
21 | |
| 21 | @Environment(\.colorScheme) private var colorScheme |
22 | @Environment(\.colorScheme) private var colorScheme |
| 22 | @State private var layout = OrgLayout() |
23 | @State private var layout = OrgLayout() |
| @@ -24,11 +25,13 @@ public struct OrgView: View { |
| 24 | public init( |
25 | public init( |
| 25 | _ source: String, |
26 | _ source: String, |
| 26 | options: OrgRenderOptions = .init(), |
27 | options: OrgRenderOptions = .init(), |
| 27 | styler: OrgCodeStyler = PlainOrgCodeStyler() |
28 | styler: OrgCodeStyler = PlainOrgCodeStyler(), |
| |
29 | keywords: OrgKeywordStyle = .init() |
| 28 | ) { |
30 | ) { |
| 29 | self.source = source |
31 | self.source = source |
| 30 | self.options = options |
32 | self.options = options |
| 31 | self.styler = styler |
33 | self.styler = styler |
| |
34 | self.keywords = keywords |
| 32 | } |
35 | } |
| 33 | |
36 | |
| 34 | public var body: some View { |
37 | public var body: some View { |
| @@ -41,7 +44,7 @@ public struct OrgView: View { |
| 41 | .frame(maxWidth: .infinity, alignment: .leading) |
44 | .frame(maxWidth: .infinity, alignment: .leading) |
| 42 | .textSelection(.enabled) |
45 | .textSelection(.enabled) |
| 43 | .task(id: LayoutKey(source: source, colorScheme: colorScheme)) { |
46 | .task(id: LayoutKey(source: source, colorScheme: colorScheme)) { |
| 44 | layout = OrgLayoutBuilder(options: options, styler: styler) |
47 | layout = OrgLayoutBuilder(options: options, styler: styler, keywords: keywords) |
| 45 | .layout(OrgParser.parse(source)) |
48 | .layout(OrgParser.parse(source)) |
| 46 | } |
49 | } |
| 47 | } |
50 | } |
Tests/OrgSwiftUITests/OrgLayoutTests.swift
+29 −2
| @@ -14,9 +14,11 @@ struct OrgLayoutTests { |
| 14 | private func layout( |
14 | private func layout( |
| 15 | _ source: String, |
15 | _ source: String, |
| 16 | options: OrgRenderOptions = .init(), |
16 | options: OrgRenderOptions = .init(), |
| 17 | styler: OrgCodeStyler = PlainOrgCodeStyler() |
17 | styler: OrgCodeStyler = PlainOrgCodeStyler(), |
| |
18 | keywords: OrgKeywordStyle = .init() |
| 18 | ) -> OrgLayout { |
19 | ) -> OrgLayout { |
| 19 | OrgLayoutBuilder(options: options, styler: styler).layout(OrgParser.parse(source)) |
20 | OrgLayoutBuilder(options: options, styler: styler, keywords: keywords) |
| |
21 | .layout(OrgParser.parse(source)) |
| 20 | } |
22 | } |
| 21 | |
23 | |
| 22 | private func plain(_ string: AttributedString) -> String { String(string.characters) } |
24 | private func plain(_ string: AttributedString) -> String { String(string.characters) } |
| @@ -179,6 +181,31 @@ struct OrgLayoutTests { |
| 179 | #expect(layout("#+begin_export latex\n\\emph{hi}\n#+end_export").blocks.isEmpty) |
181 | #expect(layout("#+begin_export latex\n\\emph{hi}\n#+end_export").blocks.isEmpty) |
| 180 | } |
182 | } |
| 181 | |
183 | |
| |
184 | /// Keyword colors come from the caller. The package defaults to semantic colors rather |
| |
185 | /// than inventing hues that match no app's palette. |
| |
186 | @Test |
| |
187 | func todoKeywordsTakeTheirColorFromTheCaller() { |
| |
188 | let style = OrgKeywordStyle(todo: .pink, done: .brown) |
| |
189 | |
| |
190 | guard case .heading(let todo, _) = layout("* TODO Ship it", keywords: style) |
| |
191 | .blocks.first else { |
| |
192 | Issue.record("expected a heading"); return |
| |
193 | } |
| |
194 | #expect(todo.runs.contains { $0.foregroundColor == .pink }) |
| |
195 | |
| |
196 | guard case .heading(let done, _) = layout("* DONE Shipped", keywords: style) |
| |
197 | .blocks.first else { |
| |
198 | Issue.record("expected a heading"); return |
| |
199 | } |
| |
200 | #expect(done.runs.contains { $0.foregroundColor == .brown }) |
| |
201 | |
| |
202 | // The default invents nothing. |
| |
203 | guard case .heading(let plain, _) = layout("* TODO Ship it").blocks.first else { |
| |
204 | Issue.record("expected a heading"); return |
| |
205 | } |
| |
206 | #expect(plain.runs.allSatisfy { $0.foregroundColor != .green && $0.foregroundColor != .orange }) |
| |
207 | } |
| |
208 | |
| 182 | /// Every run carries a font, not only the emphasised ones. A plain heading run with no |
209 | /// Every run carries a font, not only the emphasised ones. A plain heading run with no |
| 183 | /// font would inherit the surrounding view's and render at body size. |
210 | /// font would inherit the surrounding view's and render at body size. |
| 184 | @Test |
211 | @Test |