Sources/OrgEditorAppKit/OrgEditor.swift
673 lines · 30629 bytes
1#if os(macOS)
2import AppKit
3import OrgCore
4import OrgDocument
5import OrgPresentation
6
7/// Hidden ranges, read by TextKit's enumeration callbacks, which may run off the main thread.
8final class HiddenRanges: @unchecked Sendable {
9 private let lock = NSLock()
10 private var ranges: [Range<Int>] = []
11
12 func set(_ new: [Range<Int>]) {
13 lock.withLock { ranges = new }
14 }
15
16 var all: [Range<Int>] {
17 lock.withLock { ranges }
18 }
19
20 func covers(_ start: Int, _ end: Int) -> Bool {
21 lock.withLock { ranges.contains { $0.lowerBound <= start && end <= $0.upperBound } }
22 }
23
24 func containing(_ offset: Int) -> Range<Int>? {
25 lock.withLock { ranges.first { $0.lowerBound <= offset && offset < $0.upperBound } }
26 }
27}
28
29/// Folded heading starts, read by the layout-fragment callback.
30final class FoldedHeadings: @unchecked Sendable {
31 private let lock = NSLock()
32 private var starts: Set<Int> = []
33
34 func set(_ new: Set<Int>) {
35 lock.withLock { starts = new }
36 }
37
38 func contains(_ offset: Int) -> Bool {
39 lock.withLock { starts.contains(offset) }
40 }
41}
42
43/// A TextKit 2 text view over a `DocumentState`. The text storage mirrors the document; every
44/// character edit goes through `DocumentState.apply`, and only the affected region is restyled.
45@MainActor
46public final class OrgEditor: NSObject {
47 public let textView: NSTextView
48 public private(set) var document: DocumentState
49 public private(set) var view = ViewState()
50 /// Called with the time from a character edit to restyled text.
51 public var onEditTiming: ((Duration) -> Void)?
52
53 let hidden = HiddenRanges()
54 let folded = FoldedHeadings()
55 let theme = Theme()
56 private var isLoading = false
57 private var previousSelection = 0
58 /// Text before this offset is styled; after it, only base attributes until the background
59 /// passes reach it. Always at a line start.
60 private(set) var styledUpTo = 0
61 private var stylingScheduled = false
62 /// One paragraph style object per indent pair, shared by every line that uses it.
63 private var paragraphStyles: [IndentKey: NSParagraphStyle] = [:]
64
65 private struct IndentKey: Hashable {
66 let firstLine: Int
67 let wrapped: Int
68 }
69
70 /// Styled before the first render: more than a screenful.
71 static let firstStyleChunk = 30_000
72 /// Styled per run-loop turn afterwards.
73 static let styleChunk = 200_000
74
75 public init(document: DocumentState, frame: NSRect = NSRect(x: 0, y: 0, width: 600, height: 800), editable: Bool = true) {
76 self.document = document
77 let orgTextView = OrgTextView(usingTextLayoutManager: true)
78 textView = orgTextView
79 dispatcher = KeyDispatcher(keymap: KeymapPreset.emacs.keymap)
80 super.init()
81 orgTextView.keyHandler = { [weak self] event in self?.handleKey(event) ?? false }
82 orgTextView.onMouseDown = { [weak self] in self?.markActive = false }
83 textView.usesFindBar = true
84 textView.frame = frame
85 textView.isRichText = false
86 textView.isEditable = editable
87 textView.allowsUndo = true
88 textView.isAutomaticQuoteSubstitutionEnabled = false
89 textView.isAutomaticDashSubstitutionEnabled = false
90 textView.isAutomaticTextReplacementEnabled = false
91 textView.isAutomaticSpellingCorrectionEnabled = false
92 textView.font = theme.font
93 textView.typingAttributes = theme.base
94 textView.isVerticallyResizable = true
95 textView.autoresizingMask = [.width]
96 textView.textContainer?.widthTracksTextView = true
97 textView.delegate = self
98 textView.textStorage?.delegate = self
99 textLayoutManager.delegate = self
100 textContentStorage.delegate = self
101
102 load(document.text)
103 if let startup = Presentation.startupFoldState(document.tree) { setFolds(startup) }
104 }
105
106 /// Replaces the storage's text. Setting the attributed string directly is about 100x
107 /// faster than `NSTextView.string` for large files.
108 private func load(_ text: String) {
109 isLoading = true
110 textView.textStorage?.setAttributedString(NSAttributedString(string: text, attributes: theme.base))
111 isLoading = false
112 styledUpTo = 0
113 styleNextChunk(Self.firstStyleChunk)
114 scheduleStyling()
115 }
116
117 // MARK: - Initial styling
118
119 /// Styles whole lines from the watermark onward, about `size` units.
120 private func styleNextChunk(_ size: Int) {
121 let text = textView.textStorage!.string as NSString
122 guard styledUpTo < text.length else { return }
123 let target = min(text.length, styledUpTo + size)
124 let end = target == text.length ? target : NSMaxRange(text.lineRange(for: NSRange(location: target, length: 0)))
125 restyleOutsideEditing(styledUpTo..<end)
126 styledUpTo = end
127 }
128
129 private func scheduleStyling() {
130 guard !stylingScheduled, styledUpTo < utf16Length else { return }
131 stylingScheduled = true
132 DispatchQueue.main.async { [weak self] in
133 guard let self else { return }
134 self.stylingScheduled = false
135 self.styleNextChunk(Self.styleChunk)
136 self.scheduleStyling()
137 }
138 }
139
140 /// Styles everything still waiting for a background pass.
141 public func finishStyling() {
142 while styledUpTo < utf16Length { styleNextChunk(Self.styleChunk) }
143 }
144
145 public var textLayoutManager: NSTextLayoutManager { textView.textLayoutManager! }
146 var textContentStorage: NSTextContentStorage { textLayoutManager.textContentManager as! NSTextContentStorage }
147 var utf16Length: Int { (textView.string as NSString).length }
148
149 /// A scroll view holding the text view, ready for a window.
150 public func makeScrollView() -> NSScrollView {
151 let scroll = NSScrollView(frame: textView.frame)
152 scroll.hasVerticalScroller = true
153 scroll.documentView = textView
154 textView.minSize = NSSize(width: 0, height: scroll.contentSize.height)
155 textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
156 return scroll
157 }
158
159 // MARK: - Saving
160
161 /// Saves through `saver`. A merge that changes the buffer reloads the text view.
162 @discardableResult
163 public func save(using saver: Saver, to url: URL) throws -> SaveOutcome {
164 try update { try saver.save(&$0, to: url) }
165 }
166
167 /// Called after every change to the document, typed or not.
168 public var onChange: (() -> Void)?
169
170 // MARK: - Commands
171
172 /// Called with messages commands report, such as why one couldn't run here.
173 public var onMessage: ((String) -> Void)?
174 /// Called with the pending prefix and what can follow it, and with nil when it ends.
175 public var onKeysPending: ((_ keys: String?, _ next: [(key: String, title: String)]) -> Void)?
176 /// Called with `app.*` commands, which the app carries out.
177 public var onAppCommand: ((String) -> Void)?
178 /// Called when a command asks a question; reply with the answer, or nil to cancel.
179 public var onPrompt: ((Prompt, _ reply: @escaping (String?) -> Void) -> Void)?
180
181 public var keymap: Keymap {
182 get { dispatcher.keymap }
183 set {
184 dispatcher.keymap = newValue
185 dispatcher.reset()
186 }
187 }
188
189 public var optionAsMeta: OptionAsMeta = [.left]
190 private var dispatcher: KeyDispatcher
191 /// A region is active: movement extends the selection, as after C-SPC in Emacs.
192 private(set) var markActive = false
193
194 private func context(now: Date = Date(), answers: [String: String] = [:]) -> EditContext {
195 let selected = textView.selectedRange()
196 return EditContext(
197 revision: document.revision, text: document.text, tree: document.tree,
198 selection: [selected.location..<NSMaxRange(selected)], now: now, calendar: .current, answers: answers,
199 hidden: hidden.all
200 )
201 }
202
203 /// Offers a key press to the keymap. False lets the text system have it.
204 func handleKey(_ event: NSEvent) -> Bool {
205 guard let chord = KeyEvents.chord(for: event, optionAsMeta: optionAsMeta) else { return false }
206 switch dispatcher.feed(chord) {
207 case .pending(let keys):
208 let next = keymap.continuations(of: keys).map { item in
209 (key: item.key.description, title: item.command.map { EditorCommands.command($0)?.title ?? $0 } ?? "+prefix")
210 }
211 onKeysPending?(KeySequence.format(keys), next)
212 return true
213 case .cancelled:
214 onKeysPending?(nil, [])
215 onMessage?("Quit")
216 return true
217 case .undefined(let keys):
218 onKeysPending?(nil, [])
219 onMessage?("\(KeySequence.format(keys)) is undefined")
220 return true
221 case .unbound:
222 return false
223 case .complete(let keys, let candidates):
224 if keys.count > 1 { onKeysPending?(nil, []) }
225 let context = context()
226 let runnable = candidates.first { binding in
227 (binding.when.map { KeyContext.holds($0, in: context) } ?? true) && applies(binding.command, in: context)
228 }
229 if let binding = runnable ?? (keys.count > 1 ? candidates.first : nil) {
230 run(binding.command)
231 return true
232 }
233 return false
234 }
235 }
236
237 private func applies(_ id: String, in context: EditContext) -> Bool {
238 switch EditorCommands.command(id)?.action {
239 case .text(let command): command.applies(in: context)
240 case .view: id != "org.cycle" || Presentation.heading(containing: context.caret, in: context.tree) != nil
241 case .edit, .mark, .app: true
242 case nil: true
243 }
244 }
245
246 /// Runs a command by id, as a key, a menu item or the palette would.
247 public func run(_ id: String) {
248 guard let command = EditorCommands.command(id) else {
249 onMessage?("Unknown command \(id)")
250 return
251 }
252 var keepMark = false
253 switch command.action {
254 case .text(let text):
255 perform(text)
256 case .view:
257 if id == "org.cycle" { cycleAtCaret() } else { cycleGlobal() }
258 case .edit(let selector, let extending):
259 if markActive, let extending {
260 keepMark = true
261 textView.doCommand(by: NSSelectorFromString(extending))
262 } else if selector == "performTextFinderAction:" {
263 let item = NSMenuItem()
264 item.tag = NSTextFinder.Action.showFindInterface.rawValue
265 textView.performTextFinderAction(item)
266 } else {
267 textView.doCommand(by: NSSelectorFromString(selector))
268 }
269 case .mark:
270 if id == "edit.set-mark" {
271 let caret = textView.selectedRange().location
272 textView.setSelectedRange(NSRange(location: caret, length: 0))
273 keepMark = true
274 onMessage?("Mark set")
275 } else {
276 let selected = textView.selectedRange()
277 if markActive || selected.length > 0 { textView.setSelectedRange(NSRange(location: NSMaxRange(selected), length: 0)) }
278 onMessage?("Quit")
279 }
280 case .app:
281 onAppCommand?(id)
282 }
283 markActive = keepMark
284 }
285
286 /// Runs `command` at the selection. Edits go through the text view, as typing does, so
287 /// they form one undo step and reach the document through the same path.
288 @discardableResult
289 public func perform(_ command: any OrgCommand, now: Date = Date(), answers: [String: String] = [:]) -> CommandStep {
290 let context = context(now: now, answers: answers)
291 let step = command.run(in: context)
292 switch step {
293 case .commit(let result):
294 guard textView.isEditable || result.edits.isEmpty else {
295 onMessage?("This file is read-only.")
296 return .failed("read-only")
297 }
298 if !result.edits.isEmpty, let storage = textView.textStorage {
299 // Each command is its own undo step, not merged with typing around it.
300 textView.breakUndoCoalescing()
301 textView.undoManager?.beginUndoGrouping()
302 for edit in result.edits.sorted(by: { $0.range.lowerBound > $1.range.lowerBound }) {
303 let range = NSRange(edit.range)
304 guard textView.shouldChangeText(in: range, replacementString: edit.replacement) else { continue }
305 storage.replaceCharacters(in: range, with: edit.replacement)
306 textView.didChangeText()
307 }
308 textView.undoManager?.endUndoGrouping()
309 textView.breakUndoCoalescing()
310 }
311 if let selection = result.selection?.first {
312 setCaret(selection.lowerBound)
313 if !selection.isEmpty { textView.setSelectedRange(NSRange(selection)) }
314 }
315 for case .message(let text) in result.effects { onMessage?(text) }
316 case .failed(let message):
317 onMessage?(message)
318 case .prompt(let prompt):
319 onPrompt?(prompt) { [weak self] answer in
320 guard let self else { return }
321 guard let answer else {
322 self.onMessage?("Quit")
323 return
324 }
325 self.perform(command, now: now, answers: answers.merging([prompt.key: answer]) { $1 })
326 }
327 }
328 return step
329 }
330
331 // MARK: - Disk
332
333 /// The file on disk now holds `bytes`: reload or merge, keeping folds where the text kept
334 /// its headings.
335 @discardableResult
336 public func diskChanged(to bytes: [UInt8]) -> DocumentState.ExternalChange {
337 update { $0.diskChanged(to: bytes) }
338 }
339
340 // MARK: - Folding
341
342 public var foldState: FoldState {
343 FoldState(folds: view.folds, bodyFolds: view.bodyFolds)
344 }
345
346 private var globalVisibility: GlobalVisibility?
347
348 public func setFolds(_ state: FoldState) {
349 view.folds = state.folds
350 view.bodyFolds = state.bodyFolds
351 view = view.pruned(to: document.tree)
352 updateHidden()
353 textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange)
354 textView.needsDisplay = true
355 let caret = textView.selectedRange().location
356 if let range = hidden.containing(caret) { setCaret(range.lowerBound - 1) }
357 }
358
359 private func updateHidden() {
360 folded.set(view.folds.union(view.bodyFolds))
361 hidden.set(Presentation.hiddenRanges(document.tree, folds: view.folds, bodyFolds: view.bodyFolds))
362 }
363
364 /// TAB on a heading: folded, children, subtree. Returns false off a heading.
365 @discardableResult
366 public func cycleAtCaret() -> Bool {
367 guard let heading = Presentation.heading(containing: textView.selectedRange().location, in: document.tree) else { return false }
368 setFolds(Presentation.cycle(heading, in: document.tree, foldState))
369 return true
370 }
371
372 /// Shift-TAB: overview, contents, show all.
373 public func cycleGlobal() {
374 let (visibility, state) = Presentation.cycleGlobal(from: globalVisibility, in: document.tree)
375 globalVisibility = visibility
376 setFolds(state)
377 }
378
379 /// Unfolds whatever hides `offset`, puts the caret there and scrolls to it.
380 public func reveal(_ offset: Int) {
381 let target = max(0, min(offset, utf16Length))
382 var state = foldState
383 for entry in Presentation.outline(document.tree) where entry.start < target && target < entry.end {
384 state.folds.remove(entry.start)
385 state.bodyFolds.remove(entry.start)
386 }
387 setFolds(state)
388 setCaret(target)
389 textView.scrollRangeToVisible(NSRange(location: target, length: 0))
390 }
391
392 private func setCaret(_ offset: Int) {
393 let clamped = max(0, min(offset, utf16Length))
394 previousSelection = clamped
395 textView.setSelectedRange(NSRange(location: clamped, length: 0))
396 }
397
398 // MARK: - Styling
399
400 private func restyleOutsideEditing(_ range: Range<Int>) {
401 guard let storage = textView.textStorage else { return }
402 storage.beginEditing()
403 restyle(range, in: storage)
404 storage.endEditing()
405 }
406
407 /// Sets attributes for whole lines in `range`. Safe inside `didProcessEditing`, where
408 /// attribute changes are allowed and characters must not change.
409 private func restyle(_ range: Range<Int>, in storage: NSTextStorage) {
410 guard !range.isEmpty else { return }
411 let whole = NSRange(range)
412 storage.setAttributes(theme.base, range: whole)
413 let text = storage.string as NSString
414 for run in Presentation.indents(document.tree, in: range) {
415 guard let clipped = clip(run.range, to: range) else { continue }
416 guard run.firstLine == run.wrapped else {
417 storage.addAttribute(.paragraphStyle, value: paragraphStyle(run.firstLine, run.wrapped), range: clipped)
418 continue
419 }
420 // Body lines wrap under their own text: past leading blanks, and past a list bullet
421 // and checkbox, as adaptive-wrap does in Emacs.
422 var location = clipped.location
423 while location < NSMaxRange(clipped) {
424 let line = text.lineRange(for: NSRange(location: location, length: 0))
425 let hanging = Self.hangingColumns(text.substring(with: line))
426 let lineRange = NSIntersectionRange(line, clipped)
427 storage.addAttribute(.paragraphStyle, value: paragraphStyle(run.firstLine, run.wrapped + hanging), range: lineRange)
428 location = NSMaxRange(line)
429 }
430 }
431 for run in Presentation.styles(document.tree, in: range) {
432 guard let clipped = clip(run.range, to: range) else { continue }
433 theme.apply(run.role, to: storage, range: clipped)
434 }
435 }
436
437 private func paragraphStyle(_ firstLine: Int, _ wrapped: Int) -> NSParagraphStyle {
438 let key = IndentKey(firstLine: firstLine, wrapped: wrapped)
439 if let style = paragraphStyles[key] { return style }
440 let style = theme.paragraphStyle(firstLine: firstLine, wrapped: wrapped)
441 paragraphStyles[key] = style
442 return style
443 }
444
445 private func clip(_ run: Range<Int>, to range: Range<Int>) -> NSRange? {
446 let clipped = run.clamped(to: range)
447 return clipped.isEmpty ? nil : NSRange(clipped)
448 }
449
450 /// The region an edit affects in one version of the tree: inside the innermost section
451 /// holding the edited lines (plus the line after), every element that overlaps them or the
452 /// line on either side. A changed heading line, or an added or removed heading, widens it
453 /// to that section's whole own body. Run on the old and the new tree, the union covers
454 /// elements that grew and elements that shrank.
455 static func affectedRange(_ tree: OrgTree, _ text: NSString, _ start: Int, _ end: Int, touchedHeadings: Bool) -> Range<Int> {
456 let lines = text.lineRange(for: NSRange(location: start, length: min(end + 1, text.length) - start))
457 let lineRange = lines.location..<(lines.location + lines.length)
458 let around = max(0, lineRange.lowerBound - 1)..<min(text.length, lineRange.upperBound + 1)
459 var container = tree.root
460 while let child = container.child(containing: lineRange.lowerBound),
461 child.kind == .section || child.kind == .zerothSection,
462 lineRange.upperBound <= child.range.upperBound {
463 container = child
464 }
465 var lower = lineRange.lowerBound
466 var upper = lineRange.upperBound
467 // Tables and lists carry no styling of their own, so inside them only the rows or
468 // items around the edit change, however large they are.
469 func overlapping(_ nodes: [SyntaxNode]) -> [SyntaxNode] {
470 nodes.filter { $0.range.overlaps(around) }.flatMap { node -> [SyntaxNode] in
471 guard [.table, .plainList, .item].contains(node.kind) else { return [node] }
472 let inner = overlapping(node.children(overlapping: around))
473 return inner.isEmpty ? [node] : inner
474 }
475 }
476 for child in overlapping(container.children(overlapping: around)) {
477 lower = min(lower, child.range.lowerBound)
478 upper = max(upper, child.range.upperBound)
479 }
480 let heading = container.firstChild(.heading)
481 if touchedHeadings || heading.map({ $0.range.overlaps(lineRange) }) == true {
482 lower = min(lower, container.range.lowerBound)
483 upper = max(upper, container.firstChild(.section)?.range.lowerBound ?? container.range.upperBound)
484 }
485 return lower..<min(upper, text.length)
486 }
487
488 // MARK: - Editing
489
490 fileprivate func storageEdited(range: NSRange, delta: Int) {
491 guard !isLoading, let storage = textView.textStorage else { return }
492 markActive = false
493 let clock = ContinuousClock()
494 let start = clock.now
495 let replacement = (storage.string as NSString).substring(with: range)
496 let edit = TextEdit(range: range.location..<(range.location + range.length - delta), replacement: replacement)
497 let oldText = document.text as NSString
498 let newText = storage.string as NSString
499 let newEnd = edit.range.lowerBound + replacement.utf16.count
500 let touchedHeadings = Self.headingLineNear(edit.range.lowerBound, edit.range.upperBound, in: oldText)
501 || Self.headingLineNear(edit.range.lowerBound, newEnd, in: newText)
502 let oldAffected = Self.affectedRange(document.tree, oldText, edit.range.lowerBound, edit.range.upperBound, touchedHeadings: touchedHeadings)
503 do {
504 try document.apply([edit], baseRevision: document.revision)
505 } catch {
506 assertionFailure("text view and document disagree: \(error)")
507 return
508 }
509 // Keep the watermark on unstyled text: shift it past the edit, or pull it back to the
510 // edited line when the edit reaches into unstyled text.
511 if styledUpTo >= edit.range.upperBound {
512 styledUpTo += edit.replacement.utf16.count - edit.range.count
513 } else if styledUpTo > edit.range.lowerBound {
514 styledUpTo = (storage.string as NSString).lineRange(for: NSRange(location: edit.range.lowerBound, length: 0)).location
515 }
516 let oldHidden = hidden.all
517 view = view.mapped(through: [edit]).pruned(to: document.tree)
518 folded.set(view.folds)
519 hidden.set(Presentation.hiddenRanges(document.tree, folds: view.folds))
520 let newAffected = Self.affectedRange(document.tree, newText, edit.range.lowerBound, newEnd, touchedHeadings: touchedHeadings)
521 let delta = edit.replacement.utf16.count - edit.range.count
522 let oldMapped = oldAffected.lowerBound..<(oldAffected.upperBound >= edit.range.upperBound ? oldAffected.upperBound + delta : newEnd)
523 restyle(min(oldMapped.lowerBound, newAffected.lowerBound)..<min(max(oldMapped.upperBound, newAffected.upperBound), newText.length), in: storage)
524 if hidden.all != oldHidden.map({ mapRange($0, through: edit) }) {
525 textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange)
526 }
527 onEditTiming?(clock.now - start)
528 onChange?()
529 }
530
531 /// Columns a wrapped continuation of `line` should start past the line's own start.
532 nonisolated static func hangingColumns(_ line: String) -> Int {
533 var columns = 0
534 var rest = Substring(line)
535 while let c = rest.first, c == " " || c == "\t" {
536 columns = c == "\t" ? (columns / 8 + 1) * 8 : columns + 1
537 rest = rest.dropFirst()
538 }
539 // A bullet (`-`, `+`, `*` when indented, `1.`, `1)`) and its space, then a checkbox.
540 var bullet = 0
541 if let first = rest.first, first == "-" || first == "+" || (first == "*" && columns > 0) {
542 bullet = 1
543 } else {
544 let digits = rest.prefix { $0.isASCII && $0.isNumber }
545 if !digits.isEmpty, let separator = rest.dropFirst(digits.count).first, separator == "." || separator == ")" {
546 bullet = digits.count + 1
547 }
548 }
549 guard bullet > 0, rest.dropFirst(bullet).first == " " else { return columns }
550 columns += bullet + 1
551 let afterBullet = rest.dropFirst(bullet + 1)
552 if afterBullet.count >= 4, afterBullet.first == "[", "X x-".contains(afterBullet.dropFirst().first!),
553 afterBullet.dropFirst(2).first == "]", afterBullet.dropFirst(3).first == " " {
554 columns += 4
555 }
556 return columns
557 }
558
559 /// Whether a heading line touches `start..<end` or starts right after it, where joining
560 /// lines could take it away.
561 static func headingLineNear(_ start: Int, _ end: Int, in text: NSString) -> Bool {
562 let length = min(end + 1, text.length) - start
563 guard length >= 0 else { return false }
564 let lines = text.substring(with: text.lineRange(for: NSRange(location: start, length: length)))
565 return lines.split(separator: "\n", omittingEmptySubsequences: false).contains { line in
566 let stars = line.prefix { $0 == "*" }
567 let after = line.dropFirst(stars.count)
568 return !stars.isEmpty && (after.isEmpty || after.first == " " || after.first == "\t" || after.first == "\r")
569 }
570 }
571
572 private func mapRange(_ range: Range<Int>, through edit: TextEdit) -> Range<Int> {
573 let mapped = ViewState(selection: [range]).mapped(through: [edit]).selection[0]
574 return mapped
575 }
576
577 /// Keeps the caret out of folded text: moving forward into a fold jumps past it, moving
578 /// back jumps to the end of the heading line.
579 fileprivate func selectionChanged() {
580 let selection = textView.selectedRange()
581 defer { previousSelection = selection.location }
582 guard selection.length == 0, let range = hidden.containing(selection.location) else { return }
583 setCaret(selection.location >= previousSelection ? range.upperBound : range.lowerBound - 1)
584 }
585}
586
587extension OrgEditor: DocumentBuffer {
588 /// Runs `change`; if it changed the text, reloads the view with folds and the selection
589 /// mapped through the change. Text-view undo refers to the old text, so it is cleared.
590 public func update<T>(_ change: (inout DocumentState) throws -> T) rethrows -> T {
591 let before = document.text
592 let selected = textView.selectedRange()
593 let result = try change(&document)
594 if document.text != before {
595 let edits = lineEdits(from: before, to: document.text)
596 var state = view
597 state.selection = [selected.location..<NSMaxRange(selected)]
598 let mapped = state.mapped(through: edits)
599 load(document.text)
600 setFolds(FoldState(folds: mapped.folds, bodyFolds: mapped.bodyFolds))
601 let selection = mapped.selection[0]
602 setCaret(selection.lowerBound)
603 if !selection.isEmpty { textView.setSelectedRange(NSRange(selection)) }
604 textView.undoManager?.removeAllActions(withTarget: textView.textStorage!)
605 textView.undoManager?.removeAllActions(withTarget: textView)
606 }
607 onChange?()
608 return result
609 }
610}
611
612extension OrgEditor: NSTextViewDelegate {
613 public func textViewDidChangeSelection(_ notification: Notification) {
614 selectionChanged()
615 }
616}
617
618extension OrgEditor: NSTextStorageDelegate {
619 public nonisolated func textStorage(
620 _ textStorage: NSTextStorage, didProcessEditing editedMask: NSTextStorageEditActions, range editedRange: NSRange, changeInLength delta: Int
621 ) {
622 guard editedMask.contains(.editedCharacters) else { return }
623 MainActor.assumeIsolated { storageEdited(range: editedRange, delta: delta) }
624 }
625}
626
627extension OrgEditor: NSTextContentStorageDelegate {
628 /// Folded text isn't enumerated, so it isn't laid out or drawn.
629 public nonisolated func textContentManager(
630 _ textContentManager: NSTextContentManager, shouldEnumerate textElement: NSTextElement, options: NSTextContentManager.EnumerationOptions = []
631 ) -> Bool {
632 guard let range = textElement.elementRange else { return true }
633 let documentStart = textContentManager.documentRange.location
634 let start = textContentManager.offset(from: documentStart, to: range.location)
635 let end = textContentManager.offset(from: documentStart, to: range.endLocation)
636 return !hidden.covers(start, end)
637 }
638}
639
640extension OrgEditor: NSTextLayoutManagerDelegate {
641 public nonisolated func textLayoutManager(
642 _ textLayoutManager: NSTextLayoutManager, textLayoutFragmentFor location: NSTextLocation, in textElement: NSTextElement
643 ) -> NSTextLayoutFragment {
644 let range = textElement.elementRange
645 if let range, let manager = textLayoutManager.textContentManager {
646 let offset = manager.offset(from: manager.documentRange.location, to: range.location)
647 if folded.contains(offset) { return FoldedHeadingFragment(textElement: textElement, range: range) }
648 }
649 return NSTextLayoutFragment(textElement: textElement, range: range)
650 }
651}
652
653/// A heading line drawn with an ellipsis after it, like org's folded headings.
654final class FoldedHeadingFragment: NSTextLayoutFragment, @unchecked Sendable {
655 override func draw(at point: CGPoint, in context: CGContext) {
656 super.draw(at: point, in: context)
657 guard let line = textLineFragments.first else { return }
658 let bounds = line.typographicBounds
659 let ellipsis = NSAttributedString(string: " …", attributes: [
660 .font: NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular),
661 .foregroundColor: NSColor.secondaryLabelColor,
662 ])
663 NSGraphicsContext.saveGraphicsState()
664 NSGraphicsContext.current = NSGraphicsContext(cgContext: context, flipped: true)
665 ellipsis.draw(at: CGPoint(x: point.x + bounds.maxX, y: point.y + bounds.minY))
666 NSGraphicsContext.restoreGraphicsState()
667 }
668
669 override var renderingSurfaceBounds: CGRect {
670 super.renderingSurfaceBounds.insetBy(dx: -40, dy: 0).offsetBy(dx: 40, dy: 0)
671 }
672}
673#endif