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