krz/orgstar

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

Sources/OrgEditorAppKit/OrgEditor.swift

1555 lines · 76787 bytes

85 symbols in this file
   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    /// Where lines start, for line numbers.
  56    let lines = LineIndex()
  57    /// Blocks drawn on a shaded band.
  58    let shaded = HiddenRanges()
  59    private(set) var theme = Theme()
  60    /// Colors and type; a change restyles the file.
  61    public var themeSpec: ThemeSpec = .default {
  62        didSet {
  63            guard themeSpec != oldValue else { return }
  64            theme = Theme(themeSpec)
  65            applyThemeToView()
  66            restyleEverything()
  67            textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange)
  68        }
  69    }
  70    private var isLoading = false
  71    private var previousSelection = 0
  72    /// The selection shaded block lines last drew.
  73    private var previousSelectionRange = NSRange(location: 0, length: 0)
  74    /// Text before this offset is styled; after it, only base attributes until the background
  75    /// passes reach it. Always at a line start.
  76    private(set) var styledUpTo = 0
  77    private var stylingScheduled = false
  78    /// One paragraph style object per indent pair, shared by every line that uses it.
  79    private var paragraphStyles: [IndentKey: NSParagraphStyle] = [:]
  80
  81    private struct IndentKey: Hashable {
  82        let firstLine: Int
  83        let wrapped: Int
  84    }
  85
  86    /// Styled before the first render: more than a screenful.
  87    static let firstStyleChunk = 30_000
  88    /// Styled per run-loop turn afterwards.
  89    static let styleChunk = 200_000
  90
  91    /// Highlights code in src blocks; nil leaves code plain.
  92    public let highlighter: (any CodeHighlighter)?
  93
  94    /// What the file is: org, or plain text, highlighted as code when the language is known.
  95    public enum Mode: Equatable, Sendable {
  96        case org
  97        case plain(language: String?)
  98    }
  99
 100    public let mode: Mode
 101    public var isOrg: Bool { mode == .org }
 102    /// The text as shown for accessibility, and the storage generation it's for.
 103    var accessibleCache: (storage: Int, text: AccessibleText)?
 104    /// Bumped on every character or attribute change to the storage.
 105    var storageGeneration = 0
 106    /// The open completion list, and the pause before one opens by itself.
 107    var completionPopup: CompletionPopup?
 108    var completionTimer: DispatchWorkItem?
 109    /// The spell checker's skipped ranges and the text they were found in.
 110    var spellSkipCache: (text: String, ranges: [Range<Int>])?
 111    /// Highlights of the whole file in plain mode, and the revision they were made for.
 112    private var codeRuns: (revision: Int, runs: [(range: Range<Int>, category: SyntaxCategory)])?
 113
 114    public init(
 115        document: DocumentState, frame: NSRect = NSRect(x: 0, y: 0, width: 600, height: 800), editable: Bool = true,
 116        highlighter: (any CodeHighlighter)? = nil, mode: Mode = .org, options: EditingOptions = .org
 117    ) {
 118        self.document = document
 119        self.options = options
 120        self.highlighter = highlighter
 121        self.mode = mode
 122        let orgTextView = OrgTextView(usingTextLayoutManager: true)
 123        textView = orgTextView
 124        dispatcher = KeyDispatcher(keymap: KeymapPreset.emacs.keymap)
 125        super.init()
 126        orgTextView.keyHandler = { [weak self] event in self?.handleKey(event) ?? false }
 127        orgTextView.onFocusChange = { [weak self] in
 128            guard let self else { return }
 129            let id = ObjectIdentifier(self.textLayoutManager)
 130            if let window = self.textView.window, window.isKeyWindow, window.firstResponder === self.textView {
 131                Theme.focusedLayouts.insert(id)
 132            } else {
 133                Theme.focusedLayouts.remove(id)
 134            }
 135            self.redrawShaded([self.textView.selectedRange()])
 136        }
 137        orgTextView.onCommandClick = { [weak self] offset in
 138            guard let self, Links.link(at: offset, text: self.document.text, tree: self.document.tree) != nil
 139                || Links.timestampDays(at: offset, text: self.document.text) != nil else { return false }
 140            self.setCaret(offset)
 141            self.run("org.link.open")
 142            return true
 143        }
 144        orgTextView.onClick = { [weak self] offset in self?.click(at: offset) ?? false }
 145        orgTextView.accessibleText = { [weak self] in
 146            guard let self, let storage = self.textView.textStorage else { return AccessibleText(NSAttributedString()) }
 147            if let cached = self.accessibleCache, cached.storage == self.storageGeneration { return cached.text }
 148            let text = AccessibleText(storage)
 149            self.accessibleCache = (self.storageGeneration, text)
 150            return text
 151        }
 152        orgTextView.onMouseDown = { [weak self] in
 153            self?.markActive = false
 154            self?.modalMouseDown()
 155        }
 156        textView.usesFindBar = true
 157        textView.frame = frame
 158        textView.isRichText = false
 159        textView.isEditable = editable
 160        textView.allowsUndo = true
 161        textView.isAutomaticQuoteSubstitutionEnabled = false
 162        textView.isAutomaticDashSubstitutionEnabled = false
 163        textView.isAutomaticTextReplacementEnabled = false
 164        textView.isAutomaticSpellingCorrectionEnabled = false
 165        applyThemeToView()
 166        textView.isVerticallyResizable = true
 167        textView.autoresizingMask = [.width]
 168        textView.textContainer?.widthTracksTextView = true
 169        textView.delegate = self
 170        textView.textStorage?.delegate = self
 171        textLayoutManager.delegate = self
 172        textContentStorage.delegate = self
 173
 174        if isOrg { shaded.set(Presentation.shadedBlocks(document.tree)) }
 175        load(document.text)
 176        // Files open at their start, as in Emacs; replacing the storage leaves it at the end.
 177        textView.setSelectedRange(NSRange(location: 0, length: 0))
 178        if isOrg {
 179            let startup = Presentation.startupDisplay(document.tree, options: options)
 180            indented = startup.indented
 181            hideStars = startup.hideStars
 182            if startup.folds != FoldState() { setFolds(startup.folds, wrappers: startup.wrappers) }
 183            shrinkAtStartup()
 184            if startup.inlineImages { showsImages = true }
 185            if startup.alignTables { _ = perform(AlignAllTables()) }
 186            if !indented || !hideStars { restyleOutsideEditing(0..<utf16Length) }
 187        }
 188        spellChecking = options.spellCheck
 189        truncatesLines = options.startupTruncated
 190    }
 191
 192    /// Replaces the storage's text. Setting the attributed string directly is about 100x
 193    /// faster than `NSTextView.string` for large files.
 194    private func load(_ text: String) {
 195        isLoading = true
 196        textView.textStorage?.setAttributedString(NSAttributedString(string: text, attributes: theme.base))
 197        isLoading = false
 198        lines.reset(text as NSString)
 199        orgTextView?.lineIndex = lines
 200        orgTextView?.updateGutter()
 201        styledUpTo = 0
 202        styleNextChunk(Self.firstStyleChunk)
 203        scheduleStyling()
 204    }
 205
 206    // MARK: - Initial styling
 207
 208    /// Styles whole lines from the watermark onward, about `size` units.
 209    private func styleNextChunk(_ size: Int) {
 210        let text = textView.textStorage!.string as NSString
 211        guard styledUpTo < text.length else { return }
 212        let target = min(text.length, styledUpTo + size)
 213        let end = target == text.length ? target : NSMaxRange(text.lineRange(for: NSRange(location: target, length: 0)))
 214        restyleOutsideEditing(styledUpTo..<end)
 215        styledUpTo = end
 216    }
 217
 218    /// The text view's own colors and font, and the line numbers'.
 219    private func applyThemeToView() {
 220        textView.font = theme.font
 221        textView.typingAttributes = theme.base
 222        textView.drawsBackground = true
 223        textView.backgroundColor = theme.color("background") ?? .textBackgroundColor
 224        textView.insertionPointColor = theme.color("cursor") ?? .textColor
 225        Theme.selection = theme.color("selection") ?? .selectedTextBackgroundColor
 226        textView.selectedTextAttributes = [.backgroundColor: Theme.selection]
 227        if let view = orgTextView {
 228            view.lineNumberFont = NSFont.monospacedDigitSystemFont(ofSize: max(8, theme.font.pointSize - 2), weight: .regular)
 229            view.lineNumberColor = theme.color("line-number") ?? .tertiaryLabelColor
 230            view.currentLineNumberColor = theme.color("line-number-current") ?? .labelColor
 231            view.updateGutter()
 232            view.needsDisplay = true
 233        }
 234    }
 235
 236    /// Styles the whole file again, a screenful now and the rest in the background.
 237    private func restyleEverything() {
 238        styledUpTo = 0
 239        revealedLine = caretLine
 240        styleNextChunk(Self.firstStyleChunk)
 241        scheduleStyling()
 242    }
 243
 244    private var caretLine: NSRange {
 245        let text = textView.string as NSString
 246        return text.lineRange(for: NSRange(location: min(textView.selectedRange().location, text.length), length: 0))
 247    }
 248
 249    private func scheduleStyling() {
 250        guard !stylingScheduled, styledUpTo < utf16Length else { return }
 251        stylingScheduled = true
 252        DispatchQueue.main.async { [weak self] in
 253            guard let self else { return }
 254            self.stylingScheduled = false
 255            self.styleNextChunk(Self.styleChunk)
 256            self.scheduleStyling()
 257        }
 258    }
 259
 260    /// Styles everything still waiting for a background pass.
 261    public func finishStyling() {
 262        while styledUpTo < utf16Length { styleNextChunk(Self.styleChunk) }
 263    }
 264
 265    public var textLayoutManager: NSTextLayoutManager { textView.textLayoutManager! }
 266    var textContentStorage: NSTextContentStorage { textLayoutManager.textContentManager as! NSTextContentStorage }
 267    var utf16Length: Int { (textView.string as NSString).length }
 268
 269    /// A scroll view holding the text view, ready for a window.
 270    public func makeScrollView() -> NSScrollView {
 271        let scroll = NSScrollView(frame: textView.frame)
 272        scroll.hasVerticalScroller = true
 273        scroll.documentView = textView
 274        orgTextView?.showsLineNumbers = showLineNumbers
 275        textView.minSize = NSSize(width: 0, height: scroll.contentSize.height)
 276        textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
 277        return scroll
 278    }
 279
 280    // MARK: - Saving
 281
 282    /// Saves through `saver`. A merge that changes the buffer reloads the text view.
 283    @discardableResult
 284    public func save(using saver: Saver, to url: URL) throws -> SaveOutcome {
 285        try update { try saver.save(&$0, to: url) }
 286    }
 287
 288    /// Called after every change to the document, typed or not.
 289    public var onChange: (() -> Void)?
 290
 291    // MARK: - Commands
 292
 293    /// Called with messages commands report, such as why one couldn't run here.
 294    public var onMessage: ((String) -> Void)?
 295    /// Called with the pending prefix and what can follow it, and with nil when it ends.
 296    /// The evil state when editing modally (`normal`, `insert`, `visual`, `visual-line`), nil otherwise.
 297    public var onModeChange: ((String?) -> Void)?
 298    /// The selection moved.
 299    public var onSelectionChange: ((NSRange) -> Void)?
 300    var reportedMode: String?
 301    public var onKeysPending: ((_ keys: String?, _ next: [(key: String, title: String)]) -> Void)?
 302    /// Called with `app.*` commands, which the app carries out.
 303    public var onAppCommand: ((String) -> Void)?
 304    /// `:e FILE`: the path as typed, for the app to open.
 305    public var onOpenFile: ((String) -> Void)?
 306    /// Called when a command asks a question; reply with the answer, or nil to cancel.
 307    public var onPrompt: ((Prompt, _ reply: @escaping (String?) -> Void) -> Void)?
 308    /// Runs work outside the text, such as a table recalculation in Emacs.
 309    public var onExternal: ((ExternalRequest) -> Void)?
 310    /// Tags used across the workspace, for tag completion.
 311    public var knownTags: @Sendable () -> [String] = { [] }
 312    /// Effects other than messages, for the app to carry out.
 313    public var onEffect: ((Effect) -> Void)?
 314
 315    public var keymap: Keymap {
 316        get { dispatcher.keymap }
 317        set {
 318            dispatcher.keymap = newValue
 319            dispatcher.reset()
 320        }
 321    }
 322
 323    public var optionAsMeta: OptionAsMeta = [.left]
 324    /// Org options commands follow.
 325    public var options: EditingOptions = .org {
 326        didSet {
 327            if options.hideEmphasisMarkers != oldValue.hideEmphasisMarkers, !showMarkup { restyleEverything() }
 328        }
 329    }
 330    /// Show link brackets and targets, and emphasis markers (when Emacs hides them, per
 331    /// `options`). Hidden markup shows on the caret's line, so it can be edited.
 332    public var showMarkup = true {
 333        didSet { if showMarkup != oldValue { restyleEverything() } }
 334    }
 335    /// Line numbers in a ruler beside the text.
 336    public var showLineNumbers = true {
 337        didSet { orgTextView?.showsLineNumbers = showLineNumbers }
 338    }
 339    var orgTextView: OrgTextView? { textView as? OrgTextView }
 340
 341    /// The line last revealed while markup is hidden, to hide again when the caret leaves.
 342    private var revealedLine: NSRange?
 343    var dispatcher: KeyDispatcher
 344    /// Evil-style editing, for modal keymaps such as Doom's.
 345    public var modal = false {
 346        didSet {
 347            guard modal != oldValue else { return }
 348            vim = Vim()
 349            dispatcher.mode = modal ? vim.mode.keymapMode : nil
 350            dispatcher.reset()
 351            updateCursor()
 352        }
 353    }
 354    var vim = Vim()
 355    /// The text and caret when insert state began, to know what was typed for `.`.
 356    var insertSnapshot: (text: String, caret: Int)?
 357    /// The evil cursor in visual states, where the selection's start isn't it.
 358    var visualCaret: Int?
 359    /// Vim's lowercase marks, mapped through edits.
 360    var vimMarks: [Character: Int] = [:]
 361    /// Uppercase marks, with the editor each is in: global, as evil's file marks are.
 362    static var fileMarks: [Character: (owner: WeakEditor, offset: Int)] = [:]
 363    /// Registers and keyboard macros, shared by every editor as evil's are global.
 364    static var vimRegisters: [Character: (text: String, linewise: Bool)] = [:]
 365    static var vimClipboardRecord: (text: String, linewise: Bool)?
 366    static var macros: [Character: [KeyChord]] = [:]
 367    /// Shows `editor`'s buffer with the caret at the offset, for a jump to a file mark set
 368    /// there.
 369    public var onJumpToEditor: ((OrgEditor, Int) -> Void)?
 370    /// Jump positions, oldest first, mapped through edits; `jumpIndex` is where C-o and C-i are.
 371    var jumpList: [Int] = []
 372    var jumpIndex = 0
 373    /// The register keys go into while `q` records, and its keys so far.
 374    var recordingRegister: Character?
 375    var recordedKeys: [KeyChord] = []
 376    var replayingMacro = 0
 377    /// A region is active: movement extends the selection, as after C-SPC in Emacs.
 378    private(set) var markActive = false
 379
 380    public func context(now: Date = Date(), answers: [String: String] = [:]) -> EditContext {
 381        let selected = textView.selectedRange()
 382        return EditContext(
 383            revision: document.revision, text: document.text, tree: document.tree,
 384            selection: [selected.location..<NSMaxRange(selected)], now: now, calendar: .current, answers: answers,
 385            hidden: hidden.all, options: options, knownTags: knownTags,
 386            lastCommand: lastCommand.flatMap { $0.revision == document.revision ? $0.id : nil }
 387        )
 388    }
 389
 390    /// Offers a key press to the keymap. False lets the text system have it.
 391    func handleKey(_ event: NSEvent) -> Bool {
 392        guard let chord = KeyEvents.chord(for: event, optionAsMeta: optionAsMeta) else { return modal && vim.mode != .insert }
 393        if let handled = completionKey(chord) { return handled }
 394        if modal { return handleModal(chord) }
 395        switch dispatcher.feed(chord) {
 396        case .pending(let keys):
 397            let context = context()
 398            let next = keymap.continuations(of: keys) { runs($0, in: context) }.map { item in
 399                (key: item.key.description, title: item.command.map { EditorCommands.command($0)?.title ?? $0 } ?? "+prefix")
 400            }
 401            onKeysPending?(KeySequence.format(keys), next)
 402            return true
 403        case .cancelled:
 404            onKeysPending?(nil, [])
 405            onMessage?("Quit")
 406            return true
 407        case .undefined(let keys):
 408            onKeysPending?(nil, [])
 409            onMessage?("\(KeySequence.format(keys)) is undefined")
 410            return true
 411        case .unbound:
 412            return electricPair(chord)
 413        case .complete(let keys, let candidates):
 414            if keys.count > 1 { onKeysPending?(nil, []) }
 415            let context = context()
 416            let runnable = candidates.first { runs($0, in: context) }
 417            if let binding = runnable ?? (keys.count > 1 ? candidates.first : nil) {
 418                run(binding.command)
 419                return true
 420            }
 421            return keys.count == 1 && electricPair(chord)
 422        }
 423    }
 424
 425    /// `electric-pair-mode` for a key the keymap leaves to the text system: true when it
 426    /// typed the key itself.
 427    func electricPair(_ key: KeyChord) -> Bool {
 428        guard isOrg, options.electricPair, key.modifiers.isEmpty, !textView.hasMarkedText(),
 429              key.key == "DEL" || key.key == "RET" || ElectricPairKey.characters.contains(key.key) else { return false }
 430        let command = ElectricPairKey(key: key.key)
 431        guard command.applies(in: context()) else { return false }
 432        _ = perform(command)
 433        return true
 434    }
 435
 436    /// Whether a key's `binding` runs in `context`: its context holds and its command applies.
 437    private func runs(_ binding: KeyBinding, in context: EditContext) -> Bool {
 438        (binding.when.map { KeyContext.holds($0, in: context) } ?? true) && applies(binding.command, in: context)
 439    }
 440
 441    private func applies(_ id: String, in context: EditContext) -> Bool {
 442        if !isOrg, id.hasPrefix("org.") { return false }
 443        return switch EditorCommands.command(id)?.action {
 444        case .text(let command): command.applies(in: context)
 445        case .view where id.hasPrefix("org.table."): KeyContext.holds("table", in: context)
 446        case .view: id != "org.cycle" || Presentation.heading(containing: context.caret, in: context.tree) != nil
 447            || Wrappers.toggleable(at: context.caret, in: context.tree) != nil
 448        case .edit, .mark, .app, .find: true
 449        case nil: true
 450        }
 451    }
 452
 453    /// The last command run and the revision it left, for `last-command`.
 454    private var lastCommand: (id: String, revision: Int)?
 455
 456    /// Runs a command by id, as a key, a menu item or the palette would.
 457    public func run(_ id: String) {
 458        defer { lastCommand = (id, document.revision) }
 459        guard let command = EditorCommands.command(id) else {
 460            onMessage?("Unknown command \(id)")
 461            return
 462        }
 463        if !isOrg, id.hasPrefix("org.") {
 464            onMessage?("Not an org file")
 465            return
 466        }
 467        // `org-ctrl-c-ctrl-c` first removes sparse tree highlights.
 468        if id == "org.ctrl-c-ctrl-c", !highlights.isEmpty {
 469            clearHighlights()
 470            return
 471        }
 472        var keepMark = false
 473        switch command.action {
 474        case .text(let text):
 475            perform(text)
 476        case .view:
 477            switch id {
 478            case "org.cycle": cycleAtCaret()
 479            case "org.cycle-global": cycleGlobal()
 480            case "org.fold.toggle", "org.fold.open", "org.fold.close": fold(id)
 481            case "org.narrow.subtree", "org.narrow.block", "org.narrow.toggle", "org.widen": narrow(id)
 482            case "org.toggle-inline-images": toggleImages()
 483            case "editor.toggle-spell-check": toggleSpellCheck()
 484            case "editor.complete": completeAtPoint()
 485            case "editor.toggle-truncate-lines": toggleTruncateLines()
 486            default: tableColumnWidth(id)
 487            }
 488        case .edit(let selector, let extending):
 489            if markActive, let extending {
 490                keepMark = true
 491                textView.doCommand(by: NSSelectorFromString(extending))
 492            } else {
 493                textView.doCommand(by: NSSelectorFromString(selector))
 494            }
 495        case .find(let action):
 496            let item = NSMenuItem()
 497            item.tag = action.rawValue
 498            textView.performTextFinderAction(item)
 499        case .mark:
 500            if id == "edit.set-mark" {
 501                let caret = textView.selectedRange().location
 502                textView.setSelectedRange(NSRange(location: caret, length: 0))
 503                keepMark = true
 504                onMessage?("Mark set")
 505            } else {
 506                let selected = textView.selectedRange()
 507                if markActive || selected.length > 0 { textView.setSelectedRange(NSRange(location: NSMaxRange(selected), length: 0)) }
 508                onMessage?("Quit")
 509            }
 510        case .app:
 511            onAppCommand?(id)
 512        }
 513        markActive = keepMark
 514    }
 515
 516    /// Runs `command` at the selection. Edits go through the text view, as typing does, so
 517    /// they form one undo step and reach the document through the same path.
 518    @discardableResult
 519    public func perform(_ command: any OrgCommand, now: Date = Date(), answers: [String: String] = [:]) -> CommandStep {
 520        let context = context(now: now, answers: answers)
 521        let step = command.run(in: context)
 522        switch step {
 523        case .commit(let result):
 524            guard textView.isEditable || result.edits.isEmpty else {
 525                onMessage?("This file is read-only.")
 526                return .failed("read-only")
 527            }
 528            commandEditing = true
 529            applyEdits(result.edits)
 530            commandEditing = false
 531            if let selection = result.selection?.first {
 532                if hidden.containing(selection.lowerBound) != nil { unfold(around: selection.lowerBound) }
 533                setCaret(selection.lowerBound)
 534                if !selection.isEmpty { textView.setSelectedRange(NSRange(selection)) }
 535            }
 536            for effect in result.effects {
 537                switch effect {
 538                case .message(let text): onMessage?(text)
 539                case .sparseTree(let points, let highlights): showSparseTree(points, highlights: highlights)
 540                case .fold(let start):
 541                    var state = foldState
 542                    state.folds.insert(start)
 543                    setFolds(state)
 544                default: onEffect?(effect)
 545                }
 546            }
 547        case .failed(let message):
 548            onMessage?(message)
 549        case .external(let request):
 550            if let onExternal { onExternal(request) } else { onMessage?("This needs Emacs, which isn't available here.") }
 551        case .prompt(let prompt):
 552            onPrompt?(prompt) { [weak self] answer in
 553                guard let self else { return }
 554                guard let answer else {
 555                    self.onMessage?("Quit")
 556                    return
 557                }
 558                self.perform(command, now: now, answers: answers.merging([prompt.key: answer]) { $1 })
 559            }
 560        }
 561        return step
 562    }
 563
 564    // MARK: - Disk
 565
 566    /// The file on disk now holds `bytes`: reload or merge, keeping folds where the text kept
 567    /// its headings.
 568    @discardableResult
 569    public func diskChanged(to bytes: [UInt8]) -> DocumentState.ExternalChange {
 570        update { $0.diskChanged(to: bytes) }
 571    }
 572
 573    // MARK: - Folding
 574
 575    public var foldState: FoldState {
 576        FoldState(folds: view.folds, bodyFolds: view.bodyFolds, wrapperFolds: view.wrapperFolds)
 577    }
 578
 579    private var globalVisibility: GlobalVisibility?
 580
 581    public func setFolds(_ state: FoldState) {
 582        setFolds(state, wrappers: nil)
 583    }
 584
 585    /// `wrappers`, when given, holds every folded drawer and block.
 586    private func setFolds(_ state: FoldState, wrappers: [Wrapper]?) {
 587        sparseHidden = nil
 588        view.folds = state.folds
 589        view.bodyFolds = state.bodyFolds
 590        view = view.pruned(to: document.tree, wrappers: false)
 591        // Found one by one, or all at once when there are many.
 592        let found = wrappers ?? (state.wrapperFolds.count > 32
 593            ? Wrappers.all(document.tree).filter { state.wrapperFolds.contains($0.start) }
 594            : state.wrapperFolds.compactMap { Wrappers.starting(at: $0, in: document.tree) })
 595        wrapperHidden = Dictionary(found.map { ($0.start, $0.hidden) }) { a, _ in a }
 596        view.wrapperFolds = Set(wrapperHidden.keys)
 597        updateHidden()
 598        orgTextView?.redrawLineNumbers()
 599        textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange)
 600        textView.needsDisplay = true
 601        let caret = textView.selectedRange().location
 602        if let range = hidden.containing(caret) { setCaret(range.lowerBound - 1) }
 603    }
 604
 605    /// What each folded drawer and block hides, by opening-line start; kept in step with
 606    /// `view.wrapperFolds` and mapped through edits.
 607    private var wrapperHidden: [Int: Range<Int>] = [:]
 608
 609    private func updateHidden() {
 610        if let sparseHidden {
 611            let ns = textView.string as NSString
 612            // Headings whose next line is hidden show the fold ellipsis.
 613            let headings = Presentation.outline(document.tree).map(\.start).filter { start in
 614                let next = NSMaxRange(ns.lineRange(for: NSRange(location: start, length: 0)))
 615                return sparseHidden.contains { $0.contains(next) } && !sparseHidden.contains { $0.contains(start) }
 616            }
 617            folded.set(Set(headings).union(view.wrapperFolds))
 618            hidden.set(sparseHidden + Array(wrapperHidden.values) + narrowHidden)
 619            return
 620        }
 621        folded.set(view.folds.union(view.bodyFolds).union(view.wrapperFolds))
 622        hidden.set(Presentation.hiddenRanges(document.tree, folds: view.folds, bodyFolds: view.bodyFolds, wrapperRanges: Array(wrapperHidden.values)) + narrowHidden)
 623    }
 624
 625    // MARK: - Sparse trees
 626
 627    /// What a sparse tree (`org-sparse-tree`) hides, replacing heading folds until the next
 628    /// fold change. Mapped through edits.
 629    private var sparseHidden: [Range<Int>]?
 630    /// Sparse tree matches, highlighted until the next edit or C-c C-c.
 631    private var highlights: [Range<Int>] = []
 632
 633    private func showSparseTree(_ points: [Int], highlights: [Range<Int>]) {
 634        let text = textView.string
 635        var state = foldState
 636        for start in state.wrapperFolds where points.contains(where: { Wrappers.starting(at: start, in: document.tree)?.hidden.contains($0) == true }) {
 637            state.wrapperFolds.remove(start)
 638        }
 639        sparseHidden = nil
 640        setFolds(state)
 641        sparseHidden = SparseTree.hidden(points, in: text, tree: document.tree)
 642        self.highlights = highlights
 643        updateHidden()
 644        restyleOutsideEditing(0..<utf16Length)
 645        textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange)
 646        orgTextView?.redrawLineNumbers()
 647        textView.needsDisplay = true
 648        let caret = textView.selectedRange().location
 649        if let range = hidden.containing(caret) { setCaret(max(0, range.lowerBound - 1)) }
 650    }
 651
 652    /// Leaves a sparse tree for ordinary folds: headings with something shown below them
 653    /// open, the others folded.
 654    private func leaveSparseTree() {
 655        guard let sparse = sparseHidden else { return }
 656        sparseHidden = nil
 657        let outline = Presentation.outline(document.tree)
 658        let ns = textView.string as NSString
 659        var open: Set<Int> = []
 660        for entry in outline where !sparse.contains(where: { $0.contains(entry.start) }) {
 661            var parent = entry.parent
 662            while let p = parent {
 663                open.insert(outline[p].start)
 664                parent = outline[p].parent
 665            }
 666            let next = NSMaxRange(ns.lineRange(for: NSRange(location: entry.start, length: 0)))
 667            if next < entry.end, !sparse.contains(where: { $0.contains(next) }) { open.insert(entry.start) }
 668        }
 669        var state = foldState
 670        state.folds = Set(outline.map(\.start)).subtracting(open)
 671        state.bodyFolds = []
 672        setFolds(state)
 673    }
 674
 675    private func clearHighlights() {
 676        guard !highlights.isEmpty else { return }
 677        let old = highlights
 678        highlights = []
 679        for range in old where range.upperBound <= utf16Length { restyleOutsideEditing(range) }
 680    }
 681
 682    private func applyHighlights(_ range: Range<Int>, in storage: NSTextStorage) {
 683        for highlight in highlights where highlight.overlaps(range) && highlight.upperBound <= storage.length {
 684            storage.addAttribute(.backgroundColor, value: NSColor.findHighlightColor.withAlphaComponent(0.45), range: NSRange(highlight.clamped(to: range)))
 685        }
 686    }
 687
 688    // MARK: - STARTUP display
 689
 690    /// `org-indent-mode`: headings' bodies indented under their titles (`#+STARTUP: indent`).
 691    private var indented = true
 692    /// `org-hide-leading-stars` (`#+STARTUP: hidestars`); org-indent hides them as well.
 693    private var hideStars = false
 694
 695    // MARK: - Inline images
 696
 697    /// `org-toggle-inline-images`: lines that are only an image link show the image.
 698    public private(set) var showsImages = false
 699    /// The file's folder, for relative image paths.
 700    public var baseDirectory: URL? {
 701        didSet { if showsImages, oldValue != baseDirectory { refreshImages() } }
 702    }
 703    private static let imageCache = NSCache<NSString, NSImage>()
 704
 705    private func toggleImages() {
 706        showsImages.toggle()
 707        refreshImages()
 708        if showsImages {
 709            let ns = textView.string as NSString
 710            var count = 0
 711            var offset = 0
 712            while offset < ns.length {
 713                let line = ns.lineRange(for: NSRange(location: offset, length: 0))
 714                if let image = InlineImages.image(atLine: line.location, in: ns), loadImage(image.path) != nil { count += 1 }
 715                offset = NSMaxRange(line)
 716            }
 717            onMessage?("\(count) images displayed inline")
 718        } else {
 719            onMessage?("Inline image display turned off")
 720        }
 721    }
 722
 723    private func refreshImages() {
 724        restyleOutsideEditing(0..<utf16Length)
 725        textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange)
 726        textView.needsDisplay = true
 727    }
 728
 729    private func loadImage(_ path: String) -> NSImage? {
 730        let expanded = (path as NSString).expandingTildeInPath
 731        let url = expanded.hasPrefix("/") ? URL(fileURLWithPath: expanded) : (baseDirectory ?? URL(fileURLWithPath: FileManager.default.currentDirectoryPath)).appendingPathComponent(expanded)
 732        let modified = (try? url.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate?.timeIntervalSince1970 ?? 0
 733        let key = "\(url.path)|\(modified)" as NSString
 734        if let cached = Self.imageCache.object(forKey: key) { return cached }
 735        guard let image = NSImage(contentsOf: url) else { return nil }
 736        Self.imageCache.setObject(image, forKey: key)
 737        return image
 738    }
 739
 740    /// Image lines in `range` show the image over the link, which is hidden unless the caret is
 741    /// on the line.
 742    private func applyImages(_ range: Range<Int>, in storage: NSTextStorage, revealed: NSRange) {
 743        guard showsImages, isOrg else { return }
 744        let text = storage.string as NSString
 745        let maxWidth = max(80, (textView.textContainer?.size.width ?? 600) - 40)
 746        var location = range.lowerBound
 747        while location < range.upperBound, location < text.length {
 748            let line = text.lineRange(for: NSRange(location: location, length: 0))
 749            location = NSMaxRange(line)
 750            guard let image = InlineImages.image(atLine: line.location, in: text), let picture = loadImage(image.path) else { continue }
 751            var width = image.width.map(CGFloat.init) ?? picture.size.width
 752            width = min(width, maxWidth)
 753            let height = picture.size.width > 0 ? width * picture.size.height / picture.size.width : 0
 754            let content = NSRange(location: line.location, length: line.length - (text.substring(with: line).hasSuffix("\n") ? 1 : 0))
 755            // Invisible, keeping the line's height, so line numbers stay apart.
 756            if line.location != revealed.location, content.length > 0 {
 757                storage.addAttributes([.foregroundColor: NSColor.clear, .underlineStyle: 0], range: content)
 758            }
 759            storage.addAttribute(.orgImage, value: InlineImageBox(image: picture, size: CGSize(width: width, height: height)), range: NSRange(location: line.location, length: max(1, content.length)))
 760            let style = (storage.attribute(.paragraphStyle, at: line.location, effectiveRange: nil) as? NSParagraphStyle)?.mutableCopy() as? NSMutableParagraphStyle ?? NSMutableParagraphStyle()
 761            // The image sits where the line is; the spacing makes room for the rest of it.
 762            style.paragraphSpacing = max(0, height + 4 - theme.font.boundingRectForFont.height)
 763            storage.addAttribute(.paragraphStyle, value: style, range: line)
 764        }
 765    }
 766
 767    // MARK: - Narrowing
 768
 769    /// The text shown while narrowed (`org-narrow-to-subtree`, `org-narrow-to-block`); the
 770    /// rest is hidden until `widen`. Mapped through edits.
 771    public private(set) var narrowed: Range<Int>?
 772
 773    private func narrow(_ id: String) {
 774        let caret = textView.selectedRange().location
 775        do {
 776            switch id {
 777            case "org.narrow.subtree": narrowed = try Narrowing.subtree(at: caret, in: textView.string)
 778            // `org-toggle-narrow-to-subtree`.
 779            case "org.narrow.toggle": narrowed = narrowed == nil ? try Narrowing.subtree(at: caret, in: textView.string) : nil
 780            case "org.narrow.block": narrowed = try Narrowing.block(at: caret, in: textView.string)
 781            default: narrowed = nil
 782            }
 783        } catch let failure as Narrowing.Failure {
 784            onMessage?(failure.message)
 785            return
 786        } catch {
 787            return
 788        }
 789        updateHidden()
 790        textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange)
 791        orgTextView?.redrawLineNumbers()
 792        textView.needsDisplay = true
 793        if let narrowed {
 794            setCaret(max(narrowed.lowerBound, min(caret, narrowed.upperBound)))
 795            textView.scrollRangeToVisible(NSRange(location: narrowed.lowerBound, length: 0))
 796        } else {
 797            textView.scrollRangeToVisible(NSRange(location: caret, length: 0))
 798        }
 799    }
 800
 801    /// What narrowing hides: the text before the region, and the lines after it.
 802    private var narrowHidden: [Range<Int>] {
 803        guard let narrowed else { return [] }
 804        let length = utf16Length
 805        var ranges: [Range<Int>] = []
 806        if narrowed.lowerBound > 0 { ranges.append(0..<narrowed.lowerBound) }
 807        let ns = textView.string as NSString
 808        let tail = narrowed.upperBound < length && ns.character(at: narrowed.upperBound) == 10 ? narrowed.upperBound + 1 : narrowed.upperBound
 809        if tail < length { ranges.append(tail..<length) }
 810        return ranges
 811    }
 812
 813    // MARK: - Shrunk table columns
 814
 815    /// Narrowed table columns (`org-table-shrink`): by table start, each column's segments.
 816    /// Recomputed as the text changes; typing into one widens its column again, as editing
 817    /// org's overlays deletes them. Commands' edits keep columns narrow, as `org-table-align`
 818    /// shrinks them again.
 819    private var shrunk: [Int: [Int: [TableShrink.Segment]]] = [:]
 820    private var commandEditing = false
 821
 822    /// Narrows `columns` of `table` and widens its others.
 823    private func setShrunk(_ columns: [Int], in table: Range<Int>) {
 824        let text = textView.string
 825        let segments = Dictionary(uniqueKeysWithValues: columns.map { ($0, TableShrink.segments(table, column: $0, in: text)) })
 826        shrunk[table.lowerBound] = segments.isEmpty ? nil : segments
 827        restyleOutsideEditing(table)
 828        textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange)
 829        textView.needsDisplay = true
 830    }
 831
 832    /// C-c TAB in a table: `org-table-toggle-column-width`; `shrink` for `org-table-shrink`,
 833    /// `expand` for `org-table-expand`.
 834    private func tableColumnWidth(_ id: String) {
 835        let caret = textView.selectedRange().location
 836        let text = textView.string
 837        guard let table = TableShrink.table(at: caret, in: text) else {
 838            onMessage?("Not in a table")
 839            return
 840        }
 841        let current = shrunk[table.lowerBound].map { Array($0.keys) } ?? []
 842        switch id {
 843        case "org.table.shrink": setShrunk(TableShrink.cookieColumns(table, in: text), in: table)
 844        case "org.table.expand": setShrunk([], in: table)
 845        default:
 846            if let column = TableShrink.column(at: caret, in: text) {
 847                setShrunk(Array(Set(current).symmetricDifference([column])), in: table)
 848                return
 849            }
 850            let line = (text as NSString).lineRange(for: NSRange(location: caret, length: 0))
 851            onPrompt?(Prompt(key: "columns", message: "Column ranges (e.g. 2-4 6-):")) { [weak self] answer in
 852                guard let self, let answer else { return }
 853                self.setShrunk(Array(Set(current).symmetricDifference(TableShrink.columns(answer, max: line.length / 2))), in: table)
 854            }
 855        }
 856    }
 857
 858    /// `#+STARTUP: shrink`: every table's columns with width cookies start narrow.
 859    private func shrinkAtStartup() {
 860        guard Presentation.startupOptions(document.tree).contains(where: { $0.lowercased() == "shrink" }) else { return }
 861        let text = textView.string
 862        var offset = 0
 863        let ns = text as NSString
 864        while offset < ns.length {
 865            let line = ns.lineRange(for: NSRange(location: offset, length: 0))
 866            defer { offset = max(NSMaxRange(line), offset + 1) }
 867            guard ns.substring(with: line).range(of: "^[ \\t]*\\|", options: .regularExpression) != nil,
 868                  let table = TableShrink.table(at: line.location, in: text) else { continue }
 869            let columns = TableShrink.cookieColumns(table, in: text)
 870            if !columns.isEmpty { shrunk[table.lowerBound] = Dictionary(uniqueKeysWithValues: columns.map { ($0, TableShrink.segments(table, column: $0, in: text)) }) }
 871            offset = table.upperBound
 872        }
 873    }
 874
 875    /// Moves shrunk columns through `edit`; returns the tables to restyle.
 876    private func mapShrunk(through edit: TextEdit) -> [Range<Int>] {
 877        guard !shrunk.isEmpty else { return [] }
 878        let text = textView.string
 879        let delta = edit.replacement.utf16.count - edit.range.count
 880        var mapped: [Int: [Int: [TableShrink.Segment]]] = [:]
 881        var tables: [Range<Int>] = []
 882        for (start, columns) in shrunk {
 883            let kept = columns.filter { _, segments in
 884                commandEditing || !segments.contains { $0.range.lowerBound <= edit.range.upperBound && edit.range.lowerBound <= $0.range.upperBound }
 885            }.keys
 886            let touched = columns.values.joined().contains { $0.range.lowerBound <= edit.range.upperBound && edit.range.lowerBound <= $0.range.upperBound }
 887            let newStart = start < edit.range.lowerBound ? start : start >= edit.range.upperBound ? start + delta : edit.range.lowerBound
 888            guard let table = TableShrink.table(at: min(newStart, max(0, text.utf16.count - 1)), in: text) else { continue }
 889            if touched || kept.count != columns.count { tables.append(table) }
 890            guard !kept.isEmpty else { continue }
 891            mapped[table.lowerBound] = Dictionary(uniqueKeysWithValues: kept.map { ($0, TableShrink.segments(table, column: $0, in: text)) })
 892        }
 893        shrunk = mapped
 894        return tables
 895    }
 896
 897    /// Hides each shrunk segment's text and draws its display where the text was.
 898    private func applyShrunk(_ range: Range<Int>, in storage: NSTextStorage) {
 899        guard !shrunk.isEmpty else { return }
 900        let cell = ("0" as NSString).size(withAttributes: [.font: theme.font]).width
 901        for segment in shrunk.values.flatMap({ $0.values.joined() }) where segment.range.overlaps(range) && segment.range.upperBound <= storage.length {
 902            let r = segment.range
 903            let shown = segment.display.count
 904            let kept = min(shown, r.count)
 905            if r.count > kept { storage.addAttributes(theme.hidden, range: NSRange(location: r.lowerBound, length: r.count - kept)) }
 906            guard kept > 0 else { continue }
 907            let visible = NSRange(location: r.upperBound - kept, length: kept)
 908            storage.addAttribute(.foregroundColor, value: NSColor.clear, range: visible)
 909            storage.addAttribute(.orgEntityDisplay, value: segment.display, range: NSRange(location: visible.location, length: 1))
 910            if shown > kept { storage.addAttribute(.kern, value: CGFloat(shown - kept) * cell, range: NSRange(location: NSMaxRange(visible) - 1, length: 1)) }
 911        }
 912    }
 913
 914    /// Moves drawer and block folds through `edit`, checking again only those near it.
 915    private func mapWrapperFolds(through edit: TextEdit, near affected: Range<Int>) {
 916        guard !wrapperHidden.isEmpty else { return }
 917        var mapped: [Int: Range<Int>] = [:]
 918        let entries = Array(wrapperHidden)
 919        // One pass maps every start and range.
 920        let moved = ViewState(selection: entries.flatMap { [$0.key..<$0.key, $0.value] }).mapped(through: [edit]).selection
 921        for i in entries.indices {
 922            let newStart = moved[2 * i].lowerBound
 923            let newRange = moved[2 * i + 1]
 924            if newStart <= affected.upperBound, newRange.upperBound >= affected.lowerBound {
 925                if let wrapper = Wrappers.starting(at: newStart, in: document.tree) { mapped[newStart] = wrapper.hidden }
 926            } else {
 927                mapped[newStart] = newRange
 928            }
 929        }
 930        wrapperHidden = mapped
 931        view.wrapperFolds = Set(mapped.keys)
 932    }
 933
 934    /// TAB on the first or last line of a drawer or block folds or unfolds it; on a heading,
 935    /// cycles folded, children, subtree. Returns false elsewhere.
 936    @discardableResult
 937    public func cycleAtCaret() -> Bool {
 938        leaveSparseTree()
 939        let caret = textView.selectedRange().location
 940        guard let state = Presentation.cycle(at: caret, in: document.tree, foldState) else { return false }
 941        setFolds(state)
 942        // Off a closing line that folding hid, to the opening line, as org does.
 943        if let wrapper = Wrappers.toggleable(at: caret, in: document.tree), hidden.containing(caret) != nil { setCaret(wrapper.start) }
 944        return true
 945    }
 946
 947    /// Doom's `za`, `zo` (on a heading line) and `zc` (anywhere in a subtree).
 948    func fold(_ id: String) {
 949        leaveSparseTree()
 950        let caret = textView.selectedRange().location
 951        let tree = document.tree
 952        let next: FoldState?
 953        if id == "org.fold.close" {
 954            next = Presentation.outline(tree).last { $0.start <= caret && (caret < $0.end || $0.end == utf16Length) }
 955                .map { Presentation.hideSubtree($0.start, foldState) }
 956        } else {
 957            next = Presentation.heading(containing: caret, in: tree).map {
 958                id == "org.fold.open" ? Presentation.openFold($0, in: tree, foldState) : Presentation.toggleFold($0, in: tree, foldState)
 959            }
 960        }
 961        if let next, next != foldState { setFolds(next) }
 962    }
 963
 964    /// Shift-TAB: overview, contents, show all.
 965    public func cycleGlobal() {
 966        leaveSparseTree()
 967        let (visibility, state) = Presentation.cycleGlobal(from: globalVisibility, in: document.tree, wrapperFolds: view.wrapperFolds)
 968        globalVisibility = visibility
 969        setFolds(state)
 970    }
 971
 972    /// Unfolds whatever hides `offset`, puts the caret there and scrolls to it.
 973    public func reveal(_ offset: Int) {
 974        let target = max(0, min(offset, utf16Length))
 975        if let narrowed, !(narrowed.lowerBound...narrowed.upperBound).contains(target) { narrow("org.widen") }
 976        unfold(around: target)
 977        setCaret(target)
 978        textView.scrollRangeToVisible(NSRange(location: target, length: 0))
 979    }
 980
 981    /// Unfolds the headings whose folds hide `offset`, leaving the selection alone.
 982    func unfold(around offset: Int) {
 983        leaveSparseTree()
 984        var state = foldState
 985        for entry in Presentation.outline(document.tree) where entry.start < offset && offset < entry.end {
 986            state.folds.remove(entry.start)
 987            state.bodyFolds.remove(entry.start)
 988        }
 989        for start in state.wrapperFolds where Wrappers.starting(at: start, in: document.tree)?.hidden.contains(offset) == true {
 990            state.wrapperFolds.remove(start)
 991        }
 992        guard state != foldState else { return }
 993        setFolds(state)
 994    }
 995
 996    /// Applies edits through the text view as one undo step, not merged with typing around it.
 997    func applyEdits(_ edits: [TextEdit]) {
 998        guard !edits.isEmpty, let storage = textView.textStorage else { return }
 999        textView.breakUndoCoalescing()
1000        textView.undoManager?.beginUndoGrouping()
1001        // By lines, so a fold between two changes stays.
1002        let text = storage.string
1003        for edit in edits.flatMap({ splitByLines($0, in: text) }).sorted(by: { $0.range.lowerBound > $1.range.lowerBound }) {
1004            let range = NSRange(edit.range)
1005            guard textView.shouldChangeText(in: range, replacementString: edit.replacement) else { continue }
1006            storage.replaceCharacters(in: range, with: edit.replacement)
1007            textView.didChangeText()
1008        }
1009        textView.undoManager?.endUndoGrouping()
1010        textView.breakUndoCoalescing()
1011    }
1012
1013    func setCaret(_ offset: Int) {
1014        let clamped = max(0, min(offset, utf16Length))
1015        previousSelection = clamped
1016        textView.setSelectedRange(NSRange(location: clamped, length: 0))
1017    }
1018
1019    // MARK: - Styling
1020
1021    private func restyleOutsideEditing(_ range: Range<Int>) {
1022        guard let storage = textView.textStorage else { return }
1023        storage.beginEditing()
1024        restyle(range, in: storage)
1025        storage.endEditing()
1026    }
1027
1028    /// Sets attributes for whole lines in `range`. Safe inside `didProcessEditing`, where
1029    /// attribute changes are allowed and characters must not change.
1030    private func restyle(_ range: Range<Int>, in storage: NSTextStorage) {
1031        guard !range.isEmpty else { return }
1032        let whole = NSRange(range)
1033        storage.setAttributes(theme.base, range: whole)
1034        guard isOrg else {
1035            for run in plainRuns() where run.range.overlaps(range) {
1036                guard let clipped = clip(run.range, to: range) else { continue }
1037                theme.apply(.syntax(run.category), to: storage, range: clipped)
1038            }
1039            return
1040        }
1041        if indented {
1042            for line in Presentation.lineIndents(document.tree, in: range) {
1043                storage.addAttribute(.paragraphStyle, value: paragraphStyle(line.firstLine, line.wrapped), range: NSRange(line.range))
1044            }
1045        }
1046        let revealed = caretLine
1047        var scriptExclusions: [Range<Int>] = []
1048        for run in Presentation.styles(document.tree, in: range, highlighter: highlighter) {
1049            guard let clipped = clip(run.range, to: range) else { continue }
1050            if Presentation.skipsLeadingStars(run.role, indented: indented, hideStars: hideStars) { continue }
1051            if !showMarkup, Presentation.hidesWithMarkup(run.role, options: options), NSIntersectionRange(clipped, revealed).length == 0 {
1052                storage.addAttributes(theme.hidden, range: clipped)
1053            } else {
1054                theme.apply(run.role, to: storage, range: clipped)
1055            }
1056            if Presentation.excludesScripts(run.role) { scriptExclusions.append(run.range) }
1057        }
1058        if !showMarkup, options.prettyEntities { prettify(range, in: storage, revealed: revealed, scriptExclusions: scriptExclusions) }
1059        applyShrunk(range, in: storage)
1060        applyHighlights(range, in: storage)
1061        applyImages(range, in: storage, revealed: revealed)
1062    }
1063
1064    /// `org-pretty-entities`: entities drawn as their character over the entity's last
1065    /// character (the rest hidden), sub/superscripts raised without their markers.
1066    private func prettify(_ range: Range<Int>, in storage: NSTextStorage, revealed: NSRange, scriptExclusions: [Range<Int>]) {
1067        let runs = Presentation.prettyRuns(
1068            storage.string as NSString, in: range, revealedLine: revealed.location, inBlock: { shaded.containing($0) != nil },
1069            scriptExclusions: scriptExclusions, subSuperscriptsNeedBraces: options.subSuperscriptsNeedBraces
1070        )
1071        for (lineStart, run) in runs {
1072            switch run {
1073            case .entity(let r, let display):
1074                let start = lineStart + r.lowerBound
1075                let last = lineStart + r.upperBound - 1
1076                if last > start { storage.addAttributes(theme.hidden, range: NSRange(location: start, length: last - start)) }
1077                storage.addAttributes([.foregroundColor: NSColor.clear, .orgEntityDisplay: display], range: NSRange(location: last, length: 1))
1078            case .script(let markers, let body, let superscript):
1079                for marker in markers {
1080                    storage.addAttributes(theme.hidden, range: NSRange(location: lineStart + marker.lowerBound, length: marker.count))
1081                }
1082                let size = theme.font.pointSize
1083                storage.addAttributes([
1084                    .font: NSFont.monospacedSystemFont(ofSize: size * 0.7, weight: .regular),
1085                    .baselineOffset: (superscript ? 0.3 : -0.3) * size,
1086                ], range: NSRange(location: lineStart + body.lowerBound, length: body.count))
1087            }
1088        }
1089    }
1090
1091    /// Moving to another line shows its markup and hides the last one's.
1092    private func revealCaretLine() {
1093        guard !showMarkup, isOrg else { return }
1094        let line = caretLine
1095        guard line != revealedLine else { return }
1096        let text = textView.string as NSString
1097        // The old line may have moved with edits since; restyle the line now at its start.
1098        let old = revealedLine.flatMap { $0.location <= text.length ? text.lineRange(for: NSRange(location: $0.location, length: 0)) : nil }
1099        revealedLine = line
1100        for range in [old, line].compactMap({ $0 }) where range.location < styledUpTo {
1101            restyleOutsideEditing(range.location..<NSMaxRange(range))
1102        }
1103    }
1104
1105    /// Code highlights for the whole file in plain mode.
1106    private func plainRuns() -> [(range: Range<Int>, category: SyntaxCategory)] {
1107        guard case .plain(let language?) = mode, let highlighter else { return [] }
1108        if let codeRuns, codeRuns.revision == document.revision { return codeRuns.runs }
1109        let runs = highlighter.highlights(language: language, code: document.text)
1110        codeRuns = (document.revision, runs)
1111        return runs
1112    }
1113
1114    private func paragraphStyle(_ firstLine: Int, _ wrapped: Int) -> NSParagraphStyle {
1115        let key = IndentKey(firstLine: firstLine, wrapped: wrapped)
1116        if let style = paragraphStyles[key] { return style }
1117        let style = theme.paragraphStyle(firstLine: firstLine, wrapped: wrapped)
1118        paragraphStyles[key] = style
1119        return style
1120    }
1121
1122    private func clip(_ run: Range<Int>, to range: Range<Int>) -> NSRange? {
1123        let clipped = run.clamped(to: range)
1124        return clipped.isEmpty ? nil : NSRange(clipped)
1125    }
1126
1127    /// The region an edit affects in one version of the tree: inside the innermost section
1128    /// holding the edited lines (plus the line after), every element that overlaps them or the
1129    /// line on either side. A changed heading line, or an added or removed heading, widens it
1130    /// to that section's whole own body. Run on the old and the new tree, the union covers
1131    /// elements that grew and elements that shrank.
1132    static func affectedRange(_ tree: OrgTree, _ text: NSString, _ start: Int, _ end: Int, touchedHeadings: Bool) -> Range<Int> {
1133        let lines = text.lineRange(for: NSRange(location: start, length: min(end + 1, text.length) - start))
1134        let lineRange = lines.location..<(lines.location + lines.length)
1135        let around = max(0, lineRange.lowerBound - 1)..<min(text.length, lineRange.upperBound + 1)
1136        var container = tree.root
1137        while let child = container.child(containing: lineRange.lowerBound),
1138              child.kind == .section || child.kind == .zerothSection,
1139              lineRange.upperBound <= child.range.upperBound {
1140            container = child
1141        }
1142        var lower = lineRange.lowerBound
1143        var upper = lineRange.upperBound
1144        // Tables and lists carry no styling of their own, so inside them only the rows or
1145        // items around the edit change, however large they are.
1146        func overlapping(_ nodes: [SyntaxNode]) -> [SyntaxNode] {
1147            nodes.filter { $0.range.overlaps(around) }.flatMap { node -> [SyntaxNode] in
1148                guard [.table, .plainList, .item].contains(node.kind) else { return [node] }
1149                let inner = overlapping(node.children(overlapping: around))
1150                return inner.isEmpty ? [node] : inner
1151            }
1152        }
1153        for child in overlapping(container.children(overlapping: around)) {
1154            lower = min(lower, child.range.lowerBound)
1155            upper = max(upper, child.range.upperBound)
1156        }
1157        let heading = container.firstChild(.heading)
1158        if touchedHeadings || heading.map({ $0.range.overlaps(lineRange) }) == true {
1159            lower = min(lower, container.range.lowerBound)
1160            upper = max(upper, container.firstChild(.section)?.range.lowerBound ?? container.range.upperBound)
1161        }
1162        return lower..<min(upper, text.length)
1163    }
1164
1165    // MARK: - Editing
1166
1167    private var blockCheck: DispatchWorkItem?
1168
1169    /// Looks for blocks over the whole file once typing pauses, catching what moving the
1170    /// ranges can't see (a block's end found far away).
1171    private func scheduleBlockCheck() {
1172        blockCheck?.cancel()
1173        let work = DispatchWorkItem { [weak self] in self?.settleBlocks() }
1174        blockCheck = work
1175        DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: work)
1176    }
1177
1178    func settleBlocks() {
1179        blockCheck?.cancel()
1180        guard isOrg else { return }
1181        let blocks = Presentation.shadedBlocks(document.tree)
1182        guard blocks != shaded.all else { return }
1183        shaded.set(blocks)
1184        textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange)
1185    }
1186
1187    /// Whether the lines around `range`, and the line after it (a deleted newline joins it to
1188    /// them), hold a block delimiter or a fixed-width line.
1189    static func mayChangeBlocks(_ text: NSString, _ range: Range<Int>) -> Bool {
1190        let start = min(range.lowerBound, text.length)
1191        let end = min(range.upperBound + 1, text.length)
1192        let lines = text.lineRange(for: NSRange(location: start, length: end - start))
1193        let content = text.substring(with: lines)
1194        return content.contains("#+") || content.range(of: "(^|\\n)([ \\t]*:|\\*)", options: .regularExpression) != nil
1195    }
1196
1197    fileprivate func storageEdited(range: NSRange, delta: Int) {
1198        guard !isLoading, let storage = textView.textStorage else { return }
1199        markActive = false
1200        let clock = ContinuousClock()
1201        let start = clock.now
1202        let replacement = (storage.string as NSString).substring(with: range)
1203        let edit = TextEdit(range: range.location..<(range.location + range.length - delta), replacement: replacement)
1204        let oldText = document.text as NSString
1205        let newText = storage.string as NSString
1206        let newEnd = edit.range.lowerBound + replacement.utf16.count
1207        let touchedHeadings = Self.headingLineNear(edit.range.lowerBound, edit.range.upperBound, in: oldText)
1208            || Self.headingLineNear(edit.range.lowerBound, newEnd, in: newText)
1209        let oldAffected = Self.affectedRange(document.tree, oldText, edit.range.lowerBound, edit.range.upperBound, touchedHeadings: touchedHeadings)
1210        do {
1211            try document.apply([edit], baseRevision: document.revision)
1212        } catch {
1213            assertionFailure("text view and document disagree: \(error)")
1214            return
1215        }
1216        // Keep the watermark on unstyled text: shift it past the edit, or pull it back to the
1217        // edited line when the edit reaches into unstyled text.
1218        if styledUpTo >= edit.range.upperBound {
1219            styledUpTo += edit.replacement.utf16.count - edit.range.count
1220        } else if styledUpTo > edit.range.lowerBound {
1221            styledUpTo = (storage.string as NSString).lineRange(for: NSRange(location: edit.range.lowerBound, length: 0)).location
1222        }
1223        let oldHidden = hidden.all
1224        let newAffected = Self.affectedRange(document.tree, newText, edit.range.lowerBound, newEnd, touchedHeadings: touchedHeadings)
1225        let delta = edit.replacement.utf16.count - edit.range.count
1226        let oldMapped = oldAffected.lowerBound..<(oldAffected.upperBound >= edit.range.upperBound ? oldAffected.upperBound + delta : newEnd)
1227        view = view.mapped(through: [edit]).pruned(to: document.tree, wrappers: false)
1228        mapWrapperFolds(through: edit, near: min(oldMapped.lowerBound, newAffected.lowerBound)..<max(oldMapped.upperBound, newAffected.upperBound))
1229        if let narrowed { self.narrowed = ViewState(selection: [narrowed]).mapped(through: [edit]).selection[0] }
1230        mapVimPositions(through: edit)
1231        if let sparse = sparseHidden { sparseHidden = ViewState(selection: sparse).mapped(through: [edit]).selection.filter { !$0.isEmpty } }
1232        let oldHighlights = ViewState(selection: highlights).mapped(through: [edit]).selection
1233        highlights = []
1234        updateHidden()
1235        restyle(min(oldMapped.lowerBound, newAffected.lowerBound)..<min(max(oldMapped.upperBound, newAffected.upperBound), newText.length), in: storage)
1236        for table in mapShrunk(through: edit) { restyle(table.lowerBound..<min(table.upperBound, newText.length), in: storage) }
1237        for range in oldHighlights where range.upperBound <= newText.length {
1238            let lines = newText.lineRange(for: NSRange(range))
1239            restyle(lines.location..<NSMaxRange(lines), in: storage)
1240        }
1241        let oldShaded = shaded.all
1242        if isOrg {
1243            // Walking the tree for blocks costs more than typing; only an edit on a line that
1244            // could start or end one (`#+…`, `: …`) needs it, others move the ranges.
1245            if Self.mayChangeBlocks(oldText, edit.range) || Self.mayChangeBlocks(newText, edit.range.lowerBound..<newEnd) {
1246                shaded.set(Presentation.shadedBlocks(document.tree))
1247            } else {
1248                shaded.set(oldShaded.map { mapRange($0, through: edit) })
1249            }
1250            scheduleBlockCheck()
1251        }
1252        if hidden.all != oldHidden.map({ mapRange($0, through: edit) })
1253            || shaded.all != oldShaded.map({ mapRange($0, through: edit) }) {
1254            textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange)
1255        }
1256        onEditTiming?(clock.now - start)
1257        let lineCount = lines.lineCount
1258        lines.replace(edit.range, newLength: edit.replacement.utf16.count, in: newText)
1259        if lines.lineCount != lineCount { orgTextView?.updateGutter() }
1260        orgTextView?.redrawLineNumbers()
1261        onChange?()
1262    }
1263
1264    /// Whether a heading line touches `start..<end` or starts right after it, where joining
1265    /// lines could take it away.
1266    static func headingLineNear(_ start: Int, _ end: Int, in text: NSString) -> Bool {
1267        let length = min(end + 1, text.length) - start
1268        guard length >= 0 else { return false }
1269        let lines = text.substring(with: text.lineRange(for: NSRange(location: start, length: length)))
1270        return lines.split(separator: "\n", omittingEmptySubsequences: false).contains { line in
1271            let stars = line.prefix { $0 == "*" }
1272            let after = line.dropFirst(stars.count)
1273            return !stars.isEmpty && (after.isEmpty || after.first == " " || after.first == "\t" || after.first == "\r")
1274        }
1275    }
1276
1277    private func mapRange(_ range: Range<Int>, through edit: TextEdit) -> Range<Int> {
1278        let mapped = ViewState(selection: [range]).mapped(through: [edit]).selection[0]
1279        return mapped
1280    }
1281
1282    /// Shaded block lines the old or new selection covers are drawn again, as they draw the
1283    /// selection over their band.
1284    private func redrawShadedSelection(_ selection: NSRange) {
1285        defer { previousSelectionRange = selection }
1286        redrawShaded([previousSelectionRange, selection])
1287    }
1288
1289    private func redrawShaded(_ ranges: [NSRange]) {
1290        guard let manager = textLayoutManager.textContentManager else { return }
1291        for old in ranges where old.length > 0 {
1292            for block in shaded.all where block.overlaps(old.location..<NSMaxRange(old)) {
1293                let lower = max(block.lowerBound, old.location)
1294                let upper = min(block.upperBound, NSMaxRange(old))
1295                guard let start = manager.location(manager.documentRange.location, offsetBy: lower),
1296                      let end = manager.location(start, offsetBy: upper - lower),
1297                      let range = NSTextRange(location: start, end: end) else { continue }
1298                textLayoutManager.invalidateLayout(for: range)
1299            }
1300        }
1301    }
1302
1303    /// Keeps the caret out of folded text: moving forward into a fold jumps past it, moving
1304    /// back jumps to the end of the heading line.
1305    func selectionChanged() {
1306        let selection = textView.selectedRange()
1307        defer { previousSelection = selection.location }
1308        redrawShadedSelection(selection)
1309        onSelectionChange?(selection)
1310        revealCaretLine()
1311        orgTextView?.redrawLineNumbers()
1312        // Narrowing keeps the caret in the region.
1313        if let narrowed, selection.length == 0, !(narrowed.lowerBound...narrowed.upperBound).contains(selection.location) {
1314            setCaret(max(narrowed.lowerBound, min(selection.location, narrowed.upperBound)))
1315            return
1316        }
1317        // A selection reaching into folded text, as a find match does, unfolds it.
1318        if selection.length > 0 {
1319            if hidden.containing(selection.location) != nil || hidden.containing(NSMaxRange(selection) - 1) != nil {
1320                unfold(around: selection.location)
1321                unfold(around: NSMaxRange(selection) - 1)
1322            }
1323            return
1324        }
1325        guard let range = hidden.containing(selection.location) else { return }
1326        setCaret(selection.location >= previousSelection ? range.upperBound : range.lowerBound - 1)
1327    }
1328}
1329
1330extension OrgEditor: DocumentBuffer {
1331    /// The selected text, for capture's `%i`.
1332    public var selectedText: String {
1333        let range = textView.selectedRange()
1334        guard range.length > 0 else { return "" }
1335        return (textView.string as NSString).substring(with: range)
1336    }
1337
1338    public var selection: [Range<Int>] {
1339        let selected = textView.selectedRange()
1340        return [selected.location..<NSMaxRange(selected)]
1341    }
1342
1343    /// Runs `change`; if it changed the text, reloads the view with folds and the selection
1344    /// mapped through the change. Text-view undo refers to the old text, so it is cleared.
1345    public func update<T>(_ change: (inout DocumentState) throws -> T) rethrows -> T {
1346        let before = document.text
1347        let defaultsBefore = document.defaults
1348        let selected = textView.selectedRange()
1349        let result = try change(&document)
1350        if document.text != before {
1351            let edits = lineEdits(from: before, to: document.text)
1352            var state = view
1353            state.selection = [selected.location..<NSMaxRange(selected)]
1354            let mapped = state.mapped(through: edits)
1355            load(document.text)
1356            setFolds(FoldState(folds: mapped.folds, bodyFolds: mapped.bodyFolds, wrapperFolds: mapped.wrapperFolds))
1357            let selection = mapped.selection[0]
1358            setCaret(selection.lowerBound)
1359            if !selection.isEmpty { textView.setSelectedRange(NSRange(selection)) }
1360            textView.undoManager?.removeAllActions(withTarget: textView.textStorage!)
1361            textView.undoManager?.removeAllActions(withTarget: textView)
1362        } else if document.defaults != defaultsBefore {
1363            // New settings for the same text: TODO keywords may color differently.
1364            restyleEverything()
1365        }
1366        onChange?()
1367        return result
1368    }
1369}
1370
1371extension OrgEditor: NSTextStorageDelegate {
1372    public nonisolated func textStorage(
1373        _ textStorage: NSTextStorage, didProcessEditing editedMask: NSTextStorageEditActions, range editedRange: NSRange, changeInLength delta: Int
1374    ) {
1375        MainActor.assumeIsolated { storageGeneration += 1 }
1376        guard editedMask.contains(.editedCharacters) else { return }
1377        MainActor.assumeIsolated { storageEdited(range: editedRange, delta: delta) }
1378    }
1379}
1380
1381extension OrgEditor: NSTextContentStorageDelegate {
1382    /// Folded text isn't enumerated, so it isn't laid out or drawn.
1383    public nonisolated func textContentManager(
1384        _ textContentManager: NSTextContentManager, shouldEnumerate textElement: NSTextElement, options: NSTextContentManager.EnumerationOptions = []
1385    ) -> Bool {
1386        guard let range = textElement.elementRange else { return true }
1387        let documentStart = textContentManager.documentRange.location
1388        let start = textContentManager.offset(from: documentStart, to: range.location)
1389        let end = textContentManager.offset(from: documentStart, to: range.endLocation)
1390        return !hidden.covers(start, end)
1391    }
1392}
1393
1394extension OrgEditor: NSTextLayoutManagerDelegate {
1395    public nonisolated func textLayoutManager(
1396        _ textLayoutManager: NSTextLayoutManager, textLayoutFragmentFor location: NSTextLocation, in textElement: NSTextElement
1397    ) -> NSTextLayoutFragment {
1398        let range = textElement.elementRange
1399        if let range, let manager = textLayoutManager.textContentManager {
1400            let offset = manager.offset(from: manager.documentRange.location, to: range.location)
1401            if folded.contains(offset) { return FoldedHeadingFragment(textElement: textElement, range: range) }
1402            if shaded.containing(offset) != nil { return ShadedFragment(textElement: textElement, range: range) }
1403            if let paragraph = textElement as? NSTextParagraph, ImageFragment.image(paragraph.attributedString) != nil {
1404                return ImageFragment(textElement: textElement, range: range)
1405            }
1406            if let paragraph = textElement as? NSTextParagraph, EntityFragment.hasEntities(paragraph.attributedString) {
1407                return EntityFragment(textElement: textElement, range: range)
1408            }
1409        }
1410        return NSTextLayoutFragment(textElement: textElement, range: range)
1411    }
1412}
1413
1414/// A line of a block, on a band across the whole width of the view, as Emacs extends the
1415/// `org-block` face.
1416final class ShadedFragment: NSTextLayoutFragment, @unchecked Sendable {
1417    private var band: CGRect {
1418        let width = textLayoutManager?.textContainer?.size.width ?? layoutFragmentFrame.width
1419        // Not under the empty line TextKit adds after a final newline.
1420        let height = textLineFragments.last { $0.characterRange.length > 0 }?.typographicBounds.maxY ?? layoutFragmentFrame.height
1421        return CGRect(x: -layoutFragmentFrame.minX, y: 0, width: width, height: height)
1422    }
1423
1424    override var renderingSurfaceBounds: CGRect { super.renderingSurfaceBounds.union(band) }
1425
1426    override func draw(at point: CGPoint, in context: CGContext) {
1427        context.saveGState()
1428        context.setFillColor(Theme.blockBackground.cgColor)
1429        context.fill(band.offsetBy(dx: point.x, dy: point.y))
1430        // The band covers the view's selection highlight, so the selected text is drawn on it,
1431        // in the color the view uses: the theme's when focused, the system's muted one when not.
1432        if let manager = textLayoutManager {
1433            let active = Theme.focusedLayouts.contains(ObjectIdentifier(manager))
1434            context.setFillColor((active ? Theme.selection : NSColor.unemphasizedSelectedTextBackgroundColor).cgColor)
1435            let single = textLineFragments.count == 1
1436            for selection in manager.textSelections {
1437                for range in selection.textRanges where !range.isEmpty {
1438                    guard let part = range.intersection(rangeInElement), !part.isEmpty else { continue }
1439                    manager.enumerateTextSegments(in: part, type: .selection, options: [.rangeNotRequired]) { _, frame, _, _ in
1440                        var rect = frame.offsetBy(dx: point.x - self.layoutFragmentFrame.minX, dy: point.y - self.layoutFragmentFrame.minY)
1441                        // A one-line fragment's selection covers its band, leaving no gap to the next line.
1442                        if single { rect = CGRect(x: rect.minX, y: point.y, width: rect.width, height: self.band.height) }
1443                        context.fill(rect)
1444                        return true
1445                    }
1446                }
1447            }
1448        }
1449        context.restoreGState()
1450        super.draw(at: point, in: context)
1451    }
1452}
1453
1454extension NSAttributedString.Key {
1455    /// The character an entity shows as, on the entity's last character.
1456    static let orgEntityDisplay = NSAttributedString.Key("OrgEntityDisplay")
1457    /// The image an image link's line shows under it.
1458    static let orgImage = NSAttributedString.Key("OrgImage")
1459}
1460
1461final class InlineImageBox: NSObject, @unchecked Sendable {
1462    let image: NSImage
1463    let size: CGSize
1464    init(image: NSImage, size: CGSize) {
1465        self.image = image
1466        self.size = size
1467    }
1468}
1469
1470/// An image link's line drawn as the image, with the paragraph spacing making room for its
1471/// height.
1472final class ImageFragment: NSTextLayoutFragment, @unchecked Sendable {
1473    static func image(_ string: NSAttributedString) -> InlineImageBox? {
1474        var found: InlineImageBox?
1475        string.enumerateAttribute(.orgImage, in: NSRange(location: 0, length: string.length)) { value, _, stop in
1476            if let box = value as? InlineImageBox { found = box; stop.pointee = true }
1477        }
1478        return found
1479    }
1480
1481    private var frame: CGRect? {
1482        guard let box = (textElement as? NSTextParagraph).flatMap({ Self.image($0.attributedString) }), let line = textLineFragments.first else { return nil }
1483        return CGRect(x: line.typographicBounds.minX, y: line.typographicBounds.minY + 2, width: box.size.width, height: box.size.height)
1484    }
1485
1486    override var renderingSurfaceBounds: CGRect {
1487        frame.map { super.renderingSurfaceBounds.union($0) } ?? super.renderingSurfaceBounds
1488    }
1489
1490    override func draw(at point: CGPoint, in context: CGContext) {
1491        super.draw(at: point, in: context)
1492        guard let box = (textElement as? NSTextParagraph).flatMap({ Self.image($0.attributedString) }), let frame else { return }
1493        NSGraphicsContext.saveGraphicsState()
1494        NSGraphicsContext.current = NSGraphicsContext(cgContext: context, flipped: true)
1495        box.image.draw(in: frame.offsetBy(dx: point.x, dy: point.y), from: .zero, operation: .sourceOver, fraction: 1, respectFlipped: true, hints: nil)
1496        NSGraphicsContext.restoreGraphicsState()
1497    }
1498}
1499
1500/// A paragraph with pretty entities: each is drawn as its character where the entity's last
1501/// (now invisible) character sits, so it takes one column as in Emacs.
1502final class EntityFragment: NSTextLayoutFragment, @unchecked Sendable {
1503    static func hasEntities(_ string: NSAttributedString) -> Bool {
1504        var found = false
1505        string.enumerateAttribute(.orgEntityDisplay, in: NSRange(location: 0, length: string.length)) { value, _, stop in
1506            if value != nil { found = true; stop.pointee = true }
1507        }
1508        return found
1509    }
1510
1511    override func draw(at point: CGPoint, in context: CGContext) {
1512        super.draw(at: point, in: context)
1513        Self.drawEntities(of: self, at: point, in: context)
1514    }
1515
1516    static func drawEntities(of fragment: NSTextLayoutFragment, at point: CGPoint, in context: CGContext) {
1517        guard let string = (fragment.textElement as? NSTextParagraph)?.attributedString else { return }
1518        NSGraphicsContext.saveGraphicsState()
1519        NSGraphicsContext.current = NSGraphicsContext(cgContext: context, flipped: true)
1520        string.enumerateAttribute(.orgEntityDisplay, in: NSRange(location: 0, length: string.length)) { value, range, _ in
1521            guard let display = value as? String,
1522                  let line = fragment.textLineFragments.first(where: { NSLocationInRange(range.location, $0.characterRange) }) else { return }
1523            var attributes = string.attributes(at: range.location, effectiveRange: nil)
1524            attributes[.foregroundColor] = NSColor.textColor
1525            attributes[.orgEntityDisplay] = nil
1526            let x = line.locationForCharacter(at: range.location).x
1527            let bounds = line.typographicBounds
1528            NSAttributedString(string: display, attributes: attributes).draw(at: CGPoint(x: point.x + bounds.minX + x, y: point.y + bounds.minY))
1529        }
1530        NSGraphicsContext.restoreGraphicsState()
1531    }
1532}
1533
1534/// A heading line drawn with an ellipsis after it, like org's folded headings.
1535final class FoldedHeadingFragment: NSTextLayoutFragment, @unchecked Sendable {
1536    override func draw(at point: CGPoint, in context: CGContext) {
1537        super.draw(at: point, in: context)
1538        EntityFragment.drawEntities(of: self, at: point, in: context)
1539        guard let line = textLineFragments.first else { return }
1540        let bounds = line.typographicBounds
1541        let ellipsis = NSAttributedString(string: " …", attributes: [
1542            .font: NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular),
1543            .foregroundColor: NSColor.secondaryLabelColor,
1544        ])
1545        NSGraphicsContext.saveGraphicsState()
1546        NSGraphicsContext.current = NSGraphicsContext(cgContext: context, flipped: true)
1547        ellipsis.draw(at: CGPoint(x: point.x + bounds.maxX, y: point.y + bounds.minY))
1548        NSGraphicsContext.restoreGraphicsState()
1549    }
1550
1551    override var renderingSurfaceBounds: CGRect {
1552        super.renderingSurfaceBounds.insetBy(dx: -40, dy: 0).offsetBy(dx: 40, dy: 0)
1553    }
1554}
1555#endif