krz/orgstar

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

Sources/OrgEditorAppKit/OrgEditor.swift

056befa97fffa110959cb6010c1986461c09dc0b
orgstar/Sources/OrgEditorAppKit/OrgEditor.swift history · blame · raw

563 lines · 25777 bytes

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