Fold drawers and blocks !70
16 files changed, +424 −36
Layout: unified · split
Sources/OrgApp/EmacsImport.swift +4 −1
| @@ -447,8 +447,11 @@ public struct EmacsImport: Sendable { | |||
| 447 | case "fill-column", "org-tags-column", "appt-message-warning-time": | 447 | case "fill-column", "org-tags-column", "appt-message-warning-time": |
| 448 | guard let n = value.integer else { return note("not a number") } | 448 | guard let n = value.integer else { return note("not a number") } |
| 449 | add(name, .integer(n)) | 449 | 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": | ||
| 451 | add(name, .bool(truthy)) | 452 | 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)) | ||
| 452 | case "org-M-RET-may-split-line": | 455 | case "org-M-RET-may-split-line": |
| 453 | if let alist = value.elements, !alist.isEmpty { | 456 | if let alist = value.elements, !alist.isEmpty { |
| 454 | // `((default . nil))`: the default entry, as most configurations write it. | 457 | // `((default . nil))`: the default entry, as most configurations write it. |
Sources/OrgApp/UserConfig.swift +4
| @@ -78,6 +78,10 @@ public enum UserConfig { | |||
| 78 | fallback: .string("nil"), comment: "nil, time (CLOSED when done) or note (CLOSED and a note)", legacy: ""), | 78 | fallback: .string("nil"), comment: "nil, time (CLOSED when done) or note (CLOSED and a note)", legacy: ""), |
| 79 | Setting(section: "", key: "org-log-into-drawer", defaultsKey: "logIntoDrawer", kind: .string, fallback: .string(""), | 79 | Setting(section: "", key: "org-log-into-drawer", defaultsKey: "logIntoDrawer", kind: .string, fallback: .string(""), |
| 80 | comment: "the drawer state notes go in (\"LOGBOOK\" is Emacs's t); \"\" for none", legacy: ""), | 80 | 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: ""), | ||
| 81 | Setting(section: "", key: "display-line-numbers-type", defaultsKey: "showLineNumbers", kind: .bool, fallback: .bool(true), | 85 | Setting(section: "", key: "display-line-numbers-type", defaultsKey: "showLineNumbers", kind: .bool, fallback: .bool(true), |
| 82 | comment: "line numbers (View ▸ Show Line Numbers)", legacy: "editor.line-numbers"), | 86 | comment: "line numbers (View ▸ Show Line Numbers)", legacy: "editor.line-numbers"), |
| 83 | Setting(section: "", key: "org-agenda-span", defaultsKey: "agendaSpan", kind: .integer, fallback: .integer(10), | 87 | 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 { | |||
| 19 | public var logIntoDrawer: String? | 19 | public var logIntoDrawer: String? |
| 20 | /// `org-use-fast-todo-selection` `auto`: with keys in the keywords, C-c C-t asks for one. | 20 | /// `org-use-fast-todo-selection` `auto`: with keys in the keywords, C-c C-t asks for one. |
| 21 | public var fastTodoSelection: Bool | 21 | 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 | ||
| 22 | /// `fill-column` for M-q. | 26 | /// `fill-column` for M-q. |
| 23 | public var fillColumn: Int | 27 | public var fillColumn: Int |
| 24 | /// `org-hide-emphasis-markers`: table alignment measures emphasis without its markers. | 28 | /// `org-hide-emphasis-markers`: table alignment measures emphasis without its markers. |
| @@ -32,8 +36,11 @@ public struct EditingOptions: Sendable, Equatable, Codable { | |||
| 32 | tagsColumn: Int = -77, insertHeadingRespectContent: Bool = false, metaReturnMaySplitLine: Bool = true, | 36 | tagsColumn: Int = -77, insertHeadingRespectContent: Bool = false, metaReturnMaySplitLine: Bool = true, |
| 33 | listAllowAlphabetical: Bool = false, logRepeat: Bool = true, fillColumn: Int = 70, | 37 | listAllowAlphabetical: Bool = false, logRepeat: Bool = true, fillColumn: Int = 70, |
| 34 | hideEmphasisMarkers: Bool = false, prettyEntities: Bool = false, subSuperscriptsNeedBraces: Bool = false, | 38 | 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 | ||
| 36 | ) { | 41 | ) { |
| 42 | self.hideDrawerStartup = hideDrawerStartup | ||
| 43 | self.hideBlockStartup = hideBlockStartup | ||
| 37 | self.fastTodoSelection = fastTodoSelection | 44 | self.fastTodoSelection = fastTodoSelection |
| 38 | self.logDone = logDone | 45 | self.logDone = logDone |
| 39 | self.logIntoDrawer = logIntoDrawer | 46 | self.logIntoDrawer = logIntoDrawer |
Sources/OrgCore/Keymap/Keymap.swift +3 −1
| @@ -130,7 +130,7 @@ public struct Keymap: Sendable, Equatable { | |||
| 130 | 130 | ||
| 131 | /// Context names a binding's `when` can use. | 131 | /// Context names a binding's `when` can use. |
| 132 | public enum KeyContext { | 132 | public 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"] |
| 134 | 134 | ||
| 135 | public static func holds(_ name: String, in context: EditContext) -> Bool { | 135 | public static func holds(_ name: String, in context: EditContext) -> Bool { |
| 136 | switch name { | 136 | switch name { |
| @@ -141,6 +141,8 @@ public enum KeyContext { | |||
| 141 | case "timestamp": return EmacsBuffer(context.text, point: context.caret).atTimestamp() != nil | 141 | case "timestamp": return EmacsBuffer(context.text, point: context.caret).atTimestamp() != nil |
| 142 | case "tblfm": return TableRecalculate.tblfmLine(in: context) != nil | 142 | case "tblfm": return TableRecalculate.tblfmLine(in: context) != nil |
| 143 | case "src": return enclosing(.block, context).map(DocumentModel.isSrcBlock) ?? false | 143 | 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 | ||
| 144 | default: return false | 146 | default: return false |
| 145 | } | 147 | } |
| 146 | } | 148 | } |
Sources/OrgCore/Keymap/Presets.swift +4
| @@ -450,6 +450,10 @@ public enum KeymapPreset: String, Sendable, CaseIterable { | |||
| 450 | keys = "TAB" | 450 | keys = "TAB" |
| 451 | command = "org.cycle" | 451 | command = "org.cycle" |
| 452 | when = "heading" | 452 | when = "heading" |
| 453 | [[bind]] | ||
| 454 | keys = "TAB" | ||
| 455 | command = "org.cycle" | ||
| 456 | when = "fold-line" | ||
| 453 | 457 | ||
| 454 | [[bind]] | 458 | [[bind]] |
| 455 | keys = "S-TAB" | 459 | 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`). | ||
| 3 | public 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 | |||
| 15 | public 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 { | |||
| 7 | public var folds: Set<Int> | 7 | public var folds: Set<Int> |
| 8 | /// Start offsets of headings whose own body is folded; their child headings stay visible. | 8 | /// Start offsets of headings whose own body is folded; their child headings stay visible. |
| 9 | public var bodyFolds: Set<Int> | 9 | public var bodyFolds: Set<Int> |
| 10 | /// Opening-line starts of folded drawers and blocks. | ||
| 11 | public var wrapperFolds: Set<Int> | ||
| 10 | 12 | ||
| 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> = []) { |
| 12 | self.selection = selection | 14 | self.selection = selection |
| 13 | self.folds = folds | 15 | self.folds = folds |
| 14 | self.bodyFolds = bodyFolds | 16 | self.bodyFolds = bodyFolds |
| 17 | self.wrapperFolds = wrapperFolds | ||
| 15 | } | 18 | } |
| 16 | 19 | ||
| 17 | /// Maps through non-overlapping edits given in old coordinates. | 20 | /// Maps through non-overlapping edits given in old coordinates. |
| @@ -20,17 +23,20 @@ public struct ViewState: Sendable, Equatable { | |||
| 20 | return ViewState( | 23 | return ViewState( |
| 21 | selection: selection.map { mapOffset($0.lowerBound, sorted)..<mapOffset($0.upperBound, sorted) }, | 24 | selection: selection.map { mapOffset($0.lowerBound, sorted)..<mapOffset($0.upperBound, sorted) }, |
| 22 | folds: Set(folds.map { mapOffset($0, sorted) }), | 25 | 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) }) | ||
| 24 | ) | 28 | ) |
| 25 | } | 29 | } |
| 26 | 30 | ||
| 27 | /// Drops folds that no longer sit at the start of a heading. | 31 | /// Drops folds that no longer sit at the start of a heading, drawer or block. |
| 28 | public func pruned(to tree: OrgTree) -> ViewState { | 32 | /// Without `wrappers`, drawer and block folds are left for the caller to check. |
| 29 | guard !folds.isEmpty || !bodyFolds.isEmpty else { return self } | 33 | public func pruned(to tree: OrgTree, wrappers: Bool = true) -> ViewState { |
| 34 | guard !folds.isEmpty || !bodyFolds.isEmpty || (wrappers && !wrapperFolds.isEmpty) else { return self } | ||
| 30 | return ViewState( | 35 | return ViewState( |
| 31 | selection: selection, | 36 | selection: selection, |
| 32 | folds: folds.filter { isHeadingStart($0, tree.root) }, | 37 | 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 | ||
| 34 | ) | 40 | ) |
| 35 | } | 41 | } |
| 36 | } | 42 | } |
Sources/OrgEditorAppKit/OrgEditor.swift +63 −13
| @@ -102,9 +102,10 @@ public final class OrgEditor: NSObject { | |||
| 102 | 102 | ||
| 103 | public init( | 103 | public init( |
| 104 | document: DocumentState, frame: NSRect = NSRect(x: 0, y: 0, width: 600, height: 800), editable: Bool = true, | 104 | 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 |
| 106 | ) { | 106 | ) { |
| 107 | self.document = document | 107 | self.document = document |
| 108 | self.options = options | ||
| 108 | self.highlighter = highlighter | 109 | self.highlighter = highlighter |
| 109 | self.mode = mode | 110 | self.mode = mode |
| 110 | let orgTextView = OrgTextView(usingTextLayoutManager: true) | 111 | let orgTextView = OrgTextView(usingTextLayoutManager: true) |
| @@ -142,7 +143,12 @@ public final class OrgEditor: NSObject { | |||
| 142 | 143 | ||
| 143 | if isOrg { shaded.set(Presentation.shadedBlocks(document.tree)) } | 144 | if isOrg { shaded.set(Presentation.shadedBlocks(document.tree)) } |
| 144 | load(document.text) | 145 | 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 | } | ||
| 146 | } | 152 | } |
| 147 | 153 | ||
| 148 | /// Replaces the storage's text. Setting the attributed string directly is about 100x | 154 | /// Replaces the storage's text. Setting the attributed string directly is about 100x |
| @@ -361,6 +367,7 @@ public final class OrgEditor: NSObject { | |||
| 361 | return switch EditorCommands.command(id)?.action { | 367 | return switch EditorCommands.command(id)?.action { |
| 362 | case .text(let command): command.applies(in: context) | 368 | case .text(let command): command.applies(in: context) |
| 363 | case .view: id != "org.cycle" || Presentation.heading(containing: context.caret, in: context.tree) != nil | 369 | case .view: id != "org.cycle" || Presentation.heading(containing: context.caret, in: context.tree) != nil |
| 370 | || Wrappers.toggleable(at: context.caret, in: context.tree) != nil | ||
| 364 | case .edit, .mark, .app, .find: true | 371 | case .edit, .mark, .app, .find: true |
| 365 | case nil: true | 372 | case nil: true |
| 366 | } | 373 | } |
| @@ -459,15 +466,26 @@ public final class OrgEditor: NSObject { | |||
| 459 | // MARK: - Folding | 466 | // MARK: - Folding |
| 460 | 467 | ||
| 461 | public var foldState: FoldState { | 468 | public var foldState: FoldState { |
| 462 | FoldState(folds: view.folds, bodyFolds: view.bodyFolds) | 469 | FoldState(folds: view.folds, bodyFolds: view.bodyFolds, wrapperFolds: view.wrapperFolds) |
| 463 | } | 470 | } |
| 464 | 471 | ||
| 465 | private var globalVisibility: GlobalVisibility? | 472 | private var globalVisibility: GlobalVisibility? |
| 466 | 473 | ||
| 467 | public func setFolds(_ state: FoldState) { | 474 | 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]?) { | ||
| 468 | view.folds = state.folds | 480 | view.folds = state.folds |
| 469 | view.bodyFolds = state.bodyFolds | 481 | 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) | ||
| 471 | updateHidden() | 489 | updateHidden() |
| 472 | orgTextView?.redrawLineNumbers() | 490 | orgTextView?.redrawLineNumbers() |
| 473 | textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange) | 491 | textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange) |
| @@ -476,22 +494,50 @@ public final class OrgEditor: NSObject { | |||
| 476 | if let range = hidden.containing(caret) { setCaret(range.lowerBound - 1) } | 494 | if let range = hidden.containing(caret) { setCaret(range.lowerBound - 1) } |
| 477 | } | 495 | } |
| 478 | 496 | ||
| 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 | |||
| 479 | private func updateHidden() { | 501 | private func updateHidden() { |
| 480 | folded.set(view.folds.union(view.bodyFolds)) | 502 | folded.set(view.folds.union(view.bodyFolds).union(view.wrapperFolds)) |
| 481 | hidden.set(Presentation.hiddenRanges(document.tree, folds: view.folds, bodyFolds: view.bodyFolds)) | 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) | ||
| 482 | } | 524 | } |
| 483 | 525 | ||
| 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. | ||
| 485 | @discardableResult | 528 | @discardableResult |
| 486 | public func cycleAtCaret() -> Bool { | 529 | public func cycleAtCaret() -> Bool { |
| 487 | guard let heading = Presentation.heading(containing: textView.selectedRange().location, in: document.tree) else { return false } | 530 | let caret = textView.selectedRange().location |
| 488 | setFolds(Presentation.cycle(heading, in: document.tree, foldState)) | 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) } | ||
| 489 | return true | 535 | return true |
| 490 | } | 536 | } |
| 491 | 537 | ||
| 492 | /// Shift-TAB: overview, contents, show all. | 538 | /// Shift-TAB: overview, contents, show all. |
| 493 | public func cycleGlobal() { | 539 | 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) |
| 495 | globalVisibility = visibility | 541 | globalVisibility = visibility |
| 496 | setFolds(state) | 542 | setFolds(state) |
| 497 | } | 543 | } |
| @@ -511,6 +557,9 @@ public final class OrgEditor: NSObject { | |||
| 511 | state.folds.remove(entry.start) | 557 | state.folds.remove(entry.start) |
| 512 | state.bodyFolds.remove(entry.start) | 558 | state.bodyFolds.remove(entry.start) |
| 513 | } | 559 | } |
| 560 | for start in state.wrapperFolds where Wrappers.starting(at: start, in: document.tree)?.hidden.contains(offset) == true { | ||
| 561 | state.wrapperFolds.remove(start) | ||
| 562 | } | ||
| 514 | guard state != foldState else { return } | 563 | guard state != foldState else { return } |
| 515 | setFolds(state) | 564 | setFolds(state) |
| 516 | } | 565 | } |
| @@ -773,11 +822,12 @@ public final class OrgEditor: NSObject { | |||
| 773 | styledUpTo = (storage.string as NSString).lineRange(for: NSRange(location: edit.range.lowerBound, length: 0)).location | 822 | styledUpTo = (storage.string as NSString).lineRange(for: NSRange(location: edit.range.lowerBound, length: 0)).location |
| 774 | } | 823 | } |
| 775 | let oldHidden = hidden.all | 824 | let oldHidden = hidden.all |
| 776 | view = view.mapped(through: [edit]).pruned(to: document.tree) | ||
| 777 | updateHidden() | ||
| 778 | let newAffected = Self.affectedRange(document.tree, newText, edit.range.lowerBound, newEnd, touchedHeadings: touchedHeadings) | 825 | let newAffected = Self.affectedRange(document.tree, newText, edit.range.lowerBound, newEnd, touchedHeadings: touchedHeadings) |
| 779 | let delta = edit.replacement.utf16.count - edit.range.count | 826 | let delta = edit.replacement.utf16.count - edit.range.count |
| 780 | let oldMapped = oldAffected.lowerBound..<(oldAffected.upperBound >= edit.range.upperBound ? oldAffected.upperBound + delta : newEnd) | 827 | 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() | ||
| 781 | restyle(min(oldMapped.lowerBound, newAffected.lowerBound)..<min(max(oldMapped.upperBound, newAffected.upperBound), newText.length), in: storage) | 831 | restyle(min(oldMapped.lowerBound, newAffected.lowerBound)..<min(max(oldMapped.upperBound, newAffected.upperBound), newText.length), in: storage) |
| 782 | let oldShaded = shaded.all | 832 | let oldShaded = shaded.all |
| 783 | if isOrg { | 833 | if isOrg { |
| @@ -894,7 +944,7 @@ extension OrgEditor: DocumentBuffer { | |||
| 894 | state.selection = [selected.location..<NSMaxRange(selected)] | 944 | state.selection = [selected.location..<NSMaxRange(selected)] |
| 895 | let mapped = state.mapped(through: edits) | 945 | let mapped = state.mapped(through: edits) |
| 896 | load(document.text) | 946 | load(document.text) |
| 897 | setFolds(FoldState(folds: mapped.folds, bodyFolds: mapped.bodyFolds)) | 947 | setFolds(FoldState(folds: mapped.folds, bodyFolds: mapped.bodyFolds, wrapperFolds: mapped.wrapperFolds)) |
| 898 | let selection = mapped.selection[0] | 948 | let selection = mapped.selection[0] |
| 899 | setCaret(selection.lowerBound) | 949 | setCaret(selection.lowerBound) |
| 900 | if !selection.isEmpty { textView.setSelectedRange(NSRange(selection)) } | 950 | if !selection.isEmpty { textView.setSelectedRange(NSRange(selection)) } |
Sources/OrgPresentation/Folding.swift +57 −5
| @@ -1,13 +1,18 @@ | |||
| 1 | import OrgCore | 1 | import OrgCore |
| 2 | 2 | ||
| 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. | ||
| 4 | public struct FoldState: Sendable, Equatable { | 6 | public struct FoldState: Sendable, Equatable { |
| 5 | public var folds: Set<Int> | 7 | public var folds: Set<Int> |
| 6 | public var bodyFolds: Set<Int> | 8 | public var bodyFolds: Set<Int> |
| 9 | /// Opening-line starts of folded drawers and blocks. | ||
| 10 | public var wrapperFolds: Set<Int> | ||
| 7 | 11 | ||
| 8 | public init(folds: Set<Int> = [], bodyFolds: Set<Int> = []) { | 12 | public init(folds: Set<Int> = [], bodyFolds: Set<Int> = [], wrapperFolds: Set<Int> = []) { |
| 9 | self.folds = folds | 13 | self.folds = folds |
| 10 | self.bodyFolds = bodyFolds | 14 | self.bodyFolds = bodyFolds |
| 15 | self.wrapperFolds = wrapperFolds | ||
| 11 | } | 16 | } |
| 12 | } | 17 | } |
| 13 | 18 | ||
| @@ -82,14 +87,61 @@ extension Presentation { | |||
| 82 | return next | 87 | return next |
| 83 | } | 88 | } |
| 84 | 89 | ||
| 85 | /// Shift-TAB: overview, contents, show all, and around. | 90 | /// TAB at `offset` (`org-cycle`): on the first or last line of a drawer or block, folds or |
| 86 | public static func cycleGlobal(from current: GlobalVisibility?, in tree: OrgTree) -> (GlobalVisibility, FoldState) { | 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) { | ||
| 87 | let next: GlobalVisibility = switch current { | 105 | let next: GlobalVisibility = switch current { |
| 88 | case .overview: .contents | 106 | case .overview: .contents |
| 89 | case .contents: .showAll | 107 | case .contents: .showAll |
| 90 | case .showAll, nil: .overview | 108 | case .showAll, nil: .overview |
| 91 | } | 109 | } |
| 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 } | ||
| 93 | } | 145 | } |
| 94 | 146 | ||
| 95 | public static func foldState(for visibility: GlobalVisibility, in tree: OrgTree) -> FoldState { | 147 | public static func foldState(for visibility: GlobalVisibility, in tree: OrgTree) -> FoldState { |
Sources/OrgPresentation/Presentation.swift +15 −4
| @@ -210,11 +210,22 @@ public enum Presentation { | |||
| 210 | /// Ranges to hide. A heading in `folds` hides everything after its line to the end of its | 210 | /// Ranges to hide. A heading in `folds` hides everything after its line to the end of its |
| 211 | /// section; one in `bodyFolds` hides only its own body, up to its first child heading. | 211 | /// section; one in `bodyFolds` hides only its own body, up to its first child heading. |
| 212 | /// Sorted, without nested duplicates. | 212 | /// Sorted, without nested duplicates. |
| 213 | public static func hiddenRanges(_ tree: OrgTree, folds: Set<Int>, bodyFolds: Set<Int> = []) -> [Range<Int>] { | 213 | /// A drawer or block in `wrapperFolds` hides the lines after its opening line. |
| 214 | guard !folds.isEmpty || !bodyFolds.isEmpty else { return [] } | 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 [] } | ||
| 215 | var ranges: [Range<Int>] = [] | 221 | var ranges: [Range<Int>] = [] |
| 216 | collectHidden(tree.root, folds, bodyFolds, &ranges) | 222 | if !folds.isEmpty || !bodyFolds.isEmpty { collectHidden(tree.root, folds, bodyFolds, &ranges) } |
| 217 | return 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 | ||
| 218 | } | 229 | } |
| 219 | 230 | ||
| 220 | private static func collectHidden(_ node: SyntaxNode, _ folds: Set<Int>, _ bodyFolds: Set<Int>, _ ranges: inout [Range<Int>]) { | 231 | 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 { | |||
| 36 | @AppStorage("prettyEntities") private var prettyEntities = true | 36 | @AppStorage("prettyEntities") private var prettyEntities = true |
| 37 | @AppStorage("logDone") private var logDone = "nil" | 37 | @AppStorage("logDone") private var logDone = "nil" |
| 38 | @AppStorage("logIntoDrawer") private var logIntoDrawer = "" | 38 | @AppStorage("logIntoDrawer") private var logIntoDrawer = "" |
| 39 | @AppStorage("hideDrawerStartup") private var hideDrawerStartup = true | ||
| 40 | @AppStorage("hideBlockStartup") private var hideBlockStartup = false | ||
| 39 | 41 | ||
| 40 | private var options: EditingOptions { | 42 | private var options: EditingOptions { |
| 41 | Preferences.options( | 43 | Preferences.options( |
| 42 | tagsColumn: tagsColumn, headingAfterSubtree: headingAfterSubtree, splitLine: splitLine, alphabeticalLists: alphabeticalLists, | 44 | 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 | ||
| 44 | ) | 47 | ) |
| 45 | } | 48 | } |
| 46 | @State private var selectedFile: String? | 49 | @State private var selectedFile: String? |
Sources/Orgstar/EditorView.swift +1 −1
| @@ -83,7 +83,7 @@ struct EditorView: NSViewRepresentable { | |||
| 83 | private func makeEditor(_ container: NSView) -> (editor: OrgEditor, scroll: NSScrollView)? { | 83 | private func makeEditor(_ container: NSView) -> (editor: OrgEditor, scroll: NSScrollView)? { |
| 84 | guard let document = session.document else { return nil } | 84 | guard let document = session.document else { return nil } |
| 85 | let mode: OrgEditor.Mode = session.isOrg ? .org : .plain(language: session.path.flatMap(FileTypes.language)) | 85 | 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) |
| 87 | let path = session.path ?? "" | 87 | let path = session.path ?? "" |
| 88 | editor.onChange = { [weak session] in session?.bufferChanged(path) } | 88 | editor.onChange = { [weak session] in session?.bufferChanged(path) } |
| 89 | editor.onMessage = { [weak commands] in commands?.show($0) } | 89 | editor.onMessage = { [weak commands] in commands?.show($0) } |
Sources/Orgstar/Preferences.swift +4 −2
| @@ -17,13 +17,15 @@ enum Preferences { | |||
| 17 | 17 | ||
| 18 | static func options( | 18 | static func options( |
| 19 | tagsColumn: Int, headingAfterSubtree: Bool, splitLine: Bool, alphabeticalLists: Bool, fillColumn: Int, | 19 | 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 | ||
| 21 | ) -> EditingOptions { | 22 | ) -> EditingOptions { |
| 22 | EditingOptions( | 23 | EditingOptions( |
| 23 | tagsColumn: tagsColumn, insertHeadingRespectContent: headingAfterSubtree, | 24 | tagsColumn: tagsColumn, insertHeadingRespectContent: headingAfterSubtree, |
| 24 | metaReturnMaySplitLine: splitLine, listAllowAlphabetical: alphabeticalLists, fillColumn: fillColumn, | 25 | metaReturnMaySplitLine: splitLine, listAllowAlphabetical: alphabeticalLists, fillColumn: fillColumn, |
| 25 | hideEmphasisMarkers: hiddenMarkers, prettyEntities: prettyEntities, subSuperscriptsNeedBraces: true, | 26 | 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 | ||
| 27 | ) | 29 | ) |
| 28 | } | 30 | } |
| 29 | } | 31 | } |
Tests/OrgEditorAppKitTests/EditorTests.swift +37
| @@ -147,6 +147,43 @@ struct FoldingTests { | |||
| 147 | #expect(pasteboard.string(forType: .string) == "* a\nbody\n** b\nmore\n* c\n") | 147 | #expect(pasteboard.string(forType: .string) == "* a\nbody\n** b\nmore\n* c\n") |
| 148 | } | 148 | } |
| 149 | 149 | ||
| 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 | |||
| 150 | @Test func foldsFollowEdits() { | 187 | @Test func foldsFollowEdits() { |
| 151 | let h = Harness(outline) | 188 | let h = Harness(outline) |
| 152 | h.caret(at: h.offset(of: "* c") + 1) | 189 | h.caret(at: h.offset(of: "* c") + 1) |
Tests/OrgEditorAppKitTests/GateTests.swift +12 −1
| @@ -15,6 +15,17 @@ import Testing | |||
| 15 | @Suite(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_GATES"] != nil)) | 15 | @Suite(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_GATES"] != nil)) |
| 16 | struct GateTests { | 16 | struct GateTests { |
| 17 | /// About 1 MB of body text under a single top-level heading. | 17 | /// 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 | |||
| 18 | static let singleSection: String = { | 29 | static let singleSection: String = { |
| 19 | var text = "* One big section\n" | 30 | var text = "* One big section\n" |
| 20 | var n = 0 | 31 | var n = 0 |
| @@ -88,7 +99,7 @@ struct GateTests { | |||
| 88 | // per process; an app pays that at launch, not per file. | 99 | // per process; an app pays that at launch, not per file. |
| 89 | NSTextView(usingTextLayoutManager: true).textStorage?.setAttributedString(NSAttributedString(string: "warm up")) | 100 | NSTextView(usingTextLayoutManager: true).textStorage?.setAttributedString(NSAttributedString(string: "warm up")) |
| 90 | // Memory limits: worst-case dense markup for the synthetic files, typical for real ones. | 101 | // 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)] |
| 92 | if let path = ProcessInfo.processInfo.environment["ORGSTAR_GATE_FILE"] { | 103 | if let path = ProcessInfo.processInfo.environment["ORGSTAR_GATE_FILE"] { |
| 93 | inputs.append((URL(fileURLWithPath: path).lastPathComponent, try String(contentsOfFile: path, encoding: .utf8), 10.0)) | 104 | inputs.append((URL(fileURLWithPath: path).lastPathComponent, try String(contentsOfFile: path, encoding: .utf8), 10.0)) |
| 94 | } | 105 | } |
Tests/OrgPresentationTests/WrapperFoldTests.swift added +119
| @@ -0,0 +1,119 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | import 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. | ||
| 8 | struct 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 | } | ||