krz/orgstar

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

Commit 515b208bfa

515b208bfa655588e8db96b4b3b6037218760bfc

parent: 72065ee289

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-08 03:55 UTC

Give za, zc and zo Doom's fold commands

za toggles a heading between folded and its children, zo opens a
folded heading one level, and zc folds the subtree around the caret,
as +org/toggle-fold, +org/open-fold and outline-hide-subtree do. They
were all org-cycle.

Layout: unified · split

Sources/OrgCore/Keymap/Presets.swift +3 −3
@@ -441,15 +441,15 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
441441 mode = "normal"
442442 [[bind]]
443443 keys = "z a"
444 command = "org.cycle"
444 command = "org.fold.toggle"
445445 mode = "normal"
446446 [[bind]]
447447 keys = "z c"
448 command = "org.cycle"
448 command = "org.fold.close"
449449 mode = "normal"
450450 [[bind]]
451451 keys = "z o"
452 command = "org.cycle"
452 command = "org.fold.open"
453453 mode = "normal"
454454 [[bind]]
455455 keys = "z A"
Sources/OrgEditorAppKit/EditorCommands.swift +3
@@ -37,6 +37,9 @@ public enum EditorCommands {
3737 public static let all: [EditorCommand] = Commands.everything.map { EditorCommand(id: $0.id, title: $0.title, action: .text($0)) } + [
3838 EditorCommand(id: "org.cycle", title: "Cycle Visibility", action: .view),
3939 EditorCommand(id: "org.cycle-global", title: "Cycle Global Visibility", action: .view),
40 EditorCommand(id: "org.fold.toggle", title: "Toggle Fold", action: .view),
41 EditorCommand(id: "org.fold.open", title: "Open Fold", action: .view),
42 EditorCommand(id: "org.fold.close", title: "Close Fold", action: .view),
4043 EditorCommand(id: "org.narrow.subtree", title: "Narrow to Subtree", action: .view),
4144 EditorCommand(id: "org.narrow.block", title: "Narrow to Block", action: .view),
4245 EditorCommand(id: "org.widen", title: "Widen", action: .view),
Sources/OrgEditorAppKit/OrgEditor.swift +18
@@ -486,6 +486,7 @@ public final class OrgEditor: NSObject {
486486 switch id {
487487 case "org.cycle": cycleAtCaret()
488488 case "org.cycle-global": cycleGlobal()
489 case "org.fold.toggle", "org.fold.open", "org.fold.close": fold(id)
489490 case "org.narrow.subtree", "org.narrow.block", "org.narrow.toggle", "org.widen": narrow(id)
490491 case "org.toggle-inline-images": toggleImages()
491492 case "editor.toggle-spell-check": toggleSpellCheck()
@@ -952,6 +953,23 @@ public final class OrgEditor: NSObject {
952953 return true
953954 }
954955
956 /// Doom's `za`, `zo` (on a heading line) and `zc` (anywhere in a subtree).
957 func fold(_ id: String) {
958 leaveSparseTree()
959 let caret = textView.selectedRange().location
960 let tree = document.tree
961 let next: FoldState?
962 if id == "org.fold.close" {
963 next = Presentation.outline(tree).last { $0.start <= caret && (caret < $0.end || $0.end == utf16Length) }
964 .map { Presentation.hideSubtree($0.start, foldState) }
965 } else {
966 next = Presentation.heading(containing: caret, in: tree).map {
967 id == "org.fold.open" ? Presentation.openFold($0, in: tree, foldState) : Presentation.toggleFold($0, in: tree, foldState)
968 }
969 }
970 if let next, next != foldState { setFolds(next) }
971 }
972
955973 /// Shift-TAB: overview, contents, show all.
956974 public func cycleGlobal() {
957975 leaveSparseTree()
Sources/OrgPresentation/Folding.swift +36
@@ -87,6 +87,42 @@ extension Presentation {
8787 return next
8888 }
8989
90 /// Doom's `za` (`+org/toggle-fold`) on a heading: folded, it opens one level (its body and
91 /// child headings, folded); otherwise it folds.
92 public static func toggleFold(_ heading: Int, in tree: OrgTree, _ state: FoldState) -> FoldState {
93 isFolded(heading, state) ? showChildren(heading, in: tree, state) : hideSubtree(heading, state)
94 }
95
96 /// Doom's `zo` (`+org/open-fold`): a folded heading opens one level; an open one stays.
97 public static func openFold(_ heading: Int, in tree: OrgTree, _ state: FoldState) -> FoldState {
98 isFolded(heading, state) ? showChildren(heading, in: tree, state) : state
99 }
100
101 /// `zc` (`outline-hide-subtree`): the heading's subtree folded.
102 public static func hideSubtree(_ heading: Int, _ state: FoldState) -> FoldState {
103 var next = state
104 next.folds.insert(heading)
105 return next
106 }
107
108 /// Whether the heading's body is hidden.
109 private static func isFolded(_ heading: Int, _ state: FoldState) -> Bool {
110 state.folds.contains(heading) || state.bodyFolds.contains(heading)
111 }
112
113 /// org's children view of a heading: its body and child headings show, their subtrees folded.
114 private static func showChildren(_ heading: Int, in tree: OrgTree, _ state: FoldState) -> FoldState {
115 let outline = outline(tree)
116 guard let index = outline.firstIndex(where: { $0.start == heading }) else { return state }
117 let entry = outline[index]
118 let subtree = outline.filter { $0.start >= entry.start && $0.start < entry.end }.map(\.start)
119 var next = state
120 next.folds.subtract(subtree)
121 next.bodyFolds.subtract(subtree)
122 next.folds.formUnion(outline.enumerated().filter { $0.element.parent == index }.map(\.element.start))
123 return next
124 }
125
90126 /// TAB at `offset` (`org-cycle`): on the first or last line of a drawer or block, folds or
91127 /// unfolds it; on a heading, cycles it. Nil elsewhere.
92128 public static func cycle(at offset: Int, in tree: OrgTree, _ state: FoldState) -> FoldState? {
Tests/OrgEditorAppKitTests/ModalTests.swift +25
@@ -1,5 +1,6 @@
11import AppKit
22import OrgCore
3import OrgPresentation
34import Testing
45@testable import OrgEditorAppKit
56
@@ -107,6 +108,30 @@ struct ModalTests {
107108 #expect(a.textView.selectedRange().location == 14)
108109 }
109110
111 @Test func foldKeysToggleOpenAndClose() {
112 let h = doom("* A\nbody\n** B\nb body\n* C\n")
113 h.caret(at: 0)
114 type(h, "z c")
115 #expect(h.editor.foldState.folds == [0])
116 type(h, "z o")
117 #expect(h.editor.foldState.folds == [9])
118 type(h, "z o")
119 #expect(h.editor.foldState.folds == [9])
120 type(h, "z a")
121 #expect(h.editor.foldState.folds.contains(0))
122 type(h, "z a")
123 #expect(h.editor.foldState.folds == [9])
124 // za and zo act on a heading line; zc closes the subtree around the caret.
125 h.editor.setFolds(FoldState())
126 h.caret(at: 5)
127 type(h, "z a")
128 type(h, "z o")
129 #expect(h.editor.foldState.folds.isEmpty)
130 h.caret(at: 15)
131 type(h, "z c")
132 #expect(h.editor.foldState.folds == [9])
133 }
134
110135 @Test func zzSavesAndClosesAsWq() {
111136 let h = doom("text\n")
112137 var app: [String] = []