krz/orgstar

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

Sources/OrgEditorAppKit/OrgEditor.swift

85c633a83a4744bef9dde8b52462f9c2a1c037e4
orgstar/Sources/OrgEditorAppKit/OrgEditor.swift history · blame · raw

673 lines · 30629 bytes

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