krz/orgstar

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

Sources/OrgEditorAppKit/OrgEditor.swift

0271e942ff3d2f72a407b3d9a6463fab8e4b48cf
orgstar/Sources/OrgEditorAppKit/OrgEditor.swift history · blame · raw

403 lines · 18478 bytes

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