Commit 34d9da1754
Verified · cmc
Layout: unified · split
Sources/OrgDocument/ViewState.swift +13 −5
| @@ -3,12 +3,15 @@ import OrgCore | ||
| 3 | 3 | /// Per-window state for one document: selection and folds, in UTF-16 offsets. |
| 4 | 4 | public struct ViewState: Sendable, Equatable { |
| 5 | 5 | public var selection: [Range<Int>] |
| 6 | /// Start offsets of folded headings. | |
| 6 | /// Start offsets of headings whose whole subtree is folded. | |
| 7 | 7 | public var folds: Set<Int> |
| 8 | /// Start offsets of headings whose own body is folded; their child headings stay visible. | |
| 9 | public var bodyFolds: Set<Int> | |
| 8 | 10 | |
| 9 | public init(selection: [Range<Int>] = [0..<0], folds: Set<Int> = []) { | |
| 11 | public init(selection: [Range<Int>] = [0..<0], folds: Set<Int> = [], bodyFolds: Set<Int> = []) { | |
| 10 | 12 | self.selection = selection |
| 11 | 13 | self.folds = folds |
| 14 | self.bodyFolds = bodyFolds | |
| 12 | 15 | } |
| 13 | 16 | |
| 14 | 17 | /// Maps through non-overlapping edits given in old coordinates. |
| @@ -16,14 +19,19 @@ public struct ViewState: Sendable, Equatable { | ||
| 16 | 19 | let sorted = edits.sorted { $0.range.lowerBound < $1.range.lowerBound } |
| 17 | 20 | return ViewState( |
| 18 | 21 | selection: selection.map { mapOffset($0.lowerBound, sorted)..<mapOffset($0.upperBound, sorted) }, |
| 19 | folds: Set(folds.map { mapOffset($0, sorted) }) | |
| 22 | folds: Set(folds.map { mapOffset($0, sorted) }), | |
| 23 | bodyFolds: Set(bodyFolds.map { mapOffset($0, sorted) }) | |
| 20 | 24 | ) |
| 21 | 25 | } |
| 22 | 26 | |
| 23 | 27 | /// Drops folds that no longer sit at the start of a heading. |
| 24 | 28 | public func pruned(to tree: OrgTree) -> ViewState { |
| 25 | guard !folds.isEmpty else { return self } | |
| 26 | return ViewState(selection: selection, folds: folds.filter { isHeadingStart($0, tree.root) }) | |
| 29 | guard !folds.isEmpty || !bodyFolds.isEmpty else { return self } | |
| 30 | return ViewState( | |
| 31 | selection: selection, | |
| 32 | folds: folds.filter { isHeadingStart($0, tree.root) }, | |
| 33 | bodyFolds: bodyFolds.filter { isHeadingStart($0, tree.root) } | |
| 34 | ) | |
| 27 | 35 | } |
| 28 | 36 | } |
| 29 | 37 | |
Sources/OrgEditorAppKit/OrgEditor.swift +101 −17
| @@ -65,12 +65,13 @@ public final class OrgEditor: NSObject { | ||
| 65 | 65 | /// Styled per run-loop turn afterwards. |
| 66 | 66 | static let styleChunk = 200_000 |
| 67 | 67 | |
| 68 | public init(document: DocumentState, frame: NSRect = NSRect(x: 0, y: 0, width: 600, height: 800)) { | |
| 68 | public init(document: DocumentState, frame: NSRect = NSRect(x: 0, y: 0, width: 600, height: 800), editable: Bool = true) { | |
| 69 | 69 | self.document = document |
| 70 | 70 | textView = NSTextView(usingTextLayoutManager: true) |
| 71 | 71 | super.init() |
| 72 | 72 | textView.frame = frame |
| 73 | 73 | textView.isRichText = false |
| 74 | textView.isEditable = editable | |
| 74 | 75 | textView.allowsUndo = true |
| 75 | 76 | textView.isAutomaticQuoteSubstitutionEnabled = false |
| 76 | 77 | textView.isAutomaticDashSubstitutionEnabled = false |
| @@ -87,6 +88,7 @@ public final class OrgEditor: NSObject { | ||
| 87 | 88 | textContentStorage.delegate = self |
| 88 | 89 | |
| 89 | 90 | load(document.text) |
| 91 | if let startup = Presentation.startupFoldState(document.tree) { setFolds(startup) } | |
| 90 | 92 | } |
| 91 | 93 | |
| 92 | 94 | /// Replaces the storage's text. Setting the attributed string directly is about 100x |
| @@ -153,37 +155,77 @@ public final class OrgEditor: NSObject { | ||
| 153 | 155 | if document.text != before { |
| 154 | 156 | let edits = lineEdits(from: before, to: document.text) |
| 155 | 157 | load(document.text) |
| 156 | setFolds(view.mapped(through: edits).folds) | |
| 158 | let mapped = view.mapped(through: edits) | |
| 159 | setFolds(FoldState(folds: mapped.folds, bodyFolds: mapped.bodyFolds)) | |
| 157 | 160 | } |
| 158 | 161 | return outcome |
| 159 | 162 | } |
| 160 | 163 | |
| 164 | // MARK: - Disk | |
| 165 | ||
| 166 | /// The file on disk now holds `bytes`: reload or merge, keeping folds where the text kept | |
| 167 | /// its headings. | |
| 168 | public func diskChanged(to bytes: [UInt8]) { | |
| 169 | let edits: [TextEdit] | |
| 170 | switch document.diskChanged(to: bytes) { | |
| 171 | case .reloaded(let changes), .merged(let changes): edits = changes | |
| 172 | case .unchanged, .conflict: return | |
| 173 | } | |
| 174 | let mapped = view.mapped(through: edits) | |
| 175 | load(document.text) | |
| 176 | setFolds(FoldState(folds: mapped.folds, bodyFolds: mapped.bodyFolds)) | |
| 177 | } | |
| 178 | ||
| 161 | 179 | // MARK: - Folding |
| 162 | 180 | |
| 163 | public func setFolds(_ folds: Set<Int>) { | |
| 164 | view.folds = folds | |
| 181 | public var foldState: FoldState { | |
| 182 | FoldState(folds: view.folds, bodyFolds: view.bodyFolds) | |
| 183 | } | |
| 184 | ||
| 185 | private var globalVisibility: GlobalVisibility? | |
| 186 | ||
| 187 | public func setFolds(_ state: FoldState) { | |
| 188 | view.folds = state.folds | |
| 189 | view.bodyFolds = state.bodyFolds | |
| 165 | 190 | view = view.pruned(to: document.tree) |
| 166 | folded.set(view.folds) | |
| 167 | hidden.set(Presentation.hiddenRanges(document.tree, folds: view.folds)) | |
| 191 | updateHidden() | |
| 168 | 192 | textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange) |
| 169 | 193 | textView.needsDisplay = true |
| 170 | 194 | let caret = textView.selectedRange().location |
| 171 | 195 | if let range = hidden.containing(caret) { setCaret(range.lowerBound - 1) } |
| 172 | 196 | } |
| 173 | 197 | |
| 174 | /// Folds or unfolds the heading whose line holds the caret. Returns false off a heading. | |
| 198 | private func updateHidden() { | |
| 199 | folded.set(view.folds.union(view.bodyFolds)) | |
| 200 | hidden.set(Presentation.hiddenRanges(document.tree, folds: view.folds, bodyFolds: view.bodyFolds)) | |
| 201 | } | |
| 202 | ||
| 203 | /// TAB on a heading: folded, children, subtree. Returns false off a heading. | |
| 175 | 204 | @discardableResult |
| 176 | public func toggleFoldAtCaret() -> Bool { | |
| 205 | public func cycleAtCaret() -> Bool { | |
| 177 | 206 | guard let heading = Presentation.heading(containing: textView.selectedRange().location, in: document.tree) else { return false } |
| 178 | var folds = view.folds | |
| 179 | if folds.contains(heading) { folds.remove(heading) } else { folds.insert(heading) } | |
| 180 | setFolds(folds) | |
| 207 | setFolds(Presentation.cycle(heading, in: document.tree, foldState)) | |
| 181 | 208 | return true |
| 182 | 209 | } |
| 183 | 210 | |
| 184 | /// Folds every heading, or unfolds everything if anything is folded. | |
| 185 | public func toggleAllFolds() { | |
| 186 | setFolds(view.folds.isEmpty ? Set(Presentation.headings(document.tree)) : []) | |
| 211 | /// Shift-TAB: overview, contents, show all. | |
| 212 | public func cycleGlobal() { | |
| 213 | let (visibility, state) = Presentation.cycleGlobal(from: globalVisibility, in: document.tree) | |
| 214 | globalVisibility = visibility | |
| 215 | setFolds(state) | |
| 216 | } | |
| 217 | ||
| 218 | /// Unfolds whatever hides `offset`, puts the caret there and scrolls to it. | |
| 219 | public func reveal(_ offset: Int) { | |
| 220 | let target = max(0, min(offset, utf16Length)) | |
| 221 | var state = foldState | |
| 222 | for entry in Presentation.outline(document.tree) where entry.start < target && target < entry.end { | |
| 223 | state.folds.remove(entry.start) | |
| 224 | state.bodyFolds.remove(entry.start) | |
| 225 | } | |
| 226 | setFolds(state) | |
| 227 | setCaret(target) | |
| 228 | textView.scrollRangeToVisible(NSRange(location: target, length: 0)) | |
| 187 | 229 | } |
| 188 | 230 | |
| 189 | 231 | private func setCaret(_ offset: Int) { |
| @@ -207,9 +249,23 @@ public final class OrgEditor: NSObject { | ||
| 207 | 249 | guard !range.isEmpty else { return } |
| 208 | 250 | let whole = NSRange(range) |
| 209 | 251 | storage.setAttributes(theme.base, range: whole) |
| 252 | let text = storage.string as NSString | |
| 210 | 253 | for run in Presentation.indents(document.tree, in: range) { |
| 211 | 254 | guard let clipped = clip(run.range, to: range) else { continue } |
| 212 | storage.addAttribute(.paragraphStyle, value: theme.paragraphStyle(firstLine: run.firstLine, wrapped: run.wrapped), range: clipped) | |
| 255 | guard run.firstLine == run.wrapped else { | |
| 256 | storage.addAttribute(.paragraphStyle, value: theme.paragraphStyle(firstLine: run.firstLine, wrapped: run.wrapped), range: clipped) | |
| 257 | continue | |
| 258 | } | |
| 259 | // Body lines wrap under their own text: past leading blanks, and past a list bullet | |
| 260 | // and checkbox, as adaptive-wrap does in Emacs. | |
| 261 | var location = clipped.location | |
| 262 | while location < NSMaxRange(clipped) { | |
| 263 | let line = text.lineRange(for: NSRange(location: location, length: 0)) | |
| 264 | let hanging = Self.hangingColumns(text.substring(with: line)) | |
| 265 | let lineRange = NSIntersectionRange(line, clipped) | |
| 266 | storage.addAttribute(.paragraphStyle, value: theme.paragraphStyle(firstLine: run.firstLine, wrapped: run.wrapped + hanging), range: lineRange) | |
| 267 | location = NSMaxRange(line) | |
| 268 | } | |
| 213 | 269 | } |
| 214 | 270 | for run in Presentation.styles(document.tree, in: range) { |
| 215 | 271 | guard let clipped = clip(run.range, to: range) else { continue } |
| @@ -301,6 +357,34 @@ public final class OrgEditor: NSObject { | ||
| 301 | 357 | onEditTiming?(clock.now - start) |
| 302 | 358 | } |
| 303 | 359 | |
| 360 | /// Columns a wrapped continuation of `line` should start past the line's own start. | |
| 361 | nonisolated static func hangingColumns(_ line: String) -> Int { | |
| 362 | var columns = 0 | |
| 363 | var rest = Substring(line) | |
| 364 | while let c = rest.first, c == " " || c == "\t" { | |
| 365 | columns = c == "\t" ? (columns / 8 + 1) * 8 : columns + 1 | |
| 366 | rest = rest.dropFirst() | |
| 367 | } | |
| 368 | // A bullet (`-`, `+`, `*` when indented, `1.`, `1)`) and its space, then a checkbox. | |
| 369 | var bullet = 0 | |
| 370 | if let first = rest.first, first == "-" || first == "+" || (first == "*" && columns > 0) { | |
| 371 | bullet = 1 | |
| 372 | } else { | |
| 373 | let digits = rest.prefix { $0.isASCII && $0.isNumber } | |
| 374 | if !digits.isEmpty, let separator = rest.dropFirst(digits.count).first, separator == "." || separator == ")" { | |
| 375 | bullet = digits.count + 1 | |
| 376 | } | |
| 377 | } | |
| 378 | guard bullet > 0, rest.dropFirst(bullet).first == " " else { return columns } | |
| 379 | columns += bullet + 1 | |
| 380 | let afterBullet = rest.dropFirst(bullet + 1) | |
| 381 | if afterBullet.count >= 4, afterBullet.first == "[", "X x-".contains(afterBullet.dropFirst().first!), | |
| 382 | afterBullet.dropFirst(2).first == "]", afterBullet.dropFirst(3).first == " " { | |
| 383 | columns += 4 | |
| 384 | } | |
| 385 | return columns | |
| 386 | } | |
| 387 | ||
| 304 | 388 | /// Whether a heading line touches `start..<end` or starts right after it, where joining |
| 305 | 389 | /// lines could take it away. |
| 306 | 390 | static func headingLineNear(_ start: Int, _ end: Int, in text: NSString) -> Bool { |
| @@ -332,9 +416,9 @@ public final class OrgEditor: NSObject { | ||
| 332 | 416 | extension OrgEditor: NSTextViewDelegate { |
| 333 | 417 | public func textView(_ textView: NSTextView, doCommandBy selector: Selector) -> Bool { |
| 334 | 418 | switch selector { |
| 335 | case #selector(NSResponder.insertTab(_:)): return toggleFoldAtCaret() | |
| 419 | case #selector(NSResponder.insertTab(_:)): return cycleAtCaret() | |
| 336 | 420 | case #selector(NSResponder.insertBacktab(_:)): |
| 337 | toggleAllFolds() | |
| 421 | cycleGlobal() | |
| 338 | 422 | return true |
| 339 | 423 | default: return false |
| 340 | 424 | } |
Sources/OrgPresentation/Folding.swift added +155
| @@ -0,0 +1,155 @@ | ||
| 1 | import OrgCore | |
| 2 | ||
| 3 | /// Which headings are folded: whole subtrees, or only their own bodies. | |
| 4 | public struct FoldState: Sendable, Equatable { | |
| 5 | public var folds: Set<Int> | |
| 6 | public var bodyFolds: Set<Int> | |
| 7 | ||
| 8 | public init(folds: Set<Int> = [], bodyFolds: Set<Int> = []) { | |
| 9 | self.folds = folds | |
| 10 | self.bodyFolds = bodyFolds | |
| 11 | } | |
| 12 | } | |
| 13 | ||
| 14 | /// org's global visibility states, cycled by Shift-TAB. | |
| 15 | public enum GlobalVisibility: Sendable, Equatable { | |
| 16 | /// Top-level headings only. | |
| 17 | case overview | |
| 18 | /// Every heading, no bodies. | |
| 19 | case contents | |
| 20 | case showAll | |
| 21 | } | |
| 22 | ||
| 23 | public struct OutlineEntry: Sendable, Equatable { | |
| 24 | /// Start offset of the heading line. | |
| 25 | public let start: Int | |
| 26 | public let level: Int | |
| 27 | /// The title as written. | |
| 28 | public let title: String | |
| 29 | /// End of the heading's section. | |
| 30 | public let end: Int | |
| 31 | /// Index of the parent entry. | |
| 32 | public let parent: Int? | |
| 33 | public let isTopLevel: Bool | |
| 34 | } | |
| 35 | ||
| 36 | extension Presentation { | |
| 37 | /// Every heading in document order. | |
| 38 | public static func outline(_ tree: OrgTree) -> [OutlineEntry] { | |
| 39 | var entries: [OutlineEntry] = [] | |
| 40 | collectOutline(tree.root, parent: nil, topLevel: true, &entries) | |
| 41 | return entries | |
| 42 | } | |
| 43 | ||
| 44 | private static func collectOutline(_ node: SyntaxNode, parent: Int?, topLevel: Bool, _ entries: inout [OutlineEntry]) { | |
| 45 | for section in node.children where section.kind == .section { | |
| 46 | guard let heading = section.firstChild(.heading) else { continue } | |
| 47 | let index = entries.count | |
| 48 | entries.append(OutlineEntry( | |
| 49 | start: heading.range.lowerBound, | |
| 50 | level: heading.tokens.first { $0.kind == .stars }?.text.count ?? 1, | |
| 51 | title: heading.firstChild(.title)?.text ?? "", | |
| 52 | end: section.range.upperBound, | |
| 53 | parent: parent, | |
| 54 | isTopLevel: topLevel | |
| 55 | )) | |
| 56 | collectOutline(section, parent: index, topLevel: false, &entries) | |
| 57 | } | |
| 58 | } | |
| 59 | ||
| 60 | /// TAB on a heading: folded, then its children, then the whole subtree, then folded again. | |
| 61 | /// A heading without children goes straight from folded to unfolded. | |
| 62 | public static func cycle(_ heading: Int, in tree: OrgTree, _ state: FoldState) -> FoldState { | |
| 63 | let outline = outline(tree) | |
| 64 | guard let index = outline.firstIndex(where: { $0.start == heading }) else { return state } | |
| 65 | let entry = outline[index] | |
| 66 | let descendants = outline.filter { $0.start > entry.start && $0.start < entry.end }.map(\.start) | |
| 67 | let children = outline.enumerated().filter { $0.element.parent == index }.map(\.element.start) | |
| 68 | var next = state | |
| 69 | func unfoldSubtree() { | |
| 70 | next.folds.subtract([heading] + descendants) | |
| 71 | next.bodyFolds.subtract([heading] + descendants) | |
| 72 | } | |
| 73 | if state.folds.contains(heading) { | |
| 74 | unfoldSubtree() | |
| 75 | next.folds.formUnion(children) | |
| 76 | } else if state.bodyFolds.contains(heading) || descendants.contains(where: { state.folds.contains($0) || state.bodyFolds.contains($0) }) { | |
| 77 | unfoldSubtree() | |
| 78 | } else { | |
| 79 | unfoldSubtree() | |
| 80 | next.folds.insert(heading) | |
| 81 | } | |
| 82 | return next | |
| 83 | } | |
| 84 | ||
| 85 | /// Shift-TAB: overview, contents, show all, and around. | |
| 86 | public static func cycleGlobal(from current: GlobalVisibility?, in tree: OrgTree) -> (GlobalVisibility, FoldState) { | |
| 87 | let next: GlobalVisibility = switch current { | |
| 88 | case .overview: .contents | |
| 89 | case .contents: .showAll | |
| 90 | case .showAll, nil: .overview | |
| 91 | } | |
| 92 | return (next, foldState(for: next, in: tree)) | |
| 93 | } | |
| 94 | ||
| 95 | public static func foldState(for visibility: GlobalVisibility, in tree: OrgTree) -> FoldState { | |
| 96 | let outline = outline(tree) | |
| 97 | switch visibility { | |
| 98 | case .overview: return FoldState(folds: Set(outline.filter(\.isTopLevel).map(\.start))) | |
| 99 | case .contents: return FoldState(bodyFolds: Set(outline.map(\.start))) | |
| 100 | case .showAll: return FoldState() | |
| 101 | } | |
| 102 | } | |
| 103 | ||
| 104 | /// Headings of levels 1 through `levels` visible, no bodies. | |
| 105 | public static func foldState(showingLevels levels: Int, in tree: OrgTree) -> FoldState { | |
| 106 | let outline = outline(tree) | |
| 107 | return FoldState( | |
| 108 | folds: Set(outline.filter { $0.level == levels }.map(\.start)), | |
| 109 | bodyFolds: Set(outline.filter { $0.level < levels }.map(\.start)) | |
| 110 | ) | |
| 111 | } | |
| 112 | ||
| 113 | /// The fold state for the file's `#+STARTUP` visibility, or nil when it sets none. | |
| 114 | /// `overview`/`fold`, `content`, `showall`/`nofold`/`showeverything`, `show2levels`... | |
| 115 | public static func startupFoldState(_ tree: OrgTree) -> FoldState? { | |
| 116 | var result: FoldState? | |
| 117 | for option in startupOptions(tree) { | |
| 118 | switch option.lowercased() { | |
| 119 | case "overview", "fold": result = foldState(for: .overview, in: tree) | |
| 120 | case "content": result = foldState(for: .contents, in: tree) | |
| 121 | case "showall", "nofold", "showeverything": result = FoldState() | |
| 122 | case let word where word.hasPrefix("show") && word.hasSuffix("levels"): | |
| 123 | if let levels = Int(word.dropFirst(4).dropLast(6)), levels > 0 { | |
| 124 | result = foldState(showingLevels: levels, in: tree) | |
| 125 | } | |
| 126 | default: break | |
| 127 | } | |
| 128 | } | |
| 129 | return result | |
| 130 | } | |
| 131 | ||
| 132 | /// Words of every `#+STARTUP:` line. Only element-level keywords count, so text inside | |
| 133 | /// blocks and paragraphs is never read as a setting. | |
| 134 | public static func startupOptions(_ tree: OrgTree) -> [String] { | |
| 135 | var options: [String] = [] | |
| 136 | func visit(_ node: GreenNode) { | |
| 137 | for case .node(let child) in node.children { | |
| 138 | switch child.kind { | |
| 139 | case .keyword: | |
| 140 | let text = child.text | |
| 141 | if text.uppercased().drop(while: { $0 == " " || $0 == "\t" }).hasPrefix("#+STARTUP:"), | |
| 142 | let colon = text.firstIndex(of: ":") { | |
| 143 | options += text[text.index(after: colon)...].split(whereSeparator: \.isWhitespace).map(String.init) | |
| 144 | } | |
| 145 | case .document, .zerothSection, .section, .drawer: | |
| 146 | visit(child) | |
| 147 | default: | |
| 148 | break | |
| 149 | } | |
| 150 | } | |
| 151 | } | |
| 152 | visit(tree.green) | |
| 153 | return options | |
| 154 | } | |
| 155 | } | |
Sources/OrgPresentation/Presentation.swift +16 −10
| @@ -130,25 +130,31 @@ public enum Presentation { | ||
| 130 | 130 | } |
| 131 | 131 | } |
| 132 | 132 | |
| 133 | /// Ranges to hide for folded headings, given by their start offsets: from the end of the | |
| 134 | /// heading line to the end of its section. Sorted, without nested duplicates. | |
| 135 | public static func hiddenRanges(_ tree: OrgTree, folds: Set<Int>) -> [Range<Int>] { | |
| 136 | guard !folds.isEmpty else { return [] } | |
| 133 | /// Ranges to hide. A heading in `folds` hides everything after its line to the end of its | |
| 134 | /// section; one in `bodyFolds` hides only its own body, up to its first child heading. | |
| 135 | /// Sorted, without nested duplicates. | |
| 136 | public static func hiddenRanges(_ tree: OrgTree, folds: Set<Int>, bodyFolds: Set<Int> = []) -> [Range<Int>] { | |
| 137 | guard !folds.isEmpty || !bodyFolds.isEmpty else { return [] } | |
| 137 | 138 | var ranges: [Range<Int>] = [] |
| 138 | collectHidden(tree.root, folds, &ranges) | |
| 139 | collectHidden(tree.root, folds, bodyFolds, &ranges) | |
| 139 | 140 | return ranges |
| 140 | 141 | } |
| 141 | 142 | |
| 142 | private static func collectHidden(_ node: SyntaxNode, _ folds: Set<Int>, _ ranges: inout [Range<Int>]) { | |
| 143 | private static func collectHidden(_ node: SyntaxNode, _ folds: Set<Int>, _ bodyFolds: Set<Int>, _ ranges: inout [Range<Int>]) { | |
| 143 | 144 | for section in node.children where section.kind == .section { |
| 144 | guard let heading = section.children.first(where: { $0.kind == .heading }) else { continue } | |
| 145 | if folds.contains(heading.range.lowerBound) { | |
| 145 | guard let heading = section.firstChild(.heading) else { continue } | |
| 146 | let start = heading.range.lowerBound | |
| 147 | if folds.contains(start) { | |
| 146 | 148 | if heading.range.upperBound < section.range.upperBound { |
| 147 | 149 | ranges.append(heading.range.upperBound..<section.range.upperBound) |
| 148 | 150 | } |
| 149 | } else { | |
| 150 | collectHidden(section, folds, &ranges) | |
| 151 | continue | |
| 152 | } | |
| 153 | if bodyFolds.contains(start) { | |
| 154 | let bodyEnd = section.firstChild(.section)?.range.lowerBound ?? section.range.upperBound | |
| 155 | if heading.range.upperBound < bodyEnd { ranges.append(heading.range.upperBound..<bodyEnd) } | |
| 151 | 156 | } |
| 157 | collectHidden(section, folds, bodyFolds, &ranges) | |
| 152 | 158 | } |
| 153 | 159 | } |
| 154 | 160 | |
Tests/OrgEditorAppKitTests/EditorTests.swift +47 −7
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import AppKit |
| 2 | 2 | import OrgCore |
| 3 | 3 | import OrgDocument |
| 4 | import OrgPresentation | |
| 4 | 5 | import Testing |
| 5 | 6 | @testable import OrgEditorAppKit |
| 6 | 7 | |
| @@ -77,16 +78,45 @@ struct FoldingTests { | ||
| 77 | 78 | let h = Harness(outline) |
| 78 | 79 | #expect(h.visibleLines == ["* a\n", "body\n", "** b\n", "more\n", "* c\n", "text\n"]) |
| 79 | 80 | h.caret(at: 1) |
| 80 | #expect(h.editor.toggleFoldAtCaret()) | |
| 81 | #expect(h.editor.cycleAtCaret()) | |
| 81 | 82 | #expect(h.visibleLines == ["* a\n", "* c\n", "text\n"]) |
| 82 | h.editor.toggleFoldAtCaret() | |
| 83 | h.editor.cycleAtCaret() | |
| 84 | #expect(h.visibleLines == ["* a\n", "body\n", "** b\n", "* c\n", "text\n"]) | |
| 85 | h.editor.cycleAtCaret() | |
| 83 | 86 | #expect(h.visibleLines.count == 6) |
| 84 | 87 | } |
| 85 | 88 | |
| 89 | @Test func globalCycleAndStartup() { | |
| 90 | let h = Harness(outline) | |
| 91 | h.textView.doCommand(by: #selector(NSResponder.insertBacktab(_:))) | |
| 92 | h.textView.doCommand(by: #selector(NSResponder.insertBacktab(_:))) | |
| 93 | #expect(h.visibleLines == ["* a\n", "** b\n", "* c\n"]) | |
| 94 | h.textView.doCommand(by: #selector(NSResponder.insertBacktab(_:))) | |
| 95 | #expect(h.visibleLines.count == 6) | |
| 96 | #expect(Harness("#+STARTUP: overview\n" + outline).visibleLines == ["#+STARTUP: overview\n", "* a\n", "* c\n"]) | |
| 97 | } | |
| 98 | ||
| 99 | @Test func revealUnfoldsWhatHidesTheTarget() { | |
| 100 | let h = Harness("#+STARTUP: overview\n" + outline) | |
| 101 | let target = h.offset(of: "more") | |
| 102 | h.editor.reveal(target) | |
| 103 | #expect(h.visibleLines.contains("more\n")) | |
| 104 | #expect(h.caret == target) | |
| 105 | } | |
| 106 | ||
| 107 | @Test func readOnlyAndReloadKeepFolds() { | |
| 108 | let editor = OrgEditor(document: DocumentState(bytes: Array(outline.utf8)), editable: false) | |
| 109 | #expect(!editor.textView.isEditable) | |
| 110 | editor.setFolds(FoldState(folds: [19])) | |
| 111 | editor.diskChanged(to: Array(("new line\n" + outline).utf8)) | |
| 112 | #expect(editor.document.text == "new line\n" + outline) | |
| 113 | #expect(editor.foldState.folds == [28]) | |
| 114 | } | |
| 115 | ||
| 86 | 116 | @Test func tabFoldsOnlyOnHeadings() { |
| 87 | 117 | let h = Harness(outline) |
| 88 | 118 | h.caret(at: 5) |
| 89 | #expect(!h.editor.toggleFoldAtCaret()) | |
| 119 | #expect(!h.editor.cycleAtCaret()) | |
| 90 | 120 | h.textView.doCommand(by: #selector(NSResponder.insertBacktab(_:))) |
| 91 | 121 | #expect(h.visibleLines == ["* a\n", "* c\n"]) |
| 92 | 122 | } |
| @@ -94,7 +124,7 @@ struct FoldingTests { | ||
| 94 | 124 | @Test func caretMovesOverFoldedText() { |
| 95 | 125 | let h = Harness(outline) |
| 96 | 126 | h.caret(at: 1) |
| 97 | h.editor.toggleFoldAtCaret() | |
| 127 | h.editor.cycleAtCaret() | |
| 98 | 128 | h.caret(at: 0) |
| 99 | 129 | h.textView.moveDown(nil) |
| 100 | 130 | #expect(h.caret >= h.offset(of: "* c") && h.caret < h.offset(of: "text")) |
| @@ -108,7 +138,7 @@ struct FoldingTests { | ||
| 108 | 138 | @Test func copyingAcrossAFoldIncludesHiddenText() { |
| 109 | 139 | let h = Harness(outline) |
| 110 | 140 | h.caret(at: 1) |
| 111 | h.editor.toggleFoldAtCaret() | |
| 141 | h.editor.cycleAtCaret() | |
| 112 | 142 | h.textView.setSelectedRange(NSRange(location: 0, length: h.offset(of: "text"))) |
| 113 | 143 | let pasteboard = NSPasteboard(name: NSPasteboard.Name("orgstar-test-\(UUID().uuidString)")) |
| 114 | 144 | defer { pasteboard.releaseGlobally() } |
| @@ -119,7 +149,7 @@ struct FoldingTests { | ||
| 119 | 149 | @Test func foldsFollowEdits() { |
| 120 | 150 | let h = Harness(outline) |
| 121 | 151 | h.caret(at: h.offset(of: "* c") + 1) |
| 122 | h.editor.toggleFoldAtCaret() | |
| 152 | h.editor.cycleAtCaret() | |
| 123 | 153 | h.caret(at: 0) |
| 124 | 154 | h.textView.insertText("new ", replacementRange: NSRange(location: 2, length: 0)) |
| 125 | 155 | #expect(h.visibleLines == ["* new a\n", "body\n", "** b\n", "more\n", "* c\n"]) |
| @@ -177,7 +207,7 @@ struct EditingTests { | ||
| 177 | 207 | @Test func accessibilityReadsTheWholeText() { |
| 178 | 208 | let h = Harness(outline) |
| 179 | 209 | h.caret(at: 1) |
| 180 | h.editor.toggleFoldAtCaret() | |
| 210 | h.editor.cycleAtCaret() | |
| 181 | 211 | #expect(h.textView.accessibilityValue() as? String == outline) |
| 182 | 212 | h.textView.setSelectedRange(NSRange(location: 0, length: 3)) |
| 183 | 213 | #expect(h.textView.accessibilitySelectedText() == "* a") |
| @@ -280,3 +310,13 @@ struct InitialStylingTests { | ||
| 280 | 310 | #expect(h.editor.styledUpTo == before + 3) |
| 281 | 311 | } |
| 282 | 312 | } |
| 313 | ||
| 314 | struct HangingIndentTests { | |
| 315 | @Test(arguments: [ | |
| 316 | ("plain", 0), (" indented", 2), ("- item", 2), (" - [X] done", 8), ("12. step", 4), | |
| 317 | ("* heading-like", 0), (" * starred item", 4), ("-not a bullet", 0), ("\t- tab", 10), | |
| 318 | ]) | |
| 319 | func columns(line: String, expected: Int) { | |
| 320 | #expect(OrgEditor.hangingColumns(line) == expected) | |
| 321 | } | |
| 322 | } | |
Tests/OrgPresentationTests/PresentationTests.swift +44
| @@ -76,3 +76,47 @@ struct FoldTests { | ||
| 76 | 76 | #expect(Presentation.headings(tree) == [0, 9, 19]) |
| 77 | 77 | } |
| 78 | 78 | } |
| 79 | ||
| 80 | struct CyclingTests { | |
| 81 | let text = "* a\nbody\n** b\nmore\n*** c\ndeep\n* d\n" | |
| 82 | var tree: OrgTree { OrgParser.parse(text) } | |
| 83 | ||
| 84 | @Test func outline() { | |
| 85 | let entries = Presentation.outline(tree) | |
| 86 | #expect(entries.map(\.title) == ["a", "b", "c", "d"]) | |
| 87 | #expect(entries.map(\.parent) == [nil, 0, 1, nil]) | |
| 88 | #expect(entries.map(\.isTopLevel) == [true, false, false, true]) | |
| 89 | } | |
| 90 | ||
| 91 | @Test func localCycle() { | |
| 92 | var state = FoldState() | |
| 93 | state = Presentation.cycle(0, in: tree, state) | |
| 94 | #expect(state == FoldState(folds: [0])) | |
| 95 | state = Presentation.cycle(0, in: tree, state) | |
| 96 | #expect(state == FoldState(folds: [9])) | |
| 97 | state = Presentation.cycle(0, in: tree, state) | |
| 98 | #expect(state == FoldState()) | |
| 99 | #expect(Presentation.cycle(30, in: tree, FoldState(folds: [30])) == FoldState()) | |
| 100 | } | |
| 101 | ||
| 102 | @Test func globalStates() { | |
| 103 | #expect(Presentation.foldState(for: .overview, in: tree) == FoldState(folds: [0, 30])) | |
| 104 | #expect(Presentation.foldState(for: .contents, in: tree) == FoldState(bodyFolds: [0, 9, 19, 30])) | |
| 105 | #expect(Presentation.cycleGlobal(from: nil, in: tree).0 == .overview) | |
| 106 | #expect(Presentation.cycleGlobal(from: .overview, in: tree).0 == .contents) | |
| 107 | #expect(Presentation.cycleGlobal(from: .contents, in: tree).0 == .showAll) | |
| 108 | } | |
| 109 | ||
| 110 | @Test func bodyFoldsKeepChildHeadings() { | |
| 111 | #expect(Presentation.hiddenRanges(tree, folds: [], bodyFolds: [0, 9, 19, 30]) == [4..<9, 14..<19, 25..<30]) | |
| 112 | } | |
| 113 | ||
| 114 | @Test func startup() { | |
| 115 | #expect(Presentation.startupFoldState(OrgParser.parse("#+STARTUP: content\n" + text)) != nil) | |
| 116 | #expect(Presentation.startupFoldState(OrgParser.parse(text)) == nil) | |
| 117 | let levels = OrgParser.parse("#+startup: show2levels\n" + text) | |
| 118 | let state = Presentation.startupFoldState(levels) | |
| 119 | #expect(state?.folds.count == 1 && state?.bodyFolds.count == 2) | |
| 120 | #expect(Presentation.startupOptions(OrgParser.parse("#+begin_example\n#+STARTUP: overview\n#+end_example\n")).isEmpty) | |
| 121 | } | |
| 122 | } | |