Cancel Emacs tasks; Mac preset keys !169
7 files changed, +149 −6
Layout: unified · split
Sources/OrgApp/DocumentSession.swift +2
| @@ -721,6 +721,8 @@ public final class DocumentSession { | |||
| 721 | if case .failed(let message) = current.perform(PrecomputedEdit(result), now: Date(), answers: [:]) { return message } | 721 | if case .failed(let message) = current.perform(PrecomputedEdit(result), now: Date(), answers: [:]) { return message } |
| 722 | changed(entry) | 722 | changed(entry) |
| 723 | return "Recalculated in Emacs" | 723 | return "Recalculated in Emacs" |
| 724 | } catch is CancellationError { | ||
| 725 | return "Recalculation canceled" | ||
| 724 | } catch { | 726 | } catch { |
| 725 | return "\(error)" | 727 | return "\(error)" |
| 726 | } | 728 | } |
Sources/OrgCore/Keymap/Presets.swift +84
| @@ -1225,5 +1225,89 @@ public enum KeymapPreset: String, Sendable, CaseIterable { | |||
| 1225 | command = "org.timestamp.day-earlier" | 1225 | command = "org.timestamp.day-earlier" |
| 1226 | when = "timestamp" | 1226 | when = "timestamp" |
| 1227 | 1227 | ||
| 1228 | # Act at point, links, clocking, export and the rest of C-c. | ||
| 1229 | [[bind]] | ||
| 1230 | keys = "C-s-x" | ||
| 1231 | command = "org.ctrl-c-ctrl-c" | ||
| 1232 | [[bind]] | ||
| 1233 | keys = "C-s-x" | ||
| 1234 | command = "org.dblock.update" | ||
| 1235 | when = "dblock" | ||
| 1236 | [[bind]] | ||
| 1237 | keys = "C-s-x" | ||
| 1238 | command = "org.table.recalc-all" | ||
| 1239 | when = "tblfm" | ||
| 1240 | [[bind]] | ||
| 1241 | keys = "C-s-x" | ||
| 1242 | command = "org.babel.execute" | ||
| 1243 | when = "src" | ||
| 1244 | [[bind]] | ||
| 1245 | keys = "s-k" | ||
| 1246 | command = "org.link.insert" | ||
| 1247 | [[bind]] | ||
| 1248 | keys = "C-s-o" | ||
| 1249 | command = "org.link.open" | ||
| 1250 | [[bind]] | ||
| 1251 | keys = "C-s-l" | ||
| 1252 | command = "org.link.store" | ||
| 1253 | [[bind]] | ||
| 1254 | keys = "C-s-i" | ||
| 1255 | command = "app.clock.in" | ||
| 1256 | [[bind]] | ||
| 1257 | keys = "C-S-s-i" | ||
| 1258 | command = "app.clock.out" | ||
| 1259 | [[bind]] | ||
| 1260 | keys = "C-s-j" | ||
| 1261 | command = "app.clock.goto" | ||
| 1262 | [[bind]] | ||
| 1263 | keys = "s-S-e" | ||
| 1264 | command = "app.export-dialog" | ||
| 1265 | [[bind]] | ||
| 1266 | keys = "C-s-n" | ||
| 1267 | command = "org.narrow.toggle" | ||
| 1268 | [[bind]] | ||
| 1269 | keys = "C-S-s-s" | ||
| 1270 | command = "org.sort" | ||
| 1271 | [[bind]] | ||
| 1272 | keys = "C-S-s-p" | ||
| 1273 | command = "org.property.read-and-set" | ||
| 1274 | [[bind]] | ||
| 1275 | keys = "C-S-s-f" | ||
| 1276 | command = "org.footnote.action" | ||
| 1277 | [[bind]] | ||
| 1278 | keys = "C-s-'" | ||
| 1279 | command = "org.edit-special" | ||
| 1280 | [[bind]] | ||
| 1281 | keys = "C-S-s-<right>" | ||
| 1282 | command = "org.todo.next" | ||
| 1283 | when = "heading" | ||
| 1284 | [[bind]] | ||
| 1285 | keys = "C-S-s-<left>" | ||
| 1286 | command = "org.todo.previous" | ||
| 1287 | when = "heading" | ||
| 1288 | [[bind]] | ||
| 1289 | keys = "C-S-s-<right>" | ||
| 1290 | command = "org.property.next-value" | ||
| 1291 | when = "property" | ||
| 1292 | [[bind]] | ||
| 1293 | keys = "C-S-s-<left>" | ||
| 1294 | command = "org.property.previous-value" | ||
| 1295 | when = "property" | ||
| 1296 | [[bind]] | ||
| 1297 | keys = "M-S-s-<down>" | ||
| 1298 | command = "org.heading.forward-same-level" | ||
| 1299 | [[bind]] | ||
| 1300 | keys = "M-S-s-<up>" | ||
| 1301 | command = "org.heading.backward-same-level" | ||
| 1302 | [[bind]] | ||
| 1303 | keys = "C-S-s-e" | ||
| 1304 | command = "org.effort.set" | ||
| 1305 | [[bind]] | ||
| 1306 | keys = "C-s-," | ||
| 1307 | command = "org.priority.set" | ||
| 1308 | [[bind]] | ||
| 1309 | keys = "C-s-v" | ||
| 1310 | command = "org.babel.tangle" | ||
| 1311 | |||
| 1228 | """ | 1312 | """ |
| 1229 | } | 1313 | } |
Sources/OrgEditorAppKit/EditorCommands.swift +1 −1
| @@ -68,7 +68,7 @@ public enum EditorCommands { | |||
| 68 | EditorCommand(id: "app.buffer.next", title: "Next Buffer", action: .app), | 68 | EditorCommand(id: "app.buffer.next", title: "Next Buffer", action: .app), |
| 69 | EditorCommand(id: "app.buffer.previous", title: "Previous Buffer", action: .app), | 69 | EditorCommand(id: "app.buffer.previous", title: "Previous Buffer", action: .app), |
| 70 | EditorCommand(id: "app.buffer.last", title: "Last Buffer", action: .app), | 70 | EditorCommand(id: "app.buffer.last", title: "Last Buffer", action: .app), |
| 71 | EditorCommand(id: "app.babel.cancel", title: "Cancel Running Block", action: .app), | 71 | EditorCommand(id: "app.babel.cancel", title: "Cancel Running Task", action: .app), |
| 72 | EditorCommand(id: "app.toggle-tab-bar", title: "Show or Hide Tab Bar", action: .app), | 72 | EditorCommand(id: "app.toggle-tab-bar", title: "Show or Hide Tab Bar", action: .app), |
| 73 | EditorCommand(id: "app.toggle-outline", title: "Show or Hide Outline", action: .app), | 73 | EditorCommand(id: "app.toggle-outline", title: "Show or Hide Outline", action: .app), |
| 74 | EditorCommand(id: "app.toggle-backlinks", title: "Show or Hide Backlinks", action: .app), | 74 | EditorCommand(id: "app.toggle-backlinks", title: "Show or Hide Backlinks", action: .app), |
Sources/Orgstar/ContentView.swift +11 −3
| @@ -401,7 +401,7 @@ struct ContentView: View { | |||
| 401 | let functions = ["pdf": "org-latex-export-to-pdf", "odt": "org-odt-export-to-odt", "latex": "org-latex-export-to-latex", "text": "org-ascii-export-to-ascii"] | 401 | let functions = ["pdf": "org-latex-export-to-pdf", "odt": "org-odt-export-to-odt", "latex": "org-latex-export-to-latex", "text": "org-ascii-export-to-ascii"] |
| 402 | guard let function = functions[format] else { return } | 402 | guard let function = functions[format] else { return } |
| 403 | commands.show("Exporting with Emacs…") | 403 | commands.show("Exporting with Emacs…") |
| 404 | Task { | 404 | commands.runInEmacs { |
| 405 | do { | 405 | do { |
| 406 | var written = URL(fileURLWithPath: try await EmacsRunner.export(text: text, path: path, function: function)) | 406 | var written = URL(fileURLWithPath: try await EmacsRunner.export(text: text, path: path, function: function)) |
| 407 | if let destination, destination.standardizedFileURL != written.standardizedFileURL { | 407 | if let destination, destination.standardizedFileURL != written.standardizedFileURL { |
| @@ -411,6 +411,8 @@ struct ContentView: View { | |||
| 411 | } | 411 | } |
| 412 | commands.show("Exported to \(written.lastPathComponent)") | 412 | commands.show("Exported to \(written.lastPathComponent)") |
| 413 | if open { NSWorkspace.shared.open(written) } | 413 | if open { NSWorkspace.shared.open(written) } |
| 414 | } catch is CancellationError { | ||
| 415 | commands.show("Export canceled") | ||
| 414 | } catch { | 416 | } catch { |
| 415 | commands.show("\(error)") | 417 | commands.show("\(error)") |
| 416 | } | 418 | } |
| @@ -435,7 +437,7 @@ struct ContentView: View { | |||
| 435 | case .emacsTable(let table, let point, let form, let reason, let lisp): | 437 | case .emacsTable(let table, let point, let form, let reason, let lisp): |
| 436 | let run = { | 438 | let run = { |
| 437 | commands.show("Recalculating in Emacs (\(reason))…") | 439 | commands.show("Recalculating in Emacs (\(reason))…") |
| 438 | Task { commands.show(await session.recalculateInEmacs(table: table, point: point, form: form)) } | 440 | commands.runInEmacs { commands.show(await session.recalculateInEmacs(table: table, point: point, form: form)) } |
| 439 | } | 441 | } |
| 440 | guard lisp, let path = session.path, let text = session.document?.text else { return run() } | 442 | guard lisp, let path = session.path, let text = session.document?.text else { return run() } |
| 441 | let content = (text as NSString).substring(with: NSRange(location: table.lowerBound, length: min((text as NSString).length, table.upperBound + 2000) - table.lowerBound)) | 443 | let content = (text as NSString).substring(with: NSRange(location: table.lowerBound, length: min((text as NSString).length, table.upperBound + 2000) - table.lowerBound)) |
| @@ -550,7 +552,13 @@ struct ContentView: View { | |||
| 550 | if UnsavedChanges.resolve(session, paths: all) { all.forEach(session.kill) } | 552 | if UnsavedChanges.resolve(session, paths: all) { all.forEach(session.kill) } |
| 551 | case "app.toggle-tab-bar": showTabBar.toggle() | 553 | case "app.toggle-tab-bar": showTabBar.toggle() |
| 552 | case "app.babel.cancel": | 554 | case "app.babel.cancel": |
| 553 | if session.isRunningBabel { session.cancelBabel() } else { commands.show("No block is running") } | 555 | if session.isRunningBabel { |
| 556 | session.cancelBabel() | ||
| 557 | } else if let task = commands.emacsTask { | ||
| 558 | task.cancel() | ||
| 559 | } else { | ||
| 560 | commands.show("Nothing is running") | ||
| 561 | } | ||
| 554 | case "app.toggle-outline": toggleOutline() | 562 | case "app.toggle-outline": toggleOutline() |
| 555 | case "app.toggle-backlinks": backlinksShown.toggle() | 563 | case "app.toggle-backlinks": backlinksShown.toggle() |
| 556 | case "app.toggle-inspector": inspectorShown.toggle() | 564 | case "app.toggle-inspector": inspectorShown.toggle() |
Sources/Orgstar/OrgstarApp.swift +17 −2
| @@ -70,6 +70,21 @@ final class AppCommands { | |||
| 70 | request.reply(text) | 70 | request.reply(text) |
| 71 | } | 71 | } |
| 72 | 72 | ||
| 73 | /// An export or table recalculation running in Emacs, for Cancel Running Task. | ||
| 74 | private(set) var emacsTask: Task<Void, Never>? | ||
| 75 | private var emacsSerial = 0 | ||
| 76 | |||
| 77 | /// Runs `work` as the Emacs task, cancelling the one before. | ||
| 78 | func runInEmacs(_ work: @escaping @MainActor () async -> Void) { | ||
| 79 | emacsTask?.cancel() | ||
| 80 | emacsSerial += 1 | ||
| 81 | let serial = emacsSerial | ||
| 82 | emacsTask = Task { | ||
| 83 | await work() | ||
| 84 | if emacsSerial == serial { emacsTask = nil } | ||
| 85 | } | ||
| 86 | } | ||
| 87 | |||
| 73 | /// An app command from a menu, run by the main window. | 88 | /// An app command from a menu, run by the main window. |
| 74 | private(set) var runRequest: (id: String, serial: Int)? | 89 | private(set) var runRequest: (id: String, serial: Int)? |
| 75 | private var runSerial = 0 | 90 | private var runSerial = 0 |
| @@ -363,9 +378,9 @@ struct OrgstarApp: App { | |||
| 363 | FindMenu(commands: commands) | 378 | FindMenu(commands: commands) |
| 364 | } | 379 | } |
| 365 | CommandGroup(after: .textEditing) { | 380 | CommandGroup(after: .textEditing) { |
| 366 | Button("Cancel Running Block") { commands.run("app.babel.cancel") } | 381 | Button("Cancel Running Task") { commands.run("app.babel.cancel") } |
| 367 | .keyboardShortcut(".") | 382 | .keyboardShortcut(".") |
| 368 | .disabled(!session.isRunningBabel) | 383 | .disabled(!session.isRunningBabel && commands.emacsTask == nil) |
| 369 | Button("Search Notes") { commands.focusSearch = true } | 384 | Button("Search Notes") { commands.focusSearch = true } |
| 370 | .keyboardShortcut("f", modifiers: [.command, .shift]) | 385 | .keyboardShortcut("f", modifiers: [.command, .shift]) |
| 371 | } | 386 | } |
Tests/OrgAppTests/AppTests.swift +18
| @@ -721,6 +721,24 @@ struct EmacsFallbackTests { | |||
| 721 | #expect(message == "Recalculated in Emacs") | 721 | #expect(message == "Recalculated in Emacs") |
| 722 | #expect(session.document?.text == "* Prices\n| item | n | twice |\n|------+---+-------|\n| x | a | 2 a |\n| y | 2 | 4 |\n#+TBLFM: $3=$2*2\n") | 722 | #expect(session.document?.text == "* Prices\n| item | n | twice |\n|------+---+-------|\n| x | a | 2 a |\n| y | 2 | 4 |\n#+TBLFM: $3=$2*2\n") |
| 723 | } | 723 | } |
| 724 | |||
| 725 | @Test(.enabled(if: EmacsRunner.executable() != nil && ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil)) | ||
| 726 | func cancelingARecalculationLeavesTheTable() async throws { | ||
| 727 | let notes = try Folder() | ||
| 728 | let text = "| n | twice |\n|---+-------|\n| a | |\n#+TBLFM: $2=$1*2\n" | ||
| 729 | let file = try notes.write("t.org", text) | ||
| 730 | let session = DocumentSession(recovery: FileRecoveryStore(directory: notes.url.appendingPathComponent(".r"))) | ||
| 731 | session.open(file.path) | ||
| 732 | let step = session.perform(TableRecalculate(all: true), path: file.path, at: 2, expect: nil) | ||
| 733 | guard case .external(.emacsTable(let table, let point, let form, _, _)) = step else { | ||
| 734 | Issue.record("\(step)") | ||
| 735 | return | ||
| 736 | } | ||
| 737 | let task = Task { await session.recalculateInEmacs(table: table, point: point, form: form) } | ||
| 738 | task.cancel() | ||
| 739 | #expect(await task.value == "Recalculation canceled") | ||
| 740 | #expect(session.document?.text == text) | ||
| 741 | } | ||
| 724 | } | 742 | } |
| 725 | 743 | ||
| 726 | @MainActor | 744 | @MainActor |
Tests/OrgCoreTests/KeymapTests.swift +16
| @@ -153,6 +153,22 @@ struct KeymapTests { | |||
| 153 | #expect(KeymapPreset.mac.keymap.keys(for: "org.deadline") == [KeyChord("e", [.control, .command])]) | 153 | #expect(KeymapPreset.mac.keymap.keys(for: "org.deadline") == [KeyChord("e", [.control, .command])]) |
| 154 | } | 154 | } |
| 155 | 155 | ||
| 156 | /// The Mac preset reaches links, clocking, export and act-at-point without the palette. | ||
| 157 | @Test func macPresetCoversOrgAreas() { | ||
| 158 | let mac = KeymapPreset.mac.keymap | ||
| 159 | let expected: [(String, String)] = [ | ||
| 160 | ("org.ctrl-c-ctrl-c", "C-s-x"), ("org.link.insert", "s-k"), ("org.link.open", "C-s-o"), ("org.link.store", "C-s-l"), | ||
| 161 | ("app.clock.in", "C-s-i"), ("app.clock.out", "C-s-I"), ("app.clock.goto", "C-s-j"), ("app.export-dialog", "s-E"), | ||
| 162 | ("org.narrow.toggle", "C-s-n"), ("org.sort", "C-s-S"), ("org.property.read-and-set", "C-s-P"), | ||
| 163 | ("org.footnote.action", "C-s-F"), ("org.edit-special", "C-s-'"), ("org.todo.next", "C-S-s-<right>"), | ||
| 164 | ("org.heading.forward-same-level", "M-S-s-<down>"), ("org.effort.set", "C-s-E"), ("org.priority.set", "C-s-,"), | ||
| 165 | ("org.babel.tangle", "C-s-v"), | ||
| 166 | ] | ||
| 167 | for (command, keys) in expected { | ||
| 168 | #expect(mac.keys(for: command).map(KeySequence.format) == keys, "\(command)") | ||
| 169 | } | ||
| 170 | } | ||
| 171 | |||
| 156 | /// A binding whose keys start a longer live binding never runs: the dispatcher waits for more. | 172 | /// A binding whose keys start a longer live binding never runs: the dispatcher waits for more. |
| 157 | @Test func presetsBindNoPrefixes() { | 173 | @Test func presetsBindNoPrefixes() { |
| 158 | for preset in KeymapPreset.allCases { | 174 | for preset in KeymapPreset.allCases { |