krz/orgstar

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

Sources/OrgCore/Commands/Command.swift

b8d091bdcbf2889c220b12b2d0640d50cea17c24
orgstar/Sources/OrgCore/Commands/Command.swift history · blame · raw

185 lines · 8234 bytes

  1import Foundation
  2
  3/// Org options that change what commands write, as the user's Emacs has them.
  4public struct EditingOptions: Sendable, Equatable, Codable {
  5    /// `org-tags-column`: negative aligns tags to end at that column, 0 puts them one space
  6    /// after the title.
  7    public var tagsColumn: Int
  8    /// `org-insert-heading-respect-content`: M-RET adds the heading after the subtree.
  9    public var insertHeadingRespectContent: Bool
 10    /// `org-M-RET-may-split-line`: M-RET in the middle of a line splits it.
 11    public var metaReturnMaySplitLine: Bool
 12    /// `org-list-allow-alphabetical`: `a.` and `A)` are list bullets.
 13    public var listAllowAlphabetical: Bool
 14    /// `org-log-repeat` `time`: completing a repeating entry records LAST_REPEAT and a state note.
 15    public var logRepeat: Bool
 16    /// `fill-column` for M-q.
 17    public var fillColumn: Int
 18    /// `org-hide-emphasis-markers`: table alignment measures emphasis without its markers.
 19    public var hideEmphasisMarkers: Bool
 20    /// `org-pretty-entities`: table alignment measures `\alpha` as `α`.
 21    public var prettyEntities: Bool
 22    /// `org-use-sub-superscripts` `{}`: only `_{…}` and `^{…}` are sub- and superscripts.
 23    public var subSuperscriptsNeedBraces: Bool
 24
 25    public init(
 26        tagsColumn: Int = -77, insertHeadingRespectContent: Bool = false, metaReturnMaySplitLine: Bool = true,
 27        listAllowAlphabetical: Bool = false, logRepeat: Bool = true, fillColumn: Int = 70,
 28        hideEmphasisMarkers: Bool = false, prettyEntities: Bool = false, subSuperscriptsNeedBraces: Bool = false
 29    ) {
 30        self.tagsColumn = tagsColumn
 31        self.insertHeadingRespectContent = insertHeadingRespectContent
 32        self.metaReturnMaySplitLine = metaReturnMaySplitLine
 33        self.listAllowAlphabetical = listAllowAlphabetical
 34        self.logRepeat = logRepeat
 35        self.fillColumn = fillColumn
 36        self.hideEmphasisMarkers = hideEmphasisMarkers
 37        self.prettyEntities = prettyEntities
 38        self.subSuperscriptsNeedBraces = subSuperscriptsNeedBraces
 39    }
 40
 41    /// Org's own defaults.
 42    public static let org = EditingOptions()
 43}
 44
 45/// Everything a command may read. Commands never read the clock or the file system, so the
 46/// same context always gives the same result.
 47public struct EditContext: Sendable {
 48    /// The revision `text` and `tree` belong to; results are applied only at this revision.
 49    public let revision: Int
 50    public let text: String
 51    public let tree: OrgTree
 52    /// Selections in UTF-16 offsets. The first is the main caret.
 53    public let selection: [Range<Int>]
 54    public let now: Date
 55    /// Includes the time zone.
 56    public let calendar: Calendar
 57    /// Replies to earlier prompts, by prompt key.
 58    public let answers: [String: String]
 59    /// Ranges the view hides (folded text); motion commands skip headings inside them.
 60    public let hidden: [Range<Int>]
 61    public let options: EditingOptions
 62
 63    public init(
 64        revision: Int, text: String, tree: OrgTree, selection: [Range<Int>],
 65        now: Date = Date(), calendar: Calendar = .current, answers: [String: String] = [:], hidden: [Range<Int>] = [],
 66        options: EditingOptions = .org
 67    ) {
 68        self.revision = revision
 69        self.text = text
 70        self.tree = tree
 71        self.selection = selection
 72        self.now = now
 73        self.calendar = calendar
 74        self.answers = answers
 75        self.hidden = hidden
 76        self.options = options
 77    }
 78
 79    public var caret: Int { selection.first?.lowerBound ?? 0 }
 80}
 81
 82public struct Prompt: Sendable, Equatable {
 83    /// The key the answer comes back under in `EditContext.answers`.
 84    public let key: String
 85    public let message: String
 86    /// Text the answer field starts with.
 87    public let initial: String
 88    /// When set, the answer is one of these.
 89    public let choices: [String]
 90
 91    public init(key: String, message: String, initial: String = "", choices: [String] = []) {
 92        self.key = key
 93        self.message = message
 94        self.initial = initial
 95        self.choices = choices
 96    }
 97}
 98
 99/// Edits computed elsewhere (refiling to another file, archiving), applied like any command's
100/// result. Fails if the document changed since they were computed.
101public struct PrecomputedEdit: OrgCommand {
102    public let result: EditResult
103    public init(_ result: EditResult) { self.result = result }
104    public var id: String { "internal.precomputed" }
105    public var title: String { "Apply Edit" }
106    public func applies(in context: EditContext) -> Bool { context.revision == result.baseRevision }
107    public func run(in context: EditContext) -> CommandStep {
108        context.revision == result.baseRevision ? .commit(result) : .failed("The file changed; try again.")
109    }
110}
111
112/// Things a command asks for besides text changes; the platform layer carries them out.
113public enum Effect: Sendable, Equatable {
114    case message(String)
115}
116
117public struct EditResult: Sendable, Equatable {
118    public let baseRevision: Int
119    /// Non-overlapping, in `baseRevision` coordinates.
120    public let edits: [TextEdit]
121    /// Selection after the edits, in new coordinates; nil maps the old selection through.
122    public let selection: [Range<Int>]?
123    public let effects: [Effect]
124
125    public init(baseRevision: Int, edits: [TextEdit], selection: [Range<Int>]? = nil, effects: [Effect] = []) {
126        self.baseRevision = baseRevision
127        self.edits = edits
128        self.selection = selection
129        self.effects = effects
130    }
131}
132
133public enum CommandStep: Sendable, Equatable {
134    case commit(EditResult)
135    /// Ask, then run again with the answer in `EditContext.answers`.
136    case prompt(Prompt)
137    /// The command can't run here; nothing changes. The message is for the user, as org's
138    /// `user-error`.
139    case failed(String)
140    /// Work the platform does outside the text (Emacs, an interpreter); its result comes back
141    /// as an edit.
142    case external(ExternalRequest)
143}
144
145public enum ExternalRequest: Sendable, Equatable {
146    /// Recalculate the table whose text is at `table` in Emacs, running `form` with point at
147    /// `point`, and put Emacs's table text in its place. `lisp` when its formulas run Lisp.
148    case emacsTable(table: Range<Int>, point: Int, form: String, reason: String, lisp: Bool)
149    /// Run a src block; `Babel.finish` turns its output into the result edit.
150    case babel(BabelJob)
151}
152
153/// A named operation. Keys, menus, the palette and touch controls all run commands.
154public protocol OrgCommand: Sendable {
155    /// Stable identifier, used by keymaps: `org.todo.cycle`.
156    var id: String { get }
157    /// Shown in the command palette.
158    var title: String { get }
159    /// Whether the command means something at the caret; context dispatch (one key, several
160    /// commands) runs the first that applies.
161    func applies(in context: EditContext) -> Bool
162    func run(in context: EditContext) -> CommandStep
163}
164
165public enum Commands {
166    public static let all: [any OrgCommand] = [
167        TodoCycle(), PriorityUp(), PriorityDown(), PromoteHeading(), DemoteHeading(),
168        InsertHeading(), InsertHeadingAfterSubtree(), InsertTodoHeading(),
169        MoveSubtreeUp(), MoveSubtreeDown(), PromoteSubtree(), DemoteSubtree(),
170        NextHeading(), PreviousHeading(), ForwardSameLevel(), BackwardSameLevel(), UpHeading(),
171        SetTags(),
172        InsertItem(), InsertCheckboxItem(), IndentItem(), OutdentItem(), IndentItemTree(), OutdentItemTree(),
173        MoveItemUp(), MoveItemDown(), ToggleCheckbox(),
174        InsertTimestamp(), InsertTimestamp(inactive: true), SetPlanning(deadline: false), SetPlanning(deadline: true),
175        RemovePlanning(deadline: false), RemovePlanning(deadline: true), FillParagraph(), CreateTable(),
176        TableRecalculate(all: false), TableRecalculate(all: true), ExecuteSrcBlock(), OrgDwim(),
177        ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false),
178    ]
179
180    public static var everything: [any OrgCommand] { all + TableCommand.Action.allCases.map(TableCommand.init) }
181
182    public static func command(_ id: String) -> (any OrgCommand)? {
183        everything.first { $0.id == id }
184    }
185}