Sources/OrgEditorAppKit/OrgEditor.swift
403 lines · 18478 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
63 /// Styled before the first render: more than a screenful.
64 static let firstStyleChunk = 30_000
65 /// Styled per run-loop turn afterwards.
66 static let styleChunk = 200_000
67
68 public init(document: DocumentState, frame: NSRect = NSRect(x: 0, y: 0, width: 600, height: 800)) {
69 self.document = document
70 textView = NSTextView(usingTextLayoutManager: true)
71 super.init()
72 textView.frame = frame
73 textView.isRichText = false
74 textView.allowsUndo = true
75 textView.isAutomaticQuoteSubstitutionEnabled = false
76 textView.isAutomaticDashSubstitutionEnabled = false
77 textView.isAutomaticTextReplacementEnabled = false
78 textView.isAutomaticSpellingCorrectionEnabled = false
79 textView.font = theme.font
80 textView.typingAttributes = theme.base
81 textView.isVerticallyResizable = true
82 textView.autoresizingMask = [.width]
83 textView.textContainer?.widthTracksTextView = true
84 textView.delegate = self
85 textView.textStorage?.delegate = self
86 textLayoutManager.delegate = self
87 textContentStorage.delegate = self
88
89 load(document.text)
90 }
91
92 /// Replaces the storage's text. Setting the attributed string directly is about 100x
93 /// faster than `NSTextView.string` for large files.
94 private func load(_ text: String) {
95 isLoading = true
96 textView.textStorage?.setAttributedString(NSAttributedString(string: text, attributes: theme.base))
97 isLoading = false
98 styledUpTo = 0
99 styleNextChunk(Self.firstStyleChunk)
100 scheduleStyling()
101 }
102
103 // MARK: - Initial styling
104
105 /// Styles whole lines from the watermark onward, about `size` units.
106 private func styleNextChunk(_ size: Int) {
107 let text = textView.textStorage!.string as NSString
108 guard styledUpTo < text.length else { return }
109 let target = min(text.length, styledUpTo + size)
110 let end = target == text.length ? target : NSMaxRange(text.lineRange(for: NSRange(location: target, length: 0)))
111 restyleOutsideEditing(styledUpTo..<end)
112 styledUpTo = end
113 }
114
115 private func scheduleStyling() {
116 guard !stylingScheduled, styledUpTo < utf16Length else { return }
117 stylingScheduled = true
118 DispatchQueue.main.async { [weak self] in
119 guard let self else { return }
120 self.stylingScheduled = false
121 self.styleNextChunk(Self.styleChunk)
122 self.scheduleStyling()
123 }
124 }
125
126 /// Styles everything still waiting for a background pass.
127 public func finishStyling() {
128 while styledUpTo < utf16Length { styleNextChunk(Self.styleChunk) }
129 }
130
131 public var textLayoutManager: NSTextLayoutManager { textView.textLayoutManager! }
132 var textContentStorage: NSTextContentStorage { textLayoutManager.textContentManager as! NSTextContentStorage }
133 var utf16Length: Int { (textView.string as NSString).length }
134
135 /// A scroll view holding the text view, ready for a window.
136 public func makeScrollView() -> NSScrollView {
137 let scroll = NSScrollView(frame: textView.frame)
138 scroll.hasVerticalScroller = true
139 scroll.documentView = textView
140 textView.minSize = NSSize(width: 0, height: scroll.contentSize.height)
141 textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
142 return scroll
143 }
144
145 // MARK: - Saving
146
147 /// Saves through `saver`. When a merge changes the buffer, the text view is reloaded and
148 /// folds are mapped through the change.
149 @discardableResult
150 public func save(using saver: Saver, to url: URL) throws -> SaveOutcome {
151 let before = document.text
152 let outcome = try saver.save(&document, to: url)
153 if document.text != before {
154 let edits = lineEdits(from: before, to: document.text)
155 load(document.text)
156 setFolds(view.mapped(through: edits).folds)
157 }
158 return outcome
159 }
160
161 // MARK: - Folding
162
163 public func setFolds(_ folds: Set<Int>) {
164 view.folds = folds
165 view = view.pruned(to: document.tree)
166 folded.set(view.folds)
167 hidden.set(Presentation.hiddenRanges(document.tree, folds: view.folds))
168 textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange)
169 textView.needsDisplay = true
170 let caret = textView.selectedRange().location
171 if let range = hidden.containing(caret) { setCaret(range.lowerBound - 1) }
172 }
173
174 /// Folds or unfolds the heading whose line holds the caret. Returns false off a heading.
175 @discardableResult
176 public func toggleFoldAtCaret() -> Bool {
177 guard let heading = Presentation.heading(containing: textView.selectedRange().location, in: document.tree) else { return false }
178 var folds = view.folds
179 if folds.contains(heading) { folds.remove(heading) } else { folds.insert(heading) }
180 setFolds(folds)
181 return true
182 }
183
184 /// Folds every heading, or unfolds everything if anything is folded.
185 public func toggleAllFolds() {
186 setFolds(view.folds.isEmpty ? Set(Presentation.headings(document.tree)) : [])
187 }
188
189 private func setCaret(_ offset: Int) {
190 let clamped = max(0, min(offset, utf16Length))
191 previousSelection = clamped
192 textView.setSelectedRange(NSRange(location: clamped, length: 0))
193 }
194
195 // MARK: - Styling
196
197 private func restyleOutsideEditing(_ range: Range<Int>) {
198 guard let storage = textView.textStorage else { return }
199 storage.beginEditing()
200 restyle(range, in: storage)
201 storage.endEditing()
202 }
203
204 /// Sets attributes for whole lines in `range`. Safe inside `didProcessEditing`, where
205 /// attribute changes are allowed and characters must not change.
206 private func restyle(_ range: Range<Int>, in storage: NSTextStorage) {
207 guard !range.isEmpty else { return }
208 let whole = NSRange(range)
209 storage.setAttributes(theme.base, range: whole)
210 for run in Presentation.indents(document.tree, in: range) {
211 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)
213 }
214 for run in Presentation.styles(document.tree, in: range) {
215 guard let clipped = clip(run.range, to: range) else { continue }
216 theme.apply(run.role, to: storage, range: clipped)
217 }
218 }
219
220 private func clip(_ run: Range<Int>, to range: Range<Int>) -> NSRange? {
221 let clipped = run.clamped(to: range)
222 return clipped.isEmpty ? nil : NSRange(clipped)
223 }
224
225 /// The region an edit affects in one version of the tree: inside the innermost section
226 /// holding the edited lines (plus the line after), every element that overlaps them or the
227 /// line on either side. A changed heading line, or an added or removed heading, widens it
228 /// to that section's whole own body. Run on the old and the new tree, the union covers
229 /// elements that grew and elements that shrank.
230 static func affectedRange(_ tree: OrgTree, _ text: NSString, _ start: Int, _ end: Int, touchedHeadings: Bool) -> Range<Int> {
231 let lines = text.lineRange(for: NSRange(location: start, length: min(end + 1, text.length) - start))
232 let lineRange = lines.location..<(lines.location + lines.length)
233 let around = max(0, lineRange.lowerBound - 1)..<min(text.length, lineRange.upperBound + 1)
234 var container = tree.root
235 while let child = container.child(containing: lineRange.lowerBound),
236 child.kind == .section || child.kind == .zerothSection,
237 lineRange.upperBound <= child.range.upperBound {
238 container = child
239 }
240 var lower = lineRange.lowerBound
241 var upper = lineRange.upperBound
242 // Tables and lists carry no styling of their own, so inside them only the rows or
243 // items around the edit change, however large they are.
244 func overlapping(_ nodes: [SyntaxNode]) -> [SyntaxNode] {
245 nodes.filter { $0.range.overlaps(around) }.flatMap { node -> [SyntaxNode] in
246 guard [.table, .plainList, .item].contains(node.kind) else { return [node] }
247 let inner = overlapping(node.children(overlapping: around))
248 return inner.isEmpty ? [node] : inner
249 }
250 }
251 for child in overlapping(container.children(overlapping: around)) {
252 lower = min(lower, child.range.lowerBound)
253 upper = max(upper, child.range.upperBound)
254 }
255 let heading = container.firstChild(.heading)
256 if touchedHeadings || heading.map({ $0.range.overlaps(lineRange) }) == true {
257 lower = min(lower, container.range.lowerBound)
258 upper = max(upper, container.firstChild(.section)?.range.lowerBound ?? container.range.upperBound)
259 }
260 return lower..<min(upper, text.length)
261 }
262
263 // MARK: - Editing
264
265 fileprivate func storageEdited(range: NSRange, delta: Int) {
266 guard !isLoading, let storage = textView.textStorage else { return }
267 let clock = ContinuousClock()
268 let start = clock.now
269 let replacement = (storage.string as NSString).substring(with: range)
270 let edit = TextEdit(range: range.location..<(range.location + range.length - delta), replacement: replacement)
271 let oldText = document.text as NSString
272 let newText = storage.string as NSString
273 let newEnd = edit.range.lowerBound + replacement.utf16.count
274 let touchedHeadings = Self.headingLineNear(edit.range.lowerBound, edit.range.upperBound, in: oldText)
275 || Self.headingLineNear(edit.range.lowerBound, newEnd, in: newText)
276 let oldAffected = Self.affectedRange(document.tree, oldText, edit.range.lowerBound, edit.range.upperBound, touchedHeadings: touchedHeadings)
277 do {
278 try document.apply([edit], baseRevision: document.revision)
279 } catch {
280 assertionFailure("text view and document disagree: \(error)")
281 return
282 }
283 // Keep the watermark on unstyled text: shift it past the edit, or pull it back to the
284 // edited line when the edit reaches into unstyled text.
285 if styledUpTo >= edit.range.upperBound {
286 styledUpTo += edit.replacement.utf16.count - edit.range.count
287 } else if styledUpTo > edit.range.lowerBound {
288 styledUpTo = (storage.string as NSString).lineRange(for: NSRange(location: edit.range.lowerBound, length: 0)).location
289 }
290 let oldHidden = hidden.all
291 view = view.mapped(through: [edit]).pruned(to: document.tree)
292 folded.set(view.folds)
293 hidden.set(Presentation.hiddenRanges(document.tree, folds: view.folds))
294 let newAffected = Self.affectedRange(document.tree, newText, edit.range.lowerBound, newEnd, touchedHeadings: touchedHeadings)
295 let delta = edit.replacement.utf16.count - edit.range.count
296 let oldMapped = oldAffected.lowerBound..<(oldAffected.upperBound >= edit.range.upperBound ? oldAffected.upperBound + delta : newEnd)
297 restyle(min(oldMapped.lowerBound, newAffected.lowerBound)..<min(max(oldMapped.upperBound, newAffected.upperBound), newText.length), in: storage)
298 if hidden.all != oldHidden.map({ mapRange($0, through: edit) }) {
299 textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange)
300 }
301 onEditTiming?(clock.now - start)
302 }
303
304 /// Whether a heading line touches `start..<end` or starts right after it, where joining
305 /// lines could take it away.
306 static func headingLineNear(_ start: Int, _ end: Int, in text: NSString) -> Bool {
307 let length = min(end + 1, text.length) - start
308 guard length >= 0 else { return false }
309 let lines = text.substring(with: text.lineRange(for: NSRange(location: start, length: length)))
310 return lines.split(separator: "\n", omittingEmptySubsequences: false).contains { line in
311 let stars = line.prefix { $0 == "*" }
312 let after = line.dropFirst(stars.count)
313 return !stars.isEmpty && (after.isEmpty || after.first == " " || after.first == "\t" || after.first == "\r")
314 }
315 }
316
317 private func mapRange(_ range: Range<Int>, through edit: TextEdit) -> Range<Int> {
318 let mapped = ViewState(selection: [range]).mapped(through: [edit]).selection[0]
319 return mapped
320 }
321
322 /// Keeps the caret out of folded text: moving forward into a fold jumps past it, moving
323 /// back jumps to the end of the heading line.
324 fileprivate func selectionChanged() {
325 let selection = textView.selectedRange()
326 defer { previousSelection = selection.location }
327 guard selection.length == 0, let range = hidden.containing(selection.location) else { return }
328 setCaret(selection.location >= previousSelection ? range.upperBound : range.lowerBound - 1)
329 }
330}
331
332extension OrgEditor: NSTextViewDelegate {
333 public func textView(_ textView: NSTextView, doCommandBy selector: Selector) -> Bool {
334 switch selector {
335 case #selector(NSResponder.insertTab(_:)): return toggleFoldAtCaret()
336 case #selector(NSResponder.insertBacktab(_:)):
337 toggleAllFolds()
338 return true
339 default: return false
340 }
341 }
342
343 public func textViewDidChangeSelection(_ notification: Notification) {
344 selectionChanged()
345 }
346}
347
348extension OrgEditor: NSTextStorageDelegate {
349 public nonisolated func textStorage(
350 _ textStorage: NSTextStorage, didProcessEditing editedMask: NSTextStorageEditActions, range editedRange: NSRange, changeInLength delta: Int
351 ) {
352 guard editedMask.contains(.editedCharacters) else { return }
353 MainActor.assumeIsolated { storageEdited(range: editedRange, delta: delta) }
354 }
355}
356
357extension OrgEditor: NSTextContentStorageDelegate {
358 /// Folded text isn't enumerated, so it isn't laid out or drawn.
359 public nonisolated func textContentManager(
360 _ textContentManager: NSTextContentManager, shouldEnumerate textElement: NSTextElement, options: NSTextContentManager.EnumerationOptions = []
361 ) -> Bool {
362 guard let range = textElement.elementRange else { return true }
363 let documentStart = textContentManager.documentRange.location
364 let start = textContentManager.offset(from: documentStart, to: range.location)
365 let end = textContentManager.offset(from: documentStart, to: range.endLocation)
366 return !hidden.covers(start, end)
367 }
368}
369
370extension OrgEditor: NSTextLayoutManagerDelegate {
371 public nonisolated func textLayoutManager(
372 _ textLayoutManager: NSTextLayoutManager, textLayoutFragmentFor location: NSTextLocation, in textElement: NSTextElement
373 ) -> NSTextLayoutFragment {
374 let range = textElement.elementRange
375 if let range, let manager = textLayoutManager.textContentManager {
376 let offset = manager.offset(from: manager.documentRange.location, to: range.location)
377 if folded.contains(offset) { return FoldedHeadingFragment(textElement: textElement, range: range) }
378 }
379 return NSTextLayoutFragment(textElement: textElement, range: range)
380 }
381}
382
383/// A heading line drawn with an ellipsis after it, like org's folded headings.
384final class FoldedHeadingFragment: NSTextLayoutFragment, @unchecked Sendable {
385 override func draw(at point: CGPoint, in context: CGContext) {
386 super.draw(at: point, in: context)
387 guard let line = textLineFragments.first else { return }
388 let bounds = line.typographicBounds
389 let ellipsis = NSAttributedString(string: " …", attributes: [
390 .font: NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular),
391 .foregroundColor: NSColor.secondaryLabelColor,
392 ])
393 NSGraphicsContext.saveGraphicsState()
394 NSGraphicsContext.current = NSGraphicsContext(cgContext: context, flipped: true)
395 ellipsis.draw(at: CGPoint(x: point.x + bounds.maxX, y: point.y + bounds.minY))
396 NSGraphicsContext.restoreGraphicsState()
397 }
398
399 override var renderingSurfaceBounds: CGRect {
400 super.renderingSurfaceBounds.insetBy(dx: -40, dy: 0).offsetBy(dx: 40, dy: 0)
401 }
402}
403#endif