Commit 23deff7456
Verified · cmc
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 @@ | |||
| 1 | import Foundation | ||
| 2 | import OrgCore | ||
| 3 | |||
| 4 | /// How a file shows when it opens, from the display settings and its `#+STARTUP` words. | ||
| 5 | public 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. | ||
| 23 | extension 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 | ||
| 434 | struct 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 |
| 445 | struct CommandTests { | 435 | struct CommandTests { |
| 446 | @Test func performEditsThroughTheTextView() { | 436 | @Test func performEditsThroughTheTextView() { |
Tests/OrgPresentationTests/DisplayTests.swift added +106
| @@ -0,0 +1,106 @@ | |||
| 1 | import Foundation | ||
| 2 | import OrgCore | ||
| 3 | import Testing | ||
| 4 | @testable import OrgPresentation | ||
| 5 | |||
| 6 | struct 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 | |||
| 60 | struct 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 | |||
| 89 | struct 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 | } | ||