Emphasis border rule, and caller-supplied TODO/DONE colors !4

merged merged by cmc on 2026-08-31 18:32 UTC · krz/org-swift:org-fixes into main

8 files changed, +102 −7

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
105protocol is what lets the package stay dependency-free. 106protocol is what lets the package stay dependency-free.
106 107
108Color follows the same principle. Everything the renderer draws uses a semantic color, so it
109inherits 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
111package that picks concrete hues puts colors on screen that match nothing around them.
112
107One behaviour deliberately differs from HTML. An image source that will not resolve to an 113One 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
109URL to resolve, and a view has no base, so a relative source is unloadable. 115URL to resolve, and a view has no base, so a relative source is unloadable.
README.md +8
@@ -89,6 +89,14 @@ struct Highlighter: OrgCodeStyler {
89OrgView(orgSource, options: options, styler: Highlighter()) 89OrgView(orgSource, options: options, styler: Highlighter())
90``` 90```
91 91
92Org's `TODO`/`DONE` keywords default to semantic colors, because a package
93cannot know your palette. Pass your own:
94
95```swift
96OrgView(orgSource, options: options,
97 keywords: OrgKeywordStyle(todo: .gbOK, done: .gbDone))
98```
99
92### Title and heading level 100### Title and heading level
93 101
94Two presentation knobs, both defaulting to how Hutch rendered: 102Two presentation knobs, both defaulting to how Hutch rendered:
Sources/OrgSwift/AST/OrgInlineParser.swift +8 −2
@@ -142,13 +142,19 @@ extension OrgParser {
142 142
143 private static func scanEmphasis(_ chars: [Character], from start: Int, marker: Character) 143 private static func scanEmphasis(_ chars: [Character], from start: Int, marker: Character)
144 -> (body: String, next: Int)? { 144 -> (body: String, next: Int)? {
145 // org's border rule: the body may not begin with whitespace, so an opener followed by
146 // a space is not an opener at all.
147 guard start + 1 < chars.count, !chars[start + 1].isWhitespace else { return nil }
148
145 var j = start + 1 149 var j = start + 1
146 var body = "" 150 var body = ""
147 while j < chars.count { 151 while j < chars.count {
148 if chars[j] == marker { 152 // The body may not end with whitespace either — which is what makes the closer of
153 // `=ssh.mode = "system"=` the last `=` and not the middle one.
154 if chars[j] == marker, let last = body.last, !last.isWhitespace {
149 // The closer must end the run or be followed by space/punctuation. 155 // The closer must end the run or be followed by space/punctuation.
150 let after = j + 1 < chars.count ? chars[j + 1] : " " 156 let after = j + 1 < chars.count ? chars[j + 1] : " "
151 if !body.isEmpty, after.isWhitespace || ".,;:!?)]}'\"".contains(after) || j + 1 == chars.count { 157 if after.isWhitespace || ".,;:!?)]}'\"".contains(after) || j + 1 == chars.count {
152 return (body, j + 1) 158 return (body, j + 1)
153 } 159 }
154 } 160 }
Sources/OrgSwiftUI/OrgKeywordStyle.swift added +23
@@ -0,0 +1,23 @@
1import 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.
10public 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
11struct OrgLayoutBuilder { 11struct 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/OrgSwiftTests/OrgTreeTests.swift +21
@@ -323,6 +323,27 @@ struct OrgRenderOptionsTests {
323 #expect(without.contains("<p>Body.</p>")) 323 #expect(without.contains("<p>Body.</p>"))
324 } 324 }
325 325
326 /// org's border rule: an emphasis body may not begin or end with whitespace. Without the
327 /// trailing half, `=ssh.mode = "system"=` closed at the middle `=` and rendered as two
328 /// verbatim spans with the quoted value loose between them.
329 @Test
330 func emphasisBodyCannotBeginOrEndWithWhitespace() {
331 let objects = OrgParser.parseInline(#"=ssh.mode = "system"= to run"#)
332 let verbatims = objects.compactMap { object -> String? in
333 if case .verbatim(let text) = object { return text } else { return nil }
334 }
335 #expect(verbatims == [#"ssh.mode = "system""#])
336
337 // A leading space means the marker never opens.
338 #expect(OrgParser.parseInline("= not verbatim=") == [.text("= not verbatim=")])
339
340 // The ordinary cases still work.
341 #expect(OrgParser.parseInline("=plain=") == [.verbatim("plain")])
342 let bold = OrgParser.parseInline("*bold* and *two words*")
343 #expect(bold.contains(.bold([.text("bold")])))
344 #expect(bold.contains(.bold([.text("two words")])))
345 }
346
326 @Test 347 @Test
327 func rejectsUnsafeSchemes() { 348 func rejectsUnsafeSchemes() {
328 let html = OrgRenderer.renderToHTML("[[javascript:alert(1)][click]]") 349 let html = OrgRenderer.renderToHTML("[[javascript:alert(1)][click]]")
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