krz/orgstar

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

Sources/OrgCore/Commands/Command.swift

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

288 lines · 14592 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    /// `org-log-done`: CLOSED when an entry becomes done, and a note with `.note`.
 17    public var logDone: LogHow?
 18    /// `org-log-into-drawer`: the drawer state notes go in, or nil for none.
 19    public var logIntoDrawer: String?
 20    /// `org-log-reschedule` and `org-log-redeadline`: a note when a date changes or goes.
 21    public var logReschedule: LogHow?
 22    public var logRedeadline: LogHow?
 23    /// `org-use-fast-todo-selection` `auto`: with keys in the keywords, C-c C-t asks for one.
 24    public var fastTodoSelection: Bool
 25    /// `org-use-speed-commands`: single keys at the start of a heading line run commands.
 26    public var useSpeedCommands: Bool
 27    /// `org-startup-with-inline-images`.
 28    public var startupWithInlineImages: Bool
 29    /// `org-startup-indented`: org-indent's virtual indentation.
 30    public var startupIndented = true
 31    /// `org-hide-leading-stars`; org-indent hides them too.
 32    public var hideLeadingStars = false
 33    /// `org-startup-align-all-tables`.
 34    public var startupAlignAllTables = false
 35    /// `org-startup-truncated`: long lines run off the right edge instead of wrapping.
 36    public var startupTruncated = false
 37    /// Spell checking while typing, outside code and markup.
 38    public var spellCheck = false
 39    /// `electric-pair-mode`: brackets and quotes typed in pairs.
 40    public var electricPair = false
 41    /// `org-cycle-hide-drawer-startup`: drawers are folded when a file opens.
 42    public var hideDrawerStartup: Bool
 43    /// `org-cycle-hide-block-startup`: blocks are folded when a file opens.
 44    public var hideBlockStartup: Bool
 45    /// `fill-column` for M-q.
 46    public var fillColumn: Int
 47    /// `org-hide-emphasis-markers`: table alignment measures emphasis without its markers.
 48    public var hideEmphasisMarkers: Bool
 49    /// `org-pretty-entities`: table alignment measures `\alpha` as `α`.
 50    public var prettyEntities: Bool
 51    /// `org-use-sub-superscripts` `{}`: only `_{…}` and `^{…}` are sub- and superscripts.
 52    public var subSuperscriptsNeedBraces: Bool
 53
 54    public init(
 55        tagsColumn: Int = -77, insertHeadingRespectContent: Bool = false, metaReturnMaySplitLine: Bool = true,
 56        listAllowAlphabetical: Bool = false, logRepeat: Bool = true, fillColumn: Int = 70,
 57        hideEmphasisMarkers: Bool = false, prettyEntities: Bool = false, subSuperscriptsNeedBraces: Bool = false,
 58        logDone: LogHow? = nil, logIntoDrawer: String? = nil, fastTodoSelection: Bool = true,
 59        hideDrawerStartup: Bool = true, hideBlockStartup: Bool = false, logReschedule: LogHow? = nil, logRedeadline: LogHow? = nil,
 60        useSpeedCommands: Bool = false, startupWithInlineImages: Bool = false,
 61        startupIndented: Bool = true, hideLeadingStars: Bool = false, startupAlignAllTables: Bool = false,
 62        startupTruncated: Bool = false, spellCheck: Bool = false
 63    ) {
 64        self.startupTruncated = startupTruncated
 65        self.spellCheck = spellCheck
 66        self.startupIndented = startupIndented
 67        self.hideLeadingStars = hideLeadingStars
 68        self.startupAlignAllTables = startupAlignAllTables
 69        self.useSpeedCommands = useSpeedCommands
 70        self.startupWithInlineImages = startupWithInlineImages
 71        self.logReschedule = logReschedule
 72        self.logRedeadline = logRedeadline
 73        self.hideDrawerStartup = hideDrawerStartup
 74        self.hideBlockStartup = hideBlockStartup
 75        self.fastTodoSelection = fastTodoSelection
 76        self.logDone = logDone
 77        self.logIntoDrawer = logIntoDrawer
 78        self.tagsColumn = tagsColumn
 79        self.insertHeadingRespectContent = insertHeadingRespectContent
 80        self.metaReturnMaySplitLine = metaReturnMaySplitLine
 81        self.listAllowAlphabetical = listAllowAlphabetical
 82        self.logRepeat = logRepeat
 83        self.fillColumn = fillColumn
 84        self.hideEmphasisMarkers = hideEmphasisMarkers
 85        self.prettyEntities = prettyEntities
 86        self.subSuperscriptsNeedBraces = subSuperscriptsNeedBraces
 87    }
 88
 89    /// Org's own defaults.
 90    public static let org = EditingOptions()
 91}
 92
 93/// Everything a command may read. Commands never read the clock or the file system, so the
 94/// same context always gives the same result.
 95public struct EditContext: Sendable {
 96    /// The revision `text` and `tree` belong to; results are applied only at this revision.
 97    public let revision: Int
 98    public let text: String
 99    public let tree: OrgTree
100    /// Selections in UTF-16 offsets. The first is the main caret.
101    public let selection: [Range<Int>]
102    public let now: Date
103    /// Includes the time zone.
104    public let calendar: Calendar
105    /// Replies to earlier prompts, by prompt key.
106    public let answers: [String: String]
107    /// Ranges the view hides (folded text); motion commands skip headings inside them.
108    public let hidden: [Range<Int>]
109    public let options: EditingOptions
110    /// Tags used across the workspace, for completion; read when a command needs them.
111    public let knownTags: @Sendable () -> [String]
112    /// The command run just before, with nothing typed since (`last-command`).
113    public let lastCommand: String?
114
115    public init(
116        revision: Int, text: String, tree: OrgTree, selection: [Range<Int>],
117        now: Date = Date(), calendar: Calendar = .current, answers: [String: String] = [:], hidden: [Range<Int>] = [],
118        options: EditingOptions = .org, knownTags: @escaping @Sendable () -> [String] = { [] }, lastCommand: String? = nil
119    ) {
120        self.lastCommand = lastCommand
121        self.knownTags = knownTags
122        self.revision = revision
123        self.text = text
124        self.tree = tree
125        self.selection = selection
126        self.now = now
127        self.calendar = calendar
128        self.answers = answers
129        self.hidden = hidden
130        self.options = options
131    }
132
133    public var caret: Int { selection.first?.lowerBound ?? 0 }
134}
135
136public struct Prompt: Sendable, Equatable {
137    /// The key the answer comes back under in `EditContext.answers`.
138    public let key: String
139    public let message: String
140    /// Text the answer field starts with.
141    public let initial: String
142    /// When set, the answer is one of these, or with `requireMatch` false, these are offered.
143    public let choices: [String]
144    public let requireMatch: Bool
145    /// Choices complete the text after the last of these characters (`:` between tags).
146    public let completionSeparator: Character?
147    /// Single keys answer instead of text.
148    public let fast: FastSelection?
149    /// A date prompt, read by `org-read-date` against this default (nil: today).
150    public let date: DateRead?
151
152    public struct DateRead: Sendable, Equatable {
153        public let defaultDate: Date?
154        /// Whether the answer becomes an inactive timestamp, shown in brackets.
155        public let inactive: Bool
156        public init(defaultDate: Date?, inactive: Bool = false) {
157            self.defaultDate = defaultDate
158            self.inactive = inactive
159        }
160    }
161
162    public init(
163        key: String, message: String, initial: String = "", choices: [String] = [], requireMatch: Bool = true,
164        completionSeparator: Character? = nil, fast: FastSelection? = nil, date: DateRead? = nil
165    ) {
166        self.key = key
167        self.message = message
168        self.initial = initial
169        self.choices = choices
170        self.requireMatch = requireMatch
171        self.completionSeparator = completionSeparator
172        self.fast = fast
173        self.date = date
174    }
175}
176
177/// Edits computed elsewhere (refiling to another file, archiving), applied like any command's
178/// result. Fails if the document changed since they were computed.
179public struct PrecomputedEdit: OrgCommand {
180    public let result: EditResult
181    public init(_ result: EditResult) { self.result = result }
182    public var id: String { "internal.precomputed" }
183    public var title: String { "Apply Edit" }
184    public func applies(in context: EditContext) -> Bool { context.revision == result.baseRevision }
185    public func run(in context: EditContext) -> CommandStep {
186        context.revision == result.baseRevision ? .commit(result) : .failed("The file changed; try again.")
187    }
188}
189
190/// Things a command asks for besides text changes; the platform layer carries them out.
191public enum Effect: Sendable, Equatable {
192    case message(String)
193    /// An inserted link was a stored one; it leaves `org-stored-links`.
194    case storedLinkUsed(String)
195    /// Text for the clipboard (the kill ring).
196    case copy(String)
197    /// Show a sparse tree: the context of each point, the rest folded, and highlights until
198    /// the next edit or C-c C-c.
199    case sparseTree(show: [Int], highlights: [Range<Int>])
200    /// Fold the subtree of the heading starting here (in the new text).
201    case fold(Int)
202    /// Read the file's `#+SETUPFILE`s again (`org-mode-restart`).
203    case refreshSetup
204}
205
206public struct EditResult: Sendable, Equatable {
207    public let baseRevision: Int
208    /// Non-overlapping, in `baseRevision` coordinates.
209    public let edits: [TextEdit]
210    /// Selection after the edits, in new coordinates; nil maps the old selection through.
211    public let selection: [Range<Int>]?
212    public let effects: [Effect]
213
214    public init(baseRevision: Int, edits: [TextEdit], selection: [Range<Int>]? = nil, effects: [Effect] = []) {
215        self.baseRevision = baseRevision
216        self.edits = edits
217        self.selection = selection
218        self.effects = effects
219    }
220}
221
222public enum CommandStep: Sendable, Equatable {
223    case commit(EditResult)
224    /// Ask, then run again with the answer in `EditContext.answers`.
225    case prompt(Prompt)
226    /// The command can't run here; nothing changes. The message is for the user, as org's
227    /// `user-error`.
228    case failed(String)
229    /// Work the platform does outside the text (Emacs, an interpreter); its result comes back
230    /// as an edit.
231    case external(ExternalRequest)
232}
233
234public enum ExternalRequest: Sendable, Equatable {
235    /// Recalculate the table whose text is at `table` in Emacs, running `form` with point at
236    /// `point`, and put Emacs's table text in its place. `lisp` when its formulas run Lisp.
237    case emacsTable(table: Range<Int>, point: Int, form: String, reason: String, lisp: Bool)
238    /// Run a src block; `Babel.finish` turns its output into the result edit.
239    case babel(BabelJob)
240    /// Follow a link: the app opens URLs and files and searches headings.
241    case openLink(OrgLink)
242    /// Edit a block's text apart (`org-edit-special`); `ReplaceBlockLines` puts it back.
243    case editBlock(BlockEdit)
244    /// Show a column view.
245    case columnView(ColumnView.View)
246    /// Show the agenda for `days` days from `start` (a `Days` number), as
247    /// `org-follow-timestamp-link` does.
248    case agenda(start: Int, days: Int)
249    /// Write source blocks to their files (`org-babel-tangle`); the app knows the file's path.
250    case tangle(Tangle.Scope)
251}
252
253/// A named operation. Keys, menus, the palette and touch controls all run commands.
254public protocol OrgCommand: Sendable {
255    /// Stable identifier, used by keymaps: `org.todo.cycle`.
256    var id: String { get }
257    /// Shown in the command palette.
258    var title: String { get }
259    /// Whether the command means something at the caret; context dispatch (one key, several
260    /// commands) runs the first that applies.
261    func applies(in context: EditContext) -> Bool
262    func run(in context: EditContext) -> CommandStep
263}
264
265public enum Commands {
266    public static let all: [any OrgCommand] = [
267        TodoCycle(), TodoShift(right: true), TodoShift(right: false), PriorityUp(), PriorityDown(), PromoteHeading(), DemoteHeading(),
268        InsertHeading(), InsertHeadingAfterSubtree(), InsertTodoHeading(),
269        MoveSubtreeUp(), MoveSubtreeDown(), PromoteSubtree(), DemoteSubtree(),
270        NextHeading(), PreviousHeading(), ForwardSameLevel(), BackwardSameLevel(), UpHeading(),
271        SetTags(),
272        InsertItem(), InsertCheckboxItem(), IndentItem(), OutdentItem(), IndentItemTree(), OutdentItemTree(),
273        MoveItemUp(), MoveItemDown(), ToggleCheckbox(),
274        InsertTimestamp(), InsertTimestamp(inactive: true), SetPlanning(deadline: false), SetPlanning(deadline: true),
275        RemovePlanning(deadline: false), RemovePlanning(deadline: true), FillParagraph(), CreateTable(),
276        TableRecalculate(all: false), TableRecalculate(all: true), ExecuteSrcBlock(), OrgDwim(),
277        ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false),
278        OpenLinkAtPoint(), CreateID(), ToggleComment(), ToggleHeading(), ToggleItem(), MarkSubtree(),
279        CopySubtree(cut: false), CopySubtree(cut: true), CloneSubtree(), SortEntries(), InsertStructureTemplate(), EditSpecial(), TableEditField(), TangleCommand(.all), TangleCommand(.block), TangleCommand(.target),
280        UpdateDynamicBlock(), ClockReportTable(), SetEffort(), CtrlCCtrlC(), EvaluateTimeRange(), FootnoteAction(), FootnoteAction(special: true), TableEvalFormula(named: false), TableEvalFormula(named: true), SortTableLines(), TransposeTable(), GotoHeading(), SparseTreeCommand(), SetPriority(), SetPriority(value: "A"), SetPriority(value: "B"), SetPriority(value: "C"), SetPriority(value: " "), ReadAndSetProperty(), DeleteProperty(), DeleteProperty(globally: true), PropertyAction(), NextAllowedValue(previous: false), NextAllowedValue(previous: true), InsertColumnView(), ShowColumnView(), ShowColumnView(global: true), ToggleArchiveTag(), ArchiveToSibling(),
281    ]
282
283    public static var everything: [any OrgCommand] { all + TableCommand.Action.allCases.map(TableCommand.init) }
284
285    public static func command(_ id: String) -> (any OrgCommand)? {
286        everything.first { $0.id == id }
287    }
288}