Commit 34d9da1754
Verified · cmc
Layout: unified · split
Sources/OrgDocument/ViewState.swift +13 −5
| @@ -3,12 +3,15 @@ import OrgCore | |||
| 3 | /// Per-window state for one document: selection and folds, in UTF-16 offsets. | 3 | /// Per-window state for one document: selection and folds, in UTF-16 offsets. |
| 4 | public struct ViewState: Sendable, Equatable { | 4 | public struct ViewState: Sendable, Equatable { |
| 5 | public var selection: [Range<Int>] | 5 | public var selection: [Range<Int>] |
| 6 | /// Start offsets of folded headings. | 6 | /// Start offsets of headings whose whole subtree is folded. |
| 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. | ||
| 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 | self.selection = selection | 12 | self.selection = selection |
| 11 | self.folds = folds | 13 | self.folds = folds |
| 14 | self.bodyFolds = bodyFolds | ||
| 12 | } | 15 | } |
| 13 | 16 | ||
| 14 | /// Maps through non-overlapping edits given in old coordinates. | 17 | /// Maps through non-overlapping edits given in old coordinates. |
| @@ -16,14 +19,19 @@ public struct ViewState: Sendable, Equatable { | |||
| 16 | let sorted = edits.sorted { $0.range.lowerBound < $1.range.lowerBound } | 19 | let sorted = edits.sorted { $0.range.lowerBound < $1.range.lowerBound } |
| 17 | return ViewState( | 20 | return ViewState( |
| 18 | selection: selection.map { mapOffset($0.lowerBound, sorted)..<mapOffset($0.upperBound, sorted) }, | 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 | /// Drops folds that no longer sit at the start of a heading. | 27 | /// Drops folds that no longer sit at the start of a heading. |
| 24 | public func pruned(to tree: OrgTree) -> ViewState { | 28 | public func pruned(to tree: OrgTree) -> ViewState { |
| 25 | guard !folds.isEmpty else { return self } | 29 | guard !folds.isEmpty || !bodyFolds.isEmpty else { return self } |
| 26 | return ViewState(selection: selection, folds: folds.filter { isHeadingStart($0, tree.root) }) | 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 | /// Styled per run-loop turn afterwards. | 65 | /// Styled per run-loop turn afterwards. |
| 66 | static let styleChunk = 200_000 | 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 | self.document = document | 69 | self.document = document |
| 70 | textView = NSTextView(usingTextLayoutManager: true) | 70 | textView = NSTextView(usingTextLayoutManager: true) |
| 71 | super.init() | 71 | super.init() |
| 72 | textView.frame = frame | 72 | textView.frame = frame |
| 73 | textView.isRichText = false | 73 | textView.isRichText = false |
| 74 | textView.isEditable = editable | ||
| 74 | textView.allowsUndo = true | 75 | textView.allowsUndo = true |
| 75 | textView.isAutomaticQuoteSubstitutionEnabled = false | 76 | textView.isAutomaticQuoteSubstitutionEnabled = false |
| 76 | textView.isAutomaticDashSubstitutionEnabled = false | 77 | textView.isAutomaticDashSubstitutionEnabled = false |
| @@ -87,6 +88,7 @@ public final class OrgEditor: NSObject { | |||
| 87 | textContentStorage.delegate = self | 88 | textContentStorage.delegate = self |
| 88 | 89 | ||
| 89 | load(document.text) | 90 | load(document.text) |
| 91 | if let startup = Presentation.startupFoldState(document.tree) { setFolds(startup) } | ||
| 90 | } | 92 | } |
| 91 | 93 | ||
| 92 | /// Replaces the storage's text. Setting the attributed string directly is about 100x | 94 | /// Replaces the storage's text. Setting the attributed string directly is about 100x |
| @@ -153,37 +155,77 @@ public final class OrgEditor: NSObject { | |||
| 153 | if document.text != before { | 155 | if document.text != before { |
| 154 | let edits = lineEdits(from: before, to: document.text) | 156 | let edits = lineEdits(from: before, to: document.text) |
| 155 | load(document.text) | 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 | return outcome | 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 | // MARK: - Folding | 179 | // MARK: - Folding |
| 162 | 180 | ||
| 163 | public func setFolds(_ folds: Set<Int>) { | 181 | public var foldState: FoldState { |
| 164 | view.folds = folds | 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 | view = view.pruned(to: document.tree) | 190 | view = view.pruned(to: document.tree) |
| 166 | folded.set(view.folds) | 191 | updateHidden() |
| 167 | hidden.set(Presentation.hiddenRanges(document.tree, folds: view.folds)) | ||
| 168 | textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange) | 192 | textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange) |
| 169 | textView.needsDisplay = true | 193 | textView.needsDisplay = true |
| 170 | let caret = textView.selectedRange().location | 194 | let caret = textView.selectedRange().location |
| 171 | if let range = hidden.containing(caret) { setCaret(range.lowerBound - 1) } | 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 | @discardableResult | 204 | @discardableResult |
| 176 | public func toggleFoldAtCaret() -> Bool { | 205 | public func cycleAtCaret() -> Bool { |
| 177 | guard let heading = Presentation.heading(containing: textView.selectedRange().location, in: document.tree) else { return false } | 206 | guard let heading = Presentation.heading(containing: textView.selectedRange().location, in: document.tree) else { return false } |
| 178 | var folds = view.folds | 207 | setFolds(Presentation.cycle(heading, in: document.tree, foldState)) |
| 179 | if folds.contains(heading) { folds.remove(heading) } else { folds.insert(heading) } | ||
| 180 | setFolds(folds) | ||
| 181 | return true | 208 | return true |
| 182 | } | 209 | } |
| 183 | 210 | ||
| 184 | /// Folds every heading, or unfolds everything if anything is folded. | 211 | /// Shift-TAB: overview, contents, show all. |
| 185 | public func toggleAllFolds() { | 212 | public func cycleGlobal() { |
| 186 | setFolds(view.folds.isEmpty ? Set(Presentation.headings(document.tree)) : []) | 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 | private func setCaret(_ offset: Int) { | 231 | private func setCaret(_ offset: Int) { |
| @@ -207,9 +249,23 @@ public final class OrgEditor: NSObject { | |||
| 207 | guard !range.isEmpty else { return } | 249 | guard !range.isEmpty else { return } |
| 208 | let whole = NSRange(range) | 250 | let whole = NSRange(range) |
| 209 | storage.setAttributes(theme.base, range: whole) | 251 | storage.setAttributes(theme.base, range: whole) |
| 252 | let text = storage.string as NSString | ||
| 210 | for run in Presentation.indents(document.tree, in: range) { | 253 | for run in Presentation.indents(document.tree, in: range) { |
| 211 | guard let clipped = clip(run.range, to: range) else { continue } | 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 | for run in Presentation.styles(document.tree, in: range) { | 270 | for run in Presentation.styles(document.tree, in: range) { |
| 215 | guard let clipped = clip(run.range, to: range) else { continue } | 271 | guard let clipped = clip(run.range, to: range) else { continue } |
| @@ -301,6 +357,34 @@ public final class OrgEditor: NSObject { | |||
| 301 | onEditTiming?(clock.now - start) | 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 | /// Whether a heading line touches `start..<end` or starts right after it, where joining | 388 | /// Whether a heading line touches `start..<end` or starts right after it, where joining |
| 305 | /// lines could take it away. | 389 | /// lines could take it away. |
| 306 | static func headingLineNear(_ start: Int, _ end: Int, in text: NSString) -> Bool { | 390 | static func headingLineNear(_ start: Int, _ end: Int, in text: NSString) -> Bool { |
| @@ -332,9 +416,9 @@ public final class OrgEditor: NSObject { | |||
| 332 | extension OrgEditor: NSTextViewDelegate { | 416 | extension OrgEditor: NSTextViewDelegate { |
| 333 | public func textView(_ textView: NSTextView, doCommandBy selector: Selector) -> Bool { | 417 | public func textView(_ textView: NSTextView, doCommandBy selector: Selector) -> Bool { |
| 334 | switch selector { | 418 | switch selector { |
| 335 | case #selector(NSResponder.insertTab(_:)): return toggleFoldAtCaret() | 419 | case #selector(NSResponder.insertTab(_:)): return cycleAtCaret() |
| 336 | case #selector(NSResponder.insertBacktab(_:)): | 420 | case #selector(NSResponder.insertBacktab(_:)): |
| 337 | toggleAllFolds() | 421 | cycleGlobal() |
| 338 | return true | 422 | return true |
| 339 | default: return false | 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 | 133 | /// Ranges to hide. A heading in `folds` hides everything after its line to the end of its |
| 134 | /// heading line to the end of its section. Sorted, without nested duplicates. | 134 | /// section; one in `bodyFolds` hides only its own body, up to its first child heading. |
| 135 | public static func hiddenRanges(_ tree: OrgTree, folds: Set<Int>) -> [Range<Int>] { | 135 | /// Sorted, without nested duplicates. |
| 136 | guard !folds.isEmpty else { return [] } | 136 | public static func hiddenRanges(_ tree: OrgTree, folds: Set<Int>, bodyFolds: Set<Int> = []) -> [Range<Int>] { |
| 137 | guard !folds.isEmpty || !bodyFolds.isEmpty else { return [] } | ||
| 137 | var ranges: [Range<Int>] = [] | 138 | var ranges: [Range<Int>] = [] |
| 138 | collectHidden(tree.root, folds, &ranges) | 139 | collectHidden(tree.root, folds, bodyFolds, &ranges) |
| 139 | return ranges | 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 | for section in node.children where section.kind == .section { | 144 | for section in node.children where section.kind == .section { |
| 144 | guard let heading = section.children.first(where: { $0.kind == .heading }) else { continue } | 145 | guard let heading = section.firstChild(.heading) else { continue } |
| 145 | if folds.contains(heading.range.lowerBound) { | 146 | let start = heading.range.lowerBound |
| 147 | if folds.contains(start) { | ||
| 146 | if heading.range.upperBound < section.range.upperBound { | 148 | if heading.range.upperBound < section.range.upperBound { |
| 147 | ranges.append(heading.range.upperBound..<section.range.upperBound) | 149 | ranges.append(heading.range.upperBound..<section.range.upperBound) |
| 148 | } | 150 | } |
| 149 | } else { | 151 | continue |
| 150 | collectHidden(section, folds, &ranges) | 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 | import AppKit | 1 | import AppKit |
| 2 | import OrgCore | 2 | import OrgCore |
| 3 | import OrgDocument | 3 | import OrgDocument |
| 4 | import OrgPresentation | ||
| 4 | import Testing | 5 | import Testing |
| 5 | @testable import OrgEditorAppKit | 6 | @testable import OrgEditorAppKit |
| 6 | 7 | ||
| @@ -77,16 +78,45 @@ struct FoldingTests { | |||
| 77 | let h = Harness(outline) | 78 | let h = Harness(outline) |
| 78 | #expect(h.visibleLines == ["* a\n", "body\n", "** b\n", "more\n", "* c\n", "text\n"]) | 79 | #expect(h.visibleLines == ["* a\n", "body\n", "** b\n", "more\n", "* c\n", "text\n"]) |
| 79 | h.caret(at: 1) | 80 | h.caret(at: 1) |
| 80 | #expect(h.editor.toggleFoldAtCaret()) | 81 | #expect(h.editor.cycleAtCaret()) |
| 81 | #expect(h.visibleLines == ["* a\n", "* c\n", "text\n"]) | 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 | #expect(h.visibleLines.count == 6) | 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 | @Test func tabFoldsOnlyOnHeadings() { | 116 | @Test func tabFoldsOnlyOnHeadings() { |
| 87 | let h = Harness(outline) | 117 | let h = Harness(outline) |
| 88 | h.caret(at: 5) | 118 | h.caret(at: 5) |
| 89 | #expect(!h.editor.toggleFoldAtCaret()) | 119 | #expect(!h.editor.cycleAtCaret()) |
| 90 | h.textView.doCommand(by: #selector(NSResponder.insertBacktab(_:))) | 120 | h.textView.doCommand(by: #selector(NSResponder.insertBacktab(_:))) |
| 91 | #expect(h.visibleLines == ["* a\n", "* c\n"]) | 121 | #expect(h.visibleLines == ["* a\n", "* c\n"]) |
| 92 | } | 122 | } |
| @@ -94,7 +124,7 @@ struct FoldingTests { | |||
| 94 | @Test func caretMovesOverFoldedText() { | 124 | @Test func caretMovesOverFoldedText() { |
| 95 | let h = Harness(outline) | 125 | let h = Harness(outline) |
| 96 | h.caret(at: 1) | 126 | h.caret(at: 1) |
| 97 | h.editor.toggleFoldAtCaret() | 127 | h.editor.cycleAtCaret() |
| 98 | h.caret(at: 0) | 128 | h.caret(at: 0) |
| 99 | h.textView.moveDown(nil) | 129 | h.textView.moveDown(nil) |
| 100 | #expect(h.caret >= h.offset(of: "* c") && h.caret < h.offset(of: "text")) | 130 | #expect(h.caret >= h.offset(of: "* c") && h.caret < h.offset(of: "text")) |
| @@ -108,7 +138,7 @@ struct FoldingTests { | |||
| 108 | @Test func copyingAcrossAFoldIncludesHiddenText() { | 138 | @Test func copyingAcrossAFoldIncludesHiddenText() { |
| 109 | let h = Harness(outline) | 139 | let h = Harness(outline) |
| 110 | h.caret(at: 1) | 140 | h.caret(at: 1) |
| 111 | h.editor.toggleFoldAtCaret() | 141 | h.editor.cycleAtCaret() |
| 112 | h.textView.setSelectedRange(NSRange(location: 0, length: h.offset(of: "text"))) | 142 | h.textView.setSelectedRange(NSRange(location: 0, length: h.offset(of: "text"))) |
| 113 | let pasteboard = NSPasteboard(name: NSPasteboard.Name("orgstar-test-\(UUID().uuidString)")) | 143 | let pasteboard = NSPasteboard(name: NSPasteboard.Name("orgstar-test-\(UUID().uuidString)")) |
| 114 | defer { pasteboard.releaseGlobally() } | 144 | defer { pasteboard.releaseGlobally() } |
| @@ -119,7 +149,7 @@ struct FoldingTests { | |||
| 119 | @Test func foldsFollowEdits() { | 149 | @Test func foldsFollowEdits() { |
| 120 | let h = Harness(outline) | 150 | let h = Harness(outline) |
| 121 | h.caret(at: h.offset(of: "* c") + 1) | 151 | h.caret(at: h.offset(of: "* c") + 1) |
| 122 | h.editor.toggleFoldAtCaret() | 152 | h.editor.cycleAtCaret() |
| 123 | h.caret(at: 0) | 153 | h.caret(at: 0) |
| 124 | h.textView.insertText("new ", replacementRange: NSRange(location: 2, length: 0)) | 154 | h.textView.insertText("new ", replacementRange: NSRange(location: 2, length: 0)) |
| 125 | #expect(h.visibleLines == ["* new a\n", "body\n", "** b\n", "more\n", "* c\n"]) | 155 | #expect(h.visibleLines == ["* new a\n", "body\n", "** b\n", "more\n", "* c\n"]) |
| @@ -177,7 +207,7 @@ struct EditingTests { | |||
| 177 | @Test func accessibilityReadsTheWholeText() { | 207 | @Test func accessibilityReadsTheWholeText() { |
| 178 | let h = Harness(outline) | 208 | let h = Harness(outline) |
| 179 | h.caret(at: 1) | 209 | h.caret(at: 1) |
| 180 | h.editor.toggleFoldAtCaret() | 210 | h.editor.cycleAtCaret() |
| 181 | #expect(h.textView.accessibilityValue() as? String == outline) | 211 | #expect(h.textView.accessibilityValue() as? String == outline) |
| 182 | h.textView.setSelectedRange(NSRange(location: 0, length: 3)) | 212 | h.textView.setSelectedRange(NSRange(location: 0, length: 3)) |
| 183 | #expect(h.textView.accessibilitySelectedText() == "* a") | 213 | #expect(h.textView.accessibilitySelectedText() == "* a") |
| @@ -280,3 +310,13 @@ struct InitialStylingTests { | |||
| 280 | #expect(h.editor.styledUpTo == before + 3) | 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 | #expect(Presentation.headings(tree) == [0, 9, 19]) | 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 | } | ||