krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Sources/OrgEditorAppKit/OrgEditor.swift

10c33661279a1fb0e8d3dd51509db9637778769e
orgstar/Sources/OrgEditorAppKit/OrgEditor.swift history · blame · raw

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