krz/orgstar

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

Commit aeb1840b38

aeb1840b38be2e4ec9c378c33bb0934ae3cce8a2

parent: d5fbe7e875

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-08 03:40 UTC

Table formulas: keep stored and removed formulas, with their message

A blank C-c = answer removes the formula, as org-table-eval-formula's prompt does, and the removal is saved. A typed or prompted formula that needs Emacs stays stored and TAB still moves. commitBuffer reports the buffer's message. The formula editor points to Recalculate Table instead of C-u C-c C-c.

Layout: unified · split

Sources/OrgCore/Commands/EmacsBuffer.swift +6 −3
@@ -38,6 +38,8 @@ final class EmacsBuffer {
3838 var calendar = Calendar.current
3939 /// `org-last-inserted-timestamp`.
4040 var lastInsertedTimestamp: String?
41 /// Shown after the command commits, as `message`.
42 var message: String?
4143
4244 let options: EditingOptions
4345
@@ -466,7 +468,7 @@ final class EmacsBuffer {
466468}
467469
468470/// Runs a buffer-editing body against a context and turns the result into one minimal edit
469/// and the new caret. A user error becomes `.failed`.
471/// and the new caret, with the buffer's message. A user error becomes `.failed`.
470472func commitBuffer(_ context: EditContext, _ body: (EmacsBuffer) throws -> Void) -> CommandStep {
471473 let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings, options: context.options)
472474 buffer.now = context.now
@@ -499,9 +501,10 @@ func commitBuffer(_ context: EditContext, _ body: (EmacsBuffer) throws -> Void)
499501 while prefix > 0, prefix < old.count, UTF16.isTrailSurrogate(old[prefix]) { prefix -= 1 }
500502 while suffix > 0, UTF16.isTrailSurrogate(old[old.count - suffix]) { suffix -= 1 }
501503 let caret = buffer.point
504 let effects: [Effect] = buffer.message.map { [.message($0)] } ?? []
502505 guard old.count != new.count || prefix != old.count else {
503 return .commit(EditResult(baseRevision: context.revision, edits: [], selection: [caret..<caret]))
506 return .commit(EditResult(baseRevision: context.revision, edits: [], selection: [caret..<caret], effects: effects))
504507 }
505508 let edit = TextEdit(range: (lower + prefix)..<(lower + old.count - suffix), replacement: String(decoding: new[prefix..<(new.count - suffix)], as: UTF16.self))
506 return .commit(EditResult(baseRevision: context.revision, edits: [edit], selection: [caret..<caret]))
509 return .commit(EditResult(baseRevision: context.revision, edits: [edit], selection: [caret..<caret], effects: effects))
507510}
Sources/OrgCore/Compute/TableFormulas.swift +14 −10
@@ -1012,18 +1012,21 @@ extension EmacsBuffer {
10121012 }
10131013 }
10141014
1015 /// `org-table-get-formula` with EQUATION given: stores it for the current column, or with
1016 /// `named` the current field, and returns it.
1017 func tableGetFormula(_ equation: String, named: Bool, analysis: TableAnalysis) throws -> String {
1015 /// `org-table-get-formula`: stores EQUATION for the current column, or with `named` the
1016 /// current field, and returns it; nil when a blank EQUATION removed the formula. `read`
1017 /// when EQUATION is the prompt's answer, which the stored formula never replaces.
1018 func tableGetFormula(_ equation: String, named: Bool, read: Bool = false, analysis: TableAnalysis) throws -> String? {
10181019 var stored = try Self.storedFormulas(firstTblfm(after: tableEnd()) ?? "")
10191020 let ref = "@\(tableCurrentDataLine())$\(tableCurrentColumn())"
10201021 let scol = named ? ref : "$\(tableCurrentColumn())"
10211022 let old = stored.first { $0.lhs == scol }?.rhs
1022 var eq = old != nil && equation.range(of: "^ *=? *$", options: .regularExpression) != nil ? old! : equation
1023 var eq = !read && old != nil && equation.range(of: "^ *=? *$", options: .regularExpression) != nil ? old! : equation
10231024 guard eq.contains(where: { !$0.isWhitespace }) else {
10241025 stored.removeAll { $0.lhs == scol }
10251026 tableStoreFormulas(stored, analysis: analysis)
1026 throw UserError(message: "Formula removed")
1027 // Org's user error, which keeps the stored formulas and stops the command.
1028 message = "Formula removed"
1029 return nil
10271030 }
10281031 eq = eq.replacingOccurrences(of: "^ *=?", with: "", options: .regularExpression)
10291032 eq = eq.replacingOccurrences(of: " *$", with: "", options: .regularExpression)
@@ -1042,14 +1045,15 @@ extension EmacsBuffer {
10421045 }
10431046
10441047 /// `org-table-eval-formula` for the current field with EQUATION, which is stored first.
1045 func tableEvalFormula(equation: String, named: Bool) throws {
1048 /// A formula only Emacs can evaluate stays stored, the field unchanged.
1049 func tableEvalFormula(equation: String, named: Bool, read: Bool = false) throws {
10461050 try checkInsideDataField()
10471051 let analysis = try tableAnalyze()
1048 let formula = try tableGetFormula(equation, named: named, analysis: analysis)
1052 guard let formula = try tableGetFormula(equation, named: named, read: read, analysis: analysis) else { return }
10491053 do {
10501054 try tableEvalFormula(formula, analysis: analysis, substituted: false)
10511055 } catch let error as Calc.Unsupported {
1052 throw UserError(message: "The formula was stored; recalculating it needs Emacs: " + error.reason)
1056 message = "The formula was stored; recalculating it needs Emacs: " + error.reason
10531057 }
10541058 tableAlign()
10551059 }
@@ -1085,7 +1089,7 @@ public struct TableEvalFormula: OrgCommand {
10851089 let stored = (try? EmacsBuffer.storedFormulas(buffer.firstTblfm(after: buffer.tableEnd()) ?? ""))?.first { $0.lhs == scol }?.rhs
10861090 return .prompt(Prompt(key: "formula", message: "\(named ? "Field" : "Column") formula \(scol)=", initial: stored ?? ""))
10871091 }
1088 return commitBuffer(context) { try $0.tableEvalFormula(equation: answer, named: named) }
1092 return commitBuffer(context) { try $0.tableEvalFormula(equation: answer, named: named, read: true) }
10891093 }
10901094}
10911095
@@ -1163,7 +1167,7 @@ extension BlockEdit {
11631167 }
11641168 guard case .commit(let result) = step else { return step }
11651169 return .commit(EditResult(baseRevision: result.baseRevision, edits: result.edits, selection: result.selection,
1166 effects: result.effects + [.message("New formulas installed - press C-u C-c C-c to apply.")]))
1170 effects: result.effects + [.message("New formulas installed - Recalculate Table (C-c C-c on the #+TBLFM line) applies them.")]))
11671171 }
11681172}
11691173
Tests/OrgCoreTests/TableFormulaEntryTests.swift +31
@@ -13,6 +13,7 @@ struct TableFormulaEntryTests {
1313 let replaced = "| a | b |\n| 1 | =$1*5 |\n#+TBLFM: $2=$1*2::@1$2=x\n"
1414 let reuse = "| a | b |\n| 2 | = |\n#+TBLFM: $2=$1*2\n\nText\n"
1515 let last = "| 1 | =$1+1 |\n"
16 let prompt = { (arg: String) in "(cl-letf (((symbol-function 'read-string) (lambda (&rest _) \"\"))) (org-table-eval-formula \(arg)))" }
1617 let cases: [(String, String, any OrgCommand, [String: String], String)] = [
1718 (plain, "*2", TableCommand(.nextField), [:], "(org-table-next-field)"),
1819 (plain, "*2", TableCommand(.nextRow), [:], "(org-table-next-row)"),
@@ -25,6 +26,8 @@ struct TableFormulaEntryTests {
2526 (stored, "| 3", TableEvalFormula(named: false), ["formula": "$1*3"], "(org-table-eval-formula nil \"$1*3\")"),
2627 (stored, "| 3", TableEvalFormula(named: true), ["formula": "99"], "(org-table-eval-formula '(4) \"99\")"),
2728 (stored, "| 1 | 2", CtrlCCtrlC(), [:], "(org-ctrl-c-ctrl-c)"),
29 (stored, "| 3 | 4", TableEvalFormula(named: false), ["formula": ""], prompt("nil")),
30 (stored, "| 1", TableEvalFormula(named: true), ["formula": ""], prompt("'(4)")),
2831 ]
2932 let emacs = try EmacsOracle.run(cases.map { c in
3033 let caret = NSMaxRange((c.0 as NSString).range(of: c.1))
@@ -67,3 +70,31 @@ extension TableFormulaEntryTests {
6770 }
6871 }
6972}
73
74extension TableFormulaEntryTests {
75 /// Runs a command that should commit: the new text, the caret and the message.
76 func committed(_ command: any OrgCommand, _ text: String, after needle: String, answers: [String: String] = [:]) -> (text: String, caret: Int, message: String?)? {
77 let caret = NSMaxRange((text as NSString).range(of: needle))
78 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret], answers: answers)
79 guard case .commit(let result) = command.run(in: context) else { return nil }
80 var new = text
81 for edit in result.edits.sorted(by: { $0.range.lowerBound > $1.range.lowerBound }) { new = edit.apply(to: new) }
82 let message = result.effects.compactMap { effect -> String? in if case .message(let m) = effect { m } else { nil } }.first
83 return (new, result.selection?.first?.lowerBound ?? caret, message)
84 }
85
86 @Test func formulaEditsAreKeptWithTheirMessage() throws {
87 let text = "| a | b |\n|---+---|\n| 1 | 2 |\n#+TBLFM: $2=$1*2\n"
88 let removed = try #require(committed(TableEvalFormula(named: false), text, after: "| 1 | 2", answers: ["formula": ""]))
89 #expect(removed.text == "| a | b |\n|---+---|\n| 1 | 2 |\n#+TBLFM: \n" && removed.message == "Formula removed", "\(removed)")
90 // Text in a field is a Calc symbol, which needs Emacs: the formula is stored and TAB
91 // still moves on.
92 let symbolic = "| a | b |\n| x | =$1*2 |\n| y | |\n"
93 let tab = try #require(committed(TableCommand(.nextField), symbolic, after: "*2"))
94 #expect(tab.text == "| a | b |\n| x | =$1*2 |\n| y | |\n#+TBLFM: $2=$1*2\n", "\(tab.text.debugDescription)")
95 #expect(tab.caret == (tab.text as NSString).range(of: "| y").location + 2)
96 #expect(tab.message?.hasPrefix("The formula was stored; recalculating it needs Emacs") == true)
97 let prompted = try #require(committed(TableEvalFormula(named: false), symbolic, after: "| x", answers: ["formula": "$1+1"]))
98 #expect(prompted.text.hasSuffix("#+TBLFM: $1=$1+1\n") && prompted.message != nil, "\(prompted)")
99 }
100}