krz/orgstar

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

Commit 9bfa676f0c

9bfa676f0cc63e8c40f66859dc1b243db0efc3e2

parent: 8747c1880d

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-06 02:02 UTC

Fold drawers and blocks

- Drawers and blocks fold on their own, apart from heading folds, as
  org's drawer and block fold specs: TAB on a drawer's or block's first
  or last line toggles it; heading cycling leaves them as they are.
- Drawers fold when a file opens (org-cycle-hide-drawer-startup, on by
  default as with Doom's org-startup-folded nil); blocks with
  org-cycle-hide-block-startup. #+STARTUP hidedrawers, nohidedrawers,
  hideblocks, nohideblocks and showeverything apply.
- Shift-TAB show-all opens blocks, not drawers; overview and contents
  fold drawers before the first heading.
- Reveal (search, jumps) opens the drawer or block hiding the target.
- Edits map folded drawer and block ranges and recheck only those near
  the edit; a 3,000-entry gate file with 6,000 drawers is added.

Oracle tests compare visible text with Org 9.8.7.

Layout: unified · split

Sources/OrgApp/EmacsImport.swift +4 −1
@@ -447,8 +447,11 @@ public struct EmacsImport: Sendable {
447447 case "fill-column", "org-tags-column", "appt-message-warning-time":
448448 guard let n = value.integer else { return note("not a number") }
449449 add(name, .integer(n))
450 case "org-insert-heading-respect-content", "org-list-allow-alphabetical", "org-hide-emphasis-markers", "org-pretty-entities":
450 case "org-insert-heading-respect-content", "org-list-allow-alphabetical", "org-hide-emphasis-markers", "org-pretty-entities",
451 "org-cycle-hide-drawer-startup", "org-cycle-hide-block-startup":
451452 add(name, .bool(truthy))
453 case "org-hide-drawer-startup", "org-hide-block-startup":
454 add(name == "org-hide-drawer-startup" ? "org-cycle-hide-drawer-startup" : "org-cycle-hide-block-startup", .bool(truthy))
452455 case "org-M-RET-may-split-line":
453456 if let alist = value.elements, !alist.isEmpty {
454457 // `((default . nil))`: the default entry, as most configurations write it.
Sources/OrgApp/UserConfig.swift +4
@@ -78,6 +78,10 @@ public enum UserConfig {
7878 fallback: .string("nil"), comment: "nil, time (CLOSED when done) or note (CLOSED and a note)", legacy: ""),
7979 Setting(section: "", key: "org-log-into-drawer", defaultsKey: "logIntoDrawer", kind: .string, fallback: .string(""),
8080 comment: "the drawer state notes go in (\"LOGBOOK\" is Emacs's t); \"\" for none", legacy: ""),
81 Setting(section: "", key: "org-cycle-hide-drawer-startup", defaultsKey: "hideDrawerStartup", kind: .bool, fallback: .bool(true),
82 comment: "fold drawers when a file opens; #+STARTUP: nohidedrawers overrides", legacy: ""),
83 Setting(section: "", key: "org-cycle-hide-block-startup", defaultsKey: "hideBlockStartup", kind: .bool, fallback: .bool(false),
84 comment: "fold blocks when a file opens; #+STARTUP: hideblocks overrides", legacy: ""),
8185 Setting(section: "", key: "display-line-numbers-type", defaultsKey: "showLineNumbers", kind: .bool, fallback: .bool(true),
8286 comment: "line numbers (View ▸ Show Line Numbers)", legacy: "editor.line-numbers"),
8387 Setting(section: "", key: "org-agenda-span", defaultsKey: "agendaSpan", kind: .integer, fallback: .integer(10),
Sources/OrgCore/Commands/Command.swift +8 −1
@@ -19,6 +19,10 @@ public struct EditingOptions: Sendable, Equatable, Codable {
1919 public var logIntoDrawer: String?
2020 /// `org-use-fast-todo-selection` `auto`: with keys in the keywords, C-c C-t asks for one.
2121 public var fastTodoSelection: Bool
22 /// `org-cycle-hide-drawer-startup`: drawers are folded when a file opens.
23 public var hideDrawerStartup: Bool
24 /// `org-cycle-hide-block-startup`: blocks are folded when a file opens.
25 public var hideBlockStartup: Bool
2226 /// `fill-column` for M-q.
2327 public var fillColumn: Int
2428 /// `org-hide-emphasis-markers`: table alignment measures emphasis without its markers.
@@ -32,8 +36,11 @@ public struct EditingOptions: Sendable, Equatable, Codable {
3236 tagsColumn: Int = -77, insertHeadingRespectContent: Bool = false, metaReturnMaySplitLine: Bool = true,
3337 listAllowAlphabetical: Bool = false, logRepeat: Bool = true, fillColumn: Int = 70,
3438 hideEmphasisMarkers: Bool = false, prettyEntities: Bool = false, subSuperscriptsNeedBraces: Bool = false,
35 logDone: LogHow? = nil, logIntoDrawer: String? = nil, fastTodoSelection: Bool = true
39 logDone: LogHow? = nil, logIntoDrawer: String? = nil, fastTodoSelection: Bool = true,
40 hideDrawerStartup: Bool = true, hideBlockStartup: Bool = false
3641 ) {
42 self.hideDrawerStartup = hideDrawerStartup
43 self.hideBlockStartup = hideBlockStartup
3744 self.fastTodoSelection = fastTodoSelection
3845 self.logDone = logDone
3946 self.logIntoDrawer = logIntoDrawer
Sources/OrgCore/Keymap/Keymap.swift +3 −1
@@ -130,7 +130,7 @@ public struct Keymap: Sendable, Equatable {
130130
131131/// Context names a binding's `when` can use.
132132public enum KeyContext {
133 public static let names: Set<String> = ["heading", "table", "item", "region", "timestamp", "tblfm", "src"]
133 public static let names: Set<String> = ["heading", "table", "item", "region", "timestamp", "tblfm", "src", "fold-line"]
134134
135135 public static func holds(_ name: String, in context: EditContext) -> Bool {
136136 switch name {
@@ -141,6 +141,8 @@ public enum KeyContext {
141141 case "timestamp": return EmacsBuffer(context.text, point: context.caret).atTimestamp() != nil
142142 case "tblfm": return TableRecalculate.tblfmLine(in: context) != nil
143143 case "src": return enclosing(.block, context).map(DocumentModel.isSrcBlock) ?? false
144 // The first or last line of a drawer or block, where TAB folds it.
145 case "fold-line": return Wrappers.toggleable(at: context.caret, in: context.tree) != nil
144146 default: return false
145147 }
146148 }
Sources/OrgCore/Keymap/Presets.swift +4
@@ -450,6 +450,10 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
450450 keys = "TAB"
451451 command = "org.cycle"
452452 when = "heading"
453 [[bind]]
454 keys = "TAB"
455 command = "org.cycle"
456 when = "fold-line"
453457
454458 [[bind]]
455459 keys = "S-TAB"
Sources/OrgCore/Syntax/Wrappers.swift added +77
@@ -0,0 +1,77 @@
1/// Drawers and blocks, the elements org folds on their own (`org-fold-hide-drawer-toggle`,
2/// `org-fold-hide-block-toggle`).
3public struct Wrapper: Sendable, Equatable {
4 public enum Kind: Sendable, Equatable { case drawer, block }
5
6 public let kind: Kind
7 /// Start of the opening line (`:PROPERTIES:`, `#+begin_src`).
8 public let start: Int
9 /// Start of the closing line (`:END:`, `#+end_src`).
10 public let closeLine: Int
11 /// What folding hides: every line after the opening one, through the closing line.
12 public let hidden: Range<Int>
13}
14
15public enum Wrappers {
16 static func kind(_ kind: SyntaxKind) -> Wrapper.Kind? {
17 switch kind {
18 case .drawer, .propertyDrawer: .drawer
19 case .block, .dynamicBlock: .block
20 default: nil
21 }
22 }
23
24 static func wrapper(_ node: SyntaxNode) -> Wrapper? {
25 guard let kind = kind(node.kind) else { return nil }
26 let units = Array(node.text.utf16)
27 // Blank lines after the element belong to it in the tree, not to the fold.
28 var end = units.count
29 while end > 0, [9, 10, 13, 32].contains(units[end - 1]) { end -= 1 }
30 guard let firstNewline = units.firstIndex(of: 10), firstNewline < end else { return nil }
31 var closeStart = end
32 while closeStart > 0, units[closeStart - 1] != 10 { closeStart -= 1 }
33 let hiddenEnd = end < units.count ? end + 1 : end
34 return Wrapper(kind: kind, start: node.range.lowerBound, closeLine: node.range.lowerBound + closeStart,
35 hidden: (node.range.lowerBound + firstNewline + 1)..<(node.range.lowerBound + hiddenEnd))
36 }
37
38 /// Elements a drawer or block can sit in.
39 static let containers: Set<SyntaxKind> = [.document, .zerothSection, .section, .drawer, .block, .dynamicBlock, .plainList, .item, .footnoteDefinition]
40
41 /// Every drawer and block, in document order, nested ones included.
42 public static func all(_ tree: OrgTree) -> [Wrapper] {
43 var result: [Wrapper] = []
44 func walk(_ node: SyntaxNode) {
45 for child in node.children {
46 if let wrapper = wrapper(child) { result.append(wrapper) }
47 if containers.contains(child.kind) { walk(child) }
48 }
49 }
50 walk(tree.root)
51 return result
52 }
53
54 /// The drawer or block whose opening line starts at `offset`.
55 public static func starting(at offset: Int, in tree: OrgTree) -> Wrapper? {
56 var node = tree.root
57 while let child = node.child(containing: offset) {
58 if child.range.lowerBound == offset, let wrapper = wrapper(child) { return wrapper }
59 node = child
60 }
61 return nil
62 }
63
64 /// The innermost drawer or block whose opening or closing line holds `offset`, the lines
65 /// TAB toggles it from.
66 public static func toggleable(at offset: Int, in tree: OrgTree) -> Wrapper? {
67 var node = tree.root
68 var found: Wrapper?
69 while let child = node.child(containing: offset) {
70 if let wrapper = wrapper(child), offset < wrapper.hidden.lowerBound || (wrapper.closeLine..<wrapper.hidden.upperBound).contains(offset) {
71 found = wrapper
72 }
73 node = child
74 }
75 return found
76 }
77}
Sources/OrgDocument/ViewState.swift +12 −6
@@ -7,11 +7,14 @@ public struct ViewState: Sendable, Equatable {
77 public var folds: Set<Int>
88 /// Start offsets of headings whose own body is folded; their child headings stay visible.
99 public var bodyFolds: Set<Int>
10 /// Opening-line starts of folded drawers and blocks.
11 public var wrapperFolds: Set<Int>
1012
11 public init(selection: [Range<Int>] = [0..<0], folds: Set<Int> = [], bodyFolds: Set<Int> = []) {
13 public init(selection: [Range<Int>] = [0..<0], folds: Set<Int> = [], bodyFolds: Set<Int> = [], wrapperFolds: Set<Int> = []) {
1214 self.selection = selection
1315 self.folds = folds
1416 self.bodyFolds = bodyFolds
17 self.wrapperFolds = wrapperFolds
1518 }
1619
1720 /// Maps through non-overlapping edits given in old coordinates.
@@ -20,17 +23,20 @@ public struct ViewState: Sendable, Equatable {
2023 return ViewState(
2124 selection: selection.map { mapOffset($0.lowerBound, sorted)..<mapOffset($0.upperBound, sorted) },
2225 folds: Set(folds.map { mapOffset($0, sorted) }),
23 bodyFolds: Set(bodyFolds.map { mapOffset($0, sorted) })
26 bodyFolds: Set(bodyFolds.map { mapOffset($0, sorted) }),
27 wrapperFolds: Set(wrapperFolds.map { mapOffset($0, sorted) })
2428 )
2529 }
2630
27 /// Drops folds that no longer sit at the start of a heading.
28 public func pruned(to tree: OrgTree) -> ViewState {
29 guard !folds.isEmpty || !bodyFolds.isEmpty else { return self }
31 /// Drops folds that no longer sit at the start of a heading, drawer or block.
32 /// Without `wrappers`, drawer and block folds are left for the caller to check.
33 public func pruned(to tree: OrgTree, wrappers: Bool = true) -> ViewState {
34 guard !folds.isEmpty || !bodyFolds.isEmpty || (wrappers && !wrapperFolds.isEmpty) else { return self }
3035 return ViewState(
3136 selection: selection,
3237 folds: folds.filter { isHeadingStart($0, tree.root) },
33 bodyFolds: bodyFolds.filter { isHeadingStart($0, tree.root) }
38 bodyFolds: bodyFolds.filter { isHeadingStart($0, tree.root) },
39 wrapperFolds: wrappers ? wrapperFolds.filter { Wrappers.starting(at: $0, in: tree) != nil } : wrapperFolds
3440 )
3541 }
3642}
Sources/OrgEditorAppKit/OrgEditor.swift +63 −13
@@ -102,9 +102,10 @@ public final class OrgEditor: NSObject {
102102
103103 public init(
104104 document: DocumentState, frame: NSRect = NSRect(x: 0, y: 0, width: 600, height: 800), editable: Bool = true,
105 highlighter: (any CodeHighlighter)? = nil, mode: Mode = .org
105 highlighter: (any CodeHighlighter)? = nil, mode: Mode = .org, options: EditingOptions = .org
106106 ) {
107107 self.document = document
108 self.options = options
108109 self.highlighter = highlighter
109110 self.mode = mode
110111 let orgTextView = OrgTextView(usingTextLayoutManager: true)
@@ -142,7 +143,12 @@ public final class OrgEditor: NSObject {
142143
143144 if isOrg { shaded.set(Presentation.shadedBlocks(document.tree)) }
144145 load(document.text)
145 if isOrg, let startup = Presentation.startupFoldState(document.tree) { setFolds(startup) }
146 if isOrg {
147 var startup = Presentation.startupFoldState(document.tree) ?? FoldState()
148 let wrappers = Presentation.startupWrappers(document.tree, hideDrawers: options.hideDrawerStartup, hideBlocks: options.hideBlockStartup)
149 startup.wrapperFolds = Set(wrappers.map(\.start))
150 if startup != FoldState() { setFolds(startup, wrappers: wrappers) }
151 }
146152 }
147153
148154 /// Replaces the storage's text. Setting the attributed string directly is about 100x
@@ -361,6 +367,7 @@ public final class OrgEditor: NSObject {
361367 return switch EditorCommands.command(id)?.action {
362368 case .text(let command): command.applies(in: context)
363369 case .view: id != "org.cycle" || Presentation.heading(containing: context.caret, in: context.tree) != nil
370 || Wrappers.toggleable(at: context.caret, in: context.tree) != nil
364371 case .edit, .mark, .app, .find: true
365372 case nil: true
366373 }
@@ -459,15 +466,26 @@ public final class OrgEditor: NSObject {
459466 // MARK: - Folding
460467
461468 public var foldState: FoldState {
462 FoldState(folds: view.folds, bodyFolds: view.bodyFolds)
469 FoldState(folds: view.folds, bodyFolds: view.bodyFolds, wrapperFolds: view.wrapperFolds)
463470 }
464471
465472 private var globalVisibility: GlobalVisibility?
466473
467474 public func setFolds(_ state: FoldState) {
475 setFolds(state, wrappers: nil)
476 }
477
478 /// `wrappers`, when given, holds every folded drawer and block.
479 private func setFolds(_ state: FoldState, wrappers: [Wrapper]?) {
468480 view.folds = state.folds
469481 view.bodyFolds = state.bodyFolds
470 view = view.pruned(to: document.tree)
482 view = view.pruned(to: document.tree, wrappers: false)
483 // Found one by one, or all at once when there are many.
484 let found = wrappers ?? (state.wrapperFolds.count > 32
485 ? Wrappers.all(document.tree).filter { state.wrapperFolds.contains($0.start) }
486 : state.wrapperFolds.compactMap { Wrappers.starting(at: $0, in: document.tree) })
487 wrapperHidden = Dictionary(found.map { ($0.start, $0.hidden) }) { a, _ in a }
488 view.wrapperFolds = Set(wrapperHidden.keys)
471489 updateHidden()
472490 orgTextView?.redrawLineNumbers()
473491 textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange)
@@ -476,22 +494,50 @@ public final class OrgEditor: NSObject {
476494 if let range = hidden.containing(caret) { setCaret(range.lowerBound - 1) }
477495 }
478496
497 /// What each folded drawer and block hides, by opening-line start; kept in step with
498 /// `view.wrapperFolds` and mapped through edits.
499 private var wrapperHidden: [Int: Range<Int>] = [:]
500
479501 private func updateHidden() {
480 folded.set(view.folds.union(view.bodyFolds))
481 hidden.set(Presentation.hiddenRanges(document.tree, folds: view.folds, bodyFolds: view.bodyFolds))
502 folded.set(view.folds.union(view.bodyFolds).union(view.wrapperFolds))
503 hidden.set(Presentation.hiddenRanges(document.tree, folds: view.folds, bodyFolds: view.bodyFolds, wrapperRanges: Array(wrapperHidden.values)))
504 }
505
506 /// Moves drawer and block folds through `edit`, checking again only those near it.
507 private func mapWrapperFolds(through edit: TextEdit, near affected: Range<Int>) {
508 guard !wrapperHidden.isEmpty else { return }
509 var mapped: [Int: Range<Int>] = [:]
510 let entries = Array(wrapperHidden)
511 // One pass maps every start and range.
512 let moved = ViewState(selection: entries.flatMap { [$0.key..<$0.key, $0.value] }).mapped(through: [edit]).selection
513 for i in entries.indices {
514 let newStart = moved[2 * i].lowerBound
515 let newRange = moved[2 * i + 1]
516 if newStart <= affected.upperBound, newRange.upperBound >= affected.lowerBound {
517 if let wrapper = Wrappers.starting(at: newStart, in: document.tree) { mapped[newStart] = wrapper.hidden }
518 } else {
519 mapped[newStart] = newRange
520 }
521 }
522 wrapperHidden = mapped
523 view.wrapperFolds = Set(mapped.keys)
482524 }
483525
484 /// TAB on a heading: folded, children, subtree. Returns false off a heading.
526 /// TAB on the first or last line of a drawer or block folds or unfolds it; on a heading,
527 /// cycles folded, children, subtree. Returns false elsewhere.
485528 @discardableResult
486529 public func cycleAtCaret() -> Bool {
487 guard let heading = Presentation.heading(containing: textView.selectedRange().location, in: document.tree) else { return false }
488 setFolds(Presentation.cycle(heading, in: document.tree, foldState))
530 let caret = textView.selectedRange().location
531 guard let state = Presentation.cycle(at: caret, in: document.tree, foldState) else { return false }
532 setFolds(state)
533 // Off a closing line that folding hid, to the opening line, as org does.
534 if let wrapper = Wrappers.toggleable(at: caret, in: document.tree), hidden.containing(caret) != nil { setCaret(wrapper.start) }
489535 return true
490536 }
491537
492538 /// Shift-TAB: overview, contents, show all.
493539 public func cycleGlobal() {
494 let (visibility, state) = Presentation.cycleGlobal(from: globalVisibility, in: document.tree)
540 let (visibility, state) = Presentation.cycleGlobal(from: globalVisibility, in: document.tree, wrapperFolds: view.wrapperFolds)
495541 globalVisibility = visibility
496542 setFolds(state)
497543 }
@@ -511,6 +557,9 @@ public final class OrgEditor: NSObject {
511557 state.folds.remove(entry.start)
512558 state.bodyFolds.remove(entry.start)
513559 }
560 for start in state.wrapperFolds where Wrappers.starting(at: start, in: document.tree)?.hidden.contains(offset) == true {
561 state.wrapperFolds.remove(start)
562 }
514563 guard state != foldState else { return }
515564 setFolds(state)
516565 }
@@ -773,11 +822,12 @@ public final class OrgEditor: NSObject {
773822 styledUpTo = (storage.string as NSString).lineRange(for: NSRange(location: edit.range.lowerBound, length: 0)).location
774823 }
775824 let oldHidden = hidden.all
776 view = view.mapped(through: [edit]).pruned(to: document.tree)
777 updateHidden()
778825 let newAffected = Self.affectedRange(document.tree, newText, edit.range.lowerBound, newEnd, touchedHeadings: touchedHeadings)
779826 let delta = edit.replacement.utf16.count - edit.range.count
780827 let oldMapped = oldAffected.lowerBound..<(oldAffected.upperBound >= edit.range.upperBound ? oldAffected.upperBound + delta : newEnd)
828 view = view.mapped(through: [edit]).pruned(to: document.tree, wrappers: false)
829 mapWrapperFolds(through: edit, near: min(oldMapped.lowerBound, newAffected.lowerBound)..<max(oldMapped.upperBound, newAffected.upperBound))
830 updateHidden()
781831 restyle(min(oldMapped.lowerBound, newAffected.lowerBound)..<min(max(oldMapped.upperBound, newAffected.upperBound), newText.length), in: storage)
782832 let oldShaded = shaded.all
783833 if isOrg {
@@ -894,7 +944,7 @@ extension OrgEditor: DocumentBuffer {
894944 state.selection = [selected.location..<NSMaxRange(selected)]
895945 let mapped = state.mapped(through: edits)
896946 load(document.text)
897 setFolds(FoldState(folds: mapped.folds, bodyFolds: mapped.bodyFolds))
947 setFolds(FoldState(folds: mapped.folds, bodyFolds: mapped.bodyFolds, wrapperFolds: mapped.wrapperFolds))
898948 let selection = mapped.selection[0]
899949 setCaret(selection.lowerBound)
900950 if !selection.isEmpty { textView.setSelectedRange(NSRange(selection)) }
Sources/OrgPresentation/Folding.swift +57 −5
@@ -1,13 +1,18 @@
11import OrgCore
22
3/// Which headings are folded: whole subtrees, or only their own bodies.
3/// Which headings are folded, whole subtrees or only their own bodies, and which drawers and
4/// blocks. Drawer and block folds are kept apart, as org's `drawer` and `block` fold specs:
5/// cycling headings leaves them as they are.
46public struct FoldState: Sendable, Equatable {
57 public var folds: Set<Int>
68 public var bodyFolds: Set<Int>
9 /// Opening-line starts of folded drawers and blocks.
10 public var wrapperFolds: Set<Int>
711
8 public init(folds: Set<Int> = [], bodyFolds: Set<Int> = []) {
12 public init(folds: Set<Int> = [], bodyFolds: Set<Int> = [], wrapperFolds: Set<Int> = []) {
913 self.folds = folds
1014 self.bodyFolds = bodyFolds
15 self.wrapperFolds = wrapperFolds
1116 }
1217}
1318
@@ -82,14 +87,61 @@ extension Presentation {
8287 return next
8388 }
8489
85 /// Shift-TAB: overview, contents, show all, and around.
86 public static func cycleGlobal(from current: GlobalVisibility?, in tree: OrgTree) -> (GlobalVisibility, FoldState) {
90 /// TAB at `offset` (`org-cycle`): on the first or last line of a drawer or block, folds or
91 /// unfolds it; on a heading, cycles it. Nil elsewhere.
92 public static func cycle(at offset: Int, in tree: OrgTree, _ state: FoldState) -> FoldState? {
93 if let wrapper = Wrappers.toggleable(at: offset, in: tree) {
94 var next = state
95 if next.wrapperFolds.remove(wrapper.start) == nil { next.wrapperFolds.insert(wrapper.start) }
96 return next
97 }
98 return heading(containing: offset, in: tree).map { cycle($0, in: tree, state) }
99 }
100
101 /// Shift-TAB: overview, contents, show all, and around. Drawer and block folds carry over
102 /// from `wrapperFolds`: show all opens blocks, and overview and contents fold the drawers
103 /// before the first heading (`org-cycle-hide-drawers`).
104 public static func cycleGlobal(from current: GlobalVisibility?, in tree: OrgTree, wrapperFolds: Set<Int> = []) -> (GlobalVisibility, FoldState) {
87105 let next: GlobalVisibility = switch current {
88106 case .overview: .contents
89107 case .contents: .showAll
90108 case .showAll, nil: .overview
91109 }
92 return (next, foldState(for: next, in: tree))
110 var state = foldState(for: next, in: tree)
111 let wrappers = Wrappers.all(tree)
112 switch next {
113 case .showAll:
114 let blocks = Set(wrappers.filter { $0.kind == .block }.map(\.start))
115 state.wrapperFolds = wrapperFolds.subtracting(blocks)
116 case .overview, .contents:
117 let firstHeading = outline(tree).first?.start ?? Int.max
118 state.wrapperFolds = wrapperFolds.union(wrappers.filter { $0.kind == .drawer && $0.start < firstHeading }.map(\.start))
119 }
120 return (next, state)
121 }
122
123 /// The drawers and blocks folded when a file opens: drawers unless `org-cycle-hide-drawer-startup`
124 /// is off, blocks when `org-cycle-hide-block-startup` is on; `#+STARTUP` (`hidedrawers`,
125 /// `nohidedrawers`, `hideblocks`, `nohideblocks`, `showeverything`) overrides both.
126 public static func startupWrapperFolds(_ tree: OrgTree, hideDrawers: Bool = true, hideBlocks: Bool = false) -> Set<Int> {
127 Set(startupWrappers(tree, hideDrawers: hideDrawers, hideBlocks: hideBlocks).map(\.start))
128 }
129
130 public static func startupWrappers(_ tree: OrgTree, hideDrawers: Bool = true, hideBlocks: Bool = false) -> [Wrapper] {
131 var drawers = hideDrawers
132 var blocks = hideBlocks
133 for option in startupOptions(tree) {
134 switch option.lowercased() {
135 case "hidedrawers": drawers = true
136 case "nohidedrawers": drawers = false
137 case "hideblocks": blocks = true
138 case "nohideblocks": blocks = false
139 case "showeverything": return []
140 default: break
141 }
142 }
143 guard drawers || blocks else { return [] }
144 return Wrappers.all(tree).filter { $0.kind == .drawer ? drawers : blocks }
93145 }
94146
95147 public static func foldState(for visibility: GlobalVisibility, in tree: OrgTree) -> FoldState {
Sources/OrgPresentation/Presentation.swift +15 −4
@@ -210,11 +210,22 @@ public enum Presentation {
210210 /// Ranges to hide. A heading in `folds` hides everything after its line to the end of its
211211 /// section; one in `bodyFolds` hides only its own body, up to its first child heading.
212212 /// Sorted, without nested duplicates.
213 public static func hiddenRanges(_ tree: OrgTree, folds: Set<Int>, bodyFolds: Set<Int> = []) -> [Range<Int>] {
214 guard !folds.isEmpty || !bodyFolds.isEmpty else { return [] }
213 /// A drawer or block in `wrapperFolds` hides the lines after its opening line.
214 public static func hiddenRanges(_ tree: OrgTree, folds: Set<Int>, bodyFolds: Set<Int> = [], wrapperFolds: Set<Int> = []) -> [Range<Int>] {
215 hiddenRanges(tree, folds: folds, bodyFolds: bodyFolds, wrapperRanges: wrapperFolds.compactMap { Wrappers.starting(at: $0, in: tree)?.hidden })
216 }
217
218 /// The same, with the folded drawers' and blocks' hidden ranges already found.
219 public static func hiddenRanges(_ tree: OrgTree, folds: Set<Int>, bodyFolds: Set<Int>, wrapperRanges: [Range<Int>]) -> [Range<Int>] {
220 guard !folds.isEmpty || !bodyFolds.isEmpty || !wrapperRanges.isEmpty else { return [] }
215221 var ranges: [Range<Int>] = []
216 collectHidden(tree.root, folds, bodyFolds, &ranges)
217 return ranges
222 if !folds.isEmpty || !bodyFolds.isEmpty { collectHidden(tree.root, folds, bodyFolds, &ranges) }
223 guard !wrapperRanges.isEmpty else { return ranges }
224 ranges += wrapperRanges
225 ranges.sort { $0.lowerBound != $1.lowerBound ? $0.lowerBound < $1.lowerBound : $0.upperBound > $1.upperBound }
226 var merged: [Range<Int>] = []
227 for range in ranges where !(merged.last.map { $0.upperBound >= range.upperBound } ?? false) { merged.append(range) }
228 return merged
218229 }
219230
220231 private static func collectHidden(_ node: SyntaxNode, _ folds: Set<Int>, _ bodyFolds: Set<Int>, _ ranges: inout [Range<Int>]) {
Sources/Orgstar/ContentView.swift +4 −1
@@ -36,11 +36,14 @@ struct ContentView: View {
3636 @AppStorage("prettyEntities") private var prettyEntities = true
3737 @AppStorage("logDone") private var logDone = "nil"
3838 @AppStorage("logIntoDrawer") private var logIntoDrawer = ""
39 @AppStorage("hideDrawerStartup") private var hideDrawerStartup = true
40 @AppStorage("hideBlockStartup") private var hideBlockStartup = false
3941
4042 private var options: EditingOptions {
4143 Preferences.options(
4244 tagsColumn: tagsColumn, headingAfterSubtree: headingAfterSubtree, splitLine: splitLine, alphabeticalLists: alphabeticalLists,
43 fillColumn: fillColumn, hiddenMarkers: hiddenMarkers, prettyEntities: prettyEntities, logDone: logDone, logIntoDrawer: logIntoDrawer
45 fillColumn: fillColumn, hiddenMarkers: hiddenMarkers, prettyEntities: prettyEntities, logDone: logDone, logIntoDrawer: logIntoDrawer,
46 hideDrawerStartup: hideDrawerStartup, hideBlockStartup: hideBlockStartup
4447 )
4548 }
4649 @State private var selectedFile: String?
Sources/Orgstar/EditorView.swift +1 −1
@@ -83,7 +83,7 @@ struct EditorView: NSViewRepresentable {
8383 private func makeEditor(_ container: NSView) -> (editor: OrgEditor, scroll: NSScrollView)? {
8484 guard let document = session.document else { return nil }
8585 let mode: OrgEditor.Mode = session.isOrg ? .org : .plain(language: session.path.flatMap(FileTypes.language))
86 let editor = OrgEditor(document: document, frame: container.bounds, editable: document.isEditable, highlighter: Self.highlighter, mode: mode)
86 let editor = OrgEditor(document: document, frame: container.bounds, editable: document.isEditable, highlighter: Self.highlighter, mode: mode, options: options)
8787 let path = session.path ?? ""
8888 editor.onChange = { [weak session] in session?.bufferChanged(path) }
8989 editor.onMessage = { [weak commands] in commands?.show($0) }
Sources/Orgstar/Preferences.swift +4 −2
@@ -17,13 +17,15 @@ enum Preferences {
1717
1818 static func options(
1919 tagsColumn: Int, headingAfterSubtree: Bool, splitLine: Bool, alphabeticalLists: Bool, fillColumn: Int,
20 hiddenMarkers: Bool, prettyEntities: Bool, logDone: String, logIntoDrawer: String
20 hiddenMarkers: Bool, prettyEntities: Bool, logDone: String, logIntoDrawer: String,
21 hideDrawerStartup: Bool, hideBlockStartup: Bool
2122 ) -> EditingOptions {
2223 EditingOptions(
2324 tagsColumn: tagsColumn, insertHeadingRespectContent: headingAfterSubtree,
2425 metaReturnMaySplitLine: splitLine, listAllowAlphabetical: alphabeticalLists, fillColumn: fillColumn,
2526 hideEmphasisMarkers: hiddenMarkers, prettyEntities: prettyEntities, subSuperscriptsNeedBraces: true,
26 logDone: LogHow(rawValue: logDone), logIntoDrawer: logIntoDrawer.isEmpty ? nil : logIntoDrawer
27 logDone: LogHow(rawValue: logDone), logIntoDrawer: logIntoDrawer.isEmpty ? nil : logIntoDrawer,
28 hideDrawerStartup: hideDrawerStartup, hideBlockStartup: hideBlockStartup
2729 )
2830 }
2931}
Tests/OrgEditorAppKitTests/EditorTests.swift +37
@@ -147,6 +147,43 @@ struct FoldingTests {
147147 #expect(pasteboard.string(forType: .string) == "* a\nbody\n** b\nmore\n* c\n")
148148 }
149149
150 @Test func drawersFoldAtOpenAndToggleWithTab() {
151 let text = "* a\n:PROPERTIES:\n:ID: x\n:END:\nbody\n#+begin_src sh\necho\n#+end_src\n* c\n"
152 let h = Harness(text)
153 #expect(h.visibleLines == ["* a\n", ":PROPERTIES:\n", "body\n", "#+begin_src sh\n", "echo\n", "#+end_src\n", "* c\n"])
154 h.caret(at: h.offset(of: ":PROPERTIES:"))
155 #expect(h.editor.cycleAtCaret())
156 #expect(h.visibleLines.count == 9)
157 h.caret(at: h.offset(of: "#+end_src"))
158 #expect(h.editor.cycleAtCaret())
159 #expect(h.visibleLines == ["* a\n", ":PROPERTIES:\n", ":ID: x\n", ":END:\n", "body\n", "#+begin_src sh\n", "* c\n"])
160 #expect(h.textView.selectedRange().location == h.offset(of: "#+begin_src"))
161 // Heading cycling leaves the block folded.
162 h.caret(at: 1)
163 h.editor.cycleAtCaret()
164 h.editor.cycleAtCaret()
165 #expect(h.visibleLines.contains("#+begin_src sh\n") && !h.visibleLines.contains("echo\n"))
166 #expect(Harness("#+STARTUP: nohidedrawers\n" + text).visibleLines.contains(":ID: x\n"))
167 let folded = Harness(text)
168 folded.editor.reveal(folded.offset(of: ":ID: x") + 1)
169 #expect(folded.visibleLines.contains(":ID: x\n"))
170 }
171
172 @Test func drawerFoldsFollowEdits() {
173 let h = Harness("* a\n:PROPERTIES:\n:ID: x\n:END:\nbody\n* b\n:LOGBOOK:\nl\n:END:\n")
174 h.caret(at: h.offset(of: "body") + 4)
175 h.textView.insertText(" more", replacementRange: h.textView.selectedRange())
176 h.textView.insertNewline(nil)
177 h.caret(at: 2)
178 h.textView.insertText("new ", replacementRange: h.textView.selectedRange())
179 #expect(h.visibleLines == ["* new a\n", ":PROPERTIES:\n", "body more\n", "\n", "* b\n", ":LOGBOOK:\n"])
180 // Renaming the drawer's opening line keeps a drawer, and its fold.
181 h.caret(at: h.offset(of: ":LOGBOOK:") + 1)
182 h.textView.insertText("X", replacementRange: h.textView.selectedRange())
183 #expect(h.visibleLines.last == ":XLOGBOOK:\n")
184 h.checkInSync()
185 }
186
150187 @Test func foldsFollowEdits() {
151188 let h = Harness(outline)
152189 h.caret(at: h.offset(of: "* c") + 1)
Tests/OrgEditorAppKitTests/GateTests.swift +12 −1
@@ -15,6 +15,17 @@ import Testing
1515@Suite(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_GATES"] != nil))
1616struct GateTests {
1717 /// About 1 MB of body text under a single top-level heading.
18 /// Entries with property and LOGBOOK drawers, all folded when the file opens.
19 static let drawers: String = {
20 var text = ""
21 for n in 0..<3_000 {
22 text += "* TODO Entry \(n) :tag:\n:PROPERTIES:\n:ID: \(n)-uuid\n:CREATED: [2026-10-04 Sun]\n:END:\n"
23 text += ":LOGBOOK:\n- State \"DONE\" from \"TODO\" [2026-10-04 Sun 10:00]\nCLOCK: [2026-10-04 Sun 09:00]--[2026-10-04 Sun 10:00] => 1:00\n:END:\n"
24 text += "Body of entry \(n), a paragraph of text.\n\n"
25 }
26 return text
27 }()
28
1829 static let singleSection: String = {
1930 var text = "* One big section\n"
2031 var n = 0
@@ -88,7 +99,7 @@ struct GateTests {
8899 // per process; an app pays that at launch, not per file.
89100 NSTextView(usingTextLayoutManager: true).textStorage?.setAttributedString(NSAttributedString(string: "warm up"))
90101 // Memory limits: worst-case dense markup for the synthetic files, typical for real ones.
91 var inputs = [("single 1 MB section", Self.singleSection, 45.0), ("5,000-row table", Self.bigTable, 45.0)]
102 var inputs = [("single 1 MB section", Self.singleSection, 45.0), ("5,000-row table", Self.bigTable, 45.0), ("3,000 entries with drawers", Self.drawers, 45.0)]
92103 if let path = ProcessInfo.processInfo.environment["ORGSTAR_GATE_FILE"] {
93104 inputs.append((URL(fileURLWithPath: path).lastPathComponent, try String(contentsOfFile: path, encoding: .utf8), 10.0))
94105 }
Tests/OrgPresentationTests/WrapperFoldTests.swift added +119
@@ -0,0 +1,119 @@
1import Foundation
2import Testing
3import OrgCore
4@testable import OrgPresentation
5
6/// Drawer and block folding against Emacs: the visible text after opening a file and after
7/// each TAB or Shift-TAB, with Doom's `org-startup-folded` nil.
8struct WrapperFoldTests {
9 static let texts = [
10 "* A\n:PROPERTIES:\n:ID: x\n:END:\nbody\n#+begin_src sh\necho\n#+end_src\n* B\n:LOGBOOK:\n- State \"DONE\" from \"TODO\"\n:END:\ntext\n",
11 "#+STARTUP: hideblocks\n:PROPERTIES:\n:CATEGORY: c\n:END:\n* A\n#+begin_quote\nq\n#+end_quote\n\n#+begin_example\ne\n#+end_example\n",
12 "#+STARTUP: nohidedrawers\n* A\n:NOTES:\nn\n:END:\n#+begin_src sh\n#+end_src\n",
13 "#+STARTUP: overview\n* A\n:PROPERTIES:\n:X: 1\n:END:\n** B\n:LOGBOOK:\nl\n:END:\n",
14 "* A\n:DRAWER:\n#+begin_src sh\necho\n#+end_src\n:END:\n",
15 ]
16
17 /// `T:text` presses TAB at the start of `text`; `G` presses Shift-TAB. Emacs cycles a heading
18 /// by the previous command and Orgstar by its fold state; they agree on repeated TABs on
19 /// one heading, so steps don't go back to a heading after other steps.
20 static let steps = [
21 ["T::PROPERTIES:", "T::PROPERTIES:", "T:#+begin_src", "T:#+end_src", "T:* A", "T:* A", "G", "G", "G", "G"],
22 ["T::END:", "T:#+begin_quote", "T:#+end_quote", "T:q\n", "G", "G", "G"],
23 ["T::NOTES:", "T::END:", "T:#+begin_src", "G", "G", "G"],
24 ["T:* A", "T:* A", "T::LOGBOOK:", "G", "G", "G"],
25 ["T::DRAWER:", "T::DRAWER:", "T:#+begin_src", "T::DRAWER:", "T::DRAWER:"],
26 ]
27
28 static func visible(_ text: String, _ hidden: [Range<Int>]) -> String {
29 let units = Array(text.utf16)
30 var out: [UInt16] = []
31 for (i, unit) in units.enumerated() where !hidden.contains(where: { $0.contains(i) }) { out.append(unit) }
32 return String(decoding: out, as: UTF16.self)
33 }
34
35 static func ours(_ text: String, _ steps: [String]) -> [String] {
36 let tree = OrgParser.parse(text)
37 var state = Presentation.startupFoldState(tree) ?? FoldState()
38 state.wrapperFolds = Presentation.startupWrapperFolds(tree)
39 var global: GlobalVisibility?
40 func show() -> String {
41 visible(text, Presentation.hiddenRanges(tree, folds: state.folds, bodyFolds: state.bodyFolds, wrapperFolds: state.wrapperFolds))
42 }
43 var out = [show()]
44 for step in steps {
45 if step == "G" {
46 let (next, folds) = Presentation.cycleGlobal(from: global, in: tree, wrapperFolds: state.wrapperFolds)
47 global = next
48 state = folds
49 } else {
50 global = nil
51 let offset = (text as NSString).range(of: String(step.dropFirst(2))).location
52 state = Presentation.cycle(at: offset, in: tree, state) ?? state
53 }
54 out.append(show())
55 }
56 return out
57 }
58
59 static func lisp(_ s: String) -> String {
60 "\"" + s.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: "\"", with: "\\\"") + "\""
61 }
62
63 static func emacs(_ text: String, _ steps: [String]) throws -> [String] {
64 let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-fold-\(UUID().uuidString)")
65 try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
66 defer { try? FileManager.default.removeItem(at: folder) }
67 let file = folder.appendingPathComponent("f.org")
68 try text.write(to: file, atomically: true, encoding: .utf8)
69 let output = folder.appendingPathComponent("out.json")
70 let actions = steps.enumerated().map { i, step in
71 let repeated = i > 0 && steps[i - 1] == step
72 return step == "G"
73 ? "(progn (setq this-command 'org-shifttab) (org-cycle '(4)) (setq last-command 'org-shifttab))"
74 : "(progn (setq last-command \(repeated ? "'org-cycle" : "nil") this-command 'org-cycle) (goto-char (point-min)) (search-forward \(lisp(String(step.dropFirst(2))))) (goto-char (match-beginning 0)) (org-cycle))"
75 }
76 let script = folder.appendingPathComponent("fold.el")
77 try """
78 ;;; -*- lexical-binding: t -*-
79 (require 'org)
80 (require 'json)
81 (setq org-startup-folded nil)
82 (defun vis () (let (s) (dotimes (i (1- (point-max))) (unless (invisible-p (1+ i)) (push (char-after (1+ i)) s))) (concat (nreverse s))))
83 (with-current-buffer (find-file-noselect "\(file.path)")
84 (let ((out (list (vis))))
85 \(actions.map { "(progn \($0) (push (vis) out))" }.joined(separator: "\n "))
86 (with-temp-file (getenv "ORACLE_OUTPUT") (insert (json-encode (vconcat (nreverse out)))))))
87 """.write(to: script, atomically: true, encoding: .utf8)
88 let process = Process()
89 process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
90 process.arguments = [ProcessInfo.processInfo.environment["ORGSTAR_EMACS"] ?? "emacs", "-Q", "--batch", "-l", script.path]
91 process.environment = ProcessInfo.processInfo.environment.merging(["ORACLE_OUTPUT": output.path]) { $1 }
92 process.standardOutput = FileHandle.nullDevice
93 process.standardError = FileHandle.nullDevice
94 try process.run()
95 process.waitUntilExit()
96 return try JSONDecoder().decode([String].self, from: Data(contentsOf: output))
97 }
98
99 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
100 func foldsLikeOrg() throws {
101 for (text, steps) in zip(Self.texts, Self.steps) {
102 let emacs = try Self.emacs(text, steps)
103 let ours = Self.ours(text, steps)
104 for (i, (mine, theirs)) in zip(ours, emacs).enumerated() {
105 #expect(mine == theirs, "after \(i == 0 ? "opening" : steps[i - 1]) in \(text.debugDescription)\nours: \(mine.debugDescription)\nemacs: \(theirs.debugDescription)")
106 }
107 #expect(ours.count == emacs.count)
108 }
109 }
110
111 @Test func togglesFromOpeningAndClosingLines() {
112 let text = "* A\n:PROPERTIES:\n:ID: x\n:END:\n\n"
113 let tree = OrgParser.parse(text)
114 #expect(Wrappers.toggleable(at: 4, in: tree)?.start == 4)
115 #expect(Wrappers.toggleable(at: 18, in: tree) == nil)
116 #expect(Wrappers.toggleable(at: 25, in: tree)?.hidden == 17..<30)
117 #expect(Wrappers.toggleable(at: 30, in: tree) == nil)
118 }
119}