Sources/OrgEditorAppKit/OrgEditor.swift
1555 lines · 76787 bytes
85 symbols in this file
HiddenRangessetcoverscontainingFoldedHeadingssetcontainsOrgEditorIndentKeyModeloadstyleNextChunkapplyThemeToViewrestyleEverythingscheduleStylingfinishStylingmakeScrollViewsavecontexthandleKeyelectricPairrunsappliesrunperformdiskChangedsetFoldssetFoldsupdateHiddenshowSparseTreeleaveSparseTreeclearHighlightsapplyHighlightstoggleImagesrefreshImagesloadImageapplyImagesnarrowsetShrunktableColumnWidthshrinkAtStartupmapShrunkapplyShrunkmapWrapperFoldscycleAtCaretfoldcycleGlobalrevealunfoldapplyEditssetCaretrestyleOutsideEditingrestyleprettifyrevealCaretLineplainRunsparagraphStyleclipaffectedRangeoverlappingscheduleBlockChecksettleBlocksmayChangeBlocksstorageEditedheadingLineNearmapRangeredrawShadedSelectionredrawShadedselectionChangedupdatetextStoragetextContentManagertextLayoutManagerShadedFragmentdrawInlineImageBoxImageFragmentimagedrawEntityFragmenthasEntitiesdrawdrawEntitiesFoldedHeadingFragmentdraw
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