Sources/OrgEditorAppKit/OrgEditor.swift
563 lines · 25777 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 textView = NSTextView(usingTextLayoutManager: true)
78 super.init()
79 textView.frame = frame
80 textView.isRichText = false
81 textView.isEditable = editable
82 textView.allowsUndo = true
83 textView.isAutomaticQuoteSubstitutionEnabled = false
84 textView.isAutomaticDashSubstitutionEnabled = false
85 textView.isAutomaticTextReplacementEnabled = false
86 textView.isAutomaticSpellingCorrectionEnabled = false
87 textView.font = theme.font
88 textView.typingAttributes = theme.base
89 textView.isVerticallyResizable = true
90 textView.autoresizingMask = [.width]
91 textView.textContainer?.widthTracksTextView = true
92 textView.delegate = self
93 textView.textStorage?.delegate = self
94 textLayoutManager.delegate = self
95 textContentStorage.delegate = self
96
97 load(document.text)
98 if let startup = Presentation.startupFoldState(document.tree) { setFolds(startup) }
99 }
100
101 /// Replaces the storage's text. Setting the attributed string directly is about 100x
102 /// faster than `NSTextView.string` for large files.
103 private func load(_ text: String) {
104 isLoading = true
105 textView.textStorage?.setAttributedString(NSAttributedString(string: text, attributes: theme.base))
106 isLoading = false
107 styledUpTo = 0
108 styleNextChunk(Self.firstStyleChunk)
109 scheduleStyling()
110 }
111
112 // MARK: - Initial styling
113
114 /// Styles whole lines from the watermark onward, about `size` units.
115 private func styleNextChunk(_ size: Int) {
116 let text = textView.textStorage!.string as NSString
117 guard styledUpTo < text.length else { return }
118 let target = min(text.length, styledUpTo + size)
119 let end = target == text.length ? target : NSMaxRange(text.lineRange(for: NSRange(location: target, length: 0)))
120 restyleOutsideEditing(styledUpTo..<end)
121 styledUpTo = end
122 }
123
124 private func scheduleStyling() {
125 guard !stylingScheduled, styledUpTo < utf16Length else { return }
126 stylingScheduled = true
127 DispatchQueue.main.async { [weak self] in
128 guard let self else { return }
129 self.stylingScheduled = false
130 self.styleNextChunk(Self.styleChunk)
131 self.scheduleStyling()
132 }
133 }
134
135 /// Styles everything still waiting for a background pass.
136 public func finishStyling() {
137 while styledUpTo < utf16Length { styleNextChunk(Self.styleChunk) }
138 }
139
140 public var textLayoutManager: NSTextLayoutManager { textView.textLayoutManager! }
141 var textContentStorage: NSTextContentStorage { textLayoutManager.textContentManager as! NSTextContentStorage }
142 var utf16Length: Int { (textView.string as NSString).length }
143
144 /// A scroll view holding the text view, ready for a window.
145 public func makeScrollView() -> NSScrollView {
146 let scroll = NSScrollView(frame: textView.frame)
147 scroll.hasVerticalScroller = true
148 scroll.documentView = textView
149 textView.minSize = NSSize(width: 0, height: scroll.contentSize.height)
150 textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
151 return scroll
152 }
153
154 // MARK: - Saving
155
156 /// Saves through `saver`. A merge that changes the buffer reloads the text view.
157 @discardableResult
158 public func save(using saver: Saver, to url: URL) throws -> SaveOutcome {
159 try update { try saver.save(&$0, to: url) }
160 }
161
162 /// Called after every change to the document, typed or not.
163 public var onChange: (() -> Void)?
164
165 // MARK: - Commands
166
167 /// Called with messages commands report, such as why one couldn't run here.
168 public var onMessage: ((String) -> Void)?
169
170 /// Runs `command` at the selection. Edits go through the text view, as typing does, so
171 /// they form one undo step and reach the document through the same path.
172 @discardableResult
173 public func perform(_ command: any OrgCommand, now: Date = Date(), answers: [String: String] = [:]) -> CommandStep {
174 let selected = textView.selectedRange()
175 let context = EditContext(
176 revision: document.revision, text: document.text, tree: document.tree,
177 selection: [selected.location..<NSMaxRange(selected)], now: now, calendar: .current, answers: answers
178 )
179 let step = command.run(in: context)
180 switch step {
181 case .commit(let result):
182 guard textView.isEditable || result.edits.isEmpty else {
183 onMessage?("This file is read-only.")
184 return .failed("read-only")
185 }
186 if !result.edits.isEmpty, let storage = textView.textStorage {
187 // Each command is its own undo step, not merged with typing around it.
188 textView.breakUndoCoalescing()
189 textView.undoManager?.beginUndoGrouping()
190 for edit in result.edits.sorted(by: { $0.range.lowerBound > $1.range.lowerBound }) {
191 let range = NSRange(edit.range)
192 guard textView.shouldChangeText(in: range, replacementString: edit.replacement) else { continue }
193 storage.replaceCharacters(in: range, with: edit.replacement)
194 textView.didChangeText()
195 }
196 textView.undoManager?.endUndoGrouping()
197 textView.breakUndoCoalescing()
198 }
199 if let selection = result.selection?.first {
200 setCaret(selection.lowerBound)
201 if !selection.isEmpty { textView.setSelectedRange(NSRange(selection)) }
202 }
203 for case .message(let text) in result.effects { onMessage?(text) }
204 case .failed(let message):
205 onMessage?(message)
206 case .prompt:
207 break
208 }
209 return step
210 }
211
212 // MARK: - Disk
213
214 /// The file on disk now holds `bytes`: reload or merge, keeping folds where the text kept
215 /// its headings.
216 @discardableResult
217 public func diskChanged(to bytes: [UInt8]) -> DocumentState.ExternalChange {
218 update { $0.diskChanged(to: bytes) }
219 }
220
221 // MARK: - Folding
222
223 public var foldState: FoldState {
224 FoldState(folds: view.folds, bodyFolds: view.bodyFolds)
225 }
226
227 private var globalVisibility: GlobalVisibility?
228
229 public func setFolds(_ state: FoldState) {
230 view.folds = state.folds
231 view.bodyFolds = state.bodyFolds
232 view = view.pruned(to: document.tree)
233 updateHidden()
234 textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange)
235 textView.needsDisplay = true
236 let caret = textView.selectedRange().location
237 if let range = hidden.containing(caret) { setCaret(range.lowerBound - 1) }
238 }
239
240 private func updateHidden() {
241 folded.set(view.folds.union(view.bodyFolds))
242 hidden.set(Presentation.hiddenRanges(document.tree, folds: view.folds, bodyFolds: view.bodyFolds))
243 }
244
245 /// TAB on a heading: folded, children, subtree. Returns false off a heading.
246 @discardableResult
247 public func cycleAtCaret() -> Bool {
248 guard let heading = Presentation.heading(containing: textView.selectedRange().location, in: document.tree) else { return false }
249 setFolds(Presentation.cycle(heading, in: document.tree, foldState))
250 return true
251 }
252
253 /// Shift-TAB: overview, contents, show all.
254 public func cycleGlobal() {
255 let (visibility, state) = Presentation.cycleGlobal(from: globalVisibility, in: document.tree)
256 globalVisibility = visibility
257 setFolds(state)
258 }
259
260 /// Unfolds whatever hides `offset`, puts the caret there and scrolls to it.
261 public func reveal(_ offset: Int) {
262 let target = max(0, min(offset, utf16Length))
263 var state = foldState
264 for entry in Presentation.outline(document.tree) where entry.start < target && target < entry.end {
265 state.folds.remove(entry.start)
266 state.bodyFolds.remove(entry.start)
267 }
268 setFolds(state)
269 setCaret(target)
270 textView.scrollRangeToVisible(NSRange(location: target, length: 0))
271 }
272
273 private func setCaret(_ offset: Int) {
274 let clamped = max(0, min(offset, utf16Length))
275 previousSelection = clamped
276 textView.setSelectedRange(NSRange(location: clamped, length: 0))
277 }
278
279 // MARK: - Styling
280
281 private func restyleOutsideEditing(_ range: Range<Int>) {
282 guard let storage = textView.textStorage else { return }
283 storage.beginEditing()
284 restyle(range, in: storage)
285 storage.endEditing()
286 }
287
288 /// Sets attributes for whole lines in `range`. Safe inside `didProcessEditing`, where
289 /// attribute changes are allowed and characters must not change.
290 private func restyle(_ range: Range<Int>, in storage: NSTextStorage) {
291 guard !range.isEmpty else { return }
292 let whole = NSRange(range)
293 storage.setAttributes(theme.base, range: whole)
294 let text = storage.string as NSString
295 for run in Presentation.indents(document.tree, in: range) {
296 guard let clipped = clip(run.range, to: range) else { continue }
297 guard run.firstLine == run.wrapped else {
298 storage.addAttribute(.paragraphStyle, value: paragraphStyle(run.firstLine, run.wrapped), range: clipped)
299 continue
300 }
301 // Body lines wrap under their own text: past leading blanks, and past a list bullet
302 // and checkbox, as adaptive-wrap does in Emacs.
303 var location = clipped.location
304 while location < NSMaxRange(clipped) {
305 let line = text.lineRange(for: NSRange(location: location, length: 0))
306 let hanging = Self.hangingColumns(text.substring(with: line))
307 let lineRange = NSIntersectionRange(line, clipped)
308 storage.addAttribute(.paragraphStyle, value: paragraphStyle(run.firstLine, run.wrapped + hanging), range: lineRange)
309 location = NSMaxRange(line)
310 }
311 }
312 for run in Presentation.styles(document.tree, in: range) {
313 guard let clipped = clip(run.range, to: range) else { continue }
314 theme.apply(run.role, to: storage, range: clipped)
315 }
316 }
317
318 private func paragraphStyle(_ firstLine: Int, _ wrapped: Int) -> NSParagraphStyle {
319 let key = IndentKey(firstLine: firstLine, wrapped: wrapped)
320 if let style = paragraphStyles[key] { return style }
321 let style = theme.paragraphStyle(firstLine: firstLine, wrapped: wrapped)
322 paragraphStyles[key] = style
323 return style
324 }
325
326 private func clip(_ run: Range<Int>, to range: Range<Int>) -> NSRange? {
327 let clipped = run.clamped(to: range)
328 return clipped.isEmpty ? nil : NSRange(clipped)
329 }
330
331 /// The region an edit affects in one version of the tree: inside the innermost section
332 /// holding the edited lines (plus the line after), every element that overlaps them or the
333 /// line on either side. A changed heading line, or an added or removed heading, widens it
334 /// to that section's whole own body. Run on the old and the new tree, the union covers
335 /// elements that grew and elements that shrank.
336 static func affectedRange(_ tree: OrgTree, _ text: NSString, _ start: Int, _ end: Int, touchedHeadings: Bool) -> Range<Int> {
337 let lines = text.lineRange(for: NSRange(location: start, length: min(end + 1, text.length) - start))
338 let lineRange = lines.location..<(lines.location + lines.length)
339 let around = max(0, lineRange.lowerBound - 1)..<min(text.length, lineRange.upperBound + 1)
340 var container = tree.root
341 while let child = container.child(containing: lineRange.lowerBound),
342 child.kind == .section || child.kind == .zerothSection,
343 lineRange.upperBound <= child.range.upperBound {
344 container = child
345 }
346 var lower = lineRange.lowerBound
347 var upper = lineRange.upperBound
348 // Tables and lists carry no styling of their own, so inside them only the rows or
349 // items around the edit change, however large they are.
350 func overlapping(_ nodes: [SyntaxNode]) -> [SyntaxNode] {
351 nodes.filter { $0.range.overlaps(around) }.flatMap { node -> [SyntaxNode] in
352 guard [.table, .plainList, .item].contains(node.kind) else { return [node] }
353 let inner = overlapping(node.children(overlapping: around))
354 return inner.isEmpty ? [node] : inner
355 }
356 }
357 for child in overlapping(container.children(overlapping: around)) {
358 lower = min(lower, child.range.lowerBound)
359 upper = max(upper, child.range.upperBound)
360 }
361 let heading = container.firstChild(.heading)
362 if touchedHeadings || heading.map({ $0.range.overlaps(lineRange) }) == true {
363 lower = min(lower, container.range.lowerBound)
364 upper = max(upper, container.firstChild(.section)?.range.lowerBound ?? container.range.upperBound)
365 }
366 return lower..<min(upper, text.length)
367 }
368
369 // MARK: - Editing
370
371 fileprivate func storageEdited(range: NSRange, delta: Int) {
372 guard !isLoading, let storage = textView.textStorage else { return }
373 let clock = ContinuousClock()
374 let start = clock.now
375 let replacement = (storage.string as NSString).substring(with: range)
376 let edit = TextEdit(range: range.location..<(range.location + range.length - delta), replacement: replacement)
377 let oldText = document.text as NSString
378 let newText = storage.string as NSString
379 let newEnd = edit.range.lowerBound + replacement.utf16.count
380 let touchedHeadings = Self.headingLineNear(edit.range.lowerBound, edit.range.upperBound, in: oldText)
381 || Self.headingLineNear(edit.range.lowerBound, newEnd, in: newText)
382 let oldAffected = Self.affectedRange(document.tree, oldText, edit.range.lowerBound, edit.range.upperBound, touchedHeadings: touchedHeadings)
383 do {
384 try document.apply([edit], baseRevision: document.revision)
385 } catch {
386 assertionFailure("text view and document disagree: \(error)")
387 return
388 }
389 // Keep the watermark on unstyled text: shift it past the edit, or pull it back to the
390 // edited line when the edit reaches into unstyled text.
391 if styledUpTo >= edit.range.upperBound {
392 styledUpTo += edit.replacement.utf16.count - edit.range.count
393 } else if styledUpTo > edit.range.lowerBound {
394 styledUpTo = (storage.string as NSString).lineRange(for: NSRange(location: edit.range.lowerBound, length: 0)).location
395 }
396 let oldHidden = hidden.all
397 view = view.mapped(through: [edit]).pruned(to: document.tree)
398 folded.set(view.folds)
399 hidden.set(Presentation.hiddenRanges(document.tree, folds: view.folds))
400 let newAffected = Self.affectedRange(document.tree, newText, edit.range.lowerBound, newEnd, touchedHeadings: touchedHeadings)
401 let delta = edit.replacement.utf16.count - edit.range.count
402 let oldMapped = oldAffected.lowerBound..<(oldAffected.upperBound >= edit.range.upperBound ? oldAffected.upperBound + delta : newEnd)
403 restyle(min(oldMapped.lowerBound, newAffected.lowerBound)..<min(max(oldMapped.upperBound, newAffected.upperBound), newText.length), in: storage)
404 if hidden.all != oldHidden.map({ mapRange($0, through: edit) }) {
405 textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange)
406 }
407 onEditTiming?(clock.now - start)
408 onChange?()
409 }
410
411 /// Columns a wrapped continuation of `line` should start past the line's own start.
412 nonisolated static func hangingColumns(_ line: String) -> Int {
413 var columns = 0
414 var rest = Substring(line)
415 while let c = rest.first, c == " " || c == "\t" {
416 columns = c == "\t" ? (columns / 8 + 1) * 8 : columns + 1
417 rest = rest.dropFirst()
418 }
419 // A bullet (`-`, `+`, `*` when indented, `1.`, `1)`) and its space, then a checkbox.
420 var bullet = 0
421 if let first = rest.first, first == "-" || first == "+" || (first == "*" && columns > 0) {
422 bullet = 1
423 } else {
424 let digits = rest.prefix { $0.isASCII && $0.isNumber }
425 if !digits.isEmpty, let separator = rest.dropFirst(digits.count).first, separator == "." || separator == ")" {
426 bullet = digits.count + 1
427 }
428 }
429 guard bullet > 0, rest.dropFirst(bullet).first == " " else { return columns }
430 columns += bullet + 1
431 let afterBullet = rest.dropFirst(bullet + 1)
432 if afterBullet.count >= 4, afterBullet.first == "[", "X x-".contains(afterBullet.dropFirst().first!),
433 afterBullet.dropFirst(2).first == "]", afterBullet.dropFirst(3).first == " " {
434 columns += 4
435 }
436 return columns
437 }
438
439 /// Whether a heading line touches `start..<end` or starts right after it, where joining
440 /// lines could take it away.
441 static func headingLineNear(_ start: Int, _ end: Int, in text: NSString) -> Bool {
442 let length = min(end + 1, text.length) - start
443 guard length >= 0 else { return false }
444 let lines = text.substring(with: text.lineRange(for: NSRange(location: start, length: length)))
445 return lines.split(separator: "\n", omittingEmptySubsequences: false).contains { line in
446 let stars = line.prefix { $0 == "*" }
447 let after = line.dropFirst(stars.count)
448 return !stars.isEmpty && (after.isEmpty || after.first == " " || after.first == "\t" || after.first == "\r")
449 }
450 }
451
452 private func mapRange(_ range: Range<Int>, through edit: TextEdit) -> Range<Int> {
453 let mapped = ViewState(selection: [range]).mapped(through: [edit]).selection[0]
454 return mapped
455 }
456
457 /// Keeps the caret out of folded text: moving forward into a fold jumps past it, moving
458 /// back jumps to the end of the heading line.
459 fileprivate func selectionChanged() {
460 let selection = textView.selectedRange()
461 defer { previousSelection = selection.location }
462 guard selection.length == 0, let range = hidden.containing(selection.location) else { return }
463 setCaret(selection.location >= previousSelection ? range.upperBound : range.lowerBound - 1)
464 }
465}
466
467extension OrgEditor: DocumentBuffer {
468 /// Runs `change`; if it changed the text, reloads the view with folds and the selection
469 /// mapped through the change. Text-view undo refers to the old text, so it is cleared.
470 public func update<T>(_ change: (inout DocumentState) throws -> T) rethrows -> T {
471 let before = document.text
472 let selected = textView.selectedRange()
473 let result = try change(&document)
474 if document.text != before {
475 let edits = lineEdits(from: before, to: document.text)
476 var state = view
477 state.selection = [selected.location..<NSMaxRange(selected)]
478 let mapped = state.mapped(through: edits)
479 load(document.text)
480 setFolds(FoldState(folds: mapped.folds, bodyFolds: mapped.bodyFolds))
481 let selection = mapped.selection[0]
482 setCaret(selection.lowerBound)
483 if !selection.isEmpty { textView.setSelectedRange(NSRange(selection)) }
484 textView.undoManager?.removeAllActions(withTarget: textView.textStorage!)
485 textView.undoManager?.removeAllActions(withTarget: textView)
486 }
487 onChange?()
488 return result
489 }
490}
491
492extension OrgEditor: NSTextViewDelegate {
493 public func textView(_ textView: NSTextView, doCommandBy selector: Selector) -> Bool {
494 switch selector {
495 case #selector(NSResponder.insertTab(_:)): return cycleAtCaret()
496 case #selector(NSResponder.insertBacktab(_:)):
497 cycleGlobal()
498 return true
499 default: return false
500 }
501 }
502
503 public func textViewDidChangeSelection(_ notification: Notification) {
504 selectionChanged()
505 }
506}
507
508extension OrgEditor: NSTextStorageDelegate {
509 public nonisolated func textStorage(
510 _ textStorage: NSTextStorage, didProcessEditing editedMask: NSTextStorageEditActions, range editedRange: NSRange, changeInLength delta: Int
511 ) {
512 guard editedMask.contains(.editedCharacters) else { return }
513 MainActor.assumeIsolated { storageEdited(range: editedRange, delta: delta) }
514 }
515}
516
517extension OrgEditor: NSTextContentStorageDelegate {
518 /// Folded text isn't enumerated, so it isn't laid out or drawn.
519 public nonisolated func textContentManager(
520 _ textContentManager: NSTextContentManager, shouldEnumerate textElement: NSTextElement, options: NSTextContentManager.EnumerationOptions = []
521 ) -> Bool {
522 guard let range = textElement.elementRange else { return true }
523 let documentStart = textContentManager.documentRange.location
524 let start = textContentManager.offset(from: documentStart, to: range.location)
525 let end = textContentManager.offset(from: documentStart, to: range.endLocation)
526 return !hidden.covers(start, end)
527 }
528}
529
530extension OrgEditor: NSTextLayoutManagerDelegate {
531 public nonisolated func textLayoutManager(
532 _ textLayoutManager: NSTextLayoutManager, textLayoutFragmentFor location: NSTextLocation, in textElement: NSTextElement
533 ) -> NSTextLayoutFragment {
534 let range = textElement.elementRange
535 if let range, let manager = textLayoutManager.textContentManager {
536 let offset = manager.offset(from: manager.documentRange.location, to: range.location)
537 if folded.contains(offset) { return FoldedHeadingFragment(textElement: textElement, range: range) }
538 }
539 return NSTextLayoutFragment(textElement: textElement, range: range)
540 }
541}
542
543/// A heading line drawn with an ellipsis after it, like org's folded headings.
544final class FoldedHeadingFragment: NSTextLayoutFragment, @unchecked Sendable {
545 override func draw(at point: CGPoint, in context: CGContext) {
546 super.draw(at: point, in: context)
547 guard let line = textLineFragments.first else { return }
548 let bounds = line.typographicBounds
549 let ellipsis = NSAttributedString(string: " …", attributes: [
550 .font: NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular),
551 .foregroundColor: NSColor.secondaryLabelColor,
552 ])
553 NSGraphicsContext.saveGraphicsState()
554 NSGraphicsContext.current = NSGraphicsContext(cgContext: context, flipped: true)
555 ellipsis.draw(at: CGPoint(x: point.x + bounds.maxX, y: point.y + bounds.minY))
556 NSGraphicsContext.restoreGraphicsState()
557 }
558
559 override var renderingSurfaceBounds: CGRect {
560 super.renderingSurfaceBounds.insetBy(dx: -40, dy: 0).offsetBy(dx: 40, dy: 0)
561 }
562}
563#endif