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 {
38 var calendar = Calendar.current 38 var calendar = Calendar.current
39 /// `org-last-inserted-timestamp`. 39 /// `org-last-inserted-timestamp`.
40 var lastInsertedTimestamp: String? 40 var lastInsertedTimestamp: String?
41 /// Shown after the command commits, as `message`.
42 var message: String?
41 43
42 let options: EditingOptions 44 let options: EditingOptions
43 45
@@ -466,7 +468,7 @@ final class EmacsBuffer {
466} 468}
467 469
468/// Runs a buffer-editing body against a context and turns the result into one minimal edit 470/// 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`.
470func commitBuffer(_ context: EditContext, _ body: (EmacsBuffer) throws -> Void) -> CommandStep { 472func commitBuffer(_ context: EditContext, _ body: (EmacsBuffer) throws -> Void) -> CommandStep {
471 let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings, options: context.options) 473 let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings, options: context.options)
472 buffer.now = context.now 474 buffer.now = context.now
@@ -499,9 +501,10 @@ func commitBuffer(_ context: EditContext, _ body: (EmacsBuffer) throws -> Void)
499 while prefix > 0, prefix < old.count, UTF16.isTrailSurrogate(old[prefix]) { prefix -= 1 } 501 while prefix > 0, prefix < old.count, UTF16.isTrailSurrogate(old[prefix]) { prefix -= 1 }
500 while suffix > 0, UTF16.isTrailSurrogate(old[old.count - suffix]) { suffix -= 1 } 502 while suffix > 0, UTF16.isTrailSurrogate(old[old.count - suffix]) { suffix -= 1 }
501 let caret = buffer.point 503 let caret = buffer.point
504 let effects: [Effect] = buffer.message.map { [.message($0)] } ?? []
502 guard old.count != new.count || prefix != old.count else { 505 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))
504 } 507 }
505 let edit = TextEdit(range: (lower + prefix)..<(lower + old.count - suffix), replacement: String(decoding: new[prefix..<(new.count - suffix)], as: UTF16.self)) 508 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))
507} 510}
Sources/OrgCore/Compute/TableFormulas.swift +14 −10
@@ -1012,18 +1012,21 @@ extension EmacsBuffer {
1012 } 1012 }
1013 } 1013 }
1014 1014
1015 /// `org-table-get-formula` with EQUATION given: stores it for the current column, or with 1015 /// `org-table-get-formula`: stores EQUATION for the current column, or with `named` the
1016 /// `named` the current field, and returns it. 1016 /// current field, and returns it; nil when a blank EQUATION removed the formula. `read`
1017 func tableGetFormula(_ equation: String, named: Bool, analysis: TableAnalysis) throws -> String { 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? {
1018 var stored = try Self.storedFormulas(firstTblfm(after: tableEnd()) ?? "") 1019 var stored = try Self.storedFormulas(firstTblfm(after: tableEnd()) ?? "")
1019 let ref = "@\(tableCurrentDataLine())$\(tableCurrentColumn())" 1020 let ref = "@\(tableCurrentDataLine())$\(tableCurrentColumn())"
1020 let scol = named ? ref : "$\(tableCurrentColumn())" 1021 let scol = named ? ref : "$\(tableCurrentColumn())"
1021 let old = stored.first { $0.lhs == scol }?.rhs 1022 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
1023 guard eq.contains(where: { !$0.isWhitespace }) else { 1024 guard eq.contains(where: { !$0.isWhitespace }) else {
1024 stored.removeAll { $0.lhs == scol } 1025 stored.removeAll { $0.lhs == scol }
1025 tableStoreFormulas(stored, analysis: analysis) 1026 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
1027 } 1030 }
1028 eq = eq.replacingOccurrences(of: "^ *=?", with: "", options: .regularExpression) 1031 eq = eq.replacingOccurrences(of: "^ *=?", with: "", options: .regularExpression)
1029 eq = eq.replacingOccurrences(of: " *$", with: "", options: .regularExpression) 1032 eq = eq.replacingOccurrences(of: " *$", with: "", options: .regularExpression)
@@ -1042,14 +1045,15 @@ extension EmacsBuffer {
1042 } 1045 }
1043 1046
1044 /// `org-table-eval-formula` for the current field with EQUATION, which is stored first. 1047 /// `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 {
1046 try checkInsideDataField() 1050 try checkInsideDataField()
1047 let analysis = try tableAnalyze() 1051 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 }
1049 do { 1053 do {
1050 try tableEvalFormula(formula, analysis: analysis, substituted: false) 1054 try tableEvalFormula(formula, analysis: analysis, substituted: false)
1051 } catch let error as Calc.Unsupported { 1055 } 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
1053 } 1057 }
1054 tableAlign() 1058 tableAlign()
1055 } 1059 }
@@ -1085,7 +1089,7 @@ public struct TableEvalFormula: OrgCommand {
1085 let stored = (try? EmacsBuffer.storedFormulas(buffer.firstTblfm(after: buffer.tableEnd()) ?? ""))?.first { $0.lhs == scol }?.rhs 1089 let stored = (try? EmacsBuffer.storedFormulas(buffer.firstTblfm(after: buffer.tableEnd()) ?? ""))?.first { $0.lhs == scol }?.rhs
1086 return .prompt(Prompt(key: "formula", message: "\(named ? "Field" : "Column") formula \(scol)=", initial: stored ?? "")) 1090 return .prompt(Prompt(key: "formula", message: "\(named ? "Field" : "Column") formula \(scol)=", initial: stored ?? ""))
1087 } 1091 }
1088 return commitBuffer(context) { try $0.tableEvalFormula(equation: answer, named: named) } 1092 return commitBuffer(context) { try $0.tableEvalFormula(equation: answer, named: named, read: true) }
1089 } 1093 }
1090} 1094}
1091 1095
@@ -1163,7 +1167,7 @@ extension BlockEdit {
1163 } 1167 }
1164 guard case .commit(let result) = step else { return step } 1168 guard case .commit(let result) = step else { return step }
1165 return .commit(EditResult(baseRevision: result.baseRevision, edits: result.edits, selection: result.selection, 1169 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.")]))
1167 } 1171 }
1168} 1172}
1169 1173
Tests/OrgCoreTests/TableFormulaEntryTests.swift +31
@@ -13,6 +13,7 @@ struct TableFormulaEntryTests {
13 let replaced = "| a | b |\n| 1 | =$1*5 |\n#+TBLFM: $2=$1*2::@1$2=x\n" 13 let replaced = "| a | b |\n| 1 | =$1*5 |\n#+TBLFM: $2=$1*2::@1$2=x\n"
14 let reuse = "| a | b |\n| 2 | = |\n#+TBLFM: $2=$1*2\n\nText\n" 14 let reuse = "| a | b |\n| 2 | = |\n#+TBLFM: $2=$1*2\n\nText\n"
15 let last = "| 1 | =$1+1 |\n" 15 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)))" }
16 let cases: [(String, String, any OrgCommand, [String: String], String)] = [ 17 let cases: [(String, String, any OrgCommand, [String: String], String)] = [
17 (plain, "*2", TableCommand(.nextField), [:], "(org-table-next-field)"), 18 (plain, "*2", TableCommand(.nextField), [:], "(org-table-next-field)"),
18 (plain, "*2", TableCommand(.nextRow), [:], "(org-table-next-row)"), 19 (plain, "*2", TableCommand(.nextRow), [:], "(org-table-next-row)"),
@@ -25,6 +26,8 @@ struct TableFormulaEntryTests {
25 (stored, "| 3", TableEvalFormula(named: false), ["formula": "$1*3"], "(org-table-eval-formula nil \"$1*3\")"), 26 (stored, "| 3", TableEvalFormula(named: false), ["formula": "$1*3"], "(org-table-eval-formula nil \"$1*3\")"),
26 (stored, "| 3", TableEvalFormula(named: true), ["formula": "99"], "(org-table-eval-formula '(4) \"99\")"), 27 (stored, "| 3", TableEvalFormula(named: true), ["formula": "99"], "(org-table-eval-formula '(4) \"99\")"),
27 (stored, "| 1 | 2", CtrlCCtrlC(), [:], "(org-ctrl-c-ctrl-c)"), 28 (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)")),
28 ] 31 ]
29 let emacs = try EmacsOracle.run(cases.map { c in 32 let emacs = try EmacsOracle.run(cases.map { c in
30 let caret = NSMaxRange((c.0 as NSString).range(of: c.1)) 33 let caret = NSMaxRange((c.0 as NSString).range(of: c.1))
@@ -67,3 +70,31 @@ extension TableFormulaEntryTests {
67 } 70 }
68 } 71 }
69} 72}
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}