krz/orgstar

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

Sources/OrgCore/Commands/CtrlCCtrlC.swift

191ddb506f3a91b93c755ba6e8123a4e8ac4f90d
orgstar/Sources/OrgCore/Commands/CtrlCCtrlC.swift history · blame · raw

123 lines · 7075 bytes

  1import Foundation
  2
  3// C-c C-c (`org-ctrl-c-ctrl-c`, Org 9.8.7) for the contexts no more specific binding takes:
  4// headings set tags, clock lines get their duration again, timestamps their weekday, cookies
  5// update, items toggle their checkbox, or without one have their list repaired, setup keywords
  6// refresh, tables evaluate a typed formula, then align or recalculate a row marked `#`, and
  7// elsewhere there's nothing to do.
  8
  9/// `org-ctrl-c-ctrl-c`.
 10public struct CtrlCCtrlC: OrgCommand {
 11    public init() {}
 12    public var id: String { "org.ctrl-c-ctrl-c" }
 13    public var title: String { "Act at Point (C-c C-c)" }
 14    public func applies(in context: EditContext) -> Bool { true }
 15
 16    static let nothing = CommandStep.failed("`C-c C-c' can do nothing useful here")
 17
 18    public func run(in context: EditContext) -> CommandStep {
 19        let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings, options: context.options)
 20        if KeyContext.holds("src", in: context) || ExecuteSrcBlock.atCallOrInline(context) { return ExecuteSrcBlock().run(in: context) }
 21        if KeyContext.holds("table", in: context) || TableRecalculate.tblfmLine(in: context) != nil {
 22            // At the table's very start the context is the table itself, which org recalculates.
 23            let atStart = buffer.saveExcursion { buffer.tableBegin() } == context.caret
 24            if TableRecalculate.tblfmLine(in: context) != nil || atStart { return TableRecalculate(all: true).run(in: context) }
 25            // `org-table-maybe-eval-formula`, then `org-table-maybe-recalculate-line` on a row
 26            // marked `#`, which aligns, or else `org-table-align`.
 27            let step = commitBuffer(context) { buffer in
 28                try buffer.tableMaybeEvalFormula()
 29                guard buffer.lookingAtLine("^[ \\t]*\\| *# *(\\||$)") else { return buffer.tableAlign() }
 30                do {
 31                    try buffer.tableRecalculate(all: false)
 32                } catch let error as Calc.Unsupported {
 33                    throw EmacsBuffer.UserError(message: TableRecalculate.emacsMessage + error.reason)
 34                }
 35            }
 36            return TableRecalculate.inEmacs(step, form: "(org-ctrl-c-ctrl-c)", in: context)
 37        }
 38        if buffer.footnoteReferenceAtPoint() != nil
 39            || buffer.footnoteDefinitions().contains(where: { $0.begin <= context.caret && context.caret < ($0.contentsBegin ?? $0.end) }) {
 40            return FootnoteAction().run(in: context)
 41        }
 42        if buffer.lookingAtLine("^[ \\t]*$") { return Self.nothing }
 43        // On a clock line, changing a timestamp updates the clock too.
 44        if buffer.lookingAtLine("^[ \\t]*CLOCK:") { return commitBuffer(context) { $0.clockUpdateTime() } }
 45        if buffer.lookingAtLine(EmacsBuffer.dblockStart) { return UpdateDynamicBlock().run(in: context) }
 46        if Self.cookieAtCaret(buffer) {
 47            return commitBuffer(context) { buffer in
 48                buffer.saveExcursion {
 49                    buffer.point = buffer.lineStart(buffer.point)
 50                    buffer.updateStatisticsCookies()
 51                }
 52            }
 53        }
 54        if buffer.atTimestamp() != nil { return commitBuffer(context) { try $0.changeTimestamp(0, what: .day) } }
 55        if buffer.atHeading { return SetTags().run(in: context) }
 56        if buffer.atItem {
 57            // The item's checkbox toggles, and the whole list is repaired, its cookie updated.
 58            return commitBuffer(context) { buffer in
 59                buffer.point = buffer.lineStart(buffer.point)
 60                let item = buffer.point
 61                var structure = buffer.listStruct()
 62                let old = structure
 63                let box = structure.checkbox(item)
 64                if let box { structure.set(item) { $0.checkbox = box == "[X]" ? "[ ]" : "[X]" } }
 65                var repaired = structure
 66                buffer.writeStruct(&repaired, repaired.parents, old: old)
 67                if box != nil, repaired == old {
 68                    throw EmacsBuffer.UserError(message: "Cannot toggle this checkbox: \(box == "[X]" ? "all subitems checked" : "unchecked subitems")")
 69                }
 70                buffer.point = item
 71                buffer.updateCheckboxCount()
 72            }
 73        }
 74        if buffer.lookingAtLine("^[ \\t]*#\\+[A-Za-z_]+:") {
 75            return .commit(EditResult(baseRevision: context.revision, edits: [], effects: [.refreshSetup, .message("Local setup has been refreshed")]))
 76        }
 77        if buffer.propertyAtPoint() != nil || buffer.lookingAtLine("^[ \\t]*:(?:PROPERTIES|END):[ \\t]*$") && buffer.existingPropertyBlock() != nil {
 78            return PropertyAction().run(in: context)
 79        }
 80        return Self.nothing
 81    }
 82
 83    /// A `[n/m]` or `[n%]` cookie holds the caret.
 84    static func cookieAtCaret(_ buffer: EmacsBuffer) -> Bool {
 85        let line = buffer.lineStart(buffer.point)..<buffer.lineEnd(buffer.point)
 86        let text = buffer.substring(line.lowerBound, line.upperBound) as NSString
 87        let regex = try! NSRegularExpression(pattern: "\\[[0-9]*(?:%|/[0-9]*)\\]")
 88        return regex.matches(in: text as String, range: NSRange(location: 0, length: text.length)).contains {
 89            let start = line.lowerBound + $0.range.location
 90            return start <= buffer.point && buffer.point < start + $0.range.length
 91        }
 92    }
 93}
 94
 95extension EmacsBuffer {
 96    /// `org-clock-update-time-maybe` on a finished clock line: both dates' weekdays fixed and
 97    /// the duration written again.
 98    func clockUpdateTime() {
 99        saveExcursion {
100            point = lineStart(point)
101            skipForward([32, 9])
102            guard let match = lookingAt("[ \\t]*CLOCK: *[\\[<]([^\\]>]+)[\\]>](-+[\\[<]([^\\]>]+)[\\]>]([ \\t]*=>.*)?)?"),
103                  match[2] != nil, let first = match[1] else { return }
104            saveExcursion {
105                point = first.lowerBound
106                try? changeTimestamp(0, what: .day, updateClock: false)
107            }
108            guard let again = lookingAt("[ \\t]*CLOCK: *[\\[<]([^\\]>]+)[\\]>](-+[\\[<]([^\\]>]+)[\\]>]([ \\t]*=>.*)?)?"), let second = again[3] else { return }
109            saveExcursion {
110                point = second.lowerBound
111                try? changeTimestamp(0, what: .day, updateClock: false)
112            }
113            guard let final = lookingAt("[ \\t]*CLOCK: *[\\[<]([^\\]>]+)[\\]>](-+[\\[<]([^\\]>]+)[\\]>]([ \\t]*=>.*)?)?"),
114                  let start = final[1], let end = final[3] else { return }
115            if let arrow = final[4] { deleteRegion(arrow.lowerBound, arrow.upperBound) }
116            point = lineEnd(point)
117            let s = TagsMatcher.seconds(substring(end.lowerBound, end.upperBound), now: now, calendar: calendar)
118                - TagsMatcher.seconds(substring(start.lowerBound, start.upperBound), now: now, calendar: calendar)
119            let total = abs(Int(s))
120            insert(" => " + String(format: s < 0 ? "-%d:%02d" : "%2d:%02d", total / 3600, total % 3600 / 60))
121        }
122    }
123}