krz/orgstar

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

Sources/OrgCore/Commands/ToggleCommands.swift

b98a6509c5ae75e5172dd333d1b6105fd6ceee0e
orgstar/Sources/OrgCore/Commands/ToggleCommands.swift history · blame · raw

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}