Org editing options !16

merged merged by cmc on 2026-10-05 04:55 UTC · krz/orgstar:phase2-options into main

23 files changed, +263 −60

Layout: unified · split

Sources/OrgApp/DocumentSession.swift +8 −4
@@ -63,6 +63,10 @@ public final class DocumentSession {
63 public var lastError: String? 63 public var lastError: String?
64 public var saveMode: SaveMode = .automatic 64 public var saveMode: SaveMode = .automatic
65 public var autosaveDelay: Duration = .seconds(1) 65 public var autosaveDelay: Duration = .seconds(1)
66 /// Org options for commands the session runs (refile, archive).
67 public var options: EditingOptions = .org
68 /// TODO keywords and priorities for files without their own settings.
69 public var defaults: OrgSettings = .default
66 70
67 private let fileSystem: FileSystem 71 private let fileSystem: FileSystem
68 private let recovery: RecoveryStore 72 private let recovery: RecoveryStore
@@ -90,7 +94,7 @@ public final class DocumentSession {
90 } 94 }
91 autosave?.cancel() 95 autosave?.cancel()
92 self.path = path 96 self.path = path
93 buffer = StateBuffer(DocumentState(bytes: bytes)) 97 buffer = StateBuffer(DocumentState(bytes: bytes, defaults: defaults))
94 conflict = nil 98 conflict = nil
95 revealRequest = nil 99 revealRequest = nil
96 refresh() 100 refresh()
@@ -197,7 +201,7 @@ public final class DocumentSession {
197 private func editContext(now: Date) -> EditContext? { 201 private func editContext(now: Date) -> EditContext? {
198 guard let buffer else { return nil } 202 guard let buffer else { return nil }
199 let document = buffer.document 203 let document = buffer.document
200 return EditContext(revision: document.revision, text: document.text, tree: document.tree, selection: buffer.selection, now: now) 204 return EditContext(revision: document.revision, text: document.text, tree: document.tree, selection: buffer.selection, now: now, options: options)
201 } 205 }
202 206
203 /// Moves the subtree at the caret under `target`. For another file, that file is written 207 /// Moves the subtree at the caret under `target`. For another file, that file is written
@@ -215,7 +219,7 @@ public final class DocumentSession {
215 let url = URL(fileURLWithPath: target.path) 219 let url = URL(fileURLWithPath: target.path)
216 do { 220 do {
217 guard let bytes = try fileSystem.read(url) else { return "\((target.path as NSString).lastPathComponent) no longer exists." } 221 guard let bytes = try fileSystem.read(url) else { return "\((target.path as NSString).lastPathComponent) no longer exists." }
218 var state = DocumentState(bytes: bytes) 222 var state = DocumentState(bytes: bytes, defaults: defaults)
219 guard state.isEditable else { return "\((target.path as NSString).lastPathComponent) isn't UTF-8, so it can't be changed." } 223 guard state.isEditable else { return "\((target.path as NSString).lastPathComponent) isn't UTF-8, so it can't be changed." }
220 guard let start = target.locate(in: state) else { return "Can't find \(target.label) any more." } 224 guard let start = target.locate(in: state) else { return "Can't find \(target.label) any more." }
221 guard let result = Refile.toOtherFile(source: context, target: state.text, under: start) else { return "Can't refile from here." } 225 guard let result = Refile.toOtherFile(source: context, target: state.text, under: start) else { return "Can't refile from here." }
@@ -241,7 +245,7 @@ public final class DocumentSession {
241 do { 245 do {
242 let url = URL(fileURLWithPath: destination.path) 246 let url = URL(fileURLWithPath: destination.path)
243 let existing = destination.isSameFile ? nil : try fileSystem.read(url) 247 let existing = destination.isSameFile ? nil : try fileSystem.read(url)
244 var state = DocumentState(bytes: existing ?? []) 248 var state = DocumentState(bytes: existing ?? [], defaults: defaults)
245 if !destination.isSameFile, !state.isEditable { return "\(url.lastPathComponent) isn't UTF-8, so it can't be changed." } 249 if !destination.isSameFile, !state.isEditable { return "\(url.lastPathComponent) isn't UTF-8, so it can't be changed." }
246 let edits: Archive.Edits 250 let edits: Archive.Edits
247 switch Archive.run(context, sourcePath: path, destination: destination, archiveText: existing.map { _ in state.text }) { 251 switch Archive.run(context, sourcePath: path, destination: destination, archiveText: existing.map { _ in state.text }) {
Sources/OrgCore/Commands/Archive.swift +1 −1
@@ -234,7 +234,7 @@ public enum Archive {
234 formatter.timeZone = context.calendar.timeZone 234 formatter.timeZone = context.calendar.timeZone
235 formatter.dateFormat = "yyyy-MM-dd EEE HH:mm" 235 formatter.dateFormat = "yyyy-MM-dd EEE HH:mm"
236 let time = formatter.string(from: context.now) 236 let time = formatter.string(from: context.now)
237 let archive = destination.isSameFile ? nil : EmacsBuffer(archiveText ?? "", point: 0, settings: context.tree.settings) 237 let archive = destination.isSameFile ? nil : EmacsBuffer(archiveText ?? "", point: 0, settings: context.tree.settings, options: context.options)
238 var archiveEdit: TextEdit? 238 var archiveEdit: TextEdit?
239 let step = commitBuffer(context) { source in 239 let step = commitBuffer(context) { source in
240 let target = archive ?? source 240 let target = archive ?? source
Sources/OrgCore/Commands/Command.swift +27 −1
@@ -1,5 +1,28 @@
1import Foundation 1import Foundation
2 2
3/// Org options that change what commands write, as the user's Emacs has them.
4public struct EditingOptions: Sendable, Equatable, Codable {
5 /// `org-tags-column`: negative aligns tags to end at that column, 0 puts them one space
6 /// after the title.
7 public var tagsColumn: Int
8 /// `org-insert-heading-respect-content`: M-RET adds the heading after the subtree.
9 public var insertHeadingRespectContent: Bool
10 /// `org-M-RET-may-split-line`: M-RET in the middle of a line splits it.
11 public var metaReturnMaySplitLine: Bool
12 /// `org-list-allow-alphabetical`: `a.` and `A)` are list bullets.
13 public var listAllowAlphabetical: Bool
14
15 public init(tagsColumn: Int = -77, insertHeadingRespectContent: Bool = false, metaReturnMaySplitLine: Bool = true, listAllowAlphabetical: Bool = false) {
16 self.tagsColumn = tagsColumn
17 self.insertHeadingRespectContent = insertHeadingRespectContent
18 self.metaReturnMaySplitLine = metaReturnMaySplitLine
19 self.listAllowAlphabetical = listAllowAlphabetical
20 }
21
22 /// Org's own defaults.
23 public static let org = EditingOptions()
24}
25
3/// Everything a command may read. Commands never read the clock or the file system, so the 26/// Everything a command may read. Commands never read the clock or the file system, so the
4/// same context always gives the same result. 27/// same context always gives the same result.
5public struct EditContext: Sendable { 28public struct EditContext: Sendable {
@@ -16,10 +39,12 @@ public struct EditContext: Sendable {
16 public let answers: [String: String] 39 public let answers: [String: String]
17 /// Ranges the view hides (folded text); motion commands skip headings inside them. 40 /// Ranges the view hides (folded text); motion commands skip headings inside them.
18 public let hidden: [Range<Int>] 41 public let hidden: [Range<Int>]
42 public let options: EditingOptions
19 43
20 public init( 44 public init(
21 revision: Int, text: String, tree: OrgTree, selection: [Range<Int>], 45 revision: Int, text: String, tree: OrgTree, selection: [Range<Int>],
22 now: Date = Date(), calendar: Calendar = .current, answers: [String: String] = [:], hidden: [Range<Int>] = [] 46 now: Date = Date(), calendar: Calendar = .current, answers: [String: String] = [:], hidden: [Range<Int>] = [],
47 options: EditingOptions = .org
23 ) { 48 ) {
24 self.revision = revision 49 self.revision = revision
25 self.text = text 50 self.text = text
@@ -29,6 +54,7 @@ public struct EditContext: Sendable {
29 self.calendar = calendar 54 self.calendar = calendar
30 self.answers = answers 55 self.answers = answers
31 self.hidden = hidden 56 self.hidden = hidden
57 self.options = options
32 } 58 }
33 59
34 public var caret: Int { selection.first?.lowerBound ?? 0 } 60 public var caret: Int { selection.first?.lowerBound ?? 0 }
Sources/OrgCore/Commands/EmacsBuffer.swift +7 −4
@@ -28,10 +28,13 @@ final class EmacsBuffer {
28 /// Groups of the last successful match, as Emacs's match data. 28 /// Groups of the last successful match, as Emacs's match data.
29 var lastMatch: [Range<Int>?] = [] 29 var lastMatch: [Range<Int>?] = []
30 30
31 init(_ text: String, point: Int, settings: OrgSettings = .default) { 31 let options: EditingOptions
32
33 init(_ text: String, point: Int, settings: OrgSettings = .default, options: EditingOptions = .org) {
32 self.text = NSMutableString(string: text) 34 self.text = NSMutableString(string: text)
33 self.point = point 35 self.point = point
34 self.settings = settings 36 self.settings = settings
37 self.options = options
35 unchangedPrefix = self.text.length 38 unchangedPrefix = self.text.length
36 unchangedSuffix = self.text.length 39 unchangedSuffix = self.text.length
37 } 40 }
@@ -407,14 +410,14 @@ final class EmacsBuffer {
407 let end = lineEnd(offset) 410 let end = lineEnd(offset)
408 let line = substring(start, end) 411 let line = substring(start, end)
409 var plain = LineBuffer(line, caret: nil) 412 var plain = LineBuffer(line, caret: nil)
410 OrgCore.alignTags(&plain) 413 OrgCore.alignTags(&plain, tagsColumn: options.tagsColumn)
411 guard plain.string != line else { return } 414 guard plain.string != line else { return }
412 func map(_ position: Int) -> Int { 415 func map(_ position: Int) -> Int {
413 guard position >= start, position <= end else { 416 guard position >= start, position <= end else {
414 return position > end ? position + (plain.string as NSString).length - (line as NSString).length : position 417 return position > end ? position + (plain.string as NSString).length - (line as NSString).length : position
415 } 418 }
416 var buffer = LineBuffer(line, caret: position - start) 419 var buffer = LineBuffer(line, caret: position - start)
417 OrgCore.alignTags(&buffer) 420 OrgCore.alignTags(&buffer, tagsColumn: options.tagsColumn)
418 return start + buffer.caret! 421 return start + buffer.caret!
419 } 422 }
420 let newPoint = map(point) 423 let newPoint = map(point)
@@ -430,7 +433,7 @@ final class EmacsBuffer {
430/// Runs a buffer-editing body against a context and turns the result into one minimal edit 433/// Runs a buffer-editing body against a context and turns the result into one minimal edit
431/// and the new caret. A user error becomes `.failed`. 434/// and the new caret. A user error becomes `.failed`.
432func commitBuffer(_ context: EditContext, _ body: (EmacsBuffer) throws -> Void) -> CommandStep { 435func commitBuffer(_ context: EditContext, _ body: (EmacsBuffer) throws -> Void) -> CommandStep {
433 let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings) 436 let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings, options: context.options)
434 do { 437 do {
435 try body(buffer) 438 try body(buffer)
436 } catch let error as EmacsBuffer.UserError { 439 } catch let error as EmacsBuffer.UserError {
Sources/OrgCore/Commands/HeadingCommands.swift +4 −4
@@ -247,7 +247,7 @@ public struct TodoCycle: OrgCommand {
247 } 247 }
248 } 248 }
249 buffer.replaceBeforeMarkers(regionStart..<regionEnd, with: next.map { " \($0) " } ?? " ") 249 buffer.replaceBeforeMarkers(regionStart..<regionEnd, with: next.map { " \($0) " } ?? " ")
250 alignTags(&buffer) 250 alignTags(&buffer, tagsColumn: context.options.tagsColumn)
251 } 251 }
252 } 252 }
253} 253}
@@ -295,7 +295,7 @@ struct PriorityChange {
295 buffer.insert(cookie + " ", at: line.titleStart - line.start) 295 buffer.insert(cookie + " ", at: line.titleStart - line.start)
296 } 296 }
297 } 297 }
298 alignTags(&buffer) 298 alignTags(&buffer, tagsColumn: context.options.tagsColumn)
299 } 299 }
300 } 300 }
301 301
@@ -341,7 +341,7 @@ public struct PromoteHeading: OrgCommand {
341 guard line.level > 1 else { return .failed("Cannot promote to level 0") } 341 guard line.level > 1 else { return .failed("Cannot promote to level 0") }
342 return line.commit(context) { buffer in 342 return line.commit(context) { buffer in
343 buffer.replace(0..<(line.level + 1), with: String(repeating: "*", count: line.level - 1) + " ") 343 buffer.replace(0..<(line.level + 1), with: String(repeating: "*", count: line.level - 1) + " ")
344 alignTags(&buffer) 344 alignTags(&buffer, tagsColumn: context.options.tagsColumn)
345 fixPositionAfterPromote(&buffer, settings: context.tree.settings) 345 fixPositionAfterPromote(&buffer, settings: context.tree.settings)
346 } 346 }
347 } 347 }
@@ -358,7 +358,7 @@ public struct DemoteHeading: OrgCommand {
358 let line = HeadingLine(heading) 358 let line = HeadingLine(heading)
359 return line.commit(context) { buffer in 359 return line.commit(context) { buffer in
360 buffer.replace(0..<(line.level + 1), with: String(repeating: "*", count: line.level + 1) + " ") 360 buffer.replace(0..<(line.level + 1), with: String(repeating: "*", count: line.level + 1) + " ")
361 alignTags(&buffer) 361 alignTags(&buffer, tagsColumn: context.options.tagsColumn)
362 fixPositionAfterPromote(&buffer, settings: context.tree.settings) 362 fixPositionAfterPromote(&buffer, settings: context.tree.settings)
363 } 363 }
364 } 364 }
Sources/OrgCore/Commands/ListCommands.swift +5 −5
@@ -11,7 +11,7 @@ extension EmacsBuffer {
11 var structure = saveExcursion { point = start; return listStruct() } 11 var structure = saveExcursion { point = start; return listStruct() }
12 let prevs = structure.prevs 12 let prevs = structure.prevs
13 let description = structure.listType(start, prevs) == .descriptive ? " :: " : nil 13 let description = structure.listType(start, prevs) == .descriptive ? " :: " : nil
14 structure = insertItem(at: pos, structure, prevs, checkbox: checkbox, afterBullet: description) 14 structure = insertItem(at: pos, structure, prevs, checkbox: checkbox, afterBullet: description, splitLine: options.metaReturnMaySplitLine)
15 writeStruct(&structure, structure.parents) 15 writeStruct(&structure, structure.parents)
16 if checkbox { updateCheckboxCount() } 16 if checkbox { updateCheckboxCount() }
17 forwardLine(0) 17 forwardLine(0)
@@ -91,17 +91,17 @@ extension EmacsBuffer {
91 let upper = marker(lineEnd(point)) 91 let upper = marker(lineEnd(point))
92 defer { release(upper) } 92 defer { release(upper) }
93 point = lower 93 point = lower
94 let current = looking(Self.itemPattern).flatMap { match -> String? in 94 let current = looking(itemPattern).flatMap { match -> String? in
95 let box = saveExcursion { () -> [Range<Int>?]? in 95 let box = saveExcursion { () -> [Range<Int>?]? in
96 point = match[0]!.upperBound 96 point = match[0]!.upperBound
97 if let counter = looking("(?:\\[@(?:start:)?[0-9]+\\][ \\t]*)") { point = counter[0]!.upperBound } 97 if let counter = looking(options.listAllowAlphabetical ? "(?:\\[@(?:start:)?([0-9]+|[A-Za-z])\\][ \\t]*)" : "(?:\\[@(?:start:)?[0-9]+\\][ \\t]*)") { point = counter[0]!.upperBound }
98 return looking("(\\[[- X]\\])[ \\t]+") 98 return looking("(\\[[- X]\\])[ \\t]+")
99 } 99 }
100 return box.map { substring($0[1]!.lowerBound, $0[1]!.upperBound) } 100 return box.map { substring($0[1]!.lowerBound, $0[1]!.upperBound) }
101 } 101 }
102 let reference = current == "[X]" ? "[ ]" : "[X]" 102 let reference = current == "[X]" ? "[ ]" : "[X]"
103 point = lower 103 point = lower
104 while point < upper.position, searchForward(Self.itemBeginningPattern, bound: upper.position, move: true) != nil { 104 while point < upper.position, searchForward(itemBeginningPattern, bound: upper.position, move: true) != nil {
105 var structure = listStruct() 105 var structure = listStruct()
106 let old = structure 106 let old = structure
107 let parents = structure.parents 107 let parents = structure.parents
@@ -302,7 +302,7 @@ public struct InsertCheckboxItem: OrgCommand {
302 302
303/// An item command that needs point on an item's first line. 303/// An item command that needs point on an item's first line.
304private func atItemLine(_ context: EditContext) -> Bool { 304private func atItemLine(_ context: EditContext) -> Bool {
305 EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings).atItem 305 EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings, options: context.options).atItem
306} 306}
307 307
308/// M-right on an item: `org-indent-item`. 308/// M-right on an item: `org-indent-item`.
Sources/OrgCore/Commands/OrgList.swift +57 −24
@@ -247,8 +247,13 @@ extension EmacsBuffer {
247 247
248 // MARK: - Context 248 // MARK: - Context
249 249
250 static let itemPattern = "([ \\t]*([-+]|(([0-9]+)[.)]))|[ \\t]+\\*)([ \\t]+|$)" 250 /// `org-item-re`.
251 static let itemBeginningPattern = "^" + itemPattern 251 var itemPattern: String {
252 let alpha = options.listAllowAlphabetical ? "|[A-Za-z]" : ""
253 return "([ \\t]*([-+]|(([0-9]+\(alpha))[.)]))|[ \\t]+\\*)([ \\t]+|$)"
254 }
255
256 var itemBeginningPattern: String { "^" + itemPattern }
252 static let fullItemPattern = "^[ \\t]*((?:[-+*]|(?:[0-9]+|[A-Za-z])[.)])(?:[ \\t]+|$))(?:\\[@(?:start:)?([0-9]+|[A-Za-z])\\][ \\t]*)?(?:(\\[[ X-]\\])(?:[ \\t]+|$))?(?:(.*)[ \\t]+::(?:[ \\t]+|$))?" 257 static let fullItemPattern = "^[ \\t]*((?:[-+*]|(?:[0-9]+|[A-Za-z])[.)])(?:[ \\t]+|$))(?:\\[@(?:start:)?([0-9]+|[A-Za-z])\\][ \\t]*)?(?:(\\[[ X-]\\])(?:[ \\t]+|$))?(?:(.*)[ \\t]+::(?:[ \\t]+|$))?"
253 static let listEndPattern = "^[ \\t]*\\n[ \\t]*\\n" 258 static let listEndPattern = "^[ \\t]*\\n[ \\t]*\\n"
254 static let drawerPattern = "^[ \\t]*:([-_\\w]+):[ \\t]*$" 259 static let drawerPattern = "^[ \\t]*:([-_\\w]+):[ \\t]*$"
@@ -307,7 +312,7 @@ extension EmacsBuffer {
307 let context = listContext() 312 let context = listContext()
308 var reference = looking("^[ \\t]*$") != nil ? 10000 : currentTextIndentation 313 var reference = looking("^[ \\t]*$") != nil ? 10000 : currentTextIndentation
309 if context.kind == .invalid { return nil } 314 if context.kind == .invalid { return nil }
310 if looking(Self.itemPattern) != nil { return point } 315 if looking(itemPattern) != nil { return point }
311 // Inside `org-list-end-re`: start above it. 316 // Inside `org-list-end-re`: start above it.
312 let windowStart = saveExcursion { forwardLine(-2); return point } 317 let windowStart = saveExcursion { forwardLine(-2); return point }
313 let windowEnd = saveExcursion { forwardLine(2); return lineEnd(point) } 318 let windowEnd = saveExcursion { forwardLine(2); return lineEnd(point) }
@@ -322,7 +327,7 @@ extension EmacsBuffer {
322 } 327 }
323 while true { 328 while true {
324 let ind = currentTextIndentation 329 let ind = currentTextIndentation
325 if looking(Self.itemPattern) != nil, ind < reference { return point } 330 if looking(itemPattern) != nil, ind < reference { return point }
326 if point <= context.up { return nil } 331 if point <= context.up { return nil }
327 if looking(Self.listEndPattern) != nil { return nil } 332 if looking(Self.listEndPattern) != nil { return nil }
328 if looking("^[ \\t]*#\\+end_") != nil, searchBackward("^[ \\t]*#\\+begin_", bound: context.up) != nil { 333 if looking("^[ \\t]*#\\+end_") != nil, searchBackward("^[ \\t]*#\\+begin_", bound: context.up) != nil {
@@ -350,7 +355,7 @@ extension EmacsBuffer {
350 beginningOfLine() 355 beginningOfLine()
351 switch listContext().kind { 356 switch listContext().kind {
352 case .invalid, .block("comment"), .block("verse"): return false 357 case .invalid, .block("comment"), .block("verse"): return false
353 default: return looking(Self.itemPattern) != nil 358 default: return looking(itemPattern) != nil
354 } 359 }
355 } 360 }
356 } 361 }
@@ -385,7 +390,7 @@ extension EmacsBuffer {
385 while true { 390 while true {
386 let ind = currentTextIndentation 391 let ind = currentTextIndentation
387 if point <= context.up { 392 if point <= context.up {
388 if looking(Self.itemPattern) == nil { 393 if looking(itemPattern) == nil {
389 trimToBegin() 394 trimToBegin()
390 } else { 395 } else {
391 beginCell = (point, ind) 396 beginCell = (point, ind)
@@ -397,7 +402,7 @@ extension EmacsBuffer {
397 trimToBegin() 402 trimToBegin()
398 return 403 return
399 } 404 }
400 if looking(Self.itemPattern) != nil { 405 if looking(itemPattern) != nil {
401 items.insert(assocAtPoint(ind), at: 0) 406 items.insert(assocAtPoint(ind), at: 0)
402 ends.insert((ind, point), at: 0) 407 ends.insert((ind, point), at: 0)
403 if ind < textMinInd { beginCell = (point, ind) } 408 if ind < textMinInd { beginCell = (point, ind) }
@@ -429,7 +434,7 @@ extension EmacsBuffer {
429 ends2.append((0, point)) 434 ends2.append((0, point))
430 break 435 break
431 } 436 }
432 if looking(Self.itemPattern) != nil { 437 if looking(itemPattern) != nil {
433 items2.append(assocAtPoint(ind)) 438 items2.append(assocAtPoint(ind))
434 ends2.append((ind, point)) 439 ends2.append((ind, point))
435 forwardLine(1) 440 forwardLine(1)
@@ -747,27 +752,38 @@ extension EmacsBuffer {
747 752
748 /// `org-list-struct-fix-bul` (alphabetical bullets off). 753 /// `org-list-struct-fix-bul` (alphabetical bullets off).
749 func fixBullets(_ structure: inout ListStruct, _ prevs: [Int: Int]) { 754 func fixBullets(_ structure: inout ListStruct, _ prevs: [Int: Int]) {
755 func find(_ pattern: String, _ string: String) -> Range<String.Index>? {
756 guard let range = string.range(of: pattern, options: .regularExpression) else { return nil }
757 stringMatched(string, range)
758 return range
759 }
750 for pos in structure.map(\.pos) { 760 for pos in structure.map(\.pos) {
751 let previous = prevs[pos] 761 let previous = prevs[pos]
752 let counter = structure.item(pos)!.counter 762 let counter = structure.item(pos)!.counter
753 let bullet = structure.bullet(pos) 763 let bullet = structure.bullet(pos)
764 let previousBullet = previous.map { structure.bullet($0) }
765 let alphap = previous == nil && useAlphabeticalBullets(pos, structure, prevs)
754 let new: String 766 let new: String
755 if let previous, let counter, counter.range(of: "[a-zA-Z]", options: .regularExpression) != nil, 767 if previous != nil, let counter, let previousBullet, find("[a-zA-Z]", counter) != nil, find("[a-zA-Z]", previousBullet) != nil {
756 structure.bullet(previous).range(of: "[a-zA-Z]", options: .regularExpression) != nil { 768 let lower = find("[a-z]", previousBullet)
757 let previousBullet = structure.bullet(previous) 769 let range = lower ?? find("[A-Z]", previousBullet)!
758 let real = previousBullet.range(of: "[a-z]", options: .regularExpression) != nil ? counter.lowercased() : counter.uppercased() 770 new = previousBullet.replacingCharacters(in: range, with: lower != nil ? counter.lowercased() : counter.uppercased())
759 let range = previousBullet.range(of: "[a-zA-Z]", options: .regularExpression)! 771 } else if previous != nil, let counter, let previousBullet, let counterRange = find("[0-9]+", counter),
760 new = previousBullet.replacingCharacters(in: range, with: real) 772 let range = find("[0-9]+", previousBullet) {
761 } else if let previous, let counter, let counterRange = counter.range(of: "[0-9]+", options: .regularExpression), 773 new = previousBullet.replacingCharacters(in: range, with: counter[counterRange])
762 let range = structure.bullet(previous).range(of: "[0-9]+", options: .regularExpression) { 774 } else if let previousBullet {
763 new = structure.bullet(previous).replacingCharacters(in: range, with: counter[counterRange]) 775 new = incrementBullet(previousBullet)
764 } else if let previous { 776 } else if let counter, useAlphabeticalBullets(pos, structure, prevs), find("[A-Za-z]", counter) != nil, find("[A-Za-z]", bullet) != nil {
765 new = incrementBullet(structure.bullet(previous)) 777 let lower = find("[a-z]", bullet)
766 } else if let counter, let counterRange = counter.range(of: "[0-9]+", options: .regularExpression), 778 let range = lower ?? find("[A-Z]", bullet)!
767 let range = bullet.range(of: "[0-9]+", options: .regularExpression) { 779 new = bullet.replacingCharacters(in: range, with: lower != nil ? counter.lowercased() : counter.uppercased())
780 } else if let counter, let counterRange = find("[0-9]+", counter), let range = find("[0-9]+", bullet) {
768 new = bullet.replacingCharacters(in: range, with: counter[counterRange]) 781 new = bullet.replacingCharacters(in: range, with: counter[counterRange])
769 } else if let range = bullet.range(of: "([0-9]+|[A-Za-z])", options: .regularExpression) { 782 } else if alphap, let range = find("[A-Z]", bullet) {
770 stringMatched(bullet, range) 783 new = bullet.replacingCharacters(in: range, with: "A")
784 } else if alphap, let range = find("[a-z]", bullet) {
785 new = bullet.replacingCharacters(in: range, with: "a")
786 } else if let range = find("([0-9]+|[A-Za-z])", bullet) {
771 new = bullet.replacingCharacters(in: range, with: "1") 787 new = bullet.replacingCharacters(in: range, with: "1")
772 } else { 788 } else {
773 new = bullet 789 new = bullet
@@ -776,6 +792,23 @@ extension EmacsBuffer {
776 } 792 }
777 } 793 }
778 794
795 /// `org-list-use-alpha-bul-p`: the list starting at `first` fits in the alphabet.
796 func useAlphabeticalBullets(_ first: Int, _ structure: ListStruct, _ prevs: [Int: Int]) -> Bool {
797 guard options.listAllowAlphabetical else { return false }
798 var item: Int? = first
799 var ascii = 64
800 while let current = item {
801 if let counter = structure.item(current)?.counter, let letter = counter.first, letter.isASCII, letter.isLetter {
802 ascii = Int(letter.uppercased().unicodeScalars.first!.value)
803 } else {
804 ascii += 1
805 }
806 if ascii > 90 { return false }
807 item = structure.nextItem(current, prevs)
808 }
809 return true
810 }
811
779 /// `org-list-struct-fix-ind`. 812 /// `org-list-struct-fix-ind`.
780 func fixIndentation(_ structure: inout ListStruct, _ parents: [(item: Int, parent: Int?)], bulletSize: Int? = nil) { 813 func fixIndentation(_ structure: inout ListStruct, _ parents: [(item: Int, parent: Int?)], bulletSize: Int? = nil) {
781 let topInd = structure.ind(structure.top) 814 let topInd = structure.ind(structure.top)
@@ -851,7 +884,7 @@ extension EmacsBuffer {
851 point = end 884 point = end
852 skipBackward(Self.whitespace) 885 skipBackward(Self.whitespace)
853 beginningOfLine() 886 beginningOfLine()
854 while point > begin || (point == begin && looking(Self.itemPattern) == nil) { 887 while point > begin || (point == begin && looking(itemPattern) == nil) {
855 if looking("^[ \\t]*\\S", setsMatch: false) != nil { 888 if looking("^[ \\t]*\\S", setsMatch: false) != nil {
856 indentLineTo(max(currentTextIndentation + delta, ind.map { $0 + 1 } ?? -1)) 889 indentLineTo(max(currentTextIndentation + delta, ind.map { $0 + 1 } ?? -1))
857 } 890 }
Sources/OrgCore/Commands/Refile.swift +1 −1
@@ -126,7 +126,7 @@ public enum Refile {
126 } 126 }
127 127
128 public static func toOtherFile(source context: EditContext, target text: String, under heading: Int?) -> Result? { 128 public static func toOtherFile(source context: EditContext, target text: String, under heading: Int?) -> Result? {
129 let target = EmacsBuffer(text, point: 0) 129 let target = EmacsBuffer(text, point: 0, settings: context.tree.settings, options: context.options)
130 var tree = "" 130 var tree = ""
131 let step = commitBuffer(context) { buffer in 131 let step = commitBuffer(context) { buffer in
132 tree = try buffer.copySubtree() 132 tree = try buffer.copySubtree()
Sources/OrgCore/Commands/StructureCommands.swift +5 −3
@@ -74,7 +74,8 @@ extension EmacsBuffer {
74 saveExcursion { insert(stars + " \n") } 74 saveExcursion { insert(stars + " \n") }
75 if !(blank && previousLineEmpty) { emptyLinesBeforeCurrent(blank ? 1 : 0) } 75 if !(blank && previousLineEmpty) { emptyLinesBeforeCurrent(blank ? 1 : 0) }
76 endOfLine() 76 endOfLine()
77 } else if let match = saveExcursion({ () -> [Range<Int>?]? in beginningOfLine(); return lookingAt(complexHeadingPattern) }), 77 } else if options.metaReturnMaySplitLine,
78 let match = saveExcursion({ () -> [Range<Int>?]? in beginningOfLine(); return lookingAt(complexHeadingPattern) }),
78 let title = match[4], title.lowerBound <= point, point <= title.upperBound { 79 let title = match[4], title.lowerBound <= point, point <= title.upperBound {
79 let split = substring(point, title.upperBound) 80 let split = substring(point, title.upperBound)
80 deleteRegion(point, title.upperBound) 81 deleteRegion(point, title.upperBound)
@@ -99,6 +100,7 @@ extension EmacsBuffer {
99 if !(blank && previousLineEmpty) { emptyLinesBeforeCurrent(blank ? 1 : 0) } 100 if !(blank && previousLineEmpty) { emptyLinesBeforeCurrent(blank ? 1 : 0) }
100 addBlankAfter(blank) 101 addBlankAfter(blank)
101 } else { 102 } else {
103 if !options.metaReturnMaySplitLine { endOfLine() }
102 insert("\n" + stars + " ") 104 insert("\n" + stars + " ")
103 if !(blank && previousLineEmpty) { emptyLinesBeforeCurrent(blank ? 1 : 0) } 105 if !(blank && previousLineEmpty) { emptyLinesBeforeCurrent(blank ? 1 : 0) }
104 addBlankAfter(blank) 106 addBlankAfter(blank)
@@ -264,7 +266,7 @@ public struct InsertHeading: OrgCommand {
264 public var title: String { "Insert Heading" } 266 public var title: String { "Insert Heading" }
265 public func applies(in context: EditContext) -> Bool { true } 267 public func applies(in context: EditContext) -> Bool { true }
266 public func run(in context: EditContext) -> CommandStep { 268 public func run(in context: EditContext) -> CommandStep {
267 headingCommand(context) { try $0.insertHeading(respectContent: false, hidden: context.hidden) } 269 headingCommand(context) { try $0.insertHeading(respectContent: context.options.insertHeadingRespectContent, hidden: context.hidden) }
268 } 270 }
269} 271}
270 272
@@ -287,7 +289,7 @@ public struct InsertTodoHeading: OrgCommand {
287 public func applies(in context: EditContext) -> Bool { true } 289 public func applies(in context: EditContext) -> Bool { true }
288 public func run(in context: EditContext) -> CommandStep { 290 public func run(in context: EditContext) -> CommandStep {
289 headingCommand(context) { buffer in 291 headingCommand(context) { buffer in
290 try buffer.insertHeading(respectContent: false, hidden: context.hidden) 292 try buffer.insertHeading(respectContent: context.options.insertHeadingRespectContent, hidden: context.hidden)
291 let previous = buffer.saveExcursion { () -> String? in 293 let previous = buffer.saveExcursion { () -> String? in
292 try? buffer.forwardHeadingSameLevel(-1) 294 try? buffer.forwardHeadingSameLevel(-1)
293 guard let match = buffer.lookingAt(buffer.todoLinePattern), let keyword = match[2] else { return nil } 295 guard let match = buffer.lookingAt(buffer.todoLinePattern), let keyword = match[2] else { return nil }
Sources/OrgCore/Parser/Settings.swift +9
@@ -58,6 +58,15 @@ public struct OrgSettings: Sendable, Equatable {
58 Set(todoSequences.flatMap { ($0.active + $0.done).map(\.name) }) 58 Set(todoSequences.flatMap { ($0.active + $0.done).map(\.name) })
59 } 59 }
60 60
61 /// These settings with TODO keywords from `#+TODO:` syntax (`TODO NEXT | DONE`); nil when
62 /// the line has no keywords.
63 public func withTodoKeywords(_ line: String) -> OrgSettings? {
64 guard let sequence = SettingsScanner.todoSequence(Substring(line), kind: .sequence) else { return nil }
65 var copy = self
66 copy.todoSequences = [sequence]
67 return copy
68 }
69
61 public func isDone(_ name: String) -> Bool { 70 public func isDone(_ name: String) -> Bool {
62 todoSequences.contains { $0.done.contains { $0.name == name } } 71 todoSequences.contains { $0.done.contains { $0.name == name } }
63 } 72 }
Sources/OrgDocument/Buffer.swift +2 −1
@@ -19,6 +19,7 @@ public protocol DocumentBuffer: AnyObject {
19public final class StateBuffer: DocumentBuffer { 19public final class StateBuffer: DocumentBuffer {
20 public private(set) var document: DocumentState 20 public private(set) var document: DocumentState
21 public var selection: [Range<Int>] = [0..<0] 21 public var selection: [Range<Int>] = [0..<0]
22 public var options: EditingOptions = .org
22 23
23 public init(_ document: DocumentState) { 24 public init(_ document: DocumentState) {
24 self.document = document 25 self.document = document
@@ -26,7 +27,7 @@ public final class StateBuffer: DocumentBuffer {
26 27
27 public func perform(_ command: any OrgCommand, now: Date, answers: [String: String]) -> CommandStep { 28 public func perform(_ command: any OrgCommand, now: Date, answers: [String: String]) -> CommandStep {
28 do { 29 do {
29 let step = try document.run(command, selection: selection, now: now, answers: answers) 30 let step = try document.run(command, selection: selection, now: now, answers: answers, options: options)
30 if case .commit(let result) = step, let new = result.selection { selection = new } 31 if case .commit(let result) = step, let new = result.selection { selection = new }
31 return step 32 return step
32 } catch { 33 } catch {
Sources/OrgDocument/DocumentState.swift +2 −2
@@ -101,11 +101,11 @@ public struct DocumentState: Sendable {
101 /// Runs `command` at `selection` and applies its edits as one undo step. 101 /// Runs `command` at `selection` and applies its edits as one undo step.
102 public mutating func run( 102 public mutating func run(
103 _ command: any OrgCommand, selection: [Range<Int>], now: Date = Date(), 103 _ command: any OrgCommand, selection: [Range<Int>], now: Date = Date(),
104 calendar: Calendar = .current, answers: [String: String] = [:] 104 calendar: Calendar = .current, answers: [String: String] = [:], options: EditingOptions = .org
105 ) throws -> CommandStep { 105 ) throws -> CommandStep {
106 let context = EditContext( 106 let context = EditContext(
107 revision: revision, text: text, tree: tree, selection: selection, 107 revision: revision, text: text, tree: tree, selection: selection,
108 now: now, calendar: calendar, answers: answers 108 now: now, calendar: calendar, answers: answers, options: options
109 ) 109 )
110 let step = command.run(in: context) 110 let step = command.run(in: context)
111 if case .commit(let result) = step, !result.edits.isEmpty { 111 if case .commit(let result) = step, !result.edits.isEmpty {
Sources/OrgEditorAppKit/OrgEditor.swift +3 −1
@@ -187,6 +187,8 @@ public final class OrgEditor: NSObject {
187 } 187 }
188 188
189 public var optionAsMeta: OptionAsMeta = [.left] 189 public var optionAsMeta: OptionAsMeta = [.left]
190 /// Org options commands follow.
191 public var options: EditingOptions = .org
190 private var dispatcher: KeyDispatcher 192 private var dispatcher: KeyDispatcher
191 /// A region is active: movement extends the selection, as after C-SPC in Emacs. 193 /// A region is active: movement extends the selection, as after C-SPC in Emacs.
192 private(set) var markActive = false 194 private(set) var markActive = false
@@ -196,7 +198,7 @@ public final class OrgEditor: NSObject {
196 return EditContext( 198 return EditContext(
197 revision: document.revision, text: document.text, tree: document.tree, 199 revision: document.revision, text: document.text, tree: document.tree,
198 selection: [selected.location..<NSMaxRange(selected)], now: now, calendar: .current, answers: answers, 200 selection: [selected.location..<NSMaxRange(selected)], now: now, calendar: .current, answers: answers,
199 hidden: hidden.all 201 hidden: hidden.all, options: options
200 ) 202 )
201 } 203 }
202 204
Sources/OrgIndex/FileRecord.swift +11
@@ -16,6 +16,17 @@ public struct IndexSettings: Sendable, Equatable {
16 public var org: OrgSettings 16 public var org: OrgSettings
17 public var semantic: SemanticSettings 17 public var semantic: SemanticSettings
18 18
19 /// Settings whose version follows the TODO keywords, so changing them reindexes.
20 public static func with(todoKeywords org: OrgSettings) -> IndexSettings {
21 let names = org.todoSequences.map { ($0.active.map(\.name) + ["|"] + $0.done.map(\.name)).joined(separator: " ") }.joined(separator: ";")
22 var hash: UInt64 = 0xcbf29ce484222325
23 for byte in names.utf8 {
24 hash ^= UInt64(byte)
25 hash = hash &* 0x100000001b3
26 }
27 return IndexSettings(version: 1 + Int(hash % 1_000_000), org: org)
28 }
29
19 public init(version: Int = 1, org: OrgSettings = .default, semantic: SemanticSettings = .default) { 30 public init(version: Int = 1, org: OrgSettings = .default, semantic: SemanticSettings = .default) {
20 self.version = version 31 self.version = version
21 self.org = org 32 self.org = org
Sources/Orgstar/ContentView.swift +10 −1
@@ -12,6 +12,14 @@ struct ContentView: View {
12 @AppStorage("keymap") private var preset = KeymapPreset.emacs 12 @AppStorage("keymap") private var preset = KeymapPreset.emacs
13 @AppStorage("optionAsMeta") private var optionAsMeta = OptionAsMeta.left.rawValue 13 @AppStorage("optionAsMeta") private var optionAsMeta = OptionAsMeta.left.rawValue
14 @State private var keymap = KeymapPreset.emacs.keymap 14 @State private var keymap = KeymapPreset.emacs.keymap
15 @AppStorage("tagsColumn") private var tagsColumn = -77
16 @AppStorage("headingAfterSubtree") private var headingAfterSubtree = true
17 @AppStorage("splitLine") private var splitLine = false
18 @AppStorage("alphabeticalLists") private var alphabeticalLists = true
19
20 private var options: EditingOptions {
21 Preferences.options(tagsColumn: tagsColumn, headingAfterSubtree: headingAfterSubtree, splitLine: splitLine, alphabeticalLists: alphabeticalLists)
22 }
15 @State private var selectedFile: String? 23 @State private var selectedFile: String?
16 @State private var query = "" 24 @State private var query = ""
17 @FocusState private var searchFocused: Bool 25 @FocusState private var searchFocused: Bool
@@ -35,7 +43,7 @@ struct ContentView: View {
35 .navigationSplitViewColumnWidth(min: 160, ideal: 220, max: 360) 43 .navigationSplitViewColumnWidth(min: 160, ideal: 220, max: 360)
36 } detail: { 44 } detail: {
37 if session.path != nil { 45 if session.path != nil {
38 EditorView(session: session, commands: commands, keymap: keymap, optionAsMeta: OptionAsMeta(rawValue: optionAsMeta), onAppCommand: perform) 46 EditorView(session: session, commands: commands, keymap: keymap, optionAsMeta: OptionAsMeta(rawValue: optionAsMeta), options: options, onAppCommand: perform)
39 .safeAreaInset(edge: .bottom, spacing: 0) { EchoArea(commands: commands) } 47 .safeAreaInset(edge: .bottom, spacing: 0) { EchoArea(commands: commands) }
40 } else { 48 } else {
41 ContentUnavailableView( 49 ContentUnavailableView(
@@ -65,6 +73,7 @@ struct ContentView: View {
65 } 73 }
66 } 74 }
67 .onChange(of: saveMode, initial: true) { _, mode in session.saveMode = mode } 75 .onChange(of: saveMode, initial: true) { _, mode in session.saveMode = mode }
76 .onChange(of: options, initial: true) { _, options in session.options = options }
68 .onChange(of: preset, initial: true) { loadKeymap() } 77 .onChange(of: preset, initial: true) { loadKeymap() }
69 .sheet(isPresented: $commands.showingPalette) { 78 .sheet(isPresented: $commands.showingPalette) {
70 PaletteView(keymap: keymap) { id in 79 PaletteView(keymap: keymap) { id in
Sources/Orgstar/EditorView.swift +2
@@ -11,6 +11,7 @@ struct EditorView: NSViewRepresentable {
11 let commands: AppCommands 11 let commands: AppCommands
12 let keymap: Keymap 12 let keymap: Keymap
13 let optionAsMeta: OptionAsMeta 13 let optionAsMeta: OptionAsMeta
14 let options: EditingOptions
14 let onAppCommand: (String) -> Void 15 let onAppCommand: (String) -> Void
15 16
16 final class Coordinator { 17 final class Coordinator {
@@ -54,6 +55,7 @@ struct EditorView: NSViewRepresentable {
54 if let editor = coordinator.editor { 55 if let editor = coordinator.editor {
55 if editor.keymap != keymap { editor.keymap = keymap } 56 if editor.keymap != keymap { editor.keymap = keymap }
56 editor.optionAsMeta = optionAsMeta 57 editor.optionAsMeta = optionAsMeta
58 editor.options = options
57 } 59 }
58 container.window?.isDocumentEdited = session.isDirty 60 container.window?.isDocumentEdited = session.isDirty
59 if let request = session.revealRequest, request.id != coordinator.revealID { 61 if let request = session.revealRequest, request.id != coordinator.revealID {
Sources/Orgstar/OrgstarApp.swift +26 −3
@@ -90,9 +90,15 @@ enum UnsavedChanges {
90@main 90@main
91struct OrgstarApp: App { 91struct OrgstarApp: App {
92 @NSApplicationDelegateAdaptor private var delegate: AppDelegate 92 @NSApplicationDelegateAdaptor private var delegate: AppDelegate
93 @State private var workspace = Result { try WorkspaceModel(directory: WorkspaceModel.defaultDirectory) } 93 @State private var workspace = Result {
94 try WorkspaceModel(directory: WorkspaceModel.defaultDirectory, settings: .with(todoKeywords: Preferences.orgSettings))
95 }
94 @State private var commands = AppCommands() 96 @State private var commands = AppCommands()
95 @State private var session = DocumentSession() 97 @State private var session: DocumentSession = {
98 let session = DocumentSession()
99 session.defaults = Preferences.orgSettings
100 return session
101 }()
96 102
97 var body: some Scene { 103 var body: some Scene {
98 Window("Orgstar", id: "main") { 104 Window("Orgstar", id: "main") {
@@ -144,6 +150,11 @@ struct SettingsView: View {
144 @AppStorage("saveMode") private var saveMode = SaveMode.automatic 150 @AppStorage("saveMode") private var saveMode = SaveMode.automatic
145 @AppStorage("keymap") private var preset = KeymapPreset.emacs 151 @AppStorage("keymap") private var preset = KeymapPreset.emacs
146 @AppStorage("optionAsMeta") private var optionAsMeta = OptionAsMeta.left.rawValue 152 @AppStorage("optionAsMeta") private var optionAsMeta = OptionAsMeta.left.rawValue
153 @AppStorage("tagsColumn") private var tagsColumn = -77
154 @AppStorage("headingAfterSubtree") private var headingAfterSubtree = true
155 @AppStorage("splitLine") private var splitLine = false
156 @AppStorage("alphabeticalLists") private var alphabeticalLists = true
157 @AppStorage("todoKeywords") private var todoKeywords = Preferences.defaultTodoKeywords
147 158
148 var body: some View { 159 var body: some View {
149 Form { 160 Form {
@@ -165,8 +176,20 @@ struct SettingsView: View {
165 .font(.caption) 176 .font(.caption)
166 .foregroundStyle(.secondary) 177 .foregroundStyle(.secondary)
167 .textSelection(.enabled) 178 .textSelection(.enabled)
179 Divider()
180 Picker("Tags:", selection: $tagsColumn) {
181 Text("Aligned to end at column 77").tag(-77)
182 Text("One space after the title").tag(0)
183 }
184 Toggle("M-RET adds the new heading after the subtree", isOn: $headingAfterSubtree)
185 Toggle("M-RET splits the line at the caret", isOn: $splitLine)
186 Toggle("Lists can use letters (a. b. c.)", isOn: $alphabeticalLists)
187 TextField("Default TODO keywords:", text: $todoKeywords)
188 Text("For files without #+TODO, in #+TODO syntax. Takes effect when Orgstar restarts.")
189 .font(.caption)
190 .foregroundStyle(.secondary)
168 } 191 }
169 .padding(20) 192 .padding(20)
170 .frame(width: 420) 193 .frame(width: 480)
171 } 194 }
172} 195}
Sources/Orgstar/Preferences.swift added +20
@@ -0,0 +1,20 @@
1import Foundation
2import OrgCore
3
4/// Org preferences, stored in user defaults by the Settings window.
5enum Preferences {
6 static let defaultTodoKeywords = "TODO PROJ LOOP STRT WAIT HOLD IDEA | DONE KILL"
7
8 /// TODO keywords for files without `#+TODO`, read at launch.
9 static var orgSettings: OrgSettings {
10 let line = UserDefaults.standard.string(forKey: "todoKeywords") ?? defaultTodoKeywords
11 return OrgSettings.default.withTodoKeywords(line) ?? .default
12 }
13
14 static func options(tagsColumn: Int, headingAfterSubtree: Bool, splitLine: Bool, alphabeticalLists: Bool) -> EditingOptions {
15 EditingOptions(
16 tagsColumn: tagsColumn, insertHeadingRespectContent: headingAfterSubtree,
17 metaReturnMaySplitLine: splitLine, listAllowAlphabetical: alphabeticalLists
18 )
19 }
20}
Tests/OrgCoreTests/EmacsOracle.swift +11 −2
@@ -122,9 +122,18 @@ enum EmacsOracle {
122 } 122 }
123} 123}
124 124
125/// The options as Emacs Lisp bindings around `form`.
126func withOptions(_ options: EditingOptions, _ form: String) -> String {
127 func flag(_ value: Bool) -> String { value ? "t" : "nil" }
128 return "(let ((org-tags-column \(options.tagsColumn)) (org-insert-heading-respect-content \(flag(options.insertHeadingRespectContent))) (org-M-RET-may-split-line \(flag(options.metaReturnMaySplitLine))) (org-list-allow-alphabetical \(flag(options.listAllowAlphabetical)))) \(form))"
129}
130
131/// The options in the user's Doom Emacs.
132let doomOptions = EditingOptions(tagsColumn: 0, insertHeadingRespectContent: true, metaReturnMaySplitLine: false, listAllowAlphabetical: true)
133
125/// Runs a command the way an editor would: build a context, run, apply the edits. 134/// Runs a command the way an editor would: build a context, run, apply the edits.
126func runCommand(_ command: any OrgCommand, _ text: String, caret: Int, answers: [String: String] = [:]) -> (text: String, caret: Int, failure: String?) { 135func runCommand(_ command: any OrgCommand, _ text: String, caret: Int, answers: [String: String] = [:], options: EditingOptions = .org) -> (text: String, caret: Int, failure: String?) {
127 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret], answers: answers) 136 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret], answers: answers, options: options)
128 switch command.run(in: context) { 137 switch command.run(in: context) {
129 case .commit(let result): 138 case .commit(let result):
130 var new = text 139 var new = text
Tests/OrgCoreTests/HeadingCommandTests.swift +8 −3
@@ -66,6 +66,11 @@ struct HeadingOracleTests {
66 try compareWithEmacs(Self.variants.map { ($0, EmacsOracle.positions($0)) }, Self.commands) 66 try compareWithEmacs(Self.variants.map { ($0, EmacsOracle.positions($0)) }, Self.commands)
67 } 67 }
68 68
69 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
70 func headingCommandsMatchEmacsWithDoomOptions() throws {
71 try compareWithEmacs(Self.variants.map { ($0, EmacsOracle.positions($0)) }, Self.commands, options: doomOptions)
72 }
73
69 /// Real headings: `ORGSTAR_ORACLE_CORPUS=<folder>` runs every command on up to 400 heading 74 /// Real headings: `ORGSTAR_ORACLE_CORPUS=<folder>` runs every command on up to 400 heading
70 /// lines from the folder's files, with the caret at the line start, the title start and 75 /// lines from the folder's files, with the caret at the line start, the title start and
71 /// the line end. 76 /// the line end.
@@ -94,7 +99,7 @@ struct HeadingOracleTests {
94 99
95/// Runs every command at every caret of every input here and in Emacs, and records each 100/// Runs every command at every caret of every input here and in Emacs, and records each
96/// difference in text, caret or failure. 101/// difference in text, caret or failure.
97func compareWithEmacs(_ inputs: [(text: String, carets: [Int])], _ commands: [(command: any OrgCommand, form: String, headingLineOnly: Bool)]) throws { 102func compareWithEmacs(_ inputs: [(text: String, carets: [Int])], _ commands: [(command: any OrgCommand, form: String, headingLineOnly: Bool)], options: EditingOptions = .org) throws {
98 try #require(EmacsOracle.isAvailable, "Emacs is required for the oracle tests; set ORGSTAR_SKIP_ORACLE to skip") 103 try #require(EmacsOracle.isAvailable, "Emacs is required for the oracle tests; set ORGSTAR_SKIP_ORACLE to skip")
99 var cases: [EmacsOracle.Case] = [] 104 var cases: [EmacsOracle.Case] = []
100 var ours: [(label: String, text: String, caret: Int, failed: Bool)] = [] 105 var ours: [(label: String, text: String, caret: Int, failed: Bool)] = []
@@ -102,8 +107,8 @@ func compareWithEmacs(_ inputs: [(text: String, carets: [Int])], _ commands: [(c
102 for offset in carets { 107 for offset in carets {
103 let onHeadingLine = headingOnLine(at: offset, in: OrgParser.parse(text)) != nil 108 let onHeadingLine = headingOnLine(at: offset, in: OrgParser.parse(text)) != nil
104 for (command, form, headingLineOnly) in commands where onHeadingLine || !headingLineOnly { 109 for (command, form, headingLineOnly) in commands where onHeadingLine || !headingLineOnly {
105 cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(offset, in: text), form: form)) 110 cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(offset, in: text), form: withOptions(options, form)))
106 let result = runCommand(command, text, caret: offset) 111 let result = runCommand(command, text, caret: offset, options: options)
107 ours.append(("\(command.id) at \(offset) in \(text.debugDescription)", result.text, result.caret, result.failure != nil)) 112 ours.append(("\(command.id) at \(offset) in \(text.debugDescription)", result.text, result.caret, result.failure != nil))
108 } 113 }
109 } 114 }
Tests/OrgCoreTests/ListCommandTests.swift +6
@@ -48,6 +48,12 @@ struct ListOracleTests {
48 try compareWithEmacs(Self.variants.map { ($0, EmacsOracle.positions($0)) }, Self.commands) 48 try compareWithEmacs(Self.variants.map { ($0, EmacsOracle.positions($0)) }, Self.commands)
49 } 49 }
50 50
51 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
52 func listCommandsMatchEmacsWithDoomOptions() throws {
53 let alphabetical = ["a. x\nb. y\n", "A) one\n - sub\nB) two\n", "- [@c] x\n- y\n", "1. a\n2. b\n"]
54 try compareWithEmacs((Self.variants + alphabetical).map { ($0, EmacsOracle.positions($0)) }, Self.commands, options: doomOptions)
55 }
56
51 /// Real lists: `ORGSTAR_ORACLE_CORPUS=<folder>` runs every list command in files up to 57 /// Real lists: `ORGSTAR_ORACLE_CORPUS=<folder>` runs every list command in files up to
52 /// 20 KB with the caret at the start, bullet end and end of up to six item lines per file. 58 /// 20 KB with the caret at the start, bullet end and end of up to six item lines per file.
53 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"] != nil)) 59 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_ORACLE_CORPUS"] != nil))
Tests/OrgCoreTests/StructureCommandTests.swift +5
@@ -72,6 +72,11 @@ struct StructureOracleTests {
72 try compareWithEmacs(Self.variants.map { ($0, EmacsOracle.positions($0)) }, Self.commands) 72 try compareWithEmacs(Self.variants.map { ($0, EmacsOracle.positions($0)) }, Self.commands)
73 } 73 }
74 74
75 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
76 func structureCommandsMatchEmacsWithDoomOptions() throws {
77 try compareWithEmacs(Self.variants.map { ($0, EmacsOracle.positions($0)) }, Self.commands, options: doomOptions)
78 }
79
75 /// Real files: `ORGSTAR_ORACLE_CORPUS=<folder>` runs every structure command in files up to 80 /// Real files: `ORGSTAR_ORACLE_CORPUS=<folder>` runs every structure command in files up to
76 /// 20 KB, with the caret at the start and end of up to eight heading lines per file and on 81 /// 20 KB, with the caret at the start and end of up to eight heading lines per file and on
77 /// a body line, for up to 60 files. 82 /// a body line, for up to 60 files.
docs/plans/2026-10-05-options.md added +33
@@ -0,0 +1,33 @@
1# Editing Options 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:** The org options that change what commands write become settings, so Orgstar can follow the user's Emacs: `org-tags-column`, `org-insert-heading-respect-content`, `org-M-RET-may-split-line`, `org-list-allow-alphabetical`, and the default TODO keywords.
6
7**Architecture:** `EditingOptions` travels in `EditContext` and `EmacsBuffer`; tag alignment, heading and item insertion, and list bullets read it. Alphabetical lists add `org-list-use-alpha-bul-p` and the alphabetical branches of `org-list-struct-fix-bul`. Default TODO keywords come from a `#+TODO`-syntax line (`OrgSettings.withTodoKeywords`); the index settings version follows them, so changing them reindexes. The app keeps all of it in Settings.
8
9**Tech Stack:** Swift 6.2 tools, SwiftUI, Swift Testing, Emacs 31.1 / Org 9.8.7 oracle.
10
11**Spec:** `docs/design.md`, "Commands and keymaps".
12
13## Why
14
15The user's Emacs is Doom, which sets tags column 0, M-RET after the subtree without splitting, alphabetical lists, and the keywords TODO PROJ LOOP STRT WAIT HOLD IDEA | DONE KILL. Their notes use KILL and have no `#+TODO` lines; 499 of 522 tagged headings end their tags at column 77.
16
17## Defaults chosen (user may change)
18
19- Tags aligned to column 77 (as the files are), not Doom's 0.
20- M-RET after the subtree, no splitting, alphabetical lists, and Doom's main keyword sequence, as the user's Emacs does today.
21
22---
23
24### Task 1: Options in commands
25
26- [ ] `EditingOptions`; plumb through every command; alphabetical bullets.
27- [ ] Oracle: heading, structure and list suites again under the Doom options (`withOptions` binds the variables around each case), plus alphabetical list variants.
28- [ ] Commit "Make org editing options settings".
29
30### Task 2: App
31
32- [ ] Settings section; options to the editor and session; default keywords to documents and the index at launch.
33- [ ] Commit "Org options in Settings".