| @@ -0,0 +1,342 @@ |
| 1 | import Foundation |
| 2 | |
| 3 | // Converting lines between headings, items and text, commenting headings and marking subtrees |
| 4 | // (Org 9.8.7): `org-toggle-comment`, `org-toggle-heading` (C-c *), `org-ctrl-c-minus` with |
| 5 | // `org-toggle-item` and `org-cycle-list-bullet` (C-c -), `org-mark-subtree`. Without a region |
| 6 | // the commands work on the caret's line; a selection is the region. |
| 7 | |
| 8 | /// C-c ;: `org-toggle-comment`. |
| 9 | public struct ToggleComment: OrgCommand { |
| 10 | public init() {} |
| 11 | public var id: String { "org.heading.toggle-comment" } |
| 12 | public var title: String { "Toggle COMMENT" } |
| 13 | public func applies(in context: EditContext) -> Bool { entryHeading(at: context.caret, in: context.tree) != nil } |
| 14 | public func run(in context: EditContext) -> CommandStep { |
| 15 | guard entryHeading(at: context.caret, in: context.tree) != nil else { return .failed("Before first headline") } |
| 16 | return commitBuffer(context) { buffer in |
| 17 | buffer.saveExcursion { |
| 18 | _ = try? buffer.backToHeading() |
| 19 | guard let match = buffer.lookingAt(buffer.complexHeadingPattern) else { return } |
| 20 | let commented = match[4].map { buffer.substring($0.lowerBound, $0.upperBound) }.map { $0 == "COMMENT" || $0.hasPrefix("COMMENT ") } ?? false |
| 21 | buffer.point = (match[3] ?? match[2] ?? match[1]!).upperBound |
| 22 | buffer.skipForward(EmacsBuffer.blanks) |
| 23 | if buffer.point == 0 || ![32, 9].contains(buffer.character(at: buffer.point - 1)) { buffer.insert(" ") } |
| 24 | if commented { |
| 25 | let start = buffer.point |
| 26 | let end = buffer.lineEnd(start) |
| 27 | if let space = buffer.searchForward(" ", bound: end, caseInsensitive: false) { |
| 28 | buffer.point = space[0]!.upperBound |
| 29 | } else { |
| 30 | buffer.point = end |
| 31 | } |
| 32 | buffer.skipForward(EmacsBuffer.blanks) |
| 33 | buffer.deleteRegion(start, buffer.point) |
| 34 | } else { |
| 35 | buffer.insert("COMMENT") |
| 36 | if !buffer.eolp { buffer.insert(" ") } |
| 37 | } |
| 38 | } |
| 39 | } |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | extension EmacsBuffer { |
| 44 | /// `org-at-comment-p`. |
| 45 | var atCommentLine: Bool { lookingAtLine("^[ \\t]*#(?: |$)") } |
| 46 | |
| 47 | /// The region's (or the line's) bounds as the toggles take them: from the first non-blank |
| 48 | /// line, to the region's end (the line's end when it ends inside a line). |
| 49 | func toggleBounds(_ selection: Range<Int>, skipComments: Bool) -> (begin: Int, end: Marker) { |
| 50 | func skipBlanks(_ pos: Int) -> Int { |
| 51 | saveExcursion { |
| 52 | point = pos |
| 53 | if skipComments { while atCommentLine, forwardLine(1) == 0 {} } |
| 54 | skipForward([32, 13, 9, 10]) |
| 55 | return lineStart(point) |
| 56 | } |
| 57 | } |
| 58 | if selection.isEmpty { |
| 59 | return (skipBlanks(lineStart(selection.lowerBound)), marker(lineEnd(selection.lowerBound))) |
| 60 | } |
| 61 | let end = selection.upperBound |
| 62 | let begin = skipComments ? skipBlanks(selection.lowerBound) : saveExcursion { point = selection.lowerBound; skipForward([32, 13, 9, 10]); return lineStart(point) } |
| 63 | return (begin, marker(skipComments ? (lineStart(end) == end ? end : lineEnd(end)) : end)) |
| 64 | } |
| 65 | |
| 66 | /// `org-current-level` of point's line, or nil before the first heading. |
| 67 | var levelAtPoint: Int? { beforeFirstHeading ? nil : outlineLevel } |
| 68 | } |
| 69 | |
| 70 | /// C-c *: `org-toggle-heading`. Headings lose their stars; items become headings, their |
| 71 | /// checkboxes TODO or DONE; other lines become headings one level below the current entry. |
| 72 | public struct ToggleHeading: OrgCommand { |
| 73 | public init() {} |
| 74 | public var id: String { "org.heading.toggle" } |
| 75 | public var title: String { "Toggle Heading" } |
| 76 | public func applies(in context: EditContext) -> Bool { true } |
| 77 | public func run(in context: EditContext) -> CommandStep { |
| 78 | var toggled = false |
| 79 | let step = commitBuffer(context) { buffer in |
| 80 | let (begin, end) = buffer.toggleBounds(context.selection.first ?? context.caret..<context.caret, skipComments: true) |
| 81 | defer { buffer.release(end) } |
| 82 | // Point is at the region's end, as in Emacs. |
| 83 | let caret = buffer.marker(context.selection.first?.upperBound ?? buffer.point) |
| 84 | defer { |
| 85 | buffer.point = caret.position |
| 86 | buffer.release(caret) |
| 87 | } |
| 88 | buffer.point = begin |
| 89 | if buffer.atHeading { |
| 90 | while buffer.point < end.position { |
| 91 | if buffer.atHeading, let stars = buffer.lookingAt("\\*+ ") { |
| 92 | buffer.deleteRegion(stars[0]!.lowerBound, stars[0]!.upperBound) |
| 93 | toggled = true |
| 94 | } |
| 95 | if buffer.forwardLine(1) != 0 { break } |
| 96 | } |
| 97 | } else if buffer.inItem() == buffer.point { |
| 98 | let done = context.tree.settings.todoSequences.first?.done.first?.name ?? "DONE" |
| 99 | let todo = context.tree.settings.todoSequences.first?.active.first?.name ?? "TODO" |
| 100 | let structure = buffer.listStruct() |
| 101 | let selected = structure.filter { $0.pos >= begin && $0.pos < end.position } |
| 102 | let base = selected.map(\.ind).min() ?? 0 |
| 103 | // Org takes the level after deleting the items: that of whatever follows them |
| 104 | // (a heading's own, or its section's), one deeper. |
| 105 | let bottom = structure.map(\.end).max() ?? end.position |
| 106 | let level = buffer.saveExcursion { () -> Int in |
| 107 | buffer.point = min(bottom, end.position + 1, buffer.length) |
| 108 | return buffer.beforeFirstHeading ? 0 : buffer.outlineLevel |
| 109 | } |
| 110 | // The last first, so earlier positions stay. |
| 111 | for item in selected.reversed() { |
| 112 | let depth = Set(selected.filter { $0.ind < item.ind && $0.ind >= base }.map(\.ind)).count |
| 113 | buffer.point = item.pos |
| 114 | let lineEnd = buffer.lineEnd(item.pos) |
| 115 | var text = buffer.substring(item.pos, lineEnd) |
| 116 | text = text.replacingOccurrences(of: "^[ \\t]*\\S+[ \\t]+(\\[@[^\\]]*\\][ \\t]*)?", with: "", options: .regularExpression) |
| 117 | var keyword = "" |
| 118 | if let box = item.checkbox { |
| 119 | text = text.replacingOccurrences(of: "^\\[[ X-]\\][ \\t]*", with: "", options: .regularExpression) |
| 120 | keyword = (box == "[X]" ? done : todo) + " " |
| 121 | } |
| 122 | buffer.deleteRegion(item.pos, lineEnd) |
| 123 | buffer.insert(String(repeating: "*", count: level + 1 + depth) + " " + keyword + text) |
| 124 | toggled = true |
| 125 | } |
| 126 | // Org replaces the items as one piece, so a point inside them ends at its start. |
| 127 | if let first = selected.first, caret.position > first.pos { caret.position = first.pos } |
| 128 | } else { |
| 129 | let stars = String(repeating: "*", count: buffer.levelAtPoint ?? 0) + "*" + " " |
| 130 | while buffer.point < end.position { |
| 131 | if !buffer.atHeading, buffer.inItem() != buffer.point, !buffer.atCommentLine, |
| 132 | let match = buffer.lookingAt("([ \\t]*)(\\S)") { |
| 133 | buffer.deleteRegion(match[1]!.lowerBound, match[1]!.upperBound) |
| 134 | buffer.point = buffer.lineStart(buffer.point) |
| 135 | buffer.insert(stars) |
| 136 | toggled = true |
| 137 | } |
| 138 | if buffer.forwardLine(1) != 0 { break } |
| 139 | } |
| 140 | } |
| 141 | } |
| 142 | if case .commit = step, !toggled { return .failed("Cannot toggle heading from here") } |
| 143 | return step |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | /// C-c - outside tables: `org-ctrl-c-minus`. On an item without a region the list's bullets |
| 148 | /// cycle (`org-cycle-list-bullet`); otherwise `org-toggle-item`: items lose their bullets, |
| 149 | /// headings become items with their text, other lines become items. |
| 150 | public struct ToggleItem: OrgCommand { |
| 151 | public init() {} |
| 152 | public var id: String { "org.item.toggle" } |
| 153 | public var title: String { "Toggle Item" } |
| 154 | public func applies(in context: EditContext) -> Bool { true } |
| 155 | public func run(in context: EditContext) -> CommandStep { |
| 156 | let region = context.selection.first.map { !$0.isEmpty } ?? false |
| 157 | if !region, EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings).inItem() != nil { |
| 158 | return commitBuffer(context) { try $0.cycleListBullet() } |
| 159 | } |
| 160 | return commitBuffer(context) { buffer in |
| 161 | let (begin, end) = buffer.toggleBounds(context.selection.first ?? context.caret..<context.caret, skipComments: false) |
| 162 | defer { buffer.release(end) } |
| 163 | // Point is at the region's end, as in Emacs. |
| 164 | let caret = buffer.marker(context.selection.first?.upperBound ?? buffer.point) |
| 165 | defer { |
| 166 | buffer.point = caret.position |
| 167 | buffer.release(caret) |
| 168 | } |
| 169 | buffer.point = begin |
| 170 | if buffer.inItem() == buffer.point { |
| 171 | while buffer.point < end.position { |
| 172 | if buffer.inItem() == buffer.point, let match = buffer.lookingAt("[ \\t]*(\\S+[ \\t]+)") { |
| 173 | buffer.deleteRegion(match[1]!.lowerBound, match[1]!.upperBound) |
| 174 | } |
| 175 | if buffer.forwardLine(1) != 0 { break } |
| 176 | } |
| 177 | } else if buffer.atHeading { |
| 178 | let refLevel = buffer.outlineLevel |
| 179 | var reference = refLevel |
| 180 | while buffer.point < end.position { |
| 181 | guard buffer.atHeading else { |
| 182 | if buffer.forwardLine(1) != 0 { break } |
| 183 | continue |
| 184 | } |
| 185 | let level = buffer.outlineLevel |
| 186 | let delta = max(0, level - reference) |
| 187 | if level < reference { reference = level } |
| 188 | let todo = buffer.todoKeyword |
| 189 | buffer.deleteMetadata() |
| 190 | guard let match = buffer.lookingAt(buffer.todoLinePattern) else { break } |
| 191 | buffer.deleteRegion(buffer.point, match[3]?.lowerBound ?? buffer.lineEnd(buffer.point)) |
| 192 | buffer.insert("- ") |
| 193 | buffer.indentLineTo(delta * 2) |
| 194 | if let todo { |
| 195 | // The checkbox goes through the list's repair, with any list above. |
| 196 | buffer.point = buffer.lineStart(buffer.point) |
| 197 | let at = buffer.point |
| 198 | var structure = buffer.listStruct() |
| 199 | let old = structure |
| 200 | structure.set(at) { $0.checkbox = context.tree.settings.isDone(todo) ? "[X]" : "[ ]" } |
| 201 | buffer.writeStruct(&structure, structure.parents, old: old) |
| 202 | buffer.point = at |
| 203 | } |
| 204 | // The section's text down to the region's end belongs to the item. |
| 205 | let sectionEnd = buffer.saveExcursion { () -> Int in |
| 206 | Capture.nextHeading(buffer) |
| 207 | return buffer.point |
| 208 | } |
| 209 | if buffer.forwardLine(1) != 0 { break } |
| 210 | buffer.shiftText(to: (delta + 1) * 2, until: min(end.position, sectionEnd)) |
| 211 | } |
| 212 | } else { |
| 213 | while buffer.point < end.position { |
| 214 | if !buffer.atHeading, buffer.inItem() != buffer.point, let match = buffer.lookingAt("([ \\t]*)(\\S)") { |
| 215 | buffer.point = match[2]!.lowerBound |
| 216 | buffer.insert("- ") |
| 217 | buffer.point = buffer.lineStart(buffer.point) |
| 218 | } |
| 219 | if buffer.forwardLine(1) != 0 { break } |
| 220 | } |
| 221 | } |
| 222 | } |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | extension EmacsBuffer { |
| 227 | /// `org-list--delete-metadata`: the heading's tags, planning line and property drawer go. |
| 228 | func deleteMetadata() { |
| 229 | saveExcursion { |
| 230 | let start = point |
| 231 | if let line = lookingAt(SetTags.tagLine), let tags = line[1] { |
| 232 | var from = tags.lowerBound |
| 233 | while from > start, let c = character(at: from - 1), c == 32 || c == 9 { from -= 1 } |
| 234 | deleteRegion(from, lineEnd(start)) |
| 235 | } |
| 236 | point = start |
| 237 | let afterHeading = lineEnd(start) < length ? lineEnd(start) + 1 : length |
| 238 | point = afterHeading |
| 239 | if lookingAt(Self.planningLinePattern) != nil { forwardLine(1) } |
| 240 | if let drawer = looking(Self.propertyDrawerPattern) { |
| 241 | point = drawer[0]!.upperBound |
| 242 | forwardLine(1) |
| 243 | } |
| 244 | skipForward([32, 9, 10, 13]) |
| 245 | let end = eobp ? point : lineStart(point) |
| 246 | if end > afterHeading { deleteRegion(afterHeading, end) } |
| 247 | } |
| 248 | } |
| 249 | |
| 250 | /// The `shift-text` of `org-toggle-item`: lines from point to `end` move so the least |
| 251 | /// indented one sits at `column`; blank lines and headings stay. Point ends at `end`'s line. |
| 252 | func shiftText(to column: Int, until limit: Int) { |
| 253 | let end = marker(limit) |
| 254 | defer { release(end) } |
| 255 | var minimum = 1000 |
| 256 | saveExcursion { |
| 257 | while point < end.position { |
| 258 | let indentation = currentIndentation |
| 259 | if lookingAt("^[ \\t]*$") != nil || atHeading { |
| 260 | } else if indentation == 0 { |
| 261 | minimum = 0 |
| 262 | break |
| 263 | } else if indentation < minimum { |
| 264 | minimum = indentation |
| 265 | } |
| 266 | if forwardLine(1) != 0 { break } |
| 267 | } |
| 268 | } |
| 269 | let delta = column - minimum |
| 270 | while point < end.position { |
| 271 | if lookingAt("^[ \\t]*$") == nil, !atHeading { indentLineTo(currentIndentation + delta) } |
| 272 | if forwardLine(1) != 0 { break } |
| 273 | } |
| 274 | } |
| 275 | |
| 276 | var currentIndentation: Int { |
| 277 | saveExcursion { |
| 278 | point = lineStart(point) |
| 279 | skipForward([32, 9]) |
| 280 | return currentColumn |
| 281 | } |
| 282 | } |
| 283 | |
| 284 | /// `org-cycle-list-bullet`: the list's bullets go through `-`, `+`, `*` (not at the |
| 285 | /// margin), `1.`, `1)` and, with alphabetical lists, `a.`, `A.`, `a)`, `A)`. |
| 286 | func cycleListBullet() throws { |
| 287 | guard let item = inItem() else { throw UserError(message: "Not at an item") } |
| 288 | let origin = marker(point) |
| 289 | defer { release(origin) } |
| 290 | point = item |
| 291 | var structure = listStruct() |
| 292 | let prevs = structure.prevs |
| 293 | let parents = structure.parents |
| 294 | let first = structure.listBegin(point, prevs) |
| 295 | let ind = structure.ind(point) |
| 296 | let bulletLength = (structure.bullet(point) as NSString).length |
| 297 | let offset = origin.position - (point + ind) |
| 298 | let offset2 = origin.position - (point + ind + bulletLength) |
| 299 | let bullet = structure.bullet(first) |
| 300 | let alpha = useAlphabeticalBullets(first, structure, prevs) |
| 301 | func has(_ pattern: String) -> Bool { bullet.range(of: pattern, options: .regularExpression) != nil } |
| 302 | let current = has("[a-z]\\.") ? "a." : has("[a-z]\\)") ? "a)" : has("[A-Z]\\.") ? "A." : has("[A-Z]\\)") ? "A)" |
| 303 | : has("\\.") ? "1." : has("\\)") ? "1)" : bullet.trimmingCharacters(in: .whitespaces) |
| 304 | let description = structure.item(point)?.tag != nil |
| 305 | var choices = ["-", "+"] |
| 306 | if lookingAt("\\S") == nil { choices.append("*") } |
| 307 | if !description { choices += ["1.", "1)"] } |
| 308 | if alpha, !description { choices += ["a.", "A.", "a)", "A)"] } |
| 309 | let index = choices.firstIndex(of: current).map { $0 + 1 } ?? choices.count |
| 310 | let new = choices[index % choices.count] |
| 311 | let old = structure |
| 312 | structure.set(first) { $0.bullet = bulletString(new) } |
| 313 | fixBullets(&structure, prevs) |
| 314 | fixIndentation(&structure, parents) |
| 315 | applyStruct(structure, old) |
| 316 | point = origin.position |
| 317 | let updated = listStruct() |
| 318 | point = lineStart(point) |
| 319 | if offset2 >= 0, let at = updated.item(point) { |
| 320 | point += at.ind + (at.bullet as NSString).length + offset2 |
| 321 | } else if offset >= 0, let at = updated.item(point) { |
| 322 | point += at.ind + offset |
| 323 | } else { |
| 324 | point = origin.position |
| 325 | } |
| 326 | } |
| 327 | } |
| 328 | |
| 329 | /// C-c @ and M-h on a heading: `org-mark-subtree`, the subtree selected from its heading. |
| 330 | public struct MarkSubtree: OrgCommand { |
| 331 | public init() {} |
| 332 | public var id: String { "org.subtree.mark" } |
| 333 | public var title: String { "Mark Subtree" } |
| 334 | public func applies(in context: EditContext) -> Bool { entryHeading(at: context.caret, in: context.tree) != nil } |
| 335 | public func run(in context: EditContext) -> CommandStep { |
| 336 | let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings) |
| 337 | guard !buffer.beforeFirstHeading, (try? buffer.backToHeading()) != nil else { return .failed("Not in a subtree") } |
| 338 | let start = buffer.point |
| 339 | Capture.endOfSubtree(buffer, heading: start) |
| 340 | return .commit(EditResult(baseRevision: context.revision, edits: [], selection: [start..<buffer.point])) |
| 341 | } |
| 342 | } |