krz/orgstar

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

Commit 23deff7456

23deff7456eb39c6e92f1263910280a44281b980

parent: ccfd66113f

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-08 06:50 UTC

Move startup display, hidden markup and indent logic to OrgPresentation

The Mac editor's #+STARTUP resolution, hidden-markup roles, org-indent line
indents and pretty-entity line scan are now in Presentation, for the iOS
editor to use.

Layout: unified · split

Sources/OrgEditorAppKit/OrgEditor.swift +32 −105
@@ -176,22 +176,13 @@ public final class OrgEditor: NSObject {
176 // Files open at their start, as in Emacs; replacing the storage leaves it at the end. 176 // Files open at their start, as in Emacs; replacing the storage leaves it at the end.
177 textView.setSelectedRange(NSRange(location: 0, length: 0)) 177 textView.setSelectedRange(NSRange(location: 0, length: 0))
178 if isOrg { 178 if isOrg {
179 let words = Presentation.startupOptions(document.tree).map { $0.lowercased() } 179 let startup = Presentation.startupDisplay(document.tree, options: options)
180 indented = words.contains("indent") ? true : words.contains("noindent") ? false : options.startupIndented 180 indented = startup.indented
181 hideStars = words.contains("hidestars") ? true : words.contains("showstars") ? false : options.hideLeadingStars 181 hideStars = startup.hideStars
182 var startup = Presentation.startupFoldState(document.tree) ?? FoldState() 182 if startup.folds != FoldState() { setFolds(startup.folds, wrappers: startup.wrappers) }
183 // VISIBILITY properties and archived trees, unless everything shows.
184 if Presentation.startupFoldState(document.tree) != nil, !words.contains("showeverything") {
185 startup = Presentation.applyVisibilityProperties(startup, in: document.tree)
186 }
187 let wrappers = Presentation.startupWrappers(document.tree, hideDrawers: options.hideDrawerStartup, hideBlocks: options.hideBlockStartup)
188 startup.wrapperFolds = Set(wrappers.map(\.start))
189 if startup != FoldState() { setFolds(startup, wrappers: wrappers) }
190 shrinkAtStartup() 183 shrinkAtStartup()
191 if InlineImages.startup(document.tree) ?? options.startupWithInlineImages { showsImages = true } 184 if startup.inlineImages { showsImages = true }
192 if words.contains("align") || (options.startupAlignAllTables && !words.contains("noalign")) { 185 if startup.alignTables { _ = perform(AlignAllTables()) }
193 _ = perform(AlignAllTables())
194 }
195 if !indented || !hideStars { restyleOutsideEditing(0..<utf16Length) } 186 if !indented || !hideStars { restyleOutsideEditing(0..<utf16Length) }
196 } 187 }
197 spellChecking = options.spellCheck 188 spellChecking = options.spellCheck
@@ -1047,38 +1038,22 @@ public final class OrgEditor: NSObject {
1047 } 1038 }
1048 return 1039 return
1049 } 1040 }
1050 let text = storage.string as NSString 1041 if indented {
1051 for run in Presentation.indents(document.tree, in: range) where indented { 1042 for line in Presentation.lineIndents(document.tree, in: range) {
1052 guard let clipped = clip(run.range, to: range) else { continue } 1043 storage.addAttribute(.paragraphStyle, value: paragraphStyle(line.firstLine, line.wrapped), range: NSRange(line.range))
1053 guard run.firstLine == run.wrapped else {
1054 storage.addAttribute(.paragraphStyle, value: paragraphStyle(run.firstLine, run.wrapped), range: clipped)
1055 continue
1056 }
1057 // Body lines wrap under their own text: past leading blanks, and past a list bullet
1058 // and checkbox, as adaptive-wrap does in Emacs.
1059 var location = clipped.location
1060 while location < NSMaxRange(clipped) {
1061 let line = text.lineRange(for: NSRange(location: location, length: 0))
1062 let hanging = Self.hangingColumns(text.substring(with: line))
1063 let lineRange = NSIntersectionRange(line, clipped)
1064 storage.addAttribute(.paragraphStyle, value: paragraphStyle(run.firstLine, run.wrapped + hanging), range: lineRange)
1065 location = NSMaxRange(line)
1066 } 1044 }
1067 } 1045 }
1068 let revealed = caretLine 1046 let revealed = caretLine
1069 var scriptExclusions: [Range<Int>] = [] 1047 var scriptExclusions: [Range<Int>] = []
1070 for run in Presentation.styles(document.tree, in: range, highlighter: highlighter) { 1048 for run in Presentation.styles(document.tree, in: range, highlighter: highlighter) {
1071 guard let clipped = clip(run.range, to: range) else { continue } 1049 guard let clipped = clip(run.range, to: range) else { continue }
1072 if run.role == .leadingStars, !indented, !hideStars { continue } 1050 if Presentation.skipsLeadingStars(run.role, indented: indented, hideStars: hideStars) { continue }
1073 if !showMarkup, hides(run.role), NSIntersectionRange(clipped, revealed).length == 0 { 1051 if !showMarkup, Presentation.hidesWithMarkup(run.role, options: options), NSIntersectionRange(clipped, revealed).length == 0 {
1074 storage.addAttributes(theme.hidden, range: clipped) 1052 storage.addAttributes(theme.hidden, range: clipped)
1075 } else { 1053 } else {
1076 theme.apply(run.role, to: storage, range: clipped) 1054 theme.apply(run.role, to: storage, range: clipped)
1077 } 1055 }
1078 switch run.role { 1056 if Presentation.excludesScripts(run.role) { scriptExclusions.append(run.range) }
1079 case .bold, .italic, .underline, .strikeThrough, .verbatim, .code, .link, .linkMarkup, .keyword: scriptExclusions.append(run.range)
1080 default: break
1081 }
1082 } 1057 }
1083 if !showMarkup, options.prettyEntities { prettify(range, in: storage, revealed: revealed, scriptExclusions: scriptExclusions) } 1058 if !showMarkup, options.prettyEntities { prettify(range, in: storage, revealed: revealed, scriptExclusions: scriptExclusions) }
1084 applyShrunk(range, in: storage) 1059 applyShrunk(range, in: storage)
@@ -1087,52 +1062,32 @@ public final class OrgEditor: NSObject {
1087 } 1062 }
1088 1063
1089 /// `org-pretty-entities`: entities drawn as their character over the entity's last 1064 /// `org-pretty-entities`: entities drawn as their character over the entity's last
1090 /// character (the rest hidden), sub/superscripts raised without their markers. Not on the 1065 /// character (the rest hidden), sub/superscripts raised without their markers.
1091 /// caret's line, in comments or in blocks; sub/superscripts not in emphasis, links or
1092 /// keywords, as `org-raise-scripts` leaves them.
1093 private func prettify(_ range: Range<Int>, in storage: NSTextStorage, revealed: NSRange, scriptExclusions: [Range<Int>]) { 1066 private func prettify(_ range: Range<Int>, in storage: NSTextStorage, revealed: NSRange, scriptExclusions: [Range<Int>]) {
1094 let text = storage.string as NSString 1067 let runs = Presentation.prettyRuns(
1095 var location = range.lowerBound 1068 storage.string as NSString, in: range, revealedLine: revealed.location, inBlock: { shaded.containing($0) != nil },
1096 while location < range.upperBound { 1069 scriptExclusions: scriptExclusions, subSuperscriptsNeedBraces: options.subSuperscriptsNeedBraces
1097 let line = text.lineRange(for: NSRange(location: location, length: 0)) 1070 )
1098 guard line.length > 0 else { break } 1071 for (lineStart, run) in runs {
1099 location = NSMaxRange(line) 1072 switch run {
1100 if line.location == revealed.location || shaded.containing(line.location) != nil { continue } 1073 case .entity(let r, let display):
1101 let content = text.substring(with: line) 1074 let start = lineStart + r.lowerBound
1102 if content.range(of: "^[ \\t]*#([ +]|$)", options: .regularExpression) != nil { continue } 1075 let last = lineStart + r.upperBound - 1
1103 for run in PrettyEntities.runs(in: content, subSuperscriptsNeedBraces: options.subSuperscriptsNeedBraces) { 1076 if last > start { storage.addAttributes(theme.hidden, range: NSRange(location: start, length: last - start)) }
1104 switch run { 1077 storage.addAttributes([.foregroundColor: NSColor.clear, .orgEntityDisplay: display], range: NSRange(location: last, length: 1))
1105 case .entity(let r, let display): 1078 case .script(let markers, let body, let superscript):
1106 let start = line.location + r.lowerBound 1079 for marker in markers {
1107 let last = line.location + r.upperBound - 1 1080 storage.addAttributes(theme.hidden, range: NSRange(location: lineStart + marker.lowerBound, length: marker.count))
1108 if last > start { storage.addAttributes(theme.hidden, range: NSRange(location: start, length: last - start)) }
1109 storage.addAttributes([.foregroundColor: NSColor.clear, .orgEntityDisplay: display], range: NSRange(location: last, length: 1))
1110 case .script(let markers, let body, let superscript):
1111 let bodyStart = line.location + body.lowerBound
1112 if scriptExclusions.contains(where: { $0.contains(bodyStart) }) || body.isEmpty { continue }
1113 for marker in markers {
1114 storage.addAttributes(theme.hidden, range: NSRange(location: line.location + marker.lowerBound, length: marker.count))
1115 }
1116 let size = theme.font.pointSize
1117 storage.addAttributes([
1118 .font: NSFont.monospacedSystemFont(ofSize: size * 0.7, weight: .regular),
1119 .baselineOffset: (superscript ? 0.3 : -0.3) * size,
1120 ], range: NSRange(location: bodyStart, length: body.count))
1121 } 1081 }
1082 let size = theme.font.pointSize
1083 storage.addAttributes([
1084 .font: NSFont.monospacedSystemFont(ofSize: size * 0.7, weight: .regular),
1085 .baselineOffset: (superscript ? 0.3 : -0.3) * size,
1086 ], range: NSRange(location: lineStart + body.lowerBound, length: body.count))
1122 } 1087 }
1123 } 1088 }
1124 } 1089 }
1125 1090
1126 /// Link brackets and targets are hidden as with `org-link-descriptive`; emphasis
1127 /// markers only when Emacs hides them too.
1128 private func hides(_ role: StyleRole) -> Bool {
1129 switch role {
1130 case .linkMarkup: true
1131 case .markup: options.hideEmphasisMarkers
1132 default: false
1133 }
1134 }
1135
1136 /// Moving to another line shows its markup and hides the last one's. 1091 /// Moving to another line shows its markup and hides the last one's.
1137 private func revealCaretLine() { 1092 private func revealCaretLine() {
1138 guard !showMarkup, isOrg else { return } 1093 guard !showMarkup, isOrg else { return }
@@ -1306,34 +1261,6 @@ public final class OrgEditor: NSObject {
1306 onChange?() 1261 onChange?()
1307 } 1262 }
1308 1263
1309 /// Columns a wrapped continuation of `line` should start past the line's own start.
1310 nonisolated static func hangingColumns(_ line: String) -> Int {
1311 var columns = 0
1312 var rest = Substring(line)
1313 while let c = rest.first, c == " " || c == "\t" {
1314 columns = c == "\t" ? (columns / 8 + 1) * 8 : columns + 1
1315 rest = rest.dropFirst()
1316 }
1317 // A bullet (`-`, `+`, `*` when indented, `1.`, `1)`) and its space, then a checkbox.
1318 var bullet = 0
1319 if let first = rest.first, first == "-" || first == "+" || (first == "*" && columns > 0) {
1320 bullet = 1
1321 } else {
1322 let digits = rest.prefix { $0.isASCII && $0.isNumber }
1323 if !digits.isEmpty, let separator = rest.dropFirst(digits.count).first, separator == "." || separator == ")" {
1324 bullet = digits.count + 1
1325 }
1326 }
1327 guard bullet > 0, rest.dropFirst(bullet).first == " " else { return columns }
1328 columns += bullet + 1
1329 let afterBullet = rest.dropFirst(bullet + 1)
1330 if afterBullet.count >= 4, afterBullet.first == "[", "X x-".contains(afterBullet.dropFirst().first!),
1331 afterBullet.dropFirst(2).first == "]", afterBullet.dropFirst(3).first == " " {
1332 columns += 4
1333 }
1334 return columns
1335 }
1336
1337 /// Whether a heading line touches `start..<end` or starts right after it, where joining 1264 /// Whether a heading line touches `start..<end` or starts right after it, where joining
1338 /// lines could take it away. 1265 /// lines could take it away.
1339 static func headingLineNear(_ start: Int, _ end: Int, in text: NSString) -> Bool { 1266 static func headingLineNear(_ start: Int, _ end: Int, in text: NSString) -> Bool {
Sources/OrgPresentation/Display.swift added +153
@@ -0,0 +1,153 @@
1import Foundation
2import OrgCore
3
4/// How a file shows when it opens, from the display settings and its `#+STARTUP` words.
5public struct StartupDisplay: Sendable, Equatable {
6 /// Headings, drawers and blocks folded.
7 public var folds: FoldState
8 /// The folded drawers and blocks.
9 public var wrappers: [Wrapper]
10 /// `org-indent-mode` (`indent`, `noindent`).
11 public var indented: Bool
12 /// `org-hide-leading-stars` (`hidestars`, `showstars`).
13 public var hideStars: Bool
14 /// `org-startup-with-inline-images` (`inlineimages`, `noinlineimages`).
15 public var inlineImages: Bool
16 /// `org-startup-align-all-tables` (`align`, `noalign`).
17 public var alignTables: Bool
18 /// `org-startup-truncated`.
19 public var truncated: Bool
20}
21
22/// Org's display, shared by the Mac and iOS editors.
23extension Presentation {
24 public static func startupDisplay(_ tree: OrgTree, options: EditingOptions) -> StartupDisplay {
25 let words = startupOptions(tree).map { $0.lowercased() }
26 var folds = startupFoldState(tree) ?? FoldState()
27 // VISIBILITY properties and archived trees, unless everything shows.
28 if startupFoldState(tree) != nil, !words.contains("showeverything") {
29 folds = applyVisibilityProperties(folds, in: tree)
30 }
31 let wrappers = startupWrappers(tree, hideDrawers: options.hideDrawerStartup, hideBlocks: options.hideBlockStartup)
32 folds.wrapperFolds = Set(wrappers.map(\.start))
33 return StartupDisplay(
34 folds: folds, wrappers: wrappers,
35 indented: words.contains("indent") ? true : words.contains("noindent") ? false : options.startupIndented,
36 hideStars: words.contains("hidestars") ? true : words.contains("showstars") ? false : options.hideLeadingStars,
37 inlineImages: InlineImages.startup(tree) ?? options.startupWithInlineImages,
38 alignTables: words.contains("align") || (options.startupAlignAllTables && !words.contains("noalign")),
39 truncated: options.startupTruncated
40 )
41 }
42
43 /// Whether `role` hides while markup is hidden: link brackets and targets as with
44 /// `org-link-descriptive`; emphasis markers only when Emacs hides them too.
45 public static func hidesWithMarkup(_ role: StyleRole, options: EditingOptions) -> Bool {
46 switch role {
47 case .linkMarkup: true
48 case .markup: options.hideEmphasisMarkers
49 default: false
50 }
51 }
52
53 /// Whether a style run is skipped: leading stars show as the heading's color unless the
54 /// view is indented or hides them.
55 public static func skipsLeadingStars(_ role: StyleRole, indented: Bool, hideStars: Bool) -> Bool {
56 role == .leadingStars && !indented && !hideStars
57 }
58
59 /// One line's indentation under org-indent, in columns.
60 public struct LineIndent: Sendable, Equatable {
61 public let range: Range<Int>
62 public let firstLine: Int
63 public let wrapped: Int
64 }
65
66 /// org-indent's indentation for the lines in `range`: each run of `indents`, with body lines
67 /// wrapping under their own text (past leading blanks, a list bullet and a checkbox) as
68 /// adaptive-wrap does in Emacs.
69 public static func lineIndents(_ tree: OrgTree, in range: Range<Int>) -> [LineIndent] {
70 let text = tree.text as NSString
71 var out: [LineIndent] = []
72 for run in indents(tree, in: range) {
73 let clipped = run.range.clamped(to: range)
74 guard !clipped.isEmpty else { continue }
75 guard run.firstLine == run.wrapped else {
76 out.append(LineIndent(range: clipped, firstLine: run.firstLine, wrapped: run.wrapped))
77 continue
78 }
79 var location = clipped.lowerBound
80 while location < clipped.upperBound {
81 let line = text.lineRange(for: NSRange(location: location, length: 0))
82 let hanging = hangingColumns(text.substring(with: line))
83 let lineRange = max(line.location, clipped.lowerBound)..<min(NSMaxRange(line), clipped.upperBound)
84 out.append(LineIndent(range: lineRange, firstLine: run.firstLine, wrapped: run.wrapped + hanging))
85 location = NSMaxRange(line)
86 }
87 }
88 return out
89 }
90
91 /// Columns a wrapped continuation of `line` should start past the line's own start.
92 public static func hangingColumns(_ line: String) -> Int {
93 var columns = 0
94 var rest = Substring(line)
95 while let c = rest.first, c == " " || c == "\t" {
96 columns = c == "\t" ? (columns / 8 + 1) * 8 : columns + 1
97 rest = rest.dropFirst()
98 }
99 // A bullet (`-`, `+`, `*` when indented, `1.`, `1)`) and its space, then a checkbox.
100 var bullet = 0
101 if let first = rest.first, first == "-" || first == "+" || (first == "*" && columns > 0) {
102 bullet = 1
103 } else {
104 let digits = rest.prefix { $0.isASCII && $0.isNumber }
105 if !digits.isEmpty, let separator = rest.dropFirst(digits.count).first, separator == "." || separator == ")" {
106 bullet = digits.count + 1
107 }
108 }
109 guard bullet > 0, rest.dropFirst(bullet).first == " " else { return columns }
110 columns += bullet + 1
111 let afterBullet = rest.dropFirst(bullet + 1)
112 if afterBullet.count >= 4, afterBullet.first == "[", "X x-".contains(afterBullet.dropFirst().first!),
113 afterBullet.dropFirst(2).first == "]", afterBullet.dropFirst(3).first == " " {
114 columns += 4
115 }
116 return columns
117 }
118
119 /// `org-pretty-entities` for the lines in `range`, with each run's line start: not on the
120 /// line starting at `revealedLine`, in comments or in blocks; sub/superscripts not in
121 /// `scriptExclusions` (emphasis, links, keywords), as `org-raise-scripts` leaves them.
122 public static func prettyRuns(
123 _ text: NSString, in range: Range<Int>, revealedLine: Int?, inBlock: (Int) -> Bool,
124 scriptExclusions: [Range<Int>], subSuperscriptsNeedBraces: Bool
125 ) -> [(lineStart: Int, run: PrettyEntities.Run)] {
126 var out: [(lineStart: Int, run: PrettyEntities.Run)] = []
127 var location = range.lowerBound
128 while location < range.upperBound {
129 let line = text.lineRange(for: NSRange(location: location, length: 0))
130 guard line.length > 0 else { break }
131 location = NSMaxRange(line)
132 if line.location == revealedLine || inBlock(line.location) { continue }
133 let content = text.substring(with: line)
134 if content.range(of: "^[ \\t]*#([ +]|$)", options: .regularExpression) != nil { continue }
135 for run in PrettyEntities.runs(in: content, subSuperscriptsNeedBraces: subSuperscriptsNeedBraces) {
136 if case .script(_, let body, _) = run {
137 let bodyStart = line.location + body.lowerBound
138 if body.isEmpty || scriptExclusions.contains(where: { $0.contains(bodyStart) }) { continue }
139 }
140 out.append((line.location, run))
141 }
142 }
143 return out
144 }
145
146 /// Roles whose text keeps sub/superscript markers as written.
147 public static func excludesScripts(_ role: StyleRole) -> Bool {
148 switch role {
149 case .bold, .italic, .underline, .strikeThrough, .verbatim, .code, .link, .linkMarkup, .keyword: true
150 default: false
151 }
152 }
153}
Tests/OrgEditorAppKitTests/EditorTests.swift −10
@@ -431,16 +431,6 @@ struct InitialStylingTests {
431 } 431 }
432} 432}
433 433
434struct HangingIndentTests {
435 @Test(arguments: [
436 ("plain", 0), (" indented", 2), ("- item", 2), (" - [X] done", 8), ("12. step", 4),
437 ("* heading-like", 0), (" * starred item", 4), ("-not a bullet", 0), ("\t- tab", 10),
438 ])
439 func columns(line: String, expected: Int) {
440 #expect(OrgEditor.hangingColumns(line) == expected)
441 }
442}
443
444@MainActor 434@MainActor
445struct CommandTests { 435struct CommandTests {
446 @Test func performEditsThroughTheTextView() { 436 @Test func performEditsThroughTheTextView() {
Tests/OrgPresentationTests/DisplayTests.swift added +106
@@ -0,0 +1,106 @@
1import Foundation
2import OrgCore
3import Testing
4@testable import OrgPresentation
5
6struct StartupDisplayTests {
7 @Test func settingsApplyWithoutStartupWords() {
8 let tree = OrgParser.parse("* A\n:LOGBOOK:\nx\n:END:\n#+begin_src sh\necho\n#+end_src\n")
9 var options = EditingOptions.org
10 options.startupIndented = false
11 options.hideLeadingStars = true
12 options.startupWithInlineImages = true
13 options.startupAlignAllTables = true
14 options.startupTruncated = true
15 options.hideBlockStartup = true
16 let display = Presentation.startupDisplay(tree, options: options)
17 #expect(!display.indented)
18 #expect(display.hideStars)
19 #expect(display.inlineImages)
20 #expect(display.alignTables)
21 #expect(display.truncated)
22 #expect(display.wrappers.map(\.kind) == [.drawer, .block])
23 #expect(display.folds.wrapperFolds == Set(display.wrappers.map(\.start)))
24 }
25
26 @Test func startupWordsOverrideSettings() {
27 let tree = OrgParser.parse("#+STARTUP: noindent showstars noinlineimages noalign nohidedrawers overview\n* A\n:LOGBOOK:\nx\n:END:\n** B\n")
28 var options = EditingOptions.org
29 options.hideLeadingStars = true
30 options.startupWithInlineImages = true
31 options.startupAlignAllTables = true
32 let display = Presentation.startupDisplay(tree, options: options)
33 #expect(!display.indented)
34 #expect(!display.hideStars)
35 #expect(!display.inlineImages)
36 #expect(!display.alignTables)
37 #expect(display.wrappers.isEmpty)
38 #expect(display.folds.folds == [tree.text.utf16.count - "* A\n:LOGBOOK:\nx\n:END:\n** B\n".utf16.count])
39 }
40
41 @Test func startupWordsTurnSettingsOn() {
42 let tree = OrgParser.parse("#+STARTUP: indent hidestars inlineimages align hideblocks\n#+begin_quote\nq\n#+end_quote\n")
43 var options = EditingOptions.org
44 options.startupIndented = false
45 let display = Presentation.startupDisplay(tree, options: options)
46 #expect(display.indented)
47 #expect(display.hideStars)
48 #expect(display.inlineImages)
49 #expect(display.alignTables)
50 #expect(display.wrappers.map(\.kind) == [.block])
51 }
52
53 @Test func visibilityPropertiesOnlyWithAStartupVisibility() {
54 let text = "* A\n:PROPERTIES:\n:VISIBILITY: folded\n:END:\nbody\n"
55 #expect(Presentation.startupDisplay(OrgParser.parse(text), options: .org).folds.folds.isEmpty)
56 #expect(Presentation.startupDisplay(OrgParser.parse("#+STARTUP: showall\n" + text), options: .org).folds.folds.count == 1)
57 }
58}
59
60struct HiddenMarkupTests {
61 @Test func linkMarkupAlwaysEmphasisMarkersBySetting() {
62 var options = EditingOptions.org
63 #expect(Presentation.hidesWithMarkup(.linkMarkup, options: options))
64 #expect(!Presentation.hidesWithMarkup(.markup, options: options))
65 options.hideEmphasisMarkers = true
66 #expect(Presentation.hidesWithMarkup(.markup, options: options))
67 #expect(!Presentation.hidesWithMarkup(.bold, options: options))
68 }
69
70 @Test func leadingStarsShowUnlessIndentedOrHidden() {
71 #expect(Presentation.skipsLeadingStars(.leadingStars, indented: false, hideStars: false))
72 #expect(!Presentation.skipsLeadingStars(.leadingStars, indented: true, hideStars: false))
73 #expect(!Presentation.skipsLeadingStars(.leadingStars, indented: false, hideStars: true))
74 #expect(!Presentation.skipsLeadingStars(.heading(level: 1), indented: false, hideStars: false))
75 }
76
77 @Test func prettyRunsSkipTheRevealedLineCommentsAndBlocks() {
78 let text = "\\alpha one\n# \\beta\n\\gamma\nx_{1}\n" as NSString
79 let lines = [0, 11, 19, 26]
80 let all = Presentation.prettyRuns(text, in: 0..<text.length, revealedLine: nil, inBlock: { _ in false }, scriptExclusions: [], subSuperscriptsNeedBraces: true)
81 #expect(all.map(\.lineStart) == [lines[0], lines[2], lines[3]])
82 let revealed = Presentation.prettyRuns(text, in: 0..<text.length, revealedLine: lines[2], inBlock: { $0 == lines[0] }, scriptExclusions: [], subSuperscriptsNeedBraces: true)
83 #expect(revealed.map(\.lineStart) == [lines[3]])
84 let excluded = Presentation.prettyRuns(text, in: 0..<text.length, revealedLine: nil, inBlock: { _ in false }, scriptExclusions: [lines[3]..<lines[3] + 5], subSuperscriptsNeedBraces: true)
85 #expect(excluded.map(\.lineStart) == [lines[0], lines[2]])
86 }
87}
88
89struct LineIndentTests {
90 @Test(arguments: [
91 ("plain", 0), (" indented", 2), ("- item", 2), (" - [X] done", 8), ("12. step", 4),
92 ("* heading-like", 0), (" * starred item", 4), ("-not a bullet", 0), ("\t- tab", 10),
93 ])
94 func hangingColumns(line: String, expected: Int) {
95 #expect(Presentation.hangingColumns(line) == expected)
96 }
97
98 @Test func bodyLinesHangUnderTheirText() {
99 let text = "** H\n- item\nplain\n"
100 let tree = OrgParser.parse(text)
101 let indents = Presentation.lineIndents(tree, in: 0..<text.utf16.count)
102 #expect(indents.map(\.range) == [0..<5, 5..<12, 12..<18])
103 #expect(indents.map(\.firstLine) == [0, 3, 3])
104 #expect(indents.map(\.wrapped) == [3, 5, 3])
105 }
106}