krz/orgstar

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

Commit 85c633a83a

85c633a83a4744bef9dde8b52462f9c2a1c037e4

parent: 5a1ddcb0e6

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 04:08 UTC

Set tags through a prompt

Layout: unified · split

Sources/OrgCore/Commands/Command.swift +5 −1
@@ -38,10 +38,13 @@ public struct Prompt: Sendable, Equatable {
38 /// The key the answer comes back under in `EditContext.answers`. 38 /// The key the answer comes back under in `EditContext.answers`.
39 public let key: String 39 public let key: String
40 public let message: String 40 public let message: String
41 /// Text the answer field starts with.
42 public let initial: String
41 43
42 public init(key: String, message: String) { 44 public init(key: String, message: String, initial: String = "") {
43 self.key = key 45 self.key = key
44 self.message = message 46 self.message = message
47 self.initial = initial
45 } 48 }
46} 49}
47 50
@@ -93,6 +96,7 @@ public enum Commands {
93 InsertHeading(), InsertHeadingAfterSubtree(), InsertTodoHeading(), 96 InsertHeading(), InsertHeadingAfterSubtree(), InsertTodoHeading(),
94 MoveSubtreeUp(), MoveSubtreeDown(), PromoteSubtree(), DemoteSubtree(), 97 MoveSubtreeUp(), MoveSubtreeDown(), PromoteSubtree(), DemoteSubtree(),
95 NextHeading(), PreviousHeading(), ForwardSameLevel(), BackwardSameLevel(), UpHeading(), 98 NextHeading(), PreviousHeading(), ForwardSameLevel(), BackwardSameLevel(), UpHeading(),
99 SetTags(),
96 ] 100 ]
97 101
98 public static func command(_ id: String) -> (any OrgCommand)? { 102 public static func command(_ id: String) -> (any OrgCommand)? {
Sources/OrgCore/Commands/TagCommands.swift added +51
@@ -0,0 +1,51 @@
1import Foundation
2
3/// C-c C-q: `org-set-tags-command` without fast tag selection. Asks for the tags, then sets
4/// them as `org-set-tags` does and realigns.
5public struct SetTags: OrgCommand {
6 public init() {}
7 public var id: String { "org.tags.set" }
8 public var title: String { "Set Tags" }
9 public func applies(in context: EditContext) -> Bool { entryHeading(at: context.caret, in: context.tree) != nil }
10
11 /// Characters that can't be in a tag separate tags, as org's `org-tag--invalid-char-re`.
12 static func tags(from answer: String) -> [String] {
13 answer.split { !($0.isLetter || $0.isNumber || "_@#%".contains($0)) }.map(String.init)
14 }
15
16 static let tagLine = "^\\*+ (?:.*[ \\t])?(:([[:alnum:]_@#%:]+):)[ \\t]*$"
17
18 public func run(in context: EditContext) -> CommandStep {
19 guard let heading = entryHeading(at: context.caret, in: context.tree) else {
20 return .failed("Setting file tags is not supported yet")
21 }
22 guard let answer = context.answers["tags"] else {
23 let current = HeadingLine(heading).tags.map { (context.text as NSString).substring(with: NSRange($0.range)) } ?? ""
24 return .prompt(Prompt(key: "tags", message: "Tags:", initial: current))
25 }
26 let tags = Self.tags(from: answer)
27 return commitBuffer(context) { buffer in
28 try buffer.saveExcursion {
29 try buffer.backToHeading()
30 try buffer.saveExcursion {
31 let line = buffer.saveExcursion { buffer.lookingAt(Self.tagLine) }
32 let old = line.flatMap { $0[2] }.map { buffer.substring($0.lowerBound, $0.upperBound).split(separator: ":").map(String.init) } ?? []
33 if tags != old {
34 buffer.point = line?[1]?.lowerBound ?? buffer.lineEnd(buffer.point)
35 buffer.skipBackward(EmacsBuffer.blanks)
36 buffer.deleteRegion(buffer.point, buffer.lineEnd(buffer.point))
37 if !buffer.atHeading { buffer.insert(" ") }
38 if !tags.isEmpty { buffer.saveExcursion { buffer.insert(" :" + tags.joined(separator: ":") + ":") } }
39 }
40 // Org realigns whenever there are tags, changed or not.
41 if !tags.isEmpty { buffer.alignTags(onLineAt: buffer.point) }
42 }
43 }
44 let afterStars = buffer.saveExcursion { () -> Bool in
45 buffer.skipBackward([42])
46 return buffer.bolp
47 }
48 if afterStars, buffer.character(at: buffer.point) == 32 { buffer.point += 1 }
49 }
50 }
51}
Sources/OrgCore/Keymap/Presets.swift +73
@@ -163,6 +163,49 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
163 keys = "M-<right>" 163 keys = "M-<right>"
164 command = "org.heading.demote" 164 command = "org.heading.demote"
165 when = "heading" 165 when = "heading"
166 [[bind]]
167 keys = "M-S-<left>"
168 command = "org.subtree.promote"
169 when = "heading"
170 [[bind]]
171 keys = "M-S-<right>"
172 command = "org.subtree.demote"
173 when = "heading"
174 [[bind]]
175 keys = "M-<up>"
176 command = "org.subtree.up"
177 when = "heading"
178 [[bind]]
179 keys = "M-<down>"
180 command = "org.subtree.down"
181 when = "heading"
182 [[bind]]
183 keys = "M-RET"
184 command = "org.heading.insert"
185 [[bind]]
186 keys = "C-RET"
187 command = "org.heading.insert-after-subtree"
188 [[bind]]
189 keys = "M-S-RET"
190 command = "org.heading.insert-todo"
191 [[bind]]
192 keys = "C-c C-n"
193 command = "org.heading.next"
194 [[bind]]
195 keys = "C-c C-p"
196 command = "org.heading.previous"
197 [[bind]]
198 keys = "C-c C-f"
199 command = "org.heading.forward-same-level"
200 [[bind]]
201 keys = "C-c C-b"
202 command = "org.heading.backward-same-level"
203 [[bind]]
204 keys = "C-c C-u"
205 command = "org.heading.up"
206 [[bind]]
207 keys = "C-c C-q"
208 command = "org.tags.set"
166 209
167 """ 210 """
168 211
@@ -184,6 +227,36 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
184 [[bind]] 227 [[bind]]
185 keys = "C-s-<right>" 228 keys = "C-s-<right>"
186 command = "org.heading.demote" 229 command = "org.heading.demote"
230 [[bind]]
231 keys = "C-M-s-<left>"
232 command = "org.subtree.promote"
233 [[bind]]
234 keys = "C-M-s-<right>"
235 command = "org.subtree.demote"
236 [[bind]]
237 keys = "C-M-s-<up>"
238 command = "org.subtree.up"
239 [[bind]]
240 keys = "C-M-s-<down>"
241 command = "org.subtree.down"
242 [[bind]]
243 keys = "s-RET"
244 command = "org.heading.insert"
245 [[bind]]
246 keys = "C-s-RET"
247 command = "org.heading.insert-after-subtree"
248 [[bind]]
249 keys = "s-S-RET"
250 command = "org.heading.insert-todo"
251 [[bind]]
252 keys = "M-s-<down>"
253 command = "org.heading.next"
254 [[bind]]
255 keys = "M-s-<up>"
256 command = "org.heading.previous"
257 [[bind]]
258 keys = "C-s-q"
259 command = "org.tags.set"
187 260
188 """ 261 """
189} 262}
Sources/OrgEditorAppKit/OrgEditor.swift +13 −3
@@ -175,6 +175,8 @@ public final class OrgEditor: NSObject {
175 public var onKeysPending: ((_ keys: String?, _ next: [(key: String, title: String)]) -> Void)? 175 public var onKeysPending: ((_ keys: String?, _ next: [(key: String, title: String)]) -> Void)?
176 /// Called with `app.*` commands, which the app carries out. 176 /// Called with `app.*` commands, which the app carries out.
177 public var onAppCommand: ((String) -> Void)? 177 public var onAppCommand: ((String) -> Void)?
178 /// Called when a command asks a question; reply with the answer, or nil to cancel.
179 public var onPrompt: ((Prompt, _ reply: @escaping (String?) -> Void) -> Void)?
178 180
179 public var keymap: Keymap { 181 public var keymap: Keymap {
180 get { dispatcher.keymap } 182 get { dispatcher.keymap }
@@ -193,7 +195,8 @@ public final class OrgEditor: NSObject {
193 let selected = textView.selectedRange() 195 let selected = textView.selectedRange()
194 return EditContext( 196 return EditContext(
195 revision: document.revision, text: document.text, tree: document.tree, 197 revision: document.revision, text: document.text, tree: document.tree,
196 selection: [selected.location..<NSMaxRange(selected)], now: now, calendar: .current, answers: answers 198 selection: [selected.location..<NSMaxRange(selected)], now: now, calendar: .current, answers: answers,
199 hidden: hidden.all
197 ) 200 )
198 } 201 }
199 202
@@ -312,8 +315,15 @@ public final class OrgEditor: NSObject {
312 for case .message(let text) in result.effects { onMessage?(text) } 315 for case .message(let text) in result.effects { onMessage?(text) }
313 case .failed(let message): 316 case .failed(let message):
314 onMessage?(message) 317 onMessage?(message)
315 case .prompt: 318 case .prompt(let prompt):
316 break 319 onPrompt?(prompt) { [weak self] answer in
320 guard let self else { return }
321 guard let answer else {
322 self.onMessage?("Quit")
323 return
324 }
325 self.perform(command, now: now, answers: answers.merging([prompt.key: answer]) { $1 })
326 }
317 } 327 }
318 return step 328 return step
319 } 329 }
Sources/Orgstar/EditorView.swift +1
@@ -40,6 +40,7 @@ struct EditorView: NSViewRepresentable {
40 commands?.keyHints = next.map { KeyHint(key: $0.key, title: $0.title) } 40 commands?.keyHints = next.map { KeyHint(key: $0.key, title: $0.title) }
41 } 41 }
42 editor.onAppCommand = onAppCommand 42 editor.onAppCommand = onAppCommand
43 editor.onPrompt = { [weak commands] prompt, reply in commands?.ask(prompt, reply: reply) }
43 commands.editor = editor 44 commands.editor = editor
44 let scroll = editor.makeScrollView() 45 let scroll = editor.makeScrollView()
45 scroll.frame = container.bounds 46 scroll.frame = container.bounds
Sources/Orgstar/Keys.swift +21 −1
@@ -13,6 +13,8 @@ struct KeyHint: Hashable {
13struct EchoArea: View { 13struct EchoArea: View {
14 let commands: AppCommands 14 let commands: AppCommands
15 @State private var showingHints = false 15 @State private var showingHints = false
16 @State private var answer = ""
17 @FocusState private var answerFocused: Bool
16 18
17 var body: some View { 19 var body: some View {
18 VStack(alignment: .leading, spacing: 0) { 20 VStack(alignment: .leading, spacing: 0) {
@@ -28,7 +30,25 @@ struct EchoArea: View {
28 } 30 }
29 .padding(10) 31 .padding(10)
30 } 32 }
31 if let text = commands.pendingKeys.map({ $0 + "-" }) ?? commands.message { 33 if let request = commands.prompt {
34 Divider()
35 HStack(spacing: 6) {
36 Text(request.prompt.message).font(.system(.callout, design: .monospaced)).foregroundStyle(.secondary)
37 TextField("", text: $answer)
38 .textFieldStyle(.plain)
39 .font(.system(.callout, design: .monospaced))
40 .focused($answerFocused)
41 .onSubmit { commands.answer(answer) }
42 .onExitCommand { commands.answer(nil) }
43 }
44 .padding(.horizontal, 10)
45 .padding(.vertical, 4)
46 .onAppear {
47 answer = request.prompt.initial
48 answerFocused = true
49 }
50 .id(request.id)
51 } else if let text = commands.pendingKeys.map({ $0 + "-" }) ?? commands.message {
32 Divider() 52 Divider()
33 Text(text) 53 Text(text)
34 .font(.system(.callout, design: .monospaced)) 54 .font(.system(.callout, design: .monospaced))
Sources/Orgstar/OrgstarApp.swift +20
@@ -18,10 +18,24 @@ final class AppCommands {
18 /// The pending key prefix, and what can follow it. 18 /// The pending key prefix, and what can follow it.
19 var pendingKeys: String? 19 var pendingKeys: String?
20 var keyHints: [KeyHint] = [] 20 var keyHints: [KeyHint] = []
21 /// A question a command asked, answered in the echo area.
22 private(set) var prompt: PromptRequest?
21 /// The last message from a command or the keymap, cleared after a few seconds. 23 /// The last message from a command or the keymap, cleared after a few seconds.
22 private(set) var message: String? 24 private(set) var message: String?
23 private var messageID = 0 25 private var messageID = 0
24 26
27 func ask(_ prompt: Prompt, reply: @escaping (String?) -> Void) {
28 self.prompt = PromptRequest(prompt: prompt, reply: reply)
29 }
30
31 /// Ends the prompt with an answer, or nil to cancel, and gives the editor the keys back.
32 func answer(_ text: String?) {
33 guard let request = prompt else { return }
34 prompt = nil
35 if let textView = editor?.textView { textView.window?.makeFirstResponder(textView) }
36 request.reply(text)
37 }
38
25 func show(_ text: String) { 39 func show(_ text: String) {
26 messageID += 1 40 messageID += 1
27 let id = messageID 41 let id = messageID
@@ -33,6 +47,12 @@ final class AppCommands {
33 } 47 }
34} 48}
35 49
50struct PromptRequest: Identifiable {
51 let id = UUID()
52 let prompt: Prompt
53 let reply: (String?) -> Void
54}
55
36@MainActor 56@MainActor
37final class AppDelegate: NSObject, NSApplicationDelegate { 57final class AppDelegate: NSObject, NSApplicationDelegate {
38 var session: DocumentSession? 58 var session: DocumentSession?
Tests/OrgCoreTests/EmacsOracle.swift +3 −2
@@ -26,6 +26,7 @@ enum EmacsOracle {
26 ;;; -*- lexical-binding: t -*- 26 ;;; -*- lexical-binding: t -*-
27 (require 'org) 27 (require 'org)
28 (require 'json) 28 (require 'json)
29 (require 'cl-lib)
29 (setq org-todo-keywords '((sequence "TODO" "DONE")) 30 (setq org-todo-keywords '((sequence "TODO" "DONE"))
30 org-log-done nil 31 org-log-done nil
31 org-log-repeat nil 32 org-log-repeat nil
@@ -119,8 +120,8 @@ enum EmacsOracle {
119} 120}
120 121
121/// Runs a command the way an editor would: build a context, run, apply the edits. 122/// Runs a command the way an editor would: build a context, run, apply the edits.
122func runCommand(_ command: any OrgCommand, _ text: String, caret: Int) -> (text: String, caret: Int, failure: String?) { 123func runCommand(_ command: any OrgCommand, _ text: String, caret: Int, answers: [String: String] = [:]) -> (text: String, caret: Int, failure: String?) {
123 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret]) 124 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret], answers: answers)
124 switch command.run(in: context) { 125 switch command.run(in: context) {
125 case .commit(let result): 126 case .commit(let result):
126 var new = text 127 var new = text
Tests/OrgCoreTests/TagCommandTests.swift added +49
@@ -0,0 +1,49 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct TagCommandTests {
6 @Test func promptsWithCurrentTags() {
7 let text = "* a :x:y:\n"
8 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [0..<0])
9 #expect(SetTags().run(in: context) == .prompt(Prompt(key: "tags", message: "Tags:", initial: ":x:y:")))
10 }
11
12 @Test func setsAndClears() {
13 let set = runCommand(SetTags(), "* a\n", caret: 0, answers: ["tags": "x y"]).text
14 #expect(set == "* a" + String(repeating: " ", count: 77 - 3 - 5) + ":x:y:\n")
15 #expect(runCommand(SetTags(), "* a :x:\n", caret: 0, answers: ["tags": ""]).text == "* a\n")
16 #expect(runCommand(SetTags(), "text\n", caret: 0, answers: ["tags": "x"]).failure != nil)
17 }
18
19 static let variants = ["* a\n", "* a :x:\n", "* TODO a :x:y:\nbody\n", "* \n", "* :x:\n", "** long " + String(repeating: "word ", count: 15) + ":t:\n"]
20 static let answers = ["x", "x:y", ":a:b:", "", "a b", "x"]
21
22 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
23 func setTagsMatchesEmacs() throws {
24 try #require(EmacsOracle.isAvailable)
25 var cases: [EmacsOracle.Case] = []
26 var ours: [(label: String, text: String, caret: Int)] = []
27 for text in Self.variants {
28 for offset in EmacsOracle.positions(text) {
29 for answer in Self.answers {
30 let list = SetTags.tags(from: answer).map { "\"\($0)\"" }.joined(separator: " ")
31 let form = "(cl-letf (((symbol-function 'completing-read-multiple) (lambda (&rest _) '(\(list))))) (org-set-tags-command))"
32 cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(offset, in: text), form: form))
33 let result = runCommand(SetTags(), text, caret: offset, answers: ["tags": answer])
34 ours.append(("\(answer.debugDescription) at \(offset) in \(text.debugDescription)", result.text, result.caret))
35 }
36 }
37 }
38 var mismatches = 0
39 for (mine, emacs) in zip(ours, try EmacsOracle.run(cases)) {
40 let caret = EmacsOracle.offset(emacs.point, in: emacs.text)
41 guard !emacs.error.isEmpty || mine.text != emacs.text || mine.caret != caret else { continue }
42 mismatches += 1
43 if mismatches <= 10 {
44 Issue.record("\(mine.label)\n ours: \(mine.text.debugDescription) @\(mine.caret)\n emacs: \(emacs.error.isEmpty ? "\(emacs.text.debugDescription) @\(caret)" : emacs.error)")
45 }
46 }
47 #expect(mismatches == 0)
48 }
49}
Tests/OrgEditorAppKitTests/KeyTests.swift +18 −1
@@ -5,7 +5,7 @@ import Testing
5 5
6@MainActor 6@MainActor
7struct KeyTests { 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] 8 static let codes: [Character: UInt16] = ["a": 0, "s": 1, "f": 3, "g": 5, "z": 6, "x": 7, "c": 8, "w": 13, "q": 12, "t": 17]
9 9
10 /// Presses a key. `control` sends the control character, as macOS does. 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) { 11 func press(_ h: Harness, _ key: Character, control: Bool = false, option: UInt = 0, typed: String? = nil) {
@@ -109,6 +109,23 @@ struct KeyTests {
109 #expect(h.string == "* [#B] a\n") 109 #expect(h.string == "* [#B] a\n")
110 } 110 }
111 111
112 @Test func promptsAskAndRunAgainWithTheAnswer() {
113 let h = Harness("* a\n")
114 var asked: [Prompt] = []
115 var reply: ((String?) -> Void)?
116 h.editor.onPrompt = { prompt, answer in
117 asked.append(prompt)
118 reply = answer
119 }
120 h.caret(at: 1)
121 press(h, "c", control: true)
122 press(h, "q", control: true)
123 #expect(asked.map(\.key) == ["tags"])
124 reply?("x")
125 #expect(h.string.hasPrefix("* a ") && h.string.hasSuffix(":x:\n"))
126 h.checkInSync()
127 }
128
112 @Test func everyPresetCommandExists() { 129 @Test func everyPresetCommandExists() {
113 for preset in KeymapPreset.allCases { 130 for preset in KeymapPreset.allCases {
114 for binding in preset.keymap.bindings { 131 for binding in preset.keymap.bindings {
docs/plans/2026-10-05-structure.md added +47
@@ -0,0 +1,47 @@
1# Heading Structure Commands Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** Org's heading structure commands, byte- and caret-exact against Emacs: insert heading (M-RET, C-RET, M-S-RET), move subtree (M-up/down), promote/demote subtree (M-S-left/right), heading motion (C-c C-n/p/f/b/u) and set tags (C-c C-q) through a prompt answered in the echo area.
6
7**Architecture:** Multi-line commands are ported from Org 9.8.7's Lisp onto `EmacsBuffer`, a whole-text buffer with Emacs's point and marker rules (`insert`, `insert-before-markers`, `delete-region`, `replace-match`, `save-excursion`) and org's regexp-based outline primitives. `commitBuffer` turns the result into one minimal edit and the caret. Motion commands skip headings inside `EditContext.hidden` (folded text). Prompts go from `CommandStep.prompt` through `OrgEditor.onPrompt` to a minibuffer field in the echo area; the answer reruns the command.
8
9**Tech Stack:** Swift 6.2 tools, Swift Testing, Emacs 31.1 / Org 9.8.7 oracle.
10
11**Spec:** `docs/design.md`, "Commands and keymaps".
12
13## Global Constraints
14
15- Org defaults: `org-blank-before-new-entry` heading `auto`, `org-M-RET-may-split-line` t, `org-auto-align-tags` t, tags column -77.
16- The oracle unfolds the buffer after `org-mode` (`#+STARTUP` may fold it); cases run with everything visible.
17- In plain lists, M-RET and M-S-RET insert items in org; list commands come in the next plan.
18
19## Defaults chosen (user may change)
20
21- Mac preset: ⌘↩ insert heading, ⌃⌘↩ after subtree, ⇧⌘↩ TODO heading, ⌃⌥⌘ arrows move/promote/demote subtree, ⌥⌘↑/↓ previous/next heading, ⌃⌘Q tags.
22- The tags prompt has no completion yet.
23
24## File structure
25
26| File | Responsibility |
27| --- | --- |
28| `Sources/OrgCore/Commands/EmacsBuffer.swift` | Buffer with point and markers, outline primitives, regexps, `commitBuffer` |
29| `Sources/OrgCore/Commands/StructureCommands.swift` | Insert, move, promote/demote subtree, motion |
30| `Sources/OrgCore/Commands/TagCommands.swift` | `SetTags` |
31| `Sources/OrgCore/Commands/Command.swift` | `EditContext.hidden`, `Prompt.initial` |
32| `Sources/OrgEditorAppKit/OrgEditor.swift` | Hidden ranges in the context, `onPrompt` |
33| `Sources/Orgstar/*.swift` | Prompt in the echo area |
34
35---
36
37### Task 1: Buffer and structure commands
38
39- [ ] Port `org-insert-heading` (with `org--blank-before-heading-p`, `org-N-empty-lines-before-current`), `org-insert-todo-heading`, `org-move-subtree-down`, `org-promote-subtree`/`org-demote-subtree` (`org-map-tree`, `org-promote`, `org-demote`, `org-fix-position-after-promote`), `org-next-visible-heading`, `org-forward-heading-same-level`, `outline-up-heading`.
40- [ ] Oracle: every command at every caret of ten variants (levels, blank lines, tags, text before the first heading, no final newline, non-BMP) and over the corpus (`ORGSTAR_ORACLE_CORPUS`, files up to 20 KB).
41- [ ] Commit "Port heading structure commands from org".
42
43### Task 2: Tags and prompts
44
45- [ ] `SetTags`: prompt with the current tags, then `org-set-tags` semantics (realign even when unchanged). Oracle with `completing-read-multiple` stubbed to the answer.
46- [ ] Editor `onPrompt`; app minibuffer field (Return answers, Escape cancels); key bindings in both presets.
47- [ ] Commit "Set tags through a prompt".