Sources/OrgCore/Commands/ToggleCommands.swift
342 lines · 17512 bytes
18 symbols in this file
1import 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`.
9public 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
43extension 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.
72public 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.
150public 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
226extension 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 the speed key @) on a heading: `org-mark-subtree`, the subtree selected from its heading.
330public 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}