krz/orgstar

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

Commit 68b118ae7a

68b118ae7a060acbf45006fcc4a8b5f3a10a1089

parent: 7f4768ef8f

Verified · cmc

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

Fix Emacs import command mapping and duplicate key bindings

- org-set-property maps to org.property.read-and-set, org-ctrl-c-ctrl-c
  to org.ctrl-c-ctrl-c, org-shiftright to org.todo.next; org-shiftleft
  maps to org.todo.previous.
- A test checks every mapped id is a command.
- Importing again skips bindings already in keymap.toml.
- keyString's comment says key vectors are skipped.

Layout: unified · split

Sources/OrgApp/EmacsImport.swift +11 −6
@@ -234,7 +234,7 @@ public struct EmacsImport: Sendable {
234 return modes.isEmpty ? [nil] : modes 234 return modes.isEmpty ? [nil] : modes
235 } 235 }
236 236
237 /// `(kbd "C-c x")`, `"C-c x"` (keymap-set), or a vector of one key. 237 /// `(kbd "C-c x")` or `"C-c x"` (keymap-set); key vectors such as `[f5]` are skipped.
238 func keyString(_ sexp: Sexp) -> String? { 238 func keyString(_ sexp: Sexp) -> String? {
239 if let s = sexp.string { return s } 239 if let s = sexp.string { return s }
240 if sexp.head == "kbd", let s = sexp.elements?.dropFirst().first?.string { return s } 240 if sexp.head == "kbd", let s = sexp.elements?.dropFirst().first?.string { return s }
@@ -618,7 +618,7 @@ public struct EmacsImport: Sendable {
618 618
619 /// Emacs commands and the Orgstar commands that do the same. 619 /// Emacs commands and the Orgstar commands that do the same.
620 static let commands: [String: String] = [ 620 static let commands: [String: String] = [
621 "org-todo": "org.todo.cycle", "org-shiftright": "org.todo.cycle", "org-priority-up": "org.priority.up", "org-priority-down": "org.priority.down", 621 "org-todo": "org.todo.cycle", "org-shiftright": "org.todo.next", "org-shiftleft": "org.todo.previous", "org-priority-up": "org.priority.up", "org-priority-down": "org.priority.down",
622 "org-metaleft": "org.heading.promote", "org-metaright": "org.heading.demote", "org-do-promote": "org.heading.promote", "org-do-demote": "org.heading.demote", 622 "org-metaleft": "org.heading.promote", "org-metaright": "org.heading.demote", "org-do-promote": "org.heading.promote", "org-do-demote": "org.heading.demote",
623 "org-promote-subtree": "org.subtree.promote", "org-demote-subtree": "org.subtree.demote", 623 "org-promote-subtree": "org.subtree.promote", "org-demote-subtree": "org.subtree.demote",
624 "org-shiftmetaleft": "org.subtree.promote", "org-shiftmetaright": "org.subtree.demote", 624 "org-shiftmetaleft": "org.subtree.promote", "org-shiftmetaright": "org.subtree.demote",
@@ -629,8 +629,8 @@ public struct EmacsImport: Sendable {
629 "org-next-visible-heading": "org.heading.next", "org-previous-visible-heading": "org.heading.previous", 629 "org-next-visible-heading": "org.heading.next", "org-previous-visible-heading": "org.heading.previous",
630 "org-forward-heading-same-level": "org.heading.forward-same-level", "org-backward-heading-same-level": "org.heading.backward-same-level", 630 "org-forward-heading-same-level": "org.heading.forward-same-level", "org-backward-heading-same-level": "org.heading.backward-same-level",
631 "outline-up-heading": "org.heading.up", "org-up-element": "org.heading.up", 631 "outline-up-heading": "org.heading.up", "org-up-element": "org.heading.up",
632 "org-set-tags-command": "org.tags.set", "org-set-property": "org.property.set", 632 "org-set-tags-command": "org.tags.set", "org-set-property": "org.property.read-and-set",
633 "org-toggle-checkbox": "org.checkbox.toggle", "org-ctrl-c-ctrl-c": "org.dwim", "+org/dwim-at-point": "org.dwim", 633 "org-toggle-checkbox": "org.checkbox.toggle", "org-ctrl-c-ctrl-c": "org.ctrl-c-ctrl-c", "+org/dwim-at-point": "org.dwim",
634 "org-schedule": "org.schedule", "org-deadline": "org.deadline", "org-time-stamp": "org.timestamp.active", "org-timestamp": "org.timestamp.active", 634 "org-schedule": "org.schedule", "org-deadline": "org.deadline", "org-time-stamp": "org.timestamp.active", "org-timestamp": "org.timestamp.active",
635 "org-time-stamp-inactive": "org.timestamp.inactive", "org-timestamp-inactive": "org.timestamp.inactive", 635 "org-time-stamp-inactive": "org.timestamp.inactive", "org-timestamp-inactive": "org.timestamp.inactive",
636 "org-fill-paragraph": "org.fill-paragraph", "org-cycle": "org.cycle", "org-shifttab": "org.cycle-global", "org-global-cycle": "org.cycle-global", 636 "org-fill-paragraph": "org.fill-paragraph", "org-cycle": "org.cycle", "org-shifttab": "org.cycle-global", "org-global-cycle": "org.cycle-global",
@@ -652,7 +652,8 @@ public struct EmacsImport: Sendable {
652extension EmacsImport { 652extension EmacsImport {
653 /// Applies `items`: settings to `defaults` (and so config.toml), folders to the workspace, 653 /// Applies `items`: settings to `defaults` (and so config.toml), folders to the workspace,
654 /// capture templates and key bindings appended to capture.toml and keymap.toml in 654 /// capture templates and key bindings appended to capture.toml and keymap.toml in
655 /// `directory`, leaving out templates whose key is there already. Returns a summary. 655 /// `directory`, leaving out templates whose key is there already and bindings that are
656 /// there already. Returns a summary.
656 @MainActor 657 @MainActor
657 public static func apply( 658 public static func apply(
658 _ items: [Item], defaults: UserDefaults = .standard, workspace: WorkspaceModel?, directory: URL = UserConfig.directory 659 _ items: [Item], defaults: UserDefaults = .standard, workspace: WorkspaceModel?, directory: URL = UserConfig.directory
@@ -695,9 +696,13 @@ extension EmacsImport {
695 } 696 }
696 let keymapFile = directory.appendingPathComponent("keymap.toml") 697 let keymapFile = directory.appendingPathComponent("keymap.toml")
697 var keymap = (try? String(contentsOf: keymapFile, encoding: .utf8)) ?? "" 698 var keymap = (try? String(contentsOf: keymapFile, encoding: .utf8)) ?? ""
699 func binding(_ keys: String?, _ command: String?, _ mode: String?) -> String { [keys ?? "", command ?? "", mode ?? ""].joined(separator: "\u{0}") }
700 var existingBindings = Set((try? TOML.parse(keymap))?.filter { $0.name == "bind" && $0.values["when"] == nil }
701 .map { binding($0.values["keys"]?.string, $0.values["command"]?.string, $0.values["mode"]?.string) } ?? [])
698 var addedKeys = "" 702 var addedKeys = ""
699 for item in items { 703 for item in items {
700 guard case .binding(let keys, let command, let mode) = item.change else { continue } 704 guard case .binding(let keys, let command, let mode) = item.change,
705 existingBindings.insert(binding(keys, command, mode)).inserted else { continue }
701 addedKeys += "\n[[bind]]\nkeys = \(UserConfig.format(.string(keys)))\ncommand = \(UserConfig.format(.string(command)))\n" 706 addedKeys += "\n[[bind]]\nkeys = \(UserConfig.format(.string(keys)))\ncommand = \(UserConfig.format(.string(command)))\n"
702 if let mode { addedKeys += "mode = \(UserConfig.format(.string(mode)))\n" } 707 if let mode { addedKeys += "mode = \(UserConfig.format(.string(mode)))\n" }
703 bindings += 1 708 bindings += 1
Tests/OrgAppTests/EmacsImportTests.swift +6
@@ -170,5 +170,11 @@ struct EmacsImportTests {
170 #expect(summary.hasPrefix("Imported 17 settings, 3 capture templates, 3 key bindings."), "\(summary)") 170 #expect(summary.hasPrefix("Imported 17 settings, 3 capture templates, 3 key bindings."), "\(summary)")
171 #expect(defaults.string(forKey: "themeFont") == "JetBrains Mono") 171 #expect(defaults.string(forKey: "themeFont") == "JetBrains Mono")
172 #expect(defaults.integer(forKey: "themeFontSize") == 14) 172 #expect(defaults.integer(forKey: "themeFontSize") == 14)
173
174 // Importing again adds no bindings or templates.
175 let before = try String(contentsOf: keymap, encoding: .utf8)
176 let again = await EmacsImport.apply(result.items, defaults: defaults, workspace: nil, directory: folder)
177 #expect(try String(contentsOf: keymap, encoding: .utf8) == before)
178 #expect(!again.contains("key binding") && !again.contains("capture template"), "\(again)")
173 } 179 }
174} 180}
Tests/OrgEditorAppKitTests/KeyTests.swift +7
@@ -1,6 +1,7 @@
1import AppKit 1import AppKit
2import OrgCore 2import OrgCore
3import Testing 3import Testing
4@testable import OrgApp
4@testable import OrgEditorAppKit 5@testable import OrgEditorAppKit
5 6
6@MainActor 7@MainActor
@@ -133,6 +134,12 @@ struct KeyTests {
133 } 134 }
134 } 135 }
135 } 136 }
137
138 @Test func everyEmacsImportCommandExists() {
139 for (emacs, command) in EmacsImport.commands {
140 #expect(EditorCommands.command(command) != nil, "\(emacs): \(command)")
141 }
142 }
136} 143}
137 144
138@MainActor 145@MainActor