krz/orgstar

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

Sources/OrgEditorAppKit/OrgEditor.swift

abca8dc581329472f7fb8ccd3af147e5fb882f29
orgstar/Sources/OrgEditorAppKit/OrgEditor.swift history · blame · raw

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