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 { |
| 441 | 441 | mode = "normal" |
| 442 | 442 | [[bind]] |
| 443 | 443 | keys = "z a" |
| 444 | | command = "org.cycle" |
| 444 | command = "org.fold.toggle" |
| 445 | 445 | mode = "normal" |
| 446 | 446 | [[bind]] |
| 447 | 447 | keys = "z c" |
| 448 | | command = "org.cycle" |
| 448 | command = "org.fold.close" |
| 449 | 449 | mode = "normal" |
| 450 | 450 | [[bind]] |
| 451 | 451 | keys = "z o" |
| 452 | | command = "org.cycle" |
| 452 | command = "org.fold.open" |
| 453 | 453 | mode = "normal" |
| 454 | 454 | [[bind]] |
| 455 | 455 | keys = "z A" |
Sources/OrgEditorAppKit/EditorCommands.swift
+3
| @@ -37,6 +37,9 @@ public enum EditorCommands { |
| 37 | 37 | public static let all: [EditorCommand] = Commands.everything.map { EditorCommand(id: $0.id, title: $0.title, action: .text($0)) } + [ |
| 38 | 38 | EditorCommand(id: "org.cycle", title: "Cycle Visibility", action: .view), |
| 39 | 39 | 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), |
| 40 | 43 | EditorCommand(id: "org.narrow.subtree", title: "Narrow to Subtree", action: .view), |
| 41 | 44 | EditorCommand(id: "org.narrow.block", title: "Narrow to Block", action: .view), |
| 42 | 45 | EditorCommand(id: "org.widen", title: "Widen", action: .view), |
Sources/OrgEditorAppKit/OrgEditor.swift
+18
| @@ -486,6 +486,7 @@ public final class OrgEditor: NSObject { |
| 486 | 486 | switch id { |
| 487 | 487 | case "org.cycle": cycleAtCaret() |
| 488 | 488 | case "org.cycle-global": cycleGlobal() |
| 489 | case "org.fold.toggle", "org.fold.open", "org.fold.close": fold(id) |
| 489 | 490 | case "org.narrow.subtree", "org.narrow.block", "org.narrow.toggle", "org.widen": narrow(id) |
| 490 | 491 | case "org.toggle-inline-images": toggleImages() |
| 491 | 492 | case "editor.toggle-spell-check": toggleSpellCheck() |
| @@ -952,6 +953,23 @@ public final class OrgEditor: NSObject { |
| 952 | 953 | return true |
| 953 | 954 | } |
| 954 | 955 | |
| 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 | |
| 955 | 973 | /// Shift-TAB: overview, contents, show all. |
| 956 | 974 | public func cycleGlobal() { |
| 957 | 975 | leaveSparseTree() |
Sources/OrgPresentation/Folding.swift
+36
| @@ -87,6 +87,42 @@ extension Presentation { |
| 87 | 87 | return next |
| 88 | 88 | } |
| 89 | 89 | |
| 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 | |
| 90 | 126 | /// TAB at `offset` (`org-cycle`): on the first or last line of a drawer or block, folds or |
| 91 | 127 | /// unfolds it; on a heading, cycles it. Nil elsewhere. |
| 92 | 128 | public static func cycle(at offset: Int, in tree: OrgTree, _ state: FoldState) -> FoldState? { |
Tests/OrgEditorAppKitTests/ModalTests.swift
+25
| @@ -1,5 +1,6 @@ |
| 1 | 1 | import AppKit |
| 2 | 2 | import OrgCore |
| 3 | import OrgPresentation |
| 3 | 4 | import Testing |
| 4 | 5 | @testable import OrgEditorAppKit |
| 5 | 6 | |
| @@ -107,6 +108,30 @@ struct ModalTests { |
| 107 | 108 | #expect(a.textView.selectedRange().location == 14) |
| 108 | 109 | } |
| 109 | 110 | |
| 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 | |
| 110 | 135 | @Test func zzSavesAndClosesAsWq() { |
| 111 | 136 | let h = doom("text\n") |
| 112 | 137 | var app: [String] = [] |