Structure templates and editing blocks apart !79

merged merged by cmc on 2026-10-06 03:20 UTC · krz/orgstar:blocks into main

7 files changed, +387 −1

Layout: unified · split

Sources/OrgCore/Commands/BlockCommands.swift added +193
@@ -0,0 +1,193 @@
1import Foundation
2
3// Blocks (Org 9.8.7): `org-insert-structure-template` (C-c C-,) with the default
4// `org-structure-template-alist`, and `org-edit-special` (C-c ') on src, example and export
5// blocks with Doom's `org-src-preserve-indentation` t: the block's lines as they are, commas
6// that protect `*` and `#+` lines taken off while editing and put back after.
7
8/// C-c C-,: `org-insert-structure-template`. Asks for a block type by key (Tab to type one),
9/// then inserts an empty block, or wraps the selection.
10public struct InsertStructureTemplate: OrgCommand {
11 public init() {}
12 public var id: String { "org.structure-template" }
13 public var title: String { "Insert Structure Template" }
14 public func applies(in context: EditContext) -> Bool { true }
15
16 /// `org-structure-template-alist`.
17 public static let templates: [(key: Character, type: String)] = [
18 ("a", "export ascii"), ("c", "center"), ("C", "comment"), ("e", "example"), ("E", "export"),
19 ("h", "export html"), ("l", "export latex"), ("q", "quote"), ("s", "src"), ("v", "verse"),
20 ]
21
22 public func run(in context: EditContext) -> CommandStep {
23 guard let key = context.answers["template"] else {
24 let items = Self.templates.map { FastSelection.Item.option(key: $0.key, name: $0.type) } + [.option(key: "\t", name: "other (Tab)")]
25 return .prompt(Prompt(key: "template", message: "Insert block: [key] or Tab to type one", fast: FastSelection(items: items, multiple: false)))
26 }
27 let type: String
28 if key == "\t" {
29 guard let typed = context.answers["type"] else { return .prompt(Prompt(key: "type", message: "Structure type:")) }
30 guard !typed.isEmpty else { return .failed("Empty structure type") }
31 type = typed
32 } else {
33 guard let found = Self.templates.first(where: { String($0.key) == key }) else { return .failed("Quit") }
34 type = found.type
35 }
36 let region = context.selection.first.flatMap { $0.isEmpty ? nil : $0 }
37 return commitBuffer(context) { buffer in
38 try buffer.insertStructureTemplate(type, region: region)
39 }
40 }
41}
42
43extension EmacsBuffer {
44 func insertStructureTemplate(_ type: String, region: Range<Int>?) throws {
45 let name = String(type.split(separator: " ").first ?? "")
46 let extended = type.range(of: "^(src|export)$", options: [.regularExpression, .caseInsensitive]) != nil
47 let verbatim = type.range(of: "^(example|export|src|comment)", options: [.regularExpression, .caseInsensitive]) != nil
48 let upcase = name == name.uppercased()
49 let regionEnd = region.map { marker($0.upperBound) }
50 defer { regionEnd.map(release) }
51 if let region { point = region.lowerBound }
52 let column = currentIndentation
53 let atIndentation = saveExcursion { () -> Bool in
54 skipBackward([32, 9])
55 return bolp
56 }
57 if atIndentation { point = lineStart(point) } else { insert("\n") }
58 saveExcursion {
59 indentTo(column)
60 insert("#+\(upcase ? "BEGIN" : "begin")_\(type)\(extended ? " " : "")\n")
61 if let regionEnd {
62 if verbatim { escapeCode(from: point, to: regionEnd.position) }
63 point = regionEnd.position
64 // Without the blank lines at the region's end.
65 skipBackward([32, 13, 9, 10])
66 if !eolp {
67 insert("\n")
68 forwardLine(-1)
69 }
70 point = lineEnd(point)
71 }
72 if !bolp { insert("\n") }
73 indentTo(column)
74 insert("#+\(upcase ? "END" : "end")_\(name)")
75 if let blank = lookingAt("[ \\t]*$"), let whole = blank[0] {
76 deleteRegion(whole.lowerBound, whole.upperBound)
77 } else {
78 insert("\n")
79 }
80 if eobp, !bolp { insert("\n") }
81 }
82 if extended {
83 point = lineEnd(point)
84 } else {
85 forwardLine(1)
86 skipForward([32, 9])
87 }
88 }
89
90 /// `org-escape-code-in-region`: a comma before lines that would read as headings or
91 /// keywords.
92 func escapeCode(from start: Int, to end: Int) {
93 let limit = marker(end)
94 defer { release(limit) }
95 point = start
96 while point < limit.position, let match = searchForward("^[ \\t]*(,*(?:\\*|#\\+))", bound: limit.position, caseInsensitive: false) {
97 point = match[1]!.lowerBound
98 insert(",")
99 point = lineEnd(point)
100 }
101 }
102}
103
104/// A block's text, as `org-edit-special` hands it to an editing buffer.
105public struct BlockEdit: Sendable, Equatable {
106 /// The lines between the opening and closing lines.
107 public let range: Range<Int>
108 /// Those lines as they were, to check nothing changed them meanwhile.
109 public let original: String
110 /// What to edit: the lines with their protecting commas taken off.
111 public let content: String
112 /// The src block's language, for highlighting.
113 public let language: String?
114 /// `src`, `example` or `export`.
115 public let kind: String
116
117 /// `org-unescape-code-in-string`.
118 public static func unescape(_ s: String) -> String {
119 s.replacingOccurrences(of: "(?m)^([ \\t]*),(,*(?:\\*|#\\+))", with: "$1$2", options: .regularExpression)
120 }
121
122 /// `org-escape-code-in-string`.
123 public static func escape(_ s: String) -> String {
124 s.replacingOccurrences(of: "(?m)^([ \\t]*)(,*(?:\\*|#\\+))", with: "$1,$2", options: .regularExpression)
125 }
126
127 /// The block's lines for edited `content`: escaped again, ending in a newline.
128 public func lines(for content: String) -> String {
129 guard !content.isEmpty else { return "" }
130 let escaped = Self.escape(content)
131 return escaped.hasSuffix("\n") ? escaped : escaped + "\n"
132 }
133
134 /// The src, example or export block whose lines hold `offset`, opening and closing
135 /// lines included.
136 public static func at(_ offset: Int, text: String, tree: OrgTree) -> BlockEdit? {
137 var node = tree.root
138 var found: SyntaxNode?
139 while let child = node.child(containing: offset) {
140 if child.kind == .block { found = child }
141 node = child
142 }
143 guard let block = found else { return nil }
144 let ns = text as NSString
145 let first = ns.lineRange(for: NSRange(location: block.range.lowerBound, length: 0))
146 let opening = ns.substring(with: first)
147 guard let header = opening.firstMatch(of: /(?i)^[ \t]*#\+begin_(src|example|export)(?:[ \t]+([^ \t\n]+))?/) else { return nil }
148 guard let wrapper = Wrappers.starting(at: block.range.lowerBound, in: tree) else { return nil }
149 let start = NSMaxRange(first)
150 let end = wrapper.closeLine
151 guard start <= end else { return nil }
152 let original = ns.substring(with: NSRange(location: start, length: end - start))
153 let kind = header.1.lowercased()
154 return BlockEdit(range: start..<end, original: original, content: unescape(original),
155 language: kind == "src" ? header.2.map(String.init) : nil, kind: kind)
156 }
157}
158
159/// C-c ': `org-edit-special` on a src, example or export block: the app edits its text apart,
160/// then `ReplaceBlockLines` puts it back.
161public struct EditSpecial: OrgCommand {
162 public init() {}
163 public var id: String { "org.edit-special" }
164 public var title: String { "Edit Block" }
165 public func applies(in context: EditContext) -> Bool { BlockEdit.at(context.caret, text: context.text, tree: context.tree) != nil }
166 public func run(in context: EditContext) -> CommandStep {
167 guard let edit = BlockEdit.at(context.caret, text: context.text, tree: context.tree) else {
168 return .failed("No special environment to edit here")
169 }
170 return .external(.editBlock(edit))
171 }
172}
173
174/// Writes an edited block back, unless its lines changed meanwhile.
175public struct ReplaceBlockLines: OrgCommand {
176 public let edit: BlockEdit
177 public let content: String
178 public init(edit: BlockEdit, content: String) {
179 self.edit = edit
180 self.content = content
181 }
182 public var id: String { "internal.replace-block" }
183 public var title: String { "Replace Block" }
184 public func applies(in context: EditContext) -> Bool { true }
185 public func run(in context: EditContext) -> CommandStep {
186 let ns = context.text as NSString
187 guard edit.range.upperBound <= ns.length, ns.substring(with: NSRange(edit.range)) == edit.original else {
188 return .failed("The block changed while you edited it; its edit wasn't saved.")
189 }
190 let lines = edit.lines(for: content)
191 return .commit(EditResult(baseRevision: context.revision, edits: [TextEdit(range: edit.range, replacement: lines)]))
192 }
193}
Sources/OrgCore/Commands/Command.swift +3 −1
@@ -185,6 +185,8 @@ public enum ExternalRequest: Sendable, Equatable {
185 case babel(BabelJob) 185 case babel(BabelJob)
186 /// Follow a link: the app opens URLs and files and searches headings. 186 /// Follow a link: the app opens URLs and files and searches headings.
187 case openLink(OrgLink) 187 case openLink(OrgLink)
188 /// Edit a block's text apart (`org-edit-special`); `ReplaceBlockLines` puts it back.
189 case editBlock(BlockEdit)
188} 190}
189 191
190/// A named operation. Keys, menus, the palette and touch controls all run commands. 192/// A named operation. Keys, menus, the palette and touch controls all run commands.
@@ -213,7 +215,7 @@ public enum Commands {
213 TableRecalculate(all: false), TableRecalculate(all: true), ExecuteSrcBlock(), OrgDwim(), 215 TableRecalculate(all: false), TableRecalculate(all: true), ExecuteSrcBlock(), OrgDwim(),
214 ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false), 216 ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false),
215 OpenLinkAtPoint(), CreateID(), ToggleComment(), ToggleHeading(), ToggleItem(), MarkSubtree(), 217 OpenLinkAtPoint(), CreateID(), ToggleComment(), ToggleHeading(), ToggleItem(), MarkSubtree(),
216 CopySubtree(cut: false), CopySubtree(cut: true), CloneSubtree(), SortEntries(), 218 CopySubtree(cut: false), CopySubtree(cut: true), CloneSubtree(), SortEntries(), InsertStructureTemplate(), EditSpecial(),
217 ] 219 ]
218 220
219 public static var everything: [any OrgCommand] { all + TableCommand.Action.allCases.map(TableCommand.init) } 221 public static var everything: [any OrgCommand] { all + TableCommand.Action.allCases.map(TableCommand.init) }
Sources/OrgCore/Keymap/Presets.swift +10
@@ -190,6 +190,10 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
190 190
191 # Localleader (SPC m) 191 # Localleader (SPC m)
192 [[bind]] 192 [[bind]]
193 keys = "SPC m '"
194 command = "org.edit-special"
195 mode = "normal"
196 [[bind]]
193 keys = "SPC m s c" 197 keys = "SPC m s c"
194 command = "org.subtree.clone" 198 command = "org.subtree.clone"
195 mode = "normal" 199 mode = "normal"
@@ -627,6 +631,12 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
627 keys = "C-c ^" 631 keys = "C-c ^"
628 command = "org.sort" 632 command = "org.sort"
629 [[bind]] 633 [[bind]]
634 keys = "C-c C-,"
635 command = "org.structure-template"
636 [[bind]]
637 keys = "C-c '"
638 command = "org.edit-special"
639 [[bind]]
630 keys = "C-x b" 640 keys = "C-x b"
631 command = "app.buffer.switch" 641 command = "app.buffer.switch"
632 [[bind]] 642 [[bind]]
Sources/OrgEditorAppKit/EditorCommands.swift +2
@@ -41,6 +41,8 @@ public enum EditorCommands {
41 EditorCommand(id: "org.link.store", title: "Store Link", action: .app), 41 EditorCommand(id: "org.link.store", title: "Store Link", action: .app),
42 EditorCommand(id: "org.id.store-link", title: "Store ID Link", action: .app), 42 EditorCommand(id: "org.id.store-link", title: "Store ID Link", action: .app),
43 EditorCommand(id: "org.subtree.paste", title: "Paste Subtree", action: .app), 43 EditorCommand(id: "org.subtree.paste", title: "Paste Subtree", action: .app),
44 EditorCommand(id: "app.block.save", title: "Save Block", action: .app),
45 EditorCommand(id: "app.block.cancel", title: "Leave Block", action: .app),
44 46
45 EditorCommand(id: "app.save", title: "Save", action: .app), 47 EditorCommand(id: "app.save", title: "Save", action: .app),
46 EditorCommand(id: "app.quit", title: "Quit Orgstar", action: .app), 48 EditorCommand(id: "app.quit", title: "Quit Orgstar", action: .app),
Sources/Orgstar/BlockEditorView.swift added +65
@@ -0,0 +1,65 @@
1import AppKit
2import OrgCore
3import OrgDocument
4import OrgEditorAppKit
5import SwiftUI
6
7/// `org-edit-special` for a block: its text in an editor of its own, highlighted as its
8/// language. ⌘Return or C-c ' puts it back; Escape or C-c C-k leaves it.
9struct BlockEditorView: View {
10 let edit: BlockEdit
11 let finish: (String?) -> Void
12 @State private var box = EditorBox()
13
14 var body: some View {
15 VStack(alignment: .leading, spacing: 8) {
16 Text("Editing \(edit.language.map { "\($0) " } ?? "")\(edit.kind) block")
17 .font(.headline)
18 CodeEditor(text: edit.content, language: edit.language, box: box) { save in finish(save ? box.text : nil) }
19 .frame(minWidth: 560, minHeight: 320)
20 .border(.separator)
21 HStack {
22 Text("C-c ' or ⌘Return saves, Escape or C-c C-k leaves").font(.caption).foregroundStyle(.secondary)
23 Spacer()
24 Button("Cancel", role: .cancel) { finish(nil) }
25 .keyboardShortcut(.cancelAction)
26 Button("Save Block") { finish(box.text) }
27 .keyboardShortcut(.return, modifiers: .command)
28 }
29 }
30 .padding(16)
31 }
32}
33
34/// Holds the editor so the view can read its text when saving.
35@MainActor
36final class EditorBox {
37 var editor: OrgEditor?
38 var text: String { editor?.document.text ?? "" }
39}
40
41/// An `OrgEditor` in plain mode for the block's language.
42struct CodeEditor: NSViewRepresentable {
43 let text: String
44 let language: String?
45 let box: EditorBox
46 let done: (Bool) -> Void
47
48 func makeNSView(context: Context) -> NSScrollView {
49 let editor = OrgEditor(document: DocumentState(bytes: [UInt8](text.utf8)), highlighter: EditorView.highlighter, mode: .plain(language: language))
50 editor.keymap = Keymap([
51 KeyBinding(keys: KeySequence.parse("C-c '")!, command: "app.block.save"),
52 KeyBinding(keys: KeySequence.parse("C-c C-k")!, command: "app.block.cancel"),
53 ])
54 editor.onAppCommand = { id in
55 if id == "app.block.save" { done(true) }
56 if id == "app.block.cancel" { done(false) }
57 }
58 box.editor = editor
59 let scroll = editor.makeScrollView()
60 DispatchQueue.main.async { scroll.window?.makeFirstResponder(editor.textView) }
61 return scroll
62 }
63
64 func updateNSView(_ view: NSScrollView, context: Context) {}
65}
Sources/Orgstar/ContentView.swift +17
@@ -46,6 +46,9 @@ struct ContentView: View {
46 hideDrawerStartup: hideDrawerStartup, hideBlockStartup: hideBlockStartup 46 hideDrawerStartup: hideDrawerStartup, hideBlockStartup: hideBlockStartup
47 ) 47 )
48 } 48 }
49 /// A block being edited apart (`org-edit-special`).
50 @State private var blockEdit: BlockEditRequest?
51
49 @State private var selectedFile: String? 52 @State private var selectedFile: String?
50 /// A file that isn't text, shown as a preview instead of the open document. 53 /// A file that isn't text, shown as a preview instead of the open document.
51 @State private var previewPath: String? 54 @State private var previewPath: String?
@@ -200,6 +203,13 @@ struct ContentView: View {
200 .onChange(of: options, initial: true) { _, options in session.options = options } 203 .onChange(of: options, initial: true) { _, options in session.options = options }
201 .onChange(of: preset, initial: true) { loadKeymap() } 204 .onChange(of: preset, initial: true) { loadKeymap() }
202 .sheet(isPresented: $showingImport) { EmacsImportView(workspace: workspace) } 205 .sheet(isPresented: $showingImport) { EmacsImportView(workspace: workspace) }
206 .sheet(item: $blockEdit) { request in
207 BlockEditorView(edit: request.edit) { content in
208 blockEdit = nil
209 guard let content else { return }
210 commands.editor?.perform(ReplaceBlockLines(edit: request.edit, content: content))
211 }
212 }
203 .sheet(isPresented: $commands.showingPalette) { 213 .sheet(isPresented: $commands.showingPalette) {
204 PaletteView(commands: commands) { id in 214 PaletteView(commands: commands) { id in
205 commands.showingPalette = false 215 commands.showingPalette = false
@@ -353,6 +363,8 @@ struct ContentView: View {
353 switch request { 363 switch request {
354 case .openLink(let link): 364 case .openLink(let link):
355 if let message = LinkOpener.open(link, session: session, workspace: workspace) { commands.show(message) } 365 if let message = LinkOpener.open(link, session: session, workspace: workspace) { commands.show(message) }
366 case .editBlock(let edit):
367 blockEdit = BlockEditRequest(edit: edit)
356 case .emacsTable(let table, let point, let form, let reason, let lisp): 368 case .emacsTable(let table, let point, let form, let reason, let lisp):
357 let run = { 369 let run = {
358 commands.show("Recalculating in Emacs (\(reason))…") 370 commands.show("Recalculating in Emacs (\(reason))…")
@@ -804,3 +816,8 @@ struct BufferTabs: View {
804 .help(path) 816 .help(path)
805 } 817 }
806} 818}
819
820struct BlockEditRequest: Identifiable {
821 let id = UUID()
822 let edit: BlockEdit
823}
Tests/OrgCoreTests/BlockCommandTests.swift added +97
@@ -0,0 +1,97 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct BlockCommandTests {
6 static let oracle = ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil
7
8 static let text = """
9 * Heading
10 Some text here.
11 Indented line
12 * Starred
13 #+keyword
14 last
15 """
16
17 @Test(.enabled(if: oracle))
18 func insertsTemplatesLikeOrg() throws {
19 let ns = Self.text as NSString
20 let points = [ns.range(of: "Some").location, ns.range(of: "text").location, ns.range(of: "Indented").location, ns.length]
21 let regions = [("Some", "here."), ("Indented", "#+keyword"), ("Some", "line\n")]
22 let types = ["src", "quote", "EXAMPLE", "export html", "verse"]
23 var cases: [EmacsOracle.Case] = []
24 var ours: [(String, Int)] = []
25 for type in types {
26 for offset in points {
27 cases.append(EmacsOracle.Case(text: Self.text, point: EmacsOracle.point(offset, in: Self.text), form: "(org-insert-structure-template \(lispString(type)))"))
28 ours.append(Self.run(type, offset..<offset))
29 }
30 for (from, to) in regions {
31 let start = ns.range(of: from).location
32 let end = NSMaxRange(ns.range(of: to))
33 cases.append(EmacsOracle.Case(text: Self.text, point: EmacsOracle.point(end, in: Self.text), form: "(progn (transient-mark-mode 1) (set-mark \(EmacsOracle.point(start, in: Self.text))) (activate-mark) (org-insert-structure-template \(lispString(type))))"))
34 ours.append(Self.run(type, start..<end))
35 }
36 }
37 for (i, (mine, theirs)) in zip(ours, try EmacsOracle.run(cases)).enumerated() {
38 #expect(mine.0 == theirs.text, "\(cases[i].form)\nours: \(mine.0.debugDescription)\nemacs: \(theirs.text.debugDescription)")
39 if mine.0 == theirs.text { #expect(mine.1 == EmacsOracle.offset(theirs.point, in: theirs.text), "\(cases[i].form) caret") }
40 }
41 }
42
43 static func run(_ type: String, _ selection: Range<Int>) -> (String, Int) {
44 let key = InsertStructureTemplate.templates.first { $0.type == type }.map { String($0.key) } ?? "\t"
45 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [selection], answers: ["template": key, "type": type])
46 guard case .commit(let result) = InsertStructureTemplate().run(in: context) else { return (text, 0) }
47 var new = text
48 for edit in result.edits.sorted(by: { $0.range.lowerBound > $1.range.lowerBound }) { new = edit.apply(to: new) }
49 return (new, result.selection?.first?.lowerBound ?? 0)
50 }
51
52 static let blocks = """
53 * Code
54 #+begin_src python :results output
55 def f():
56 return 1
57 ,* not a heading
58 ,#+not a keyword
59 #+end_src
60 #+begin_example
61 indented
62 #+end_example
63 #+begin_src sh
64 #+end_src
65 """
66
67 @Test(.enabled(if: oracle))
68 func editsBlocksLikeOrgEditSpecial() throws {
69 let ns = Self.blocks as NSString
70 let points = [ns.range(of: "def f").location, ns.range(of: "#+begin_example").location, ns.range(of: "#+begin_src sh").location + 3]
71 let added = "* new star\n#+new keyword\nplain"
72 let forms = points.map { point in
73 """
74 (progn (erase-buffer) (insert \(lispString(Self.blocks))) (goto-char \(EmacsOracle.point(point, in: Self.blocks)))
75 (org-edit-special)
76 (let ((content (buffer-string)))
77 (goto-char (point-max)) (insert \(lispString(added)))
78 (org-edit-src-exit)
79 (format "%s|%s" content (buffer-string))))
80 """
81 }
82 let emacs = try EmacsOracle.evaluate("", "(list \(forms.joined(separator: " ")))", prelude: "(setq org-src-preserve-indentation t org-src-window-setup 'current-window)")
83 let tree = OrgParser.parse(Self.blocks)
84 for (point, expected) in zip(points, emacs) {
85 guard let edit = BlockEdit.at(point, text: Self.blocks, tree: tree) else {
86 Issue.record("no block at \(point)")
87 continue
88 }
89 let context = EditContext(revision: 0, text: Self.blocks, tree: tree, selection: [point..<point])
90 var after = Self.blocks
91 if case .commit(let result) = ReplaceBlockLines(edit: edit, content: edit.content + added).run(in: context) {
92 for e in result.edits { after = e.apply(to: after) }
93 }
94 #expect("\(edit.content)|\(after)" == expected, "at \(point)")
95 }
96 }
97}