| @@ -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 | } |