krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Commit 253ae9fa74

253ae9fa745680741b628d814d5f4c978a74a978

parent: aa63d1a8ac

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 00:34 UTC

Add platform-free presentation policy

Layout: unified · split

Package.swift +5 −2
@@ -8,7 +8,8 @@ let package = Package(
8 .library(name: "OrgCore", targets: ["OrgCore"]), 8 .library(name: "OrgCore", targets: ["OrgCore"]),
9 .library(name: "OrgDocument", targets: ["OrgDocument"]), 9 .library(name: "OrgDocument", targets: ["OrgDocument"]),
10 .library(name: "OrgIndex", targets: ["OrgIndex"]), 10 .library(name: "OrgIndex", targets: ["OrgIndex"]),
11 .library(name: "OrgWorkspace", targets: ["OrgWorkspace"]) 11 .library(name: "OrgWorkspace", targets: ["OrgWorkspace"]),
12 .library(name: "OrgPresentation", targets: ["OrgPresentation"])
12 ], 13 ],
13 dependencies: [ 14 dependencies: [
14 .package(url: "https://github.com/groue/GRDB.swift", from: "7.11.1") 15 .package(url: "https://github.com/groue/GRDB.swift", from: "7.11.1")
@@ -18,9 +19,11 @@ let package = Package(
18 .target(name: "OrgDocument", dependencies: ["OrgCore"]), 19 .target(name: "OrgDocument", dependencies: ["OrgCore"]),
19 .target(name: "OrgIndex", dependencies: ["OrgCore", .product(name: "GRDB", package: "GRDB.swift")]), 20 .target(name: "OrgIndex", dependencies: ["OrgCore", .product(name: "GRDB", package: "GRDB.swift")]),
20 .target(name: "OrgWorkspace", dependencies: ["OrgIndex"]), 21 .target(name: "OrgWorkspace", dependencies: ["OrgIndex"]),
22 .target(name: "OrgPresentation", dependencies: ["OrgCore"]),
21 .testTarget(name: "OrgCoreTests", dependencies: ["OrgCore"]), 23 .testTarget(name: "OrgCoreTests", dependencies: ["OrgCore"]),
22 .testTarget(name: "OrgDocumentTests", dependencies: ["OrgDocument"]), 24 .testTarget(name: "OrgDocumentTests", dependencies: ["OrgDocument"]),
23 .testTarget(name: "OrgIndexTests", dependencies: ["OrgIndex"]), 25 .testTarget(name: "OrgIndexTests", dependencies: ["OrgIndex"]),
24 .testTarget(name: "OrgWorkspaceTests", dependencies: ["OrgWorkspace"]) 26 .testTarget(name: "OrgWorkspaceTests", dependencies: ["OrgWorkspace"]),
27 .testTarget(name: "OrgPresentationTests", dependencies: ["OrgPresentation"])
25 ] 28 ]
26) 29)
Sources/OrgPresentation/Presentation.swift added +171
@@ -0,0 +1,171 @@
1import OrgCore
2
3/// What a range of text is, for styling. Platform adapters map roles to attributes.
4public enum StyleRole: Hashable, Sendable {
5 /// A whole heading line.
6 case heading(level: Int)
7 /// All stars of a heading but the last, hidden as in `org-hide-leading-stars`.
8 case leadingStars
9 case todo, done, priority, tags
10 case bold, italic, underline, strikeThrough, verbatim, code
11 /// Emphasis markers.
12 case markup
13 case link
14 /// Brackets and the target of a described link.
15 case linkMarkup
16 case timestamp, footnote, cookie, target, macro, latex, inlineSource
17 case comment, keyword
18 /// Planning lines, drawers, property drawers, clock lines.
19 case metadata
20 case block, blockDelimiter, table, rule
21}
22
23public struct StyleRun: Sendable, Equatable {
24 public let range: Range<Int>
25 public let role: StyleRole
26}
27
28/// org-indent: how many columns a run of whole lines starts in.
29public struct IndentRun: Sendable, Equatable {
30 public let range: Range<Int>
31 public let firstLine: Int
32 /// Continuation lines of a wrapped paragraph.
33 public let wrapped: Int
34}
35
36public enum Presentation {
37 /// Style runs for nodes and tokens overlapping `range`, outer before inner, so applying
38 /// them in order lets inner roles win.
39 public static func styles(_ tree: OrgTree, in range: Range<Int>) -> [StyleRun] {
40 var runs: [StyleRun] = []
41 visit(tree.root, range, tree.settings, &runs)
42 return runs
43 }
44
45 private static func visit(_ node: SyntaxNode, _ range: Range<Int>, _ settings: OrgSettings, _ runs: inout [StyleRun]) {
46 if let role = role(of: node) { runs.append(StyleRun(range: node.range, role: role)) }
47 switch node.kind {
48 case .heading:
49 for token in node.tokens {
50 switch token.kind {
51 case .stars where token.range.count > 1:
52 runs.append(StyleRun(range: token.range.lowerBound..<(token.range.upperBound - 1), role: .leadingStars))
53 case .todoKeyword:
54 runs.append(StyleRun(range: token.range, role: settings.isDone(token.text) ? .done : .todo))
55 case .priority:
56 runs.append(StyleRun(range: token.range, role: .priority))
57 case .tags:
58 runs.append(StyleRun(range: token.range, role: .tags))
59 default:
60 break
61 }
62 }
63 case .link:
64 let described = node.children.contains { $0.kind == .linkDescription }
65 for token in node.tokens where token.kind == .marker || (described && token.kind == .linkPath) {
66 runs.append(StyleRun(range: token.range, role: .linkMarkup))
67 }
68 case .bold, .italic, .underline, .strikeThrough, .verbatim, .code:
69 for token in node.tokens where token.kind == .marker {
70 runs.append(StyleRun(range: token.range, role: .markup))
71 }
72 case .block, .dynamicBlock:
73 let lines = node.tokens.filter { $0.kind == .text }
74 for token in [lines.first, lines.count > 1 ? lines.last : nil].compactMap({ $0 }) {
75 runs.append(StyleRun(range: token.range, role: .blockDelimiter))
76 }
77 default:
78 break
79 }
80 for child in node.children where child.range.overlaps(range) {
81 visit(child, range, settings, &runs)
82 }
83 }
84
85 static func role(of node: SyntaxNode) -> StyleRole? {
86 switch node.kind {
87 case .heading: .heading(level: node.tokens.first { $0.kind == .stars }?.text.count ?? 1)
88 case .bold: .bold
89 case .italic: .italic
90 case .underline: .underline
91 case .strikeThrough: .strikeThrough
92 case .verbatim: .verbatim
93 case .code: .code
94 case .link: .link
95 case .timestamp: .timestamp
96 case .footnoteReference, .footnoteDefinition: .footnote
97 case .statisticsCookie: .cookie
98 case .target: .target
99 case .macro: .macro
100 case .latexFragment: .latex
101 case .inlineSourceBlock: .inlineSource
102 case .comment: .comment
103 case .keyword, .affiliatedKeyword: .keyword
104 case .planning, .propertyDrawer, .drawer, .clock: .metadata
105 case .block, .dynamicBlock, .fixedWidth: .block
106 case .table: .table
107 case .horizontalRule: .rule
108 default: nil
109 }
110 }
111
112 /// Heading lines start at column 0, with wrapped title lines under the title; a section's
113 /// own body starts one column past its stars.
114 public static func indents(_ tree: OrgTree, in range: Range<Int>) -> [IndentRun] {
115 var runs: [IndentRun] = []
116 collectIndents(tree.root, range, &runs)
117 return runs
118 }
119
120 private static func collectIndents(_ node: SyntaxNode, _ range: Range<Int>, _ runs: inout [IndentRun]) {
121 for section in node.children where section.kind == .section && section.range.overlaps(range) {
122 let children = section.children
123 guard let heading = children.first(where: { $0.kind == .heading }) else { continue }
124 let level = heading.tokens.first { $0.kind == .stars }?.text.count ?? 1
125 runs.append(IndentRun(range: heading.range, firstLine: 0, wrapped: level + 1))
126 let bodyEnd = children.first { $0.kind == .section }?.range.lowerBound ?? section.range.upperBound
127 if heading.range.upperBound < bodyEnd {
128 runs.append(IndentRun(range: heading.range.upperBound..<bodyEnd, firstLine: level + 1, wrapped: level + 1))
129 }
130 collectIndents(section, range, &runs)
131 }
132 }
133
134 /// Ranges to hide for folded headings, given by their start offsets: from the end of the
135 /// heading line to the end of its section. Sorted, without nested duplicates.
136 public static func hiddenRanges(_ tree: OrgTree, folds: Set<Int>) -> [Range<Int>] {
137 guard !folds.isEmpty else { return [] }
138 var ranges: [Range<Int>] = []
139 collectHidden(tree.root, folds, &ranges)
140 return ranges
141 }
142
143 private static func collectHidden(_ node: SyntaxNode, _ folds: Set<Int>, _ ranges: inout [Range<Int>]) {
144 for section in node.children where section.kind == .section {
145 guard let heading = section.children.first(where: { $0.kind == .heading }) else { continue }
146 if folds.contains(heading.range.lowerBound) {
147 if heading.range.upperBound < section.range.upperBound {
148 ranges.append(heading.range.upperBound..<section.range.upperBound)
149 }
150 } else {
151 collectHidden(section, folds, &ranges)
152 }
153 }
154 }
155
156 /// Start offset of the heading whose line contains `offset`.
157 public static func heading(containing offset: Int, in tree: OrgTree) -> Int? {
158 var node = tree.root
159 while let child = node.children.first(where: { $0.range.contains(offset) }) {
160 if child.kind == .heading { return child.range.lowerBound }
161 guard child.kind == .section || child.kind == .zerothSection else { return nil }
162 node = child
163 }
164 return nil
165 }
166
167 /// Start offsets of every heading, for folding everything.
168 public static func headings(_ tree: OrgTree) -> [Int] {
169 tree.root.descendants().filter { $0.kind == .heading }.map(\.range.lowerBound)
170 }
171}
Tests/OrgPresentationTests/PresentationTests.swift added +78
@@ -0,0 +1,78 @@
1import OrgCore
2import Testing
3@testable import OrgPresentation
4
5func roles(_ text: String) -> [String] {
6 let tree = OrgParser.parse(text)
7 let utf16 = Array(text.utf16)
8 return Presentation.styles(tree, in: 0..<utf16.count).map { run in
9 "\(run.role):\(String(decoding: utf16[run.range], as: UTF16.self))"
10 }
11}
12
13struct StyleTests {
14 @Test func headingParts() {
15 #expect(roles("** DONE [#A] Ship *it* :work:\n") == [
16 "heading(level: 2):** DONE [#A] Ship *it* :work:\n",
17 "leadingStars:*", "done:DONE", "priority:[#A]", "tags::work:",
18 "bold:*it*", "markup:*", "markup:*",
19 ])
20 }
21
22 @Test func describedLinksDimTheirTarget() {
23 #expect(roles("[[https://a.b][site]]\n") == [
24 "link:[[https://a.b][site]]", "linkMarkup:[[", "linkMarkup:https://a.b", "linkMarkup:][", "linkMarkup:]]",
25 ])
26 #expect(roles("[[https://a.b]]\n") == ["link:[[https://a.b]]", "linkMarkup:[[", "linkMarkup:]]"])
27 }
28
29 @Test func blocksAndMetadata() {
30 #expect(roles("#+begin_src sh\necho\n#+end_src\n") == [
31 "block:#+begin_src sh\necho\n#+end_src\n", "blockDelimiter:#+begin_src sh", "blockDelimiter:#+end_src",
32 ])
33 #expect(roles("* a\nSCHEDULED: <2026-10-04 Sun>\n").contains("metadata:SCHEDULED: <2026-10-04 Sun>\n"))
34 }
35
36 @Test func onlyRunsOverlappingTheRange() {
37 let text = "* a\nfirst *b*\n* c\nsecond *d*\n"
38 let tree = OrgParser.parse(text)
39 let runs = Presentation.styles(tree, in: 17..<28)
40 #expect(!runs.contains { $0.range == 10..<13 })
41 #expect(runs.contains { $0.range == 25..<28 && $0.role == .bold })
42 }
43}
44
45struct IndentTests {
46 @Test func bodiesIndentPastTheirStars() {
47 let text = "pre\n* a\nbody\n** b\nmore\n"
48 let runs = Presentation.indents(OrgParser.parse(text), in: 0..<text.utf16.count)
49 #expect(runs == [
50 IndentRun(range: 4..<8, firstLine: 0, wrapped: 2),
51 IndentRun(range: 8..<13, firstLine: 2, wrapped: 2),
52 IndentRun(range: 13..<18, firstLine: 0, wrapped: 3),
53 IndentRun(range: 18..<23, firstLine: 3, wrapped: 3),
54 ])
55 }
56}
57
58struct FoldTests {
59 let text = "* a\nbody\n** b\nmore\n* c\ntext\n"
60
61 @Test func hiddenRanges() {
62 let tree = OrgParser.parse(text)
63 #expect(Presentation.hiddenRanges(tree, folds: [0]) == [4..<19])
64 #expect(Presentation.hiddenRanges(tree, folds: [9]) == [14..<19])
65 #expect(Presentation.hiddenRanges(tree, folds: [0, 9]) == [4..<19])
66 #expect(Presentation.hiddenRanges(tree, folds: [19]) == [23..<28])
67 #expect(Presentation.hiddenRanges(OrgParser.parse("* a"), folds: [0]).isEmpty)
68 }
69
70 @Test func headingContaining() {
71 let tree = OrgParser.parse(text)
72 #expect(Presentation.heading(containing: 2, in: tree) == 0)
73 #expect(Presentation.heading(containing: 3, in: tree) == 0)
74 #expect(Presentation.heading(containing: 5, in: tree) == nil)
75 #expect(Presentation.heading(containing: 10, in: tree) == 9)
76 #expect(Presentation.headings(tree) == [0, 9, 19])
77 }
78}