Commit 85c633a83a
Verified · cmc
Layout: unified · split
Sources/OrgCore/Commands/Command.swift +5 −1
| @@ -38,10 +38,13 @@ public struct Prompt: Sendable, Equatable { | ||
| 38 | 38 | /// The key the answer comes back under in `EditContext.answers`. |
| 39 | 39 | public let key: String |
| 40 | 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 | 45 | self.key = key |
| 44 | 46 | self.message = message |
| 47 | self.initial = initial | |
| 45 | 48 | } |
| 46 | 49 | } |
| 47 | 50 | |
| @@ -93,6 +96,7 @@ public enum Commands { | ||
| 93 | 96 | InsertHeading(), InsertHeadingAfterSubtree(), InsertTodoHeading(), |
| 94 | 97 | MoveSubtreeUp(), MoveSubtreeDown(), PromoteSubtree(), DemoteSubtree(), |
| 95 | 98 | NextHeading(), PreviousHeading(), ForwardSameLevel(), BackwardSameLevel(), UpHeading(), |
| 99 | SetTags(), | |
| 96 | 100 | ] |
| 97 | 101 | |
| 98 | 102 | public static func command(_ id: String) -> (any OrgCommand)? { |
Sources/OrgCore/Commands/TagCommands.swift added +51
| @@ -0,0 +1,51 @@ | ||
| 1 | import 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. | |
| 5 | public 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 | 163 | keys = "M-<right>" |
| 164 | 164 | command = "org.heading.demote" |
| 165 | 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 | 227 | [[bind]] |
| 185 | 228 | keys = "C-s-<right>" |
| 186 | 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 | 175 | public var onKeysPending: ((_ keys: String?, _ next: [(key: String, title: String)]) -> Void)? |
| 176 | 176 | /// Called with `app.*` commands, which the app carries out. |
| 177 | 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 | 181 | public var keymap: Keymap { |
| 180 | 182 | get { dispatcher.keymap } |
| @@ -193,7 +195,8 @@ public final class OrgEditor: NSObject { | ||
| 193 | 195 | let selected = textView.selectedRange() |
| 194 | 196 | return EditContext( |
| 195 | 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 | 315 | for case .message(let text) in result.effects { onMessage?(text) } |
| 313 | 316 | case .failed(let message): |
| 314 | 317 | onMessage?(message) |
| 315 | case .prompt: | |
| 316 | break | |
| 318 | case .prompt(let prompt): | |
| 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 | 328 | return step |
| 319 | 329 | } |
Sources/Orgstar/EditorView.swift +1
| @@ -40,6 +40,7 @@ struct EditorView: NSViewRepresentable { | ||
| 40 | 40 | commands?.keyHints = next.map { KeyHint(key: $0.key, title: $0.title) } |
| 41 | 41 | } |
| 42 | 42 | editor.onAppCommand = onAppCommand |
| 43 | editor.onPrompt = { [weak commands] prompt, reply in commands?.ask(prompt, reply: reply) } | |
| 43 | 44 | commands.editor = editor |
| 44 | 45 | let scroll = editor.makeScrollView() |
| 45 | 46 | scroll.frame = container.bounds |
Sources/Orgstar/Keys.swift +21 −1
| @@ -13,6 +13,8 @@ struct KeyHint: Hashable { | ||
| 13 | 13 | struct EchoArea: View { |
| 14 | 14 | let commands: AppCommands |
| 15 | 15 | @State private var showingHints = false |
| 16 | @State private var answer = "" | |
| 17 | @FocusState private var answerFocused: Bool | |
| 16 | 18 | |
| 17 | 19 | var body: some View { |
| 18 | 20 | VStack(alignment: .leading, spacing: 0) { |
| @@ -28,7 +30,25 @@ struct EchoArea: View { | ||
| 28 | 30 | } |
| 29 | 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 | 52 | Divider() |
| 33 | 53 | Text(text) |
| 34 | 54 | .font(.system(.callout, design: .monospaced)) |
Sources/Orgstar/OrgstarApp.swift +20
| @@ -18,10 +18,24 @@ final class AppCommands { | ||
| 18 | 18 | /// The pending key prefix, and what can follow it. |
| 19 | 19 | var pendingKeys: String? |
| 20 | 20 | var keyHints: [KeyHint] = [] |
| 21 | /// A question a command asked, answered in the echo area. | |
| 22 | private(set) var prompt: PromptRequest? | |
| 21 | 23 | /// The last message from a command or the keymap, cleared after a few seconds. |
| 22 | 24 | private(set) var message: String? |
| 23 | 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 | 39 | func show(_ text: String) { |
| 26 | 40 | messageID += 1 |
| 27 | 41 | let id = messageID |
| @@ -33,6 +47,12 @@ final class AppCommands { | ||
| 33 | 47 | } |
| 34 | 48 | } |
| 35 | 49 | |
| 50 | struct PromptRequest: Identifiable { | |
| 51 | let id = UUID() | |
| 52 | let prompt: Prompt | |
| 53 | let reply: (String?) -> Void | |
| 54 | } | |
| 55 | ||
| 36 | 56 | @MainActor |
| 37 | 57 | final class AppDelegate: NSObject, NSApplicationDelegate { |
| 38 | 58 | var session: DocumentSession? |
Tests/OrgCoreTests/EmacsOracle.swift +3 −2
| @@ -26,6 +26,7 @@ enum EmacsOracle { | ||
| 26 | 26 | ;;; -*- lexical-binding: t -*- |
| 27 | 27 | (require 'org) |
| 28 | 28 | (require 'json) |
| 29 | (require 'cl-lib) | |
| 29 | 30 | (setq org-todo-keywords '((sequence "TODO" "DONE")) |
| 30 | 31 | org-log-done nil |
| 31 | 32 | org-log-repeat nil |
| @@ -119,8 +120,8 @@ enum EmacsOracle { | ||
| 119 | 120 | } |
| 120 | 121 | |
| 121 | 122 | /// Runs a command the way an editor would: build a context, run, apply the edits. |
| 122 | func runCommand(_ command: any OrgCommand, _ text: String, caret: Int) -> (text: String, caret: Int, failure: String?) { | |
| 123 | let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret]) | |
| 123 | func runCommand(_ command: any OrgCommand, _ text: String, caret: Int, answers: [String: String] = [:]) -> (text: String, caret: Int, failure: String?) { | |
| 124 | let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret], answers: answers) | |
| 124 | 125 | switch command.run(in: context) { |
| 125 | 126 | case .commit(let result): |
| 126 | 127 | var new = text |
Tests/OrgCoreTests/TagCommandTests.swift added +49
| @@ -0,0 +1,49 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import OrgCore | |
| 4 | ||
| 5 | struct 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 | 6 | @MainActor |
| 7 | 7 | struct 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 | 10 | /// Presses a key. `control` sends the control character, as macOS does. |
| 11 | 11 | func press(_ h: Harness, _ key: Character, control: Bool = false, option: UInt = 0, typed: String? = nil) { |
| @@ -109,6 +109,23 @@ struct KeyTests { | ||
| 109 | 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 | 129 | @Test func everyPresetCommandExists() { |
| 113 | 130 | for preset in KeymapPreset.allCases { |
| 114 | 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". | |