krz/orgstar

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

Commit 10c3366127

10c33661279a1fb0e8d3dd51509db9637778769e

parent: 056befa97f

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 03:54 UTC

Run commands from keys

Layout: unified · split

Sources/OrgCore/Keymap/Presets.swift −3
@@ -95,9 +95,6 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
9595 keys = "C-SPC"
9696 command = "edit.set-mark"
9797 [[bind]]
98 keys = "C-x C-x"
99 command = "edit.exchange-point-and-mark"
100 [[bind]]
10198 keys = "C-x h"
10299 command = "edit.select-all"
103100 [[bind]]
Sources/OrgEditorAppKit/EditorCommands.swift added +85
@@ -0,0 +1,85 @@
1#if os(macOS)
2import OrgCore
3
4/// Every command a key, menu or the palette can run, by id.
5public struct EditorCommand: Sendable {
6 public enum Action: Sendable {
7 /// An `OrgCore` command, run through `OrgEditor.perform`.
8 case text(any OrgCommand)
9 /// Folding and other view changes the editor makes itself.
10 case view
11 /// A text-system action; `extending` is its variant that extends the selection, used
12 /// while a mark is active.
13 case edit(selector: String, extending: String? = nil)
14 /// Mark handling in the editor.
15 case mark
16 /// Handled by the app (save, quick open, palette).
17 case app
18 }
19
20 public let id: String
21 public let title: String
22 public let action: Action
23
24 /// Shown in the palette; text-system movement and editing are left to keys.
25 public var inPalette: Bool {
26 switch action {
27 case .edit, .mark: false
28 default: true
29 }
30 }
31}
32
33public enum EditorCommands {
34 public static let all: [EditorCommand] = Commands.all.map { EditorCommand(id: $0.id, title: $0.title, action: .text($0)) } + [
35 EditorCommand(id: "org.cycle", title: "Cycle Visibility", action: .view),
36 EditorCommand(id: "org.cycle-global", title: "Cycle Global Visibility", action: .view),
37
38 EditorCommand(id: "app.save", title: "Save", action: .app),
39 EditorCommand(id: "app.quick-open", title: "Quick Open…", action: .app),
40 EditorCommand(id: "app.palette", title: "Command Palette…", action: .app),
41 EditorCommand(id: "app.search", title: "Search Notes", action: .app),
42 EditorCommand(id: "app.reload-keymap", title: "Reload Keymap", action: .app),
43
44 movement("forward-char", "Forward Character", "moveForward:"),
45 movement("backward-char", "Backward Character", "moveBackward:"),
46 movement("next-line", "Next Line", "moveDown:"),
47 movement("previous-line", "Previous Line", "moveUp:"),
48 movement("beginning-of-line", "Beginning of Line", "moveToBeginningOfParagraph:"),
49 movement("end-of-line", "End of Line", "moveToEndOfParagraph:"),
50 movement("forward-word", "Forward Word", "moveWordForward:"),
51 movement("backward-word", "Backward Word", "moveWordBackward:"),
52 movement("beginning-of-buffer", "Beginning of Buffer", "moveToBeginningOfDocument:"),
53 movement("end-of-buffer", "End of Buffer", "moveToEndOfDocument:"),
54 movement("scroll-up", "Scroll Up", "pageDown:"),
55 movement("scroll-down", "Scroll Down", "pageUp:"),
56 edit("recenter", "Recenter", "centerSelectionInVisibleArea:"),
57 edit("delete-char", "Delete Character", "deleteForward:"),
58 edit("kill-word", "Kill Word", "deleteWordForward:"),
59 edit("backward-kill-word", "Kill Word Backward", "deleteWordBackward:"),
60 edit("kill-line", "Kill Line", "deleteToEndOfParagraph:"),
61 edit("yank", "Yank", "yank:"),
62 edit("select-all", "Select All", "selectAll:"),
63 edit("kill-region", "Kill Region", "cut:"),
64 edit("copy-region", "Copy Region", "copy:"),
65 edit("undo", "Undo", "undo:"),
66 edit("transpose-chars", "Transpose Characters", "transpose:"),
67 edit("find", "Find", "performTextFinderAction:"),
68 EditorCommand(id: "edit.set-mark", title: "Set Mark", action: .mark),
69 EditorCommand(id: "edit.keyboard-quit", title: "Quit", action: .mark),
70 ]
71
72 private static func movement(_ name: String, _ title: String, _ selector: String) -> EditorCommand {
73 let extending = String(selector.dropLast()) + "AndModifySelection:"
74 return EditorCommand(id: "edit.\(name)", title: title, action: .edit(selector: selector, extending: extending))
75 }
76
77 private static func edit(_ name: String, _ title: String, _ selector: String) -> EditorCommand {
78 EditorCommand(id: "edit.\(name)", title: title, action: .edit(selector: selector))
79 }
80
81 private static let byID = Dictionary(uniqueKeysWithValues: all.map { ($0.id, $0) })
82
83 public static func command(_ id: String) -> EditorCommand? { byID[id] }
84}
85#endif
Sources/OrgEditorAppKit/Keys.swift added +75
@@ -0,0 +1,75 @@
1#if os(macOS)
2import AppKit
3import OrgCore
4
5/// Which Option keys act as Meta. The others type characters, as usual on a Mac.
6public struct OptionAsMeta: OptionSet, Sendable {
7 public let rawValue: Int
8 public init(rawValue: Int) { self.rawValue = rawValue }
9 public static let left = OptionAsMeta(rawValue: 1)
10 public static let right = OptionAsMeta(rawValue: 2)
11}
12
13/// A text view that offers key presses to the editor's keymap before the text system.
14final class OrgTextView: NSTextView {
15 /// Returns true when the key was handled.
16 var keyHandler: ((NSEvent) -> Bool)?
17 var onMouseDown: (() -> Void)?
18
19 override func keyDown(with event: NSEvent) {
20 if !hasMarkedText(), keyHandler?(event) == true { return }
21 super.keyDown(with: event)
22 }
23
24 override func performKeyEquivalent(with event: NSEvent) -> Bool {
25 if window?.firstResponder === self, !hasMarkedText(), event.modifierFlags.contains(.command), keyHandler?(event) == true {
26 return true
27 }
28 return super.performKeyEquivalent(with: event)
29 }
30
31 override func mouseDown(with event: NSEvent) {
32 onMouseDown?()
33 super.mouseDown(with: event)
34 }
35}
36
37enum KeyEvents {
38 /// Device-dependent bits for the left and right Option keys (`NX_DEVICELALTKEYMASK`,
39 /// `NX_DEVICERALTKEYMASK`).
40 static let leftOption: UInt = 0x20
41 static let rightOption: UInt = 0x40
42
43 static let named: [UInt16: String] = [
44 48: "TAB", 36: "RET", 76: "RET", 49: "SPC", 53: "ESC", 51: "DEL", 117: "<delete>",
45 123: "<left>", 124: "<right>", 125: "<down>", 126: "<up>",
46 115: "<home>", 119: "<end>", 116: "<prior>", 121: "<next>",
47 122: "<f1>", 120: "<f2>", 99: "<f3>", 118: "<f4>", 96: "<f5>", 97: "<f6>",
48 98: "<f7>", 100: "<f8>", 101: "<f9>", 109: "<f10>", 103: "<f11>", 111: "<f12>",
49 ]
50
51 /// The chord for a key press, or nil when it types a character with Option.
52 static func chord(for event: NSEvent, optionAsMeta: OptionAsMeta) -> KeyChord? {
53 guard event.type == .keyDown else { return nil }
54 let flags = event.modifierFlags
55 var modifiers: KeyChord.Modifiers = []
56 if flags.contains(.control) { modifiers.insert(.control) }
57 if flags.contains(.command) { modifiers.insert(.command) }
58 if flags.contains(.option) {
59 let raw = flags.rawValue
60 let meta = (raw & leftOption != 0 && optionAsMeta.contains(.left)) || (raw & rightOption != 0 && optionAsMeta.contains(.right))
61 if meta || !modifiers.isEmpty {
62 modifiers.insert(.meta)
63 } else {
64 return nil
65 }
66 }
67 if let name = named[event.keyCode] {
68 if flags.contains(.shift) { modifiers.insert(.shift) }
69 return KeyChord(name, modifiers)
70 }
71 guard let characters = event.charactersIgnoringModifiers, characters.count == 1 else { return nil }
72 return KeyChord(characters, modifiers)
73 }
74}
75#endif
Sources/OrgEditorAppKit/OrgEditor.swift +116 −16
@@ -74,8 +74,13 @@ public final class OrgEditor: NSObject {
7474
7575 public init(document: DocumentState, frame: NSRect = NSRect(x: 0, y: 0, width: 600, height: 800), editable: Bool = true) {
7676 self.document = document
77 textView = NSTextView(usingTextLayoutManager: true)
77 let orgTextView = OrgTextView(usingTextLayoutManager: true)
78 textView = orgTextView
79 dispatcher = KeyDispatcher(keymap: KeymapPreset.emacs.keymap)
7880 super.init()
81 orgTextView.keyHandler = { [weak self] event in self?.handleKey(event) ?? false }
82 orgTextView.onMouseDown = { [weak self] in self?.markActive = false }
83 textView.usesFindBar = true
7984 textView.frame = frame
8085 textView.isRichText = false
8186 textView.isEditable = editable
@@ -166,16 +171,120 @@ public final class OrgEditor: NSObject {
166171
167172 /// Called with messages commands report, such as why one couldn't run here.
168173 public var onMessage: ((String) -> Void)?
174 /// Called with the pending prefix and what can follow it, and with nil when it ends.
175 public var onKeysPending: ((_ keys: String?, _ next: [(key: String, title: String)]) -> Void)?
176 /// Called with `app.*` commands, which the app carries out.
177 public var onAppCommand: ((String) -> Void)?
178
179 public var keymap: Keymap {
180 get { dispatcher.keymap }
181 set {
182 dispatcher.keymap = newValue
183 dispatcher.reset()
184 }
185 }
169186
170 /// Runs `command` at the selection. Edits go through the text view, as typing does, so
171 /// they form one undo step and reach the document through the same path.
172 @discardableResult
173 public func perform(_ command: any OrgCommand, now: Date = Date(), answers: [String: String] = [:]) -> CommandStep {
187 public var optionAsMeta: OptionAsMeta = [.left]
188 private var dispatcher: KeyDispatcher
189 /// A region is active: movement extends the selection, as after C-SPC in Emacs.
190 private(set) var markActive = false
191
192 private func context(now: Date = Date(), answers: [String: String] = [:]) -> EditContext {
174193 let selected = textView.selectedRange()
175 let context = EditContext(
194 return EditContext(
176195 revision: document.revision, text: document.text, tree: document.tree,
177196 selection: [selected.location..<NSMaxRange(selected)], now: now, calendar: .current, answers: answers
178197 )
198 }
199
200 /// Offers a key press to the keymap. False lets the text system have it.
201 func handleKey(_ event: NSEvent) -> Bool {
202 guard let chord = KeyEvents.chord(for: event, optionAsMeta: optionAsMeta) else { return false }
203 switch dispatcher.feed(chord) {
204 case .pending(let keys):
205 let next = keymap.continuations(of: keys).map { item in
206 (key: item.key.description, title: item.command.map { EditorCommands.command($0)?.title ?? $0 } ?? "+prefix")
207 }
208 onKeysPending?(KeySequence.format(keys), next)
209 return true
210 case .cancelled:
211 onKeysPending?(nil, [])
212 onMessage?("Quit")
213 return true
214 case .undefined(let keys):
215 onKeysPending?(nil, [])
216 onMessage?("\(KeySequence.format(keys)) is undefined")
217 return true
218 case .unbound:
219 return false
220 case .complete(let keys, let candidates):
221 if keys.count > 1 { onKeysPending?(nil, []) }
222 let context = context()
223 let runnable = candidates.first { binding in
224 (binding.when.map { KeyContext.holds($0, in: context) } ?? true) && applies(binding.command, in: context)
225 }
226 if let binding = runnable ?? (keys.count > 1 ? candidates.first : nil) {
227 run(binding.command)
228 return true
229 }
230 return false
231 }
232 }
233
234 private func applies(_ id: String, in context: EditContext) -> Bool {
235 switch EditorCommands.command(id)?.action {
236 case .text(let command): command.applies(in: context)
237 case .view: id != "org.cycle" || Presentation.heading(containing: context.caret, in: context.tree) != nil
238 case .edit, .mark, .app: true
239 case nil: true
240 }
241 }
242
243 /// Runs a command by id, as a key, a menu item or the palette would.
244 public func run(_ id: String) {
245 guard let command = EditorCommands.command(id) else {
246 onMessage?("Unknown command \(id)")
247 return
248 }
249 var keepMark = false
250 switch command.action {
251 case .text(let text):
252 perform(text)
253 case .view:
254 if id == "org.cycle" { cycleAtCaret() } else { cycleGlobal() }
255 case .edit(let selector, let extending):
256 if markActive, let extending {
257 keepMark = true
258 textView.doCommand(by: NSSelectorFromString(extending))
259 } else if selector == "performTextFinderAction:" {
260 let item = NSMenuItem()
261 item.tag = NSTextFinder.Action.showFindInterface.rawValue
262 textView.performTextFinderAction(item)
263 } else {
264 textView.doCommand(by: NSSelectorFromString(selector))
265 }
266 case .mark:
267 if id == "edit.set-mark" {
268 let caret = textView.selectedRange().location
269 textView.setSelectedRange(NSRange(location: caret, length: 0))
270 keepMark = true
271 onMessage?("Mark set")
272 } else {
273 let selected = textView.selectedRange()
274 if markActive || selected.length > 0 { textView.setSelectedRange(NSRange(location: NSMaxRange(selected), length: 0)) }
275 onMessage?("Quit")
276 }
277 case .app:
278 onAppCommand?(id)
279 }
280 markActive = keepMark
281 }
282
283 /// Runs `command` at the selection. Edits go through the text view, as typing does, so
284 /// they form one undo step and reach the document through the same path.
285 @discardableResult
286 public func perform(_ command: any OrgCommand, now: Date = Date(), answers: [String: String] = [:]) -> CommandStep {
287 let context = context(now: now, answers: answers)
179288 let step = command.run(in: context)
180289 switch step {
181290 case .commit(let result):
@@ -370,6 +479,7 @@ public final class OrgEditor: NSObject {
370479
371480 fileprivate func storageEdited(range: NSRange, delta: Int) {
372481 guard !isLoading, let storage = textView.textStorage else { return }
482 markActive = false
373483 let clock = ContinuousClock()
374484 let start = clock.now
375485 let replacement = (storage.string as NSString).substring(with: range)
@@ -490,16 +600,6 @@ extension OrgEditor: DocumentBuffer {
490600}
491601
492602extension OrgEditor: NSTextViewDelegate {
493 public func textView(_ textView: NSTextView, doCommandBy selector: Selector) -> Bool {
494 switch selector {
495 case #selector(NSResponder.insertTab(_:)): return cycleAtCaret()
496 case #selector(NSResponder.insertBacktab(_:)):
497 cycleGlobal()
498 return true
499 default: return false
500 }
501 }
502
503603 public func textViewDidChangeSelection(_ notification: Notification) {
504604 selectionChanged()
505605 }
Tests/OrgEditorAppKitTests/EditorTests.swift +4 −4
@@ -89,10 +89,10 @@ struct FoldingTests {
8989
9090 @Test func globalCycleAndStartup() {
9191 let h = Harness(outline)
92 h.textView.doCommand(by: #selector(NSResponder.insertBacktab(_:)))
93 h.textView.doCommand(by: #selector(NSResponder.insertBacktab(_:)))
92 h.editor.run("org.cycle-global")
93 h.editor.run("org.cycle-global")
9494 #expect(h.visibleLines == ["* a\n", "** b\n", "* c\n"])
95 h.textView.doCommand(by: #selector(NSResponder.insertBacktab(_:)))
95 h.editor.run("org.cycle-global")
9696 #expect(h.visibleLines.count == 6)
9797 #expect(Harness("#+STARTUP: overview\n" + outline).visibleLines == ["#+STARTUP: overview\n", "* a\n", "* c\n"])
9898 }
@@ -118,7 +118,7 @@ struct FoldingTests {
118118 let h = Harness(outline)
119119 h.caret(at: 5)
120120 #expect(!h.editor.cycleAtCaret())
121 h.textView.doCommand(by: #selector(NSResponder.insertBacktab(_:)))
121 h.editor.run("org.cycle-global")
122122 #expect(h.visibleLines == ["* a\n", "* c\n"])
123123 }
124124
Tests/OrgEditorAppKitTests/KeyTests.swift added +119
@@ -0,0 +1,119 @@
1import AppKit
2import OrgCore
3import Testing
4@testable import OrgEditorAppKit
5
6@MainActor
7struct KeyTests {
8 static let codes: [Character: UInt16] = ["a": 0, "s": 1, "f": 3, "g": 5, "z": 6, "x": 7, "c": 8, "w": 13, "t": 17]
9
10 /// Presses a key. `control` sends the control character, as macOS does.
11 func press(_ h: Harness, _ key: Character, control: Bool = false, option: UInt = 0, typed: String? = nil) {
12 var flags: NSEvent.ModifierFlags = []
13 if control { flags.insert(.control) }
14 if option != 0 { flags = NSEvent.ModifierFlags(rawValue: flags.rawValue | NSEvent.ModifierFlags.option.rawValue | option) }
15 let ignoring = String(key)
16 let characters = typed ?? (control ? String(UnicodeScalar(UInt8(key.asciiValue! & 0x1f))) : ignoring)
17 send(h, characters: characters, ignoring: ignoring, code: Self.codes[key] ?? 0, flags: flags)
18 }
19
20 func send(_ h: Harness, characters: String, ignoring: String, code: UInt16, flags: NSEvent.ModifierFlags = []) {
21 let event = NSEvent.keyEvent(
22 with: .keyDown, location: .zero, modifierFlags: flags, timestamp: 0, windowNumber: h.window.windowNumber,
23 context: nil, characters: characters, charactersIgnoringModifiers: ignoring, isARepeat: false, keyCode: code
24 )!
25 h.textView.keyDown(with: event)
26 }
27
28 @Test func prefixSequenceRunsAnOrgCommand() {
29 let h = Harness("* a\n")
30 var pending: [String?] = []
31 var next: [(key: String, title: String)] = []
32 h.editor.onKeysPending = { keys, continuations in
33 pending.append(keys)
34 if keys != nil { next = continuations }
35 }
36 h.caret(at: 1)
37 press(h, "c", control: true)
38 #expect(pending == ["C-c"])
39 #expect(next.contains { $0.key == "C-t" && $0.title == "Cycle TODO State" })
40 press(h, "t", control: true)
41 #expect(h.string == "* TODO a\n")
42 #expect(pending == ["C-c", nil])
43 h.checkInSync()
44 }
45
46 @Test func undefinedSequenceReportsAMessage() {
47 let h = Harness("text\n")
48 var messages: [String] = []
49 h.editor.onMessage = { messages.append($0) }
50 press(h, "c", control: true)
51 press(h, "z", control: true)
52 #expect(messages == ["C-c C-z is undefined"])
53 press(h, "x", control: true)
54 press(h, "g", control: true)
55 #expect(messages.last == "Quit")
56 #expect(h.string == "text\n")
57 }
58
59 @Test func tabFoldsOnHeadingsAndIndentsElsewhere() {
60 let h = Harness(outline)
61 h.caret(at: 1)
62 send(h, characters: "\t", ignoring: "\t", code: 48)
63 #expect(h.visibleLines == ["* a\n", "* c\n", "text\n"])
64 h.caret(at: h.offset(of: "text"))
65 send(h, characters: "\t", ignoring: "\t", code: 48)
66 #expect(h.string.hasSuffix("\ttext\n"))
67 }
68
69 @Test func markExtendsMovementAndKillTakesTheRegion() {
70 let h = Harness("abc\n")
71 h.caret(at: 0)
72 send(h, characters: "\0", ignoring: " ", code: 49, flags: .control)
73 #expect(h.editor.markActive)
74 press(h, "f", control: true)
75 press(h, "f", control: true)
76 #expect(h.textView.selectedRange() == NSRange(location: 0, length: 2))
77 press(h, "w", control: true)
78 #expect(h.string == "c\n")
79 #expect(!h.editor.markActive)
80 h.checkInSync()
81 }
82
83 @Test func leftOptionIsMetaRightOptionTypes() {
84 let h = Harness("one two\n")
85 h.caret(at: 0)
86 press(h, "f", option: KeyEvents.leftOption, typed: "ƒ")
87 #expect(h.caret == 3)
88 press(h, "f", option: KeyEvents.rightOption, typed: "ƒ")
89 #expect(h.string == "oneƒ two\n")
90 }
91
92 @Test func appCommandsGoToTheApp() {
93 let h = Harness("text\n")
94 var commands: [String] = []
95 h.editor.onAppCommand = { commands.append($0) }
96 press(h, "x", control: true)
97 press(h, "s", control: true)
98 #expect(commands == ["app.save"])
99 }
100
101 @Test func userKeymapOverridesThePreset() throws {
102 let h = Harness("* a\n")
103 var problems: [String] = []
104 let user = try Keymap(toml: "[[bind]]\nkeys = \"C-c C-t\"\ncommand = \"org.priority.up\"\n", problems: &problems)
105 h.editor.keymap = .layered([KeymapPreset.emacs.keymap, user])
106 h.caret(at: 1)
107 press(h, "c", control: true)
108 press(h, "t", control: true)
109 #expect(h.string == "* [#B] a\n")
110 }
111
112 @Test func everyPresetCommandExists() {
113 for preset in KeymapPreset.allCases {
114 for binding in preset.keymap.bindings {
115 #expect(EditorCommands.command(binding.command) != nil, "\(preset): \(binding.command)")
116 }
117 }
118 }
119}