Sources/OrgEditorAppKit/OrgEditor.swift
663 lines · 30085 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
179 public var keymap: Keymap {
180 get { dispatcher.keymap }
181 set {
182 dispatcher.keymap = newValue
183 dispatcher.reset()
184 }
185 }
186
187 public var optionAsMeta: OptionAsMeta = [.left]
188 private var dispatcher: KeyDispatcher
189 /// A region is active: movement extends the selection, as after C-SPC in Emacs.
190 private(set) var markActive = false
191
192 private func context(now: Date = Date(), answers: [String: String] = [:]) -> EditContext {
193 let selected = textView.selectedRange()
194 return EditContext(
195 revision: document.revision, text: document.text, tree: document.tree,
196 selection: [selected.location..<NSMaxRange(selected)], now: now, calendar: .current, answers: answers
197 )
198 }
199
200 /// Offers a key press to the keymap. False lets the text system have it.
201 func handleKey(_ event: NSEvent) -> Bool {
202 guard let chord = KeyEvents.chord(for: event, optionAsMeta: optionAsMeta) else { return false }
203 switch dispatcher.feed(chord) {
204 case .pending(let keys):
205 let next = keymap.continuations(of: keys).map { item in
206 (key: item.key.description, title: item.command.map { EditorCommands.command($0)?.title ?? $0 } ?? "+prefix")
207 }
208 onKeysPending?(KeySequence.format(keys), next)
209 return true
210 case .cancelled:
211 onKeysPending?(nil, [])
212 onMessage?("Quit")
213 return true
214 case .undefined(let keys):
215 onKeysPending?(nil, [])
216 onMessage?("\(KeySequence.format(keys)) is undefined")
217 return true
218 case .unbound:
219 return false
220 case .complete(let keys, let candidates):
221 if keys.count > 1 { onKeysPending?(nil, []) }
222 let context = context()
223 let runnable = candidates.first { binding in
224 (binding.when.map { KeyContext.holds($0, in: context) } ?? true) && applies(binding.command, in: context)
225 }
226 if let binding = runnable ?? (keys.count > 1 ? candidates.first : nil) {
227 run(binding.command)
228 return true
229 }
230 return false
231 }
232 }
233
234 private func applies(_ id: String, in context: EditContext) -> Bool {
235 switch EditorCommands.command(id)?.action {
236 case .text(let command): command.applies(in: context)
237 case .view: id != "org.cycle" || Presentation.heading(containing: context.caret, in: context.tree) != nil
238 case .edit, .mark, .app: true
239 case nil: true
240 }
241 }
242
243 /// Runs a command by id, as a key, a menu item or the palette would.
244 public func run(_ id: String) {
245 guard let command = EditorCommands.command(id) else {
246 onMessage?("Unknown command \(id)")
247 return
248 }
249 var keepMark = false
250 switch command.action {
251 case .text(let text):
252 perform(text)
253 case .view:
254 if id == "org.cycle" { cycleAtCaret() } else { cycleGlobal() }
255 case .edit(let selector, let extending):
256 if markActive, let extending {
257 keepMark = true
258 textView.doCommand(by: NSSelectorFromString(extending))
259 } else if selector == "performTextFinderAction:" {
260 let item = NSMenuItem()
261 item.tag = NSTextFinder.Action.showFindInterface.rawValue
262 textView.performTextFinderAction(item)
263 } else {
264 textView.doCommand(by: NSSelectorFromString(selector))
265 }
266 case .mark:
267 if id == "edit.set-mark" {
268 let caret = textView.selectedRange().location
269 textView.setSelectedRange(NSRange(location: caret, length: 0))
270 keepMark = true
271 onMessage?("Mark set")
272 } else {
273 let selected = textView.selectedRange()
274 if markActive || selected.length > 0 { textView.setSelectedRange(NSRange(location: NSMaxRange(selected), length: 0)) }
275 onMessage?("Quit")
276 }
277 case .app:
278 onAppCommand?(id)
279 }
280 markActive = keepMark
281 }
282
283 /// Runs `command` at the selection. Edits go through the text view, as typing does, so
284 /// they form one undo step and reach the document through the same path.
285 @discardableResult
286 public func perform(_ command: any OrgCommand, now: Date = Date(), answers: [String: String] = [:]) -> CommandStep {
287 let context = context(now: now, answers: answers)
288 let step = command.run(in: context)
289 switch step {
290 case .commit(let result):
291 guard textView.isEditable || result.edits.isEmpty else {
292 onMessage?("This file is read-only.")
293 return .failed("read-only")
294 }
295 if !result.edits.isEmpty, let storage = textView.textStorage {
296 // Each command is its own undo step, not merged with typing around it.
297 textView.breakUndoCoalescing()
298 textView.undoManager?.beginUndoGrouping()
299 for edit in result.edits.sorted(by: { $0.range.lowerBound > $1.range.lowerBound }) {
300 let range = NSRange(edit.range)
301 guard textView.shouldChangeText(in: range, replacementString: edit.replacement) else { continue }
302 storage.replaceCharacters(in: range, with: edit.replacement)
303 textView.didChangeText()
304 }
305 textView.undoManager?.endUndoGrouping()
306 textView.breakUndoCoalescing()
307 }
308 if let selection = result.selection?.first {
309 setCaret(selection.lowerBound)
310 if !selection.isEmpty { textView.setSelectedRange(NSRange(selection)) }
311 }
312 for case .message(let text) in result.effects { onMessage?(text) }
313 case .failed(let message):
314 onMessage?(message)
315 case .prompt:
316 break
317 }
318 return step
319 }
320
321 // MARK: - Disk
322
323 /// The file on disk now holds `bytes`: reload or merge, keeping folds where the text kept
324 /// its headings.
325 @discardableResult
326 public func diskChanged(to bytes: [UInt8]) -> DocumentState.ExternalChange {
327 update { $0.diskChanged(to: bytes) }
328 }
329
330 // MARK: - Folding
331
332 public var foldState: FoldState {
333 FoldState(folds: view.folds, bodyFolds: view.bodyFolds)
334 }
335
336 private var globalVisibility: GlobalVisibility?
337
338 public func setFolds(_ state: FoldState) {
339 view.folds = state.folds
340 view.bodyFolds = state.bodyFolds
341 view = view.pruned(to: document.tree)
342 updateHidden()
343 textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange)
344 textView.needsDisplay = true
345 let caret = textView.selectedRange().location
346 if let range = hidden.containing(caret) { setCaret(range.lowerBound - 1) }
347 }
348
349 private func updateHidden() {
350 folded.set(view.folds.union(view.bodyFolds))
351 hidden.set(Presentation.hiddenRanges(document.tree, folds: view.folds, bodyFolds: view.bodyFolds))
352 }
353
354 /// TAB on a heading: folded, children, subtree. Returns false off a heading.
355 @discardableResult
356 public func cycleAtCaret() -> Bool {
357 guard let heading = Presentation.heading(containing: textView.selectedRange().location, in: document.tree) else { return false }
358 setFolds(Presentation.cycle(heading, in: document.tree, foldState))
359 return true
360 }
361
362 /// Shift-TAB: overview, contents, show all.
363 public func cycleGlobal() {
364 let (visibility, state) = Presentation.cycleGlobal(from: globalVisibility, in: document.tree)
365 globalVisibility = visibility
366 setFolds(state)
367 }
368
369 /// Unfolds whatever hides `offset`, puts the caret there and scrolls to it.
370 public func reveal(_ offset: Int) {
371 let target = max(0, min(offset, utf16Length))
372 var state = foldState
373 for entry in Presentation.outline(document.tree) where entry.start < target && target < entry.end {
374 state.folds.remove(entry.start)
375 state.bodyFolds.remove(entry.start)
376 }
377 setFolds(state)
378 setCaret(target)
379 textView.scrollRangeToVisible(NSRange(location: target, length: 0))
380 }
381
382 private func setCaret(_ offset: Int) {
383 let clamped = max(0, min(offset, utf16Length))
384 previousSelection = clamped
385 textView.setSelectedRange(NSRange(location: clamped, length: 0))
386 }
387
388 // MARK: - Styling
389
390 private func restyleOutsideEditing(_ range: Range<Int>) {
391 guard let storage = textView.textStorage else { return }
392 storage.beginEditing()
393 restyle(range, in: storage)
394 storage.endEditing()
395 }
396
397 /// Sets attributes for whole lines in `range`. Safe inside `didProcessEditing`, where
398 /// attribute changes are allowed and characters must not change.
399 private func restyle(_ range: Range<Int>, in storage: NSTextStorage) {
400 guard !range.isEmpty else { return }
401 let whole = NSRange(range)
402 storage.setAttributes(theme.base, range: whole)
403 let text = storage.string as NSString
404 for run in Presentation.indents(document.tree, in: range) {
405 guard let clipped = clip(run.range, to: range) else { continue }
406 guard run.firstLine == run.wrapped else {
407 storage.addAttribute(.paragraphStyle, value: paragraphStyle(run.firstLine, run.wrapped), range: clipped)
408 continue
409 }
410 // Body lines wrap under their own text: past leading blanks, and past a list bullet
411 // and checkbox, as adaptive-wrap does in Emacs.
412 var location = clipped.location
413 while location < NSMaxRange(clipped) {
414 let line = text.lineRange(for: NSRange(location: location, length: 0))
415 let hanging = Self.hangingColumns(text.substring(with: line))
416 let lineRange = NSIntersectionRange(line, clipped)
417 storage.addAttribute(.paragraphStyle, value: paragraphStyle(run.firstLine, run.wrapped + hanging), range: lineRange)
418 location = NSMaxRange(line)
419 }
420 }
421 for run in Presentation.styles(document.tree, in: range) {
422 guard let clipped = clip(run.range, to: range) else { continue }
423 theme.apply(run.role, to: storage, range: clipped)
424 }
425 }
426
427 private func paragraphStyle(_ firstLine: Int, _ wrapped: Int) -> NSParagraphStyle {
428 let key = IndentKey(firstLine: firstLine, wrapped: wrapped)
429 if let style = paragraphStyles[key] { return style }
430 let style = theme.paragraphStyle(firstLine: firstLine, wrapped: wrapped)
431 paragraphStyles[key] = style
432 return style
433 }
434
435 private func clip(_ run: Range<Int>, to range: Range<Int>) -> NSRange? {
436 let clipped = run.clamped(to: range)
437 return clipped.isEmpty ? nil : NSRange(clipped)
438 }
439
440 /// The region an edit affects in one version of the tree: inside the innermost section
441 /// holding the edited lines (plus the line after), every element that overlaps them or the
442 /// line on either side. A changed heading line, or an added or removed heading, widens it
443 /// to that section's whole own body. Run on the old and the new tree, the union covers
444 /// elements that grew and elements that shrank.
445 static func affectedRange(_ tree: OrgTree, _ text: NSString, _ start: Int, _ end: Int, touchedHeadings: Bool) -> Range<Int> {
446 let lines = text.lineRange(for: NSRange(location: start, length: min(end + 1, text.length) - start))
447 let lineRange = lines.location..<(lines.location + lines.length)
448 let around = max(0, lineRange.lowerBound - 1)..<min(text.length, lineRange.upperBound + 1)
449 var container = tree.root
450 while let child = container.child(containing: lineRange.lowerBound),
451 child.kind == .section || child.kind == .zerothSection,
452 lineRange.upperBound <= child.range.upperBound {
453 container = child
454 }
455 var lower = lineRange.lowerBound
456 var upper = lineRange.upperBound
457 // Tables and lists carry no styling of their own, so inside them only the rows or
458 // items around the edit change, however large they are.
459 func overlapping(_ nodes: [SyntaxNode]) -> [SyntaxNode] {
460 nodes.filter { $0.range.overlaps(around) }.flatMap { node -> [SyntaxNode] in
461 guard [.table, .plainList, .item].contains(node.kind) else { return [node] }
462 let inner = overlapping(node.children(overlapping: around))
463 return inner.isEmpty ? [node] : inner
464 }
465 }
466 for child in overlapping(container.children(overlapping: around)) {
467 lower = min(lower, child.range.lowerBound)
468 upper = max(upper, child.range.upperBound)
469 }
470 let heading = container.firstChild(.heading)
471 if touchedHeadings || heading.map({ $0.range.overlaps(lineRange) }) == true {
472 lower = min(lower, container.range.lowerBound)
473 upper = max(upper, container.firstChild(.section)?.range.lowerBound ?? container.range.upperBound)
474 }
475 return lower..<min(upper, text.length)
476 }
477
478 // MARK: - Editing
479
480 fileprivate func storageEdited(range: NSRange, delta: Int) {
481 guard !isLoading, let storage = textView.textStorage else { return }
482 markActive = false
483 let clock = ContinuousClock()
484 let start = clock.now
485 let replacement = (storage.string as NSString).substring(with: range)
486 let edit = TextEdit(range: range.location..<(range.location + range.length - delta), replacement: replacement)
487 let oldText = document.text as NSString
488 let newText = storage.string as NSString
489 let newEnd = edit.range.lowerBound + replacement.utf16.count
490 let touchedHeadings = Self.headingLineNear(edit.range.lowerBound, edit.range.upperBound, in: oldText)
491 || Self.headingLineNear(edit.range.lowerBound, newEnd, in: newText)
492 let oldAffected = Self.affectedRange(document.tree, oldText, edit.range.lowerBound, edit.range.upperBound, touchedHeadings: touchedHeadings)
493 do {
494 try document.apply([edit], baseRevision: document.revision)
495 } catch {
496 assertionFailure("text view and document disagree: \(error)")
497 return
498 }
499 // Keep the watermark on unstyled text: shift it past the edit, or pull it back to the
500 // edited line when the edit reaches into unstyled text.
501 if styledUpTo >= edit.range.upperBound {
502 styledUpTo += edit.replacement.utf16.count - edit.range.count
503 } else if styledUpTo > edit.range.lowerBound {
504 styledUpTo = (storage.string as NSString).lineRange(for: NSRange(location: edit.range.lowerBound, length: 0)).location
505 }
506 let oldHidden = hidden.all
507 view = view.mapped(through: [edit]).pruned(to: document.tree)
508 folded.set(view.folds)
509 hidden.set(Presentation.hiddenRanges(document.tree, folds: view.folds))
510 let newAffected = Self.affectedRange(document.tree, newText, edit.range.lowerBound, newEnd, touchedHeadings: touchedHeadings)
511 let delta = edit.replacement.utf16.count - edit.range.count
512 let oldMapped = oldAffected.lowerBound..<(oldAffected.upperBound >= edit.range.upperBound ? oldAffected.upperBound + delta : newEnd)
513 restyle(min(oldMapped.lowerBound, newAffected.lowerBound)..<min(max(oldMapped.upperBound, newAffected.upperBound), newText.length), in: storage)
514 if hidden.all != oldHidden.map({ mapRange($0, through: edit) }) {
515 textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange)
516 }
517 onEditTiming?(clock.now - start)
518 onChange?()
519 }
520
521 /// Columns a wrapped continuation of `line` should start past the line's own start.
522 nonisolated static func hangingColumns(_ line: String) -> Int {
523 var columns = 0
524 var rest = Substring(line)
525 while let c = rest.first, c == " " || c == "\t" {
526 columns = c == "\t" ? (columns / 8 + 1) * 8 : columns + 1
527 rest = rest.dropFirst()
528 }
529 // A bullet (`-`, `+`, `*` when indented, `1.`, `1)`) and its space, then a checkbox.
530 var bullet = 0
531 if let first = rest.first, first == "-" || first == "+" || (first == "*" && columns > 0) {
532 bullet = 1
533 } else {
534 let digits = rest.prefix { $0.isASCII && $0.isNumber }
535 if !digits.isEmpty, let separator = rest.dropFirst(digits.count).first, separator == "." || separator == ")" {
536 bullet = digits.count + 1
537 }
538 }
539 guard bullet > 0, rest.dropFirst(bullet).first == " " else { return columns }
540 columns += bullet + 1
541 let afterBullet = rest.dropFirst(bullet + 1)
542 if afterBullet.count >= 4, afterBullet.first == "[", "X x-".contains(afterBullet.dropFirst().first!),
543 afterBullet.dropFirst(2).first == "]", afterBullet.dropFirst(3).first == " " {
544 columns += 4
545 }
546 return columns
547 }
548
549 /// Whether a heading line touches `start..<end` or starts right after it, where joining
550 /// lines could take it away.
551 static func headingLineNear(_ start: Int, _ end: Int, in text: NSString) -> Bool {
552 let length = min(end + 1, text.length) - start
553 guard length >= 0 else { return false }
554 let lines = text.substring(with: text.lineRange(for: NSRange(location: start, length: length)))
555 return lines.split(separator: "\n", omittingEmptySubsequences: false).contains { line in
556 let stars = line.prefix { $0 == "*" }
557 let after = line.dropFirst(stars.count)
558 return !stars.isEmpty && (after.isEmpty || after.first == " " || after.first == "\t" || after.first == "\r")
559 }
560 }
561
562 private func mapRange(_ range: Range<Int>, through edit: TextEdit) -> Range<Int> {
563 let mapped = ViewState(selection: [range]).mapped(through: [edit]).selection[0]
564 return mapped
565 }
566
567 /// Keeps the caret out of folded text: moving forward into a fold jumps past it, moving
568 /// back jumps to the end of the heading line.
569 fileprivate func selectionChanged() {
570 let selection = textView.selectedRange()
571 defer { previousSelection = selection.location }
572 guard selection.length == 0, let range = hidden.containing(selection.location) else { return }
573 setCaret(selection.location >= previousSelection ? range.upperBound : range.lowerBound - 1)
574 }
575}
576
577extension OrgEditor: DocumentBuffer {
578 /// Runs `change`; if it changed the text, reloads the view with folds and the selection
579 /// mapped through the change. Text-view undo refers to the old text, so it is cleared.
580 public func update<T>(_ change: (inout DocumentState) throws -> T) rethrows -> T {
581 let before = document.text
582 let selected = textView.selectedRange()
583 let result = try change(&document)
584 if document.text != before {
585 let edits = lineEdits(from: before, to: document.text)
586 var state = view
587 state.selection = [selected.location..<NSMaxRange(selected)]
588 let mapped = state.mapped(through: edits)
589 load(document.text)
590 setFolds(FoldState(folds: mapped.folds, bodyFolds: mapped.bodyFolds))
591 let selection = mapped.selection[0]
592 setCaret(selection.lowerBound)
593 if !selection.isEmpty { textView.setSelectedRange(NSRange(selection)) }
594 textView.undoManager?.removeAllActions(withTarget: textView.textStorage!)
595 textView.undoManager?.removeAllActions(withTarget: textView)
596 }
597 onChange?()
598 return result
599 }
600}
601
602extension OrgEditor: NSTextViewDelegate {
603 public func textViewDidChangeSelection(_ notification: Notification) {
604 selectionChanged()
605 }
606}
607
608extension OrgEditor: NSTextStorageDelegate {
609 public nonisolated func textStorage(
610 _ textStorage: NSTextStorage, didProcessEditing editedMask: NSTextStorageEditActions, range editedRange: NSRange, changeInLength delta: Int
611 ) {
612 guard editedMask.contains(.editedCharacters) else { return }
613 MainActor.assumeIsolated { storageEdited(range: editedRange, delta: delta) }
614 }
615}
616
617extension OrgEditor: NSTextContentStorageDelegate {
618 /// Folded text isn't enumerated, so it isn't laid out or drawn.
619 public nonisolated func textContentManager(
620 _ textContentManager: NSTextContentManager, shouldEnumerate textElement: NSTextElement, options: NSTextContentManager.EnumerationOptions = []
621 ) -> Bool {
622 guard let range = textElement.elementRange else { return true }
623 let documentStart = textContentManager.documentRange.location
624 let start = textContentManager.offset(from: documentStart, to: range.location)
625 let end = textContentManager.offset(from: documentStart, to: range.endLocation)
626 return !hidden.covers(start, end)
627 }
628}
629
630extension OrgEditor: NSTextLayoutManagerDelegate {
631 public nonisolated func textLayoutManager(
632 _ textLayoutManager: NSTextLayoutManager, textLayoutFragmentFor location: NSTextLocation, in textElement: NSTextElement
633 ) -> NSTextLayoutFragment {
634 let range = textElement.elementRange
635 if let range, let manager = textLayoutManager.textContentManager {
636 let offset = manager.offset(from: manager.documentRange.location, to: range.location)
637 if folded.contains(offset) { return FoldedHeadingFragment(textElement: textElement, range: range) }
638 }
639 return NSTextLayoutFragment(textElement: textElement, range: range)
640 }
641}
642
643/// A heading line drawn with an ellipsis after it, like org's folded headings.
644final class FoldedHeadingFragment: NSTextLayoutFragment, @unchecked Sendable {
645 override func draw(at point: CGPoint, in context: CGContext) {
646 super.draw(at: point, in: context)
647 guard let line = textLineFragments.first else { return }
648 let bounds = line.typographicBounds
649 let ellipsis = NSAttributedString(string: " …", attributes: [
650 .font: NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular),
651 .foregroundColor: NSColor.secondaryLabelColor,
652 ])
653 NSGraphicsContext.saveGraphicsState()
654 NSGraphicsContext.current = NSGraphicsContext(cgContext: context, flipped: true)
655 ellipsis.draw(at: CGPoint(x: point.x + bounds.maxX, y: point.y + bounds.minY))
656 NSGraphicsContext.restoreGraphicsState()
657 }
658
659 override var renderingSurfaceBounds: CGRect {
660 super.renderingSurfaceBounds.insetBy(dx: -40, dy: 0).offsetBy(dx: 40, dy: 0)
661 }
662}
663#endif