#if os(macOS) import AppKit import OrgCore import OrgDocument import OrgPresentation /// Hidden ranges, read by TextKit's enumeration callbacks, which may run off the main thread. final class HiddenRanges: @unchecked Sendable { private let lock = NSLock() private var ranges: [Range] = [] func set(_ new: [Range]) { lock.withLock { ranges = new } } var all: [Range] { lock.withLock { ranges } } func covers(_ start: Int, _ end: Int) -> Bool { lock.withLock { ranges.contains { $0.lowerBound <= start && end <= $0.upperBound } } } func containing(_ offset: Int) -> Range? { lock.withLock { ranges.first { $0.lowerBound <= offset && offset < $0.upperBound } } } } /// Folded heading starts, read by the layout-fragment callback. final class FoldedHeadings: @unchecked Sendable { private let lock = NSLock() private var starts: Set = [] func set(_ new: Set) { lock.withLock { starts = new } } func contains(_ offset: Int) -> Bool { lock.withLock { starts.contains(offset) } } } /// A TextKit 2 text view over a `DocumentState`. The text storage mirrors the document; every /// character edit goes through `DocumentState.apply`, and only the affected region is restyled. @MainActor public final class OrgEditor: NSObject { public let textView: NSTextView public private(set) var document: DocumentState public private(set) var view = ViewState() /// Called with the time from a character edit to restyled text. public var onEditTiming: ((Duration) -> Void)? let hidden = HiddenRanges() let folded = FoldedHeadings() let theme = Theme() private var isLoading = false private var previousSelection = 0 /// Text before this offset is styled; after it, only base attributes until the background /// passes reach it. Always at a line start. private(set) var styledUpTo = 0 private var stylingScheduled = false /// One paragraph style object per indent pair, shared by every line that uses it. private var paragraphStyles: [IndentKey: NSParagraphStyle] = [:] private struct IndentKey: Hashable { let firstLine: Int let wrapped: Int } /// Styled before the first render: more than a screenful. static let firstStyleChunk = 30_000 /// Styled per run-loop turn afterwards. static let styleChunk = 200_000 public init(document: DocumentState, frame: NSRect = NSRect(x: 0, y: 0, width: 600, height: 800), editable: Bool = true) { self.document = document let orgTextView = OrgTextView(usingTextLayoutManager: true) textView = orgTextView dispatcher = KeyDispatcher(keymap: KeymapPreset.emacs.keymap) super.init() orgTextView.keyHandler = { [weak self] event in self?.handleKey(event) ?? false } orgTextView.onMouseDown = { [weak self] in self?.markActive = false } textView.usesFindBar = true textView.frame = frame textView.isRichText = false textView.isEditable = editable textView.allowsUndo = true textView.isAutomaticQuoteSubstitutionEnabled = false textView.isAutomaticDashSubstitutionEnabled = false textView.isAutomaticTextReplacementEnabled = false textView.isAutomaticSpellingCorrectionEnabled = false textView.font = theme.font textView.typingAttributes = theme.base textView.isVerticallyResizable = true textView.autoresizingMask = [.width] textView.textContainer?.widthTracksTextView = true textView.delegate = self textView.textStorage?.delegate = self textLayoutManager.delegate = self textContentStorage.delegate = self load(document.text) if let startup = Presentation.startupFoldState(document.tree) { setFolds(startup) } } /// Replaces the storage's text. Setting the attributed string directly is about 100x /// faster than `NSTextView.string` for large files. private func load(_ text: String) { isLoading = true textView.textStorage?.setAttributedString(NSAttributedString(string: text, attributes: theme.base)) isLoading = false styledUpTo = 0 styleNextChunk(Self.firstStyleChunk) scheduleStyling() } // MARK: - Initial styling /// Styles whole lines from the watermark onward, about `size` units. private func styleNextChunk(_ size: Int) { let text = textView.textStorage!.string as NSString guard styledUpTo < text.length else { return } let target = min(text.length, styledUpTo + size) let end = target == text.length ? target : NSMaxRange(text.lineRange(for: NSRange(location: target, length: 0))) restyleOutsideEditing(styledUpTo.. NSScrollView { let scroll = NSScrollView(frame: textView.frame) scroll.hasVerticalScroller = true scroll.documentView = textView textView.minSize = NSSize(width: 0, height: scroll.contentSize.height) textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) return scroll } // MARK: - Saving /// Saves through `saver`. A merge that changes the buffer reloads the text view. @discardableResult public func save(using saver: Saver, to url: URL) throws -> SaveOutcome { try update { try saver.save(&$0, to: url) } } /// Called after every change to the document, typed or not. public var onChange: (() -> Void)? // MARK: - Commands /// Called with messages commands report, such as why one couldn't run here. public var onMessage: ((String) -> Void)? /// Called with the pending prefix and what can follow it, and with nil when it ends. public var onKeysPending: ((_ keys: String?, _ next: [(key: String, title: String)]) -> Void)? /// Called with `app.*` commands, which the app carries out. public var onAppCommand: ((String) -> Void)? /// Called when a command asks a question; reply with the answer, or nil to cancel. public var onPrompt: ((Prompt, _ reply: @escaping (String?) -> Void) -> Void)? public var keymap: Keymap { get { dispatcher.keymap } set { dispatcher.keymap = newValue dispatcher.reset() } } public var optionAsMeta: OptionAsMeta = [.left] private var dispatcher: KeyDispatcher /// A region is active: movement extends the selection, as after C-SPC in Emacs. private(set) var markActive = false private func context(now: Date = Date(), answers: [String: String] = [:]) -> EditContext { let selected = textView.selectedRange() return EditContext( revision: document.revision, text: document.text, tree: document.tree, selection: [selected.location.. Bool { guard let chord = KeyEvents.chord(for: event, optionAsMeta: optionAsMeta) else { return false } switch dispatcher.feed(chord) { case .pending(let keys): let next = keymap.continuations(of: keys).map { item in (key: item.key.description, title: item.command.map { EditorCommands.command($0)?.title ?? $0 } ?? "+prefix") } onKeysPending?(KeySequence.format(keys), next) return true case .cancelled: onKeysPending?(nil, []) onMessage?("Quit") return true case .undefined(let keys): onKeysPending?(nil, []) onMessage?("\(KeySequence.format(keys)) is undefined") return true case .unbound: return false case .complete(let keys, let candidates): if keys.count > 1 { onKeysPending?(nil, []) } let context = context() let runnable = candidates.first { binding in (binding.when.map { KeyContext.holds($0, in: context) } ?? true) && applies(binding.command, in: context) } if let binding = runnable ?? (keys.count > 1 ? candidates.first : nil) { run(binding.command) return true } return false } } private func applies(_ id: String, in context: EditContext) -> Bool { switch EditorCommands.command(id)?.action { case .text(let command): command.applies(in: context) case .view: id != "org.cycle" || Presentation.heading(containing: context.caret, in: context.tree) != nil case .edit, .mark, .app: true case nil: true } } /// Runs a command by id, as a key, a menu item or the palette would. public func run(_ id: String) { guard let command = EditorCommands.command(id) else { onMessage?("Unknown command \(id)") return } var keepMark = false switch command.action { case .text(let text): perform(text) case .view: if id == "org.cycle" { cycleAtCaret() } else { cycleGlobal() } case .edit(let selector, let extending): if markActive, let extending { keepMark = true textView.doCommand(by: NSSelectorFromString(extending)) } else if selector == "performTextFinderAction:" { let item = NSMenuItem() item.tag = NSTextFinder.Action.showFindInterface.rawValue textView.performTextFinderAction(item) } else { textView.doCommand(by: NSSelectorFromString(selector)) } case .mark: if id == "edit.set-mark" { let caret = textView.selectedRange().location textView.setSelectedRange(NSRange(location: caret, length: 0)) keepMark = true onMessage?("Mark set") } else { let selected = textView.selectedRange() if markActive || selected.length > 0 { textView.setSelectedRange(NSRange(location: NSMaxRange(selected), length: 0)) } onMessage?("Quit") } case .app: onAppCommand?(id) } markActive = keepMark } /// Runs `command` at the selection. Edits go through the text view, as typing does, so /// they form one undo step and reach the document through the same path. @discardableResult public func perform(_ command: any OrgCommand, now: Date = Date(), answers: [String: String] = [:]) -> CommandStep { let context = context(now: now, answers: answers) let step = command.run(in: context) switch step { case .commit(let result): guard textView.isEditable || result.edits.isEmpty else { onMessage?("This file is read-only.") return .failed("read-only") } if !result.edits.isEmpty, let storage = textView.textStorage { // Each command is its own undo step, not merged with typing around it. textView.breakUndoCoalescing() textView.undoManager?.beginUndoGrouping() for edit in result.edits.sorted(by: { $0.range.lowerBound > $1.range.lowerBound }) { let range = NSRange(edit.range) guard textView.shouldChangeText(in: range, replacementString: edit.replacement) else { continue } storage.replaceCharacters(in: range, with: edit.replacement) textView.didChangeText() } textView.undoManager?.endUndoGrouping() textView.breakUndoCoalescing() } if let selection = result.selection?.first { setCaret(selection.lowerBound) if !selection.isEmpty { textView.setSelectedRange(NSRange(selection)) } } for case .message(let text) in result.effects { onMessage?(text) } case .failed(let message): onMessage?(message) case .prompt(let prompt): onPrompt?(prompt) { [weak self] answer in guard let self else { return } guard let answer else { self.onMessage?("Quit") return } self.perform(command, now: now, answers: answers.merging([prompt.key: answer]) { $1 }) } } return step } // MARK: - Disk /// The file on disk now holds `bytes`: reload or merge, keeping folds where the text kept /// its headings. @discardableResult public func diskChanged(to bytes: [UInt8]) -> DocumentState.ExternalChange { update { $0.diskChanged(to: bytes) } } // MARK: - Folding public var foldState: FoldState { FoldState(folds: view.folds, bodyFolds: view.bodyFolds) } private var globalVisibility: GlobalVisibility? public func setFolds(_ state: FoldState) { view.folds = state.folds view.bodyFolds = state.bodyFolds view = view.pruned(to: document.tree) updateHidden() textLayoutManager.invalidateLayout(for: textLayoutManager.documentRange) textView.needsDisplay = true let caret = textView.selectedRange().location if let range = hidden.containing(caret) { setCaret(range.lowerBound - 1) } } private func updateHidden() { folded.set(view.folds.union(view.bodyFolds)) hidden.set(Presentation.hiddenRanges(document.tree, folds: view.folds, bodyFolds: view.bodyFolds)) } /// TAB on a heading: folded, children, subtree. Returns false off a heading. @discardableResult public func cycleAtCaret() -> Bool { guard let heading = Presentation.heading(containing: textView.selectedRange().location, in: document.tree) else { return false } setFolds(Presentation.cycle(heading, in: document.tree, foldState)) return true } /// Shift-TAB: overview, contents, show all. public func cycleGlobal() { let (visibility, state) = Presentation.cycleGlobal(from: globalVisibility, in: document.tree) globalVisibility = visibility setFolds(state) } /// Unfolds whatever hides `offset`, puts the caret there and scrolls to it. public func reveal(_ offset: Int) { let target = max(0, min(offset, utf16Length)) var state = foldState for entry in Presentation.outline(document.tree) where entry.start < target && target < entry.end { state.folds.remove(entry.start) state.bodyFolds.remove(entry.start) } setFolds(state) setCaret(target) textView.scrollRangeToVisible(NSRange(location: target, length: 0)) } private func setCaret(_ offset: Int) { let clamped = max(0, min(offset, utf16Length)) previousSelection = clamped textView.setSelectedRange(NSRange(location: clamped, length: 0)) } // MARK: - Styling private func restyleOutsideEditing(_ range: Range) { guard let storage = textView.textStorage else { return } storage.beginEditing() restyle(range, in: storage) storage.endEditing() } /// Sets attributes for whole lines in `range`. Safe inside `didProcessEditing`, where /// attribute changes are allowed and characters must not change. private func restyle(_ range: Range, in storage: NSTextStorage) { guard !range.isEmpty else { return } let whole = NSRange(range) storage.setAttributes(theme.base, range: whole) let text = storage.string as NSString for run in Presentation.indents(document.tree, in: range) { guard let clipped = clip(run.range, to: range) else { continue } guard run.firstLine == run.wrapped else { storage.addAttribute(.paragraphStyle, value: paragraphStyle(run.firstLine, run.wrapped), range: clipped) continue } // Body lines wrap under their own text: past leading blanks, and past a list bullet // and checkbox, as adaptive-wrap does in Emacs. var location = clipped.location while location < NSMaxRange(clipped) { let line = text.lineRange(for: NSRange(location: location, length: 0)) let hanging = Self.hangingColumns(text.substring(with: line)) let lineRange = NSIntersectionRange(line, clipped) storage.addAttribute(.paragraphStyle, value: paragraphStyle(run.firstLine, run.wrapped + hanging), range: lineRange) location = NSMaxRange(line) } } for run in Presentation.styles(document.tree, in: range) { guard let clipped = clip(run.range, to: range) else { continue } theme.apply(run.role, to: storage, range: clipped) } } private func paragraphStyle(_ firstLine: Int, _ wrapped: Int) -> NSParagraphStyle { let key = IndentKey(firstLine: firstLine, wrapped: wrapped) if let style = paragraphStyles[key] { return style } let style = theme.paragraphStyle(firstLine: firstLine, wrapped: wrapped) paragraphStyles[key] = style return style } private func clip(_ run: Range, to range: Range) -> NSRange? { let clipped = run.clamped(to: range) return clipped.isEmpty ? nil : NSRange(clipped) } /// The region an edit affects in one version of the tree: inside the innermost section /// holding the edited lines (plus the line after), every element that overlaps them or the /// line on either side. A changed heading line, or an added or removed heading, widens it /// to that section's whole own body. Run on the old and the new tree, the union covers /// elements that grew and elements that shrank. static func affectedRange(_ tree: OrgTree, _ text: NSString, _ start: Int, _ end: Int, touchedHeadings: Bool) -> Range { let lines = text.lineRange(for: NSRange(location: start, length: min(end + 1, text.length) - start)) let lineRange = lines.location..<(lines.location + lines.length) let around = max(0, lineRange.lowerBound - 1).. [SyntaxNode] { nodes.filter { $0.range.overlaps(around) }.flatMap { node -> [SyntaxNode] in guard [.table, .plainList, .item].contains(node.kind) else { return [node] } let inner = overlapping(node.children(overlapping: around)) return inner.isEmpty ? [node] : inner } } for child in overlapping(container.children(overlapping: around)) { lower = min(lower, child.range.lowerBound) upper = max(upper, child.range.upperBound) } let heading = container.firstChild(.heading) if touchedHeadings || heading.map({ $0.range.overlaps(lineRange) }) == true { lower = min(lower, container.range.lowerBound) upper = max(upper, container.firstChild(.section)?.range.lowerBound ?? container.range.upperBound) } return lower..= edit.range.upperBound { styledUpTo += edit.replacement.utf16.count - edit.range.count } else if styledUpTo > edit.range.lowerBound { styledUpTo = (storage.string as NSString).lineRange(for: NSRange(location: edit.range.lowerBound, length: 0)).location } let oldHidden = hidden.all view = view.mapped(through: [edit]).pruned(to: document.tree) folded.set(view.folds) hidden.set(Presentation.hiddenRanges(document.tree, folds: view.folds)) let newAffected = Self.affectedRange(document.tree, newText, edit.range.lowerBound, newEnd, touchedHeadings: touchedHeadings) let delta = edit.replacement.utf16.count - edit.range.count let oldMapped = oldAffected.lowerBound..<(oldAffected.upperBound >= edit.range.upperBound ? oldAffected.upperBound + delta : newEnd) restyle(min(oldMapped.lowerBound, newAffected.lowerBound).. Int { var columns = 0 var rest = Substring(line) while let c = rest.first, c == " " || c == "\t" { columns = c == "\t" ? (columns / 8 + 1) * 8 : columns + 1 rest = rest.dropFirst() } // A bullet (`-`, `+`, `*` when indented, `1.`, `1)`) and its space, then a checkbox. var bullet = 0 if let first = rest.first, first == "-" || first == "+" || (first == "*" && columns > 0) { bullet = 1 } else { let digits = rest.prefix { $0.isASCII && $0.isNumber } if !digits.isEmpty, let separator = rest.dropFirst(digits.count).first, separator == "." || separator == ")" { bullet = digits.count + 1 } } guard bullet > 0, rest.dropFirst(bullet).first == " " else { return columns } columns += bullet + 1 let afterBullet = rest.dropFirst(bullet + 1) if afterBullet.count >= 4, afterBullet.first == "[", "X x-".contains(afterBullet.dropFirst().first!), afterBullet.dropFirst(2).first == "]", afterBullet.dropFirst(3).first == " " { columns += 4 } return columns } /// Whether a heading line touches `start.. Bool { let length = min(end + 1, text.length) - start guard length >= 0 else { return false } let lines = text.substring(with: text.lineRange(for: NSRange(location: start, length: length))) return lines.split(separator: "\n", omittingEmptySubsequences: false).contains { line in let stars = line.prefix { $0 == "*" } let after = line.dropFirst(stars.count) return !stars.isEmpty && (after.isEmpty || after.first == " " || after.first == "\t" || after.first == "\r") } } private func mapRange(_ range: Range, through edit: TextEdit) -> Range { let mapped = ViewState(selection: [range]).mapped(through: [edit]).selection[0] return mapped } /// Keeps the caret out of folded text: moving forward into a fold jumps past it, moving /// back jumps to the end of the heading line. fileprivate func selectionChanged() { let selection = textView.selectedRange() defer { previousSelection = selection.location } guard selection.length == 0, let range = hidden.containing(selection.location) else { return } setCaret(selection.location >= previousSelection ? range.upperBound : range.lowerBound - 1) } } extension OrgEditor: DocumentBuffer { /// Runs `change`; if it changed the text, reloads the view with folds and the selection /// mapped through the change. Text-view undo refers to the old text, so it is cleared. public func update(_ change: (inout DocumentState) throws -> T) rethrows -> T { let before = document.text let selected = textView.selectedRange() let result = try change(&document) if document.text != before { let edits = lineEdits(from: before, to: document.text) var state = view state.selection = [selected.location.. Bool { guard let range = textElement.elementRange else { return true } let documentStart = textContentManager.documentRange.location let start = textContentManager.offset(from: documentStart, to: range.location) let end = textContentManager.offset(from: documentStart, to: range.endLocation) return !hidden.covers(start, end) } } extension OrgEditor: NSTextLayoutManagerDelegate { public nonisolated func textLayoutManager( _ textLayoutManager: NSTextLayoutManager, textLayoutFragmentFor location: NSTextLocation, in textElement: NSTextElement ) -> NSTextLayoutFragment { let range = textElement.elementRange if let range, let manager = textLayoutManager.textContentManager { let offset = manager.offset(from: manager.documentRange.location, to: range.location) if folded.contains(offset) { return FoldedHeadingFragment(textElement: textElement, range: range) } } return NSTextLayoutFragment(textElement: textElement, range: range) } } /// A heading line drawn with an ellipsis after it, like org's folded headings. final class FoldedHeadingFragment: NSTextLayoutFragment, @unchecked Sendable { override func draw(at point: CGPoint, in context: CGContext) { super.draw(at: point, in: context) guard let line = textLineFragments.first else { return } let bounds = line.typographicBounds let ellipsis = NSAttributedString(string: " …", attributes: [ .font: NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular), .foregroundColor: NSColor.secondaryLabelColor, ]) NSGraphicsContext.saveGraphicsState() NSGraphicsContext.current = NSGraphicsContext(cgContext: context, flipped: true) ellipsis.draw(at: CGPoint(x: point.x + bounds.maxX, y: point.y + bounds.minY)) NSGraphicsContext.restoreGraphicsState() } override var renderingSurfaceBounds: CGRect { super.renderingSurfaceBounds.insetBy(dx: -40, dy: 0).offsetBy(dx: 40, dy: 0) } } #endif