1395 lines
58 KiB
Swift
1395 lines
58 KiB
Swift
import SwiftUI
|
|
import AppKit
|
|
|
|
// MARK: - MarkdownLayoutManager
|
|
|
|
class MarkdownLayoutManager: NSLayoutManager {
|
|
struct BlockRange {
|
|
let range: NSRange
|
|
let kind: BlockKind
|
|
}
|
|
|
|
enum BlockKind {
|
|
case codeBlock
|
|
case blockquote
|
|
case horizontalRule
|
|
case tableBlock(columns: Int)
|
|
}
|
|
|
|
var blockRanges: [BlockRange] = []
|
|
|
|
override func drawBackground(forGlyphRange glyphsToShow: NSRange, at origin: NSPoint) {
|
|
super.drawBackground(forGlyphRange: glyphsToShow, at: origin)
|
|
|
|
guard let textContainer = textContainers.first else { return }
|
|
|
|
for block in blockRanges {
|
|
let glyphRange = self.glyphRange(forCharacterRange: block.range, actualCharacterRange: nil)
|
|
guard NSIntersectionRange(glyphRange, glyphsToShow).length > 0 else { continue }
|
|
|
|
switch block.kind {
|
|
case .codeBlock:
|
|
drawCodeBlockBackground(glyphRange: glyphRange, origin: origin, container: textContainer)
|
|
case .blockquote:
|
|
drawBlockquoteBorder(glyphRange: glyphRange, origin: origin, container: textContainer)
|
|
case .horizontalRule:
|
|
drawHorizontalRule(glyphRange: glyphRange, origin: origin, container: textContainer)
|
|
case .tableBlock(let columns):
|
|
drawTableBorders(glyphRange: glyphRange, columns: columns, origin: origin, container: textContainer)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func drawCodeBlockBackground(glyphRange: NSRange, origin: NSPoint, container: NSTextContainer) {
|
|
var rect = boundingRect(forGlyphRange: glyphRange, in: container)
|
|
rect.origin.x = origin.x + 4
|
|
rect.origin.y += origin.y - 4
|
|
rect.size.width = container.containerSize.width - 8
|
|
rect.size.height += 8
|
|
|
|
let path = NSBezierPath(roundedRect: rect, xRadius: 6, yRadius: 6)
|
|
Theme.current.surface0.setFill()
|
|
path.fill()
|
|
Theme.current.surface1.setStroke()
|
|
path.lineWidth = 1
|
|
path.stroke()
|
|
}
|
|
|
|
private func drawBlockquoteBorder(glyphRange: NSRange, origin: NSPoint, container: NSTextContainer) {
|
|
var rect = boundingRect(forGlyphRange: glyphRange, in: container)
|
|
rect.origin.x = origin.x
|
|
rect.origin.y += origin.y
|
|
rect.size.width = container.containerSize.width
|
|
|
|
let bgRect = NSRect(x: rect.origin.x + 8, y: rect.origin.y, width: rect.size.width - 16, height: rect.size.height)
|
|
Theme.current.surface0.withAlphaComponent(0.3).setFill()
|
|
bgRect.fill()
|
|
|
|
let barRect = NSRect(x: origin.x + 8, y: rect.origin.y, width: 3, height: rect.size.height)
|
|
Theme.current.lavender.setFill()
|
|
barRect.fill()
|
|
}
|
|
|
|
private func drawHorizontalRule(glyphRange: NSRange, origin: NSPoint, container: NSTextContainer) {
|
|
let rect = boundingRect(forGlyphRange: glyphRange, in: container)
|
|
let y = rect.origin.y + origin.y + rect.size.height / 2
|
|
|
|
let path = NSBezierPath()
|
|
path.move(to: NSPoint(x: origin.x + 8, y: y))
|
|
path.line(to: NSPoint(x: origin.x + container.containerSize.width - 8, y: y))
|
|
path.lineWidth = 1
|
|
Theme.current.overlay0.setStroke()
|
|
path.stroke()
|
|
}
|
|
|
|
private func drawTableBorders(glyphRange: NSRange, columns: Int, origin: NSPoint, container: NSTextContainer) {
|
|
guard columns > 0 else { return }
|
|
var rect = boundingRect(forGlyphRange: glyphRange, in: container)
|
|
rect.origin.x = origin.x + 4
|
|
rect.origin.y += origin.y
|
|
rect.size.width = container.containerSize.width - 8
|
|
|
|
let outerPath = NSBezierPath(rect: rect)
|
|
outerPath.lineWidth = 1
|
|
Theme.current.surface2.setStroke()
|
|
outerPath.stroke()
|
|
|
|
guard let ts = textStorage else { return }
|
|
let charRange = characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil)
|
|
let text = ts.string as NSString
|
|
let tableText = text.substring(with: charRange)
|
|
let lines = tableText.components(separatedBy: "\n").filter { !$0.isEmpty }
|
|
|
|
// Draw horizontal row separators
|
|
var charOffset = charRange.location
|
|
for (i, line) in lines.enumerated() {
|
|
let lineLen = (line as NSString).length
|
|
if i > 0 {
|
|
let lineGlyphRange = self.glyphRange(forCharacterRange: NSRange(location: charOffset, length: 1), actualCharacterRange: nil)
|
|
let lineRect = boundingRect(forGlyphRange: lineGlyphRange, in: container)
|
|
let y = lineRect.origin.y + origin.y
|
|
let rowLine = NSBezierPath()
|
|
rowLine.move(to: NSPoint(x: rect.origin.x, y: y))
|
|
rowLine.line(to: NSPoint(x: rect.origin.x + rect.size.width, y: y))
|
|
rowLine.lineWidth = 0.5
|
|
Theme.current.surface2.setStroke()
|
|
rowLine.stroke()
|
|
}
|
|
charOffset += lineLen + 1
|
|
}
|
|
|
|
// Draw vertical column separators from first line pipe positions
|
|
if let firstLine = lines.first {
|
|
let colWidth = rect.size.width / CGFloat(max(columns, 1))
|
|
for col in 1..<columns {
|
|
let x = rect.origin.x + colWidth * CGFloat(col)
|
|
let colLine = NSBezierPath()
|
|
colLine.move(to: NSPoint(x: x, y: rect.origin.y))
|
|
colLine.line(to: NSPoint(x: x, y: rect.origin.y + rect.size.height))
|
|
colLine.lineWidth = 0.5
|
|
Theme.current.surface2.setStroke()
|
|
colLine.stroke()
|
|
}
|
|
|
|
// Header background
|
|
if lines.count > 1 {
|
|
let firstLineRange = NSRange(location: charRange.location, length: (firstLine as NSString).length)
|
|
let headerGlyphRange = self.glyphRange(forCharacterRange: firstLineRange, actualCharacterRange: nil)
|
|
var headerRect = boundingRect(forGlyphRange: headerGlyphRange, in: container)
|
|
headerRect.origin.x = rect.origin.x
|
|
headerRect.origin.y += origin.y
|
|
headerRect.size.width = rect.size.width
|
|
Theme.current.surface0.setFill()
|
|
headerRect.fill()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct EditorView: View {
|
|
@ObservedObject var state: AppState
|
|
|
|
var body: some View {
|
|
EditorTextView(text: $state.documentText, evalResults: state.evalResults, onEvaluate: {
|
|
state.evaluate()
|
|
})
|
|
.background(Color(ns: Theme.current.base))
|
|
}
|
|
}
|
|
|
|
struct EditorTextView: NSViewRepresentable {
|
|
@Binding var text: String
|
|
var evalResults: [Int: String]
|
|
var onEvaluate: () -> Void
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
Coordinator(self)
|
|
}
|
|
|
|
func makeNSView(context: Context) -> NSScrollView {
|
|
let scrollView = NSScrollView()
|
|
scrollView.hasVerticalScroller = true
|
|
scrollView.hasHorizontalScroller = false
|
|
scrollView.autohidesScrollers = true
|
|
scrollView.borderType = .noBorder
|
|
|
|
let textStorage = NSTextStorage()
|
|
let layoutManager = MarkdownLayoutManager()
|
|
let textContainer = NSTextContainer(containerSize: NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude))
|
|
textContainer.widthTracksTextView = true
|
|
layoutManager.addTextContainer(textContainer)
|
|
textStorage.addLayoutManager(layoutManager)
|
|
|
|
let textView = LineNumberTextView(frame: .zero, textContainer: textContainer)
|
|
textView.isEditable = true
|
|
textView.isSelectable = true
|
|
textView.allowsUndo = true
|
|
textView.isRichText = true
|
|
textView.usesFindBar = true
|
|
textView.isIncrementalSearchingEnabled = true
|
|
textView.font = Theme.editorFont
|
|
textView.textColor = Theme.current.text
|
|
textView.backgroundColor = Theme.current.base
|
|
textView.insertionPointColor = Theme.current.text
|
|
textView.selectedTextAttributes = [
|
|
.backgroundColor: Theme.current.surface1
|
|
]
|
|
textView.isAutomaticQuoteSubstitutionEnabled = false
|
|
textView.isAutomaticDashSubstitutionEnabled = false
|
|
textView.isAutomaticTextReplacementEnabled = false
|
|
textView.isAutomaticSpellingCorrectionEnabled = false
|
|
textView.smartInsertDeleteEnabled = false
|
|
|
|
textView.autoresizingMask = [.width]
|
|
textView.isVerticallyResizable = true
|
|
textView.isHorizontallyResizable = false
|
|
textView.textContainer?.containerSize = NSSize(
|
|
width: scrollView.contentSize.width,
|
|
height: CGFloat.greatestFiniteMagnitude
|
|
)
|
|
textView.textContainer?.widthTracksTextView = true
|
|
textView.maxSize = NSSize(
|
|
width: CGFloat.greatestFiniteMagnitude,
|
|
height: CGFloat.greatestFiniteMagnitude
|
|
)
|
|
|
|
textView.registerForDraggedTypes([.fileURL])
|
|
|
|
scrollView.documentView = textView
|
|
|
|
let ruler = LineNumberRulerView(textView: textView)
|
|
ruler.evalResults = evalResults
|
|
scrollView.verticalRulerView = ruler
|
|
scrollView.hasVerticalRuler = true
|
|
scrollView.rulersVisible = true
|
|
|
|
textView.string = text
|
|
textView.delegate = context.coordinator
|
|
context.coordinator.textView = textView
|
|
context.coordinator.rulerView = ruler
|
|
|
|
if let ts = textView.textStorage {
|
|
ts.beginEditing()
|
|
applySyntaxHighlighting(to: ts)
|
|
ts.endEditing()
|
|
}
|
|
updateBlockRanges(for: textView)
|
|
|
|
DispatchQueue.main.async {
|
|
context.coordinator.triggerImageUpdate()
|
|
}
|
|
|
|
return scrollView
|
|
}
|
|
|
|
func updateNSView(_ scrollView: NSScrollView, context: Context) {
|
|
guard let textView = scrollView.documentView as? LineNumberTextView else { return }
|
|
if textView.string != text {
|
|
let selectedRanges = textView.selectedRanges
|
|
textView.string = text
|
|
if let ts = textView.textStorage {
|
|
ts.beginEditing()
|
|
applySyntaxHighlighting(to: ts)
|
|
ts.endEditing()
|
|
}
|
|
textView.selectedRanges = selectedRanges
|
|
updateBlockRanges(for: textView)
|
|
}
|
|
textView.font = Theme.editorFont
|
|
textView.textColor = Theme.current.text
|
|
textView.backgroundColor = Theme.current.base
|
|
textView.insertionPointColor = Theme.current.text
|
|
|
|
if let ruler = scrollView.verticalRulerView as? LineNumberRulerView {
|
|
ruler.evalResults = evalResults
|
|
ruler.needsDisplay = true
|
|
}
|
|
}
|
|
|
|
class Coordinator: NSObject, NSTextViewDelegate {
|
|
var parent: EditorTextView
|
|
weak var textView: NSTextView?
|
|
weak var rulerView: LineNumberRulerView?
|
|
private var isUpdatingImages = false
|
|
|
|
init(_ parent: EditorTextView) {
|
|
self.parent = parent
|
|
}
|
|
|
|
func textDidChange(_ notification: Notification) {
|
|
guard let tv = textView, let ts = tv.textStorage else { return }
|
|
if isUpdatingImages { return }
|
|
parent.text = tv.string
|
|
let sel = tv.selectedRanges
|
|
ts.beginEditing()
|
|
applySyntaxHighlighting(to: ts)
|
|
ts.endEditing()
|
|
tv.selectedRanges = sel
|
|
updateBlockRanges(for: tv)
|
|
rulerView?.needsDisplay = true
|
|
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.updateInlineImages()
|
|
}
|
|
}
|
|
|
|
func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
|
|
if commandSelector == #selector(NSResponder.insertNewline(_:)) {
|
|
textView.insertNewlineIgnoringFieldEditor(nil)
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.parent.onEvaluate()
|
|
}
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func triggerImageUpdate() {
|
|
updateInlineImages()
|
|
}
|
|
|
|
private static let imageRegex: NSRegularExpression? = {
|
|
try? NSRegularExpression(pattern: "!\\[[^\\]]*\\]\\(([^)]+)\\)")
|
|
}()
|
|
|
|
private func updateInlineImages() {
|
|
guard let tv = textView, let ts = tv.textStorage else { return }
|
|
guard let regex = Coordinator.imageRegex else { return }
|
|
|
|
let text = ts.string
|
|
let nsText = text as NSString
|
|
let fullRange = NSRange(location: 0, length: nsText.length)
|
|
|
|
let existingAttachmentRanges = findExistingImageAttachments(in: ts)
|
|
let matches = regex.matches(in: text, range: fullRange)
|
|
|
|
var resolvedPaths: [String] = []
|
|
for match in matches {
|
|
let urlRange = match.range(at: 1)
|
|
let rawPath = nsText.substring(with: urlRange)
|
|
if let resolved = resolveLocalImagePath(rawPath), FileManager.default.fileExists(atPath: resolved) {
|
|
resolvedPaths.append(resolved)
|
|
}
|
|
}
|
|
|
|
let neededSet = Set(resolvedPaths)
|
|
let existingSet = Set(existingAttachmentRanges.map { $0.1 })
|
|
if neededSet == existingSet { return }
|
|
|
|
isUpdatingImages = true
|
|
ts.beginEditing()
|
|
|
|
for (range, _) in existingAttachmentRanges.reversed() {
|
|
ts.deleteCharacters(in: range)
|
|
}
|
|
|
|
let recalcText = ts.string
|
|
let recalcNS = recalcText as NSString
|
|
let recalcFull = NSRange(location: 0, length: recalcNS.length)
|
|
let recalcMatches = regex.matches(in: recalcText, range: recalcFull)
|
|
|
|
var offset = 0
|
|
for match in recalcMatches {
|
|
let urlRange = NSRange(location: match.range(at: 1).location + offset, length: match.range(at: 1).length)
|
|
let rawPath = recalcNS.substring(with: NSRange(location: urlRange.location, length: urlRange.length))
|
|
guard let resolved = resolveLocalImagePath(rawPath),
|
|
FileManager.default.fileExists(atPath: resolved),
|
|
let image = NSImage(contentsOfFile: resolved) else { continue }
|
|
|
|
let maxWidth: CGFloat = min(600, tv.bounds.width - 40)
|
|
let ratio = image.size.width > maxWidth ? maxWidth / image.size.width : 1.0
|
|
let displaySize = NSSize(
|
|
width: image.size.width * ratio,
|
|
height: image.size.height * ratio
|
|
)
|
|
image.size = displaySize
|
|
|
|
let attachment = NSTextAttachment()
|
|
let cell = NSTextAttachmentCell(imageCell: image)
|
|
attachment.attachmentCell = cell
|
|
|
|
let attachStr = NSMutableAttributedString(string: "\n")
|
|
attachStr.append(NSAttributedString(attachment: attachment))
|
|
attachStr.addAttribute(.toolTip, value: resolved, range: NSRange(location: 1, length: 1))
|
|
|
|
let lineEnd = NSMaxRange(match.range) + offset
|
|
let insertAt = min(lineEnd, ts.length)
|
|
ts.insert(attachStr, at: insertAt)
|
|
offset += attachStr.length
|
|
}
|
|
|
|
ts.endEditing()
|
|
isUpdatingImages = false
|
|
}
|
|
|
|
private func findExistingImageAttachments(in textStorage: NSTextStorage) -> [(NSRange, String)] {
|
|
var results: [(NSRange, String)] = []
|
|
let fullRange = NSRange(location: 0, length: textStorage.length)
|
|
textStorage.enumerateAttribute(.attachment, in: fullRange) { value, range, _ in
|
|
if value is NSTextAttachment {
|
|
var extRange = range
|
|
if extRange.location > 0 {
|
|
let prev = NSRange(location: extRange.location - 1, length: 1)
|
|
let ch = (textStorage.string as NSString).substring(with: prev)
|
|
if ch == "\n" {
|
|
extRange = NSRange(location: prev.location, length: extRange.length + 1)
|
|
}
|
|
}
|
|
let tip = textStorage.attribute(.toolTip, at: range.location, effectiveRange: nil) as? String ?? ""
|
|
results.append((extRange, tip))
|
|
}
|
|
}
|
|
return results
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Block Range Detection
|
|
|
|
func updateBlockRanges(for textView: NSTextView) {
|
|
guard let lm = textView.layoutManager as? MarkdownLayoutManager else { return }
|
|
let text = textView.string as NSString
|
|
guard text.length > 0 else {
|
|
lm.blockRanges = []
|
|
return
|
|
}
|
|
|
|
var blocks: [MarkdownLayoutManager.BlockRange] = []
|
|
var lineStart = 0
|
|
var openFence: Int? = nil
|
|
var blockquoteStart: Int? = nil
|
|
var blockquoteEnd: Int = 0
|
|
var tableStart: Int? = nil
|
|
var tableEnd: Int = 0
|
|
var tableColumns: Int = 0
|
|
|
|
while lineStart < text.length {
|
|
let lineRange = text.lineRange(for: NSRange(location: lineStart, length: 0))
|
|
let line = text.substring(with: lineRange)
|
|
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
// Fenced code blocks
|
|
if openFence == nil && trimmed.hasPrefix("```") {
|
|
openFence = lineRange.location
|
|
} else if openFence != nil && trimmed == "```" {
|
|
let range = NSRange(location: openFence!, length: NSMaxRange(lineRange) - openFence!)
|
|
blocks.append(.init(range: range, kind: .codeBlock))
|
|
openFence = nil
|
|
lineStart = NSMaxRange(lineRange)
|
|
continue
|
|
}
|
|
|
|
if openFence != nil {
|
|
lineStart = NSMaxRange(lineRange)
|
|
continue
|
|
}
|
|
|
|
// Blockquotes
|
|
if trimmed.hasPrefix("> ") || trimmed == ">" {
|
|
if blockquoteStart == nil { blockquoteStart = lineRange.location }
|
|
blockquoteEnd = NSMaxRange(lineRange)
|
|
} else {
|
|
if let start = blockquoteStart {
|
|
blocks.append(.init(range: NSRange(location: start, length: blockquoteEnd - start), kind: .blockquote))
|
|
blockquoteStart = nil
|
|
}
|
|
}
|
|
|
|
// Horizontal rules
|
|
if isHorizontalRule(trimmed) && openFence == nil {
|
|
blocks.append(.init(range: lineRange, kind: .horizontalRule))
|
|
}
|
|
|
|
// Tables
|
|
if trimmed.hasPrefix("|") {
|
|
if tableStart == nil {
|
|
tableStart = lineRange.location
|
|
tableColumns = trimmed.filter({ $0 == "|" }).count - 1
|
|
if tableColumns < 1 { tableColumns = 1 }
|
|
}
|
|
tableEnd = NSMaxRange(lineRange)
|
|
} else {
|
|
if let start = tableStart {
|
|
blocks.append(.init(range: NSRange(location: start, length: tableEnd - start), kind: .tableBlock(columns: tableColumns)))
|
|
tableStart = nil
|
|
tableColumns = 0
|
|
}
|
|
}
|
|
|
|
lineStart = NSMaxRange(lineRange)
|
|
}
|
|
|
|
// Flush pending blocks
|
|
if let start = blockquoteStart {
|
|
blocks.append(.init(range: NSRange(location: start, length: blockquoteEnd - start), kind: .blockquote))
|
|
}
|
|
if let start = tableStart {
|
|
blocks.append(.init(range: NSRange(location: start, length: tableEnd - start), kind: .tableBlock(columns: tableColumns)))
|
|
}
|
|
|
|
lm.blockRanges = blocks
|
|
}
|
|
|
|
// MARK: - Syntax Highlighting
|
|
|
|
private let syntaxKeywords: Set<String> = [
|
|
"let", "fn", "if", "else", "for", "map", "cast", "plot", "sch"
|
|
]
|
|
|
|
private let syntaxOperatorChars = CharacterSet(charactersIn: "+-*/=^<>!(){}[]:,")
|
|
|
|
func applySyntaxHighlighting(to textStorage: NSTextStorage) {
|
|
let text = textStorage.string
|
|
let fullRange = NSRange(location: 0, length: (text as NSString).length)
|
|
let palette = Theme.current
|
|
let syn = Theme.syntax
|
|
|
|
let baseFont = Theme.editorFont
|
|
let baseAttrs: [NSAttributedString.Key: Any] = [
|
|
.font: baseFont,
|
|
.foregroundColor: palette.text
|
|
]
|
|
textStorage.setAttributes(baseAttrs, range: fullRange)
|
|
|
|
let fencedRanges = highlightFencedCodeBlocks(textStorage: textStorage, palette: palette, baseFont: baseFont)
|
|
let tableHeaderLines = findTableHeaderLines(textStorage: textStorage, fencedRanges: fencedRanges)
|
|
|
|
let nsText = text as NSString
|
|
var lineStart = 0
|
|
|
|
while lineStart < nsText.length {
|
|
let lineRange = nsText.lineRange(for: NSRange(location: lineStart, length: 0))
|
|
|
|
if isInsideFencedBlock(lineRange, fencedRanges: fencedRanges) {
|
|
lineStart = NSMaxRange(lineRange)
|
|
continue
|
|
}
|
|
|
|
let line = nsText.substring(with: lineRange)
|
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
|
let isTableHeader = tableHeaderLines.contains(lineRange.location)
|
|
|
|
if highlightMarkdownLine(trimmed, line: line, lineRange: lineRange, textStorage: textStorage, baseFont: baseFont, palette: palette, isTableHeader: isTableHeader) {
|
|
highlightInlineCode(textStorage: textStorage, lineRange: lineRange, palette: palette, baseFont: baseFont)
|
|
highlightStrikethrough(textStorage: textStorage, lineRange: lineRange, palette: palette)
|
|
highlightFootnoteRefs(textStorage: textStorage, lineRange: lineRange, palette: palette)
|
|
lineStart = NSMaxRange(lineRange)
|
|
continue
|
|
}
|
|
|
|
highlightCodeLine(line, lineRange: lineRange, textStorage: textStorage, syn: syn)
|
|
highlightInlineCode(textStorage: textStorage, lineRange: lineRange, palette: palette, baseFont: baseFont)
|
|
highlightStrikethrough(textStorage: textStorage, lineRange: lineRange, palette: palette)
|
|
highlightFootnoteRefs(textStorage: textStorage, lineRange: lineRange, palette: palette)
|
|
|
|
lineStart = NSMaxRange(lineRange)
|
|
}
|
|
|
|
highlightBlockComments(textStorage: textStorage, syn: syn, baseFont: baseFont)
|
|
highlightLinks(textStorage: textStorage, palette: palette, fencedRanges: fencedRanges)
|
|
highlightImages(textStorage: textStorage, palette: palette, fencedRanges: fencedRanges)
|
|
highlightAutolinks(textStorage: textStorage, palette: palette, fencedRanges: fencedRanges)
|
|
}
|
|
|
|
private func highlightMarkdownLine(_ trimmed: String, line: String, lineRange: NSRange, textStorage: NSTextStorage, baseFont: NSFont, palette: CatppuccinPalette, isTableHeader: Bool = false) -> Bool {
|
|
if trimmed.hasPrefix("## ") {
|
|
let hashRange = (textStorage.string as NSString).range(of: "##", range: lineRange)
|
|
if hashRange.location != NSNotFound {
|
|
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: hashRange)
|
|
let contentStart = hashRange.location + hashRange.length
|
|
let contentRange = NSRange(location: contentStart, length: NSMaxRange(lineRange) - contentStart)
|
|
if contentRange.length > 0 {
|
|
let h2Font = NSFont.systemFont(ofSize: 18, weight: .bold)
|
|
textStorage.addAttribute(.font, value: h2Font, range: contentRange)
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
if trimmed.hasPrefix("# ") {
|
|
let hashRange = (textStorage.string as NSString).range(of: "#", range: lineRange)
|
|
if hashRange.location != NSNotFound {
|
|
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: hashRange)
|
|
let contentStart = hashRange.location + hashRange.length
|
|
let contentRange = NSRange(location: contentStart, length: NSMaxRange(lineRange) - contentStart)
|
|
if contentRange.length > 0 {
|
|
let h1Font = NSFont.systemFont(ofSize: 22, weight: .bold)
|
|
textStorage.addAttribute(.font, value: h1Font, range: contentRange)
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
if trimmed.hasPrefix("> ") {
|
|
textStorage.addAttribute(.foregroundColor, value: palette.overlay2, range: lineRange)
|
|
let indent = NSMutableParagraphStyle()
|
|
indent.headIndent = 20
|
|
indent.firstLineHeadIndent = 20
|
|
textStorage.addAttribute(.paragraphStyle, value: indent, range: lineRange)
|
|
return true
|
|
}
|
|
|
|
// Horizontal rule
|
|
if isHorizontalRule(trimmed) {
|
|
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: lineRange)
|
|
textStorage.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: lineRange)
|
|
return true
|
|
}
|
|
|
|
// Footnote definition
|
|
if trimmed.hasPrefix("[^") && trimmed.contains("]:") {
|
|
highlightFootnoteDefinition(textStorage: textStorage, lineRange: lineRange, palette: palette)
|
|
return true
|
|
}
|
|
|
|
// Task list (check before generic unordered list)
|
|
if trimmed.hasPrefix("- [ ] ") || trimmed.hasPrefix("- [x] ") || trimmed.hasPrefix("- [X] ") ||
|
|
trimmed.hasPrefix("* [ ] ") || trimmed.hasPrefix("* [x] ") || trimmed.hasPrefix("* [X] ") ||
|
|
trimmed.hasPrefix("+ [ ] ") || trimmed.hasPrefix("+ [x] ") || trimmed.hasPrefix("+ [X] ") {
|
|
highlightTaskList(trimmed, lineRange: lineRange, line: line, textStorage: textStorage, palette: palette)
|
|
return true
|
|
}
|
|
|
|
// Table rows
|
|
if trimmed.hasPrefix("|") {
|
|
let isSep = isTableSeparator(trimmed)
|
|
highlightTableLine(trimmed, lineRange: lineRange, textStorage: textStorage, palette: palette, baseFont: baseFont, isHeader: isTableHeader, isSeparator: isSep)
|
|
return true
|
|
}
|
|
|
|
// Unordered list
|
|
if let regex = try? NSRegularExpression(pattern: "^\\s*[-*+] "),
|
|
regex.firstMatch(in: line, range: NSRange(location: 0, length: (line as NSString).length)) != nil {
|
|
highlightUnorderedList(trimmed, lineRange: lineRange, line: line, textStorage: textStorage, palette: palette)
|
|
return true
|
|
}
|
|
|
|
// Ordered list
|
|
if let regex = try? NSRegularExpression(pattern: "^\\s*\\d+\\. "),
|
|
regex.firstMatch(in: line, range: NSRange(location: 0, length: (line as NSString).length)) != nil {
|
|
highlightOrderedList(trimmed, lineRange: lineRange, line: line, textStorage: textStorage, palette: palette)
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
private func highlightCodeLine(_ line: String, lineRange: NSRange, textStorage: NSTextStorage, syn: Theme.SyntaxColors) {
|
|
let nsLine = line as NSString
|
|
let baseFont = Theme.editorFont
|
|
let italicFont = NSFontManager.shared.convert(baseFont, toHaveTrait: .italicFontMask)
|
|
let boldFont = NSFontManager.shared.convert(baseFont, toHaveTrait: .boldFontMask)
|
|
|
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
|
|
|
// Line comment
|
|
if let commentRange = findLineComment(nsLine) {
|
|
let absRange = NSRange(location: lineRange.location + commentRange.location, length: commentRange.length)
|
|
textStorage.addAttributes([
|
|
.foregroundColor: syn.comment,
|
|
.font: italicFont
|
|
], range: absRange)
|
|
highlightCodeTokens(nsLine, inRange: NSRange(location: 0, length: commentRange.location), lineOffset: lineRange.location, textStorage: textStorage, syn: syn)
|
|
return
|
|
}
|
|
|
|
// Eval prefix
|
|
if trimmed.hasPrefix("/=") {
|
|
let prefixRange = (textStorage.string as NSString).range(of: "/=", range: lineRange)
|
|
if prefixRange.location != NSNotFound {
|
|
textStorage.addAttribute(.foregroundColor, value: syn.operator, range: prefixRange)
|
|
}
|
|
}
|
|
|
|
highlightCodeTokens(nsLine, inRange: NSRange(location: 0, length: nsLine.length), lineOffset: lineRange.location, textStorage: textStorage, syn: syn)
|
|
|
|
// Bold markers **text**
|
|
highlightInlineMarkdown(textStorage: textStorage, lineRange: lineRange, pattern: "\\*\\*(.+?)\\*\\*", markerLen: 2, trait: .boldFontMask, font: boldFont)
|
|
|
|
// Italic markers *text*
|
|
highlightInlineMarkdown(textStorage: textStorage, lineRange: lineRange, pattern: "(?<!\\*)\\*(?!\\*)(.+?)(?<!\\*)\\*(?!\\*)", markerLen: 1, trait: .italicFontMask, font: italicFont)
|
|
}
|
|
|
|
private func highlightInlineMarkdown(textStorage: NSTextStorage, lineRange: NSRange, pattern: String, markerLen: Int, trait: NSFontTraitMask, font: NSFont) {
|
|
let palette = Theme.current
|
|
guard let regex = try? NSRegularExpression(pattern: pattern) else { return }
|
|
let matches = regex.matches(in: textStorage.string, range: lineRange)
|
|
for match in matches {
|
|
let fullRange = match.range
|
|
let openMarker = NSRange(location: fullRange.location, length: markerLen)
|
|
let closeMarker = NSRange(location: fullRange.location + fullRange.length - markerLen, length: markerLen)
|
|
let contentRange = NSRange(location: fullRange.location + markerLen, length: fullRange.length - markerLen * 2)
|
|
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: openMarker)
|
|
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: closeMarker)
|
|
if contentRange.length > 0 {
|
|
textStorage.addAttribute(.font, value: font, range: contentRange)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func findLineComment(_ line: NSString) -> NSRange? {
|
|
guard line.length >= 2 else { return nil }
|
|
var i = 0
|
|
var inString = false
|
|
while i < line.length - 1 {
|
|
let ch = line.character(at: i)
|
|
if ch == UInt16(UnicodeScalar("\"").value) {
|
|
inString = !inString
|
|
} else if !inString && ch == UInt16(UnicodeScalar("/").value) && line.character(at: i + 1) == UInt16(UnicodeScalar("/").value) {
|
|
return NSRange(location: i, length: line.length - i)
|
|
}
|
|
i += 1
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func highlightCodeTokens(_ line: NSString, inRange range: NSRange, lineOffset: Int, textStorage: NSTextStorage, syn: Theme.SyntaxColors) {
|
|
let sub = line.substring(with: range)
|
|
let scanner = Scanner(string: sub)
|
|
scanner.charactersToBeSkipped = nil
|
|
let digitChars = CharacterSet.decimalDigits.union(CharacterSet(charactersIn: "."))
|
|
let identStart = CharacterSet.letters.union(CharacterSet(charactersIn: "_"))
|
|
let identChars = identStart.union(.decimalDigits)
|
|
|
|
while !scanner.isAtEnd {
|
|
let pos = scanner.currentIndex
|
|
|
|
// String literal
|
|
if scanner.scanString("\"") != nil {
|
|
let startIdx = sub.distance(from: sub.startIndex, to: pos)
|
|
var strContent = "\""
|
|
var escaped = false
|
|
while !scanner.isAtEnd {
|
|
let ch = sub[scanner.currentIndex]
|
|
strContent.append(ch)
|
|
scanner.currentIndex = sub.index(after: scanner.currentIndex)
|
|
if escaped {
|
|
escaped = false
|
|
continue
|
|
}
|
|
if ch == "\\" { escaped = true; continue }
|
|
if ch == "\"" { break }
|
|
}
|
|
let absRange = NSRange(location: lineOffset + range.location + startIdx, length: strContent.count)
|
|
textStorage.addAttribute(.foregroundColor, value: syn.string, range: absRange)
|
|
continue
|
|
}
|
|
|
|
// Number
|
|
if let numStr = scanner.scanCharacters(from: digitChars) {
|
|
let startIdx = sub.distance(from: sub.startIndex, to: pos)
|
|
let absRange = NSRange(location: lineOffset + range.location + startIdx, length: numStr.count)
|
|
textStorage.addAttribute(.foregroundColor, value: syn.number, range: absRange)
|
|
continue
|
|
}
|
|
|
|
// Identifier / keyword
|
|
if let ident = scanner.scanCharacters(from: identChars) {
|
|
if let first = ident.unicodeScalars.first, identStart.contains(first) {
|
|
let startIdx = sub.distance(from: sub.startIndex, to: pos)
|
|
let absRange = NSRange(location: lineOffset + range.location + startIdx, length: ident.count)
|
|
if syntaxKeywords.contains(ident) {
|
|
textStorage.addAttribute(.foregroundColor, value: syn.keyword, range: absRange)
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
|
|
// Operator
|
|
if let op = scanner.scanCharacters(from: syntaxOperatorChars) {
|
|
let startIdx = sub.distance(from: sub.startIndex, to: pos)
|
|
let absRange = NSRange(location: lineOffset + range.location + startIdx, length: op.count)
|
|
textStorage.addAttribute(.foregroundColor, value: syn.operator, range: absRange)
|
|
continue
|
|
}
|
|
|
|
// Skip unrecognized character
|
|
scanner.currentIndex = sub.index(after: scanner.currentIndex)
|
|
}
|
|
}
|
|
|
|
private func highlightBlockComments(textStorage: NSTextStorage, syn: Theme.SyntaxColors, baseFont: NSFont) {
|
|
let text = textStorage.string
|
|
let nsText = text as NSString
|
|
let italicFont = NSFontManager.shared.convert(baseFont, toHaveTrait: .italicFontMask)
|
|
|
|
guard let regex = try? NSRegularExpression(pattern: "/\\*.*?\\*/", options: .dotMatchesLineSeparators) else { return }
|
|
let fullRange = NSRange(location: 0, length: nsText.length)
|
|
let matches = regex.matches(in: text, range: fullRange)
|
|
for match in matches {
|
|
textStorage.addAttributes([
|
|
.foregroundColor: syn.comment,
|
|
.font: italicFont
|
|
], range: match.range)
|
|
}
|
|
}
|
|
|
|
// MARK: - Fenced Code Blocks
|
|
|
|
private func highlightFencedCodeBlocks(textStorage: NSTextStorage, palette: CatppuccinPalette, baseFont: NSFont) -> [NSRange] {
|
|
let text = textStorage.string
|
|
let nsText = text as NSString
|
|
let monoFont = NSFont.monospacedSystemFont(ofSize: baseFont.pointSize, weight: .regular)
|
|
var fencedRanges: [NSRange] = []
|
|
var lineStart = 0
|
|
var openFence: Int? = nil
|
|
|
|
while lineStart < nsText.length {
|
|
let lineRange = nsText.lineRange(for: NSRange(location: lineStart, length: 0))
|
|
let line = nsText.substring(with: lineRange)
|
|
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
if openFence == nil {
|
|
if trimmed.hasPrefix("```") {
|
|
openFence = lineRange.location
|
|
// Mute the fence line
|
|
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: lineRange)
|
|
textStorage.addAttribute(.font, value: monoFont, range: lineRange)
|
|
// Language identifier after ```
|
|
if trimmed.count > 3 {
|
|
let langStart = (nsText as NSString).range(of: "```", range: lineRange)
|
|
if langStart.location != NSNotFound {
|
|
let after = langStart.location + langStart.length
|
|
let langRange = NSRange(location: after, length: NSMaxRange(lineRange) - after)
|
|
if langRange.length > 0 {
|
|
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: langRange)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
if trimmed == "```" {
|
|
// Close fence
|
|
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: lineRange)
|
|
textStorage.addAttribute(.font, value: monoFont, range: lineRange)
|
|
let blockRange = NSRange(location: openFence!, length: NSMaxRange(lineRange) - openFence!)
|
|
fencedRanges.append(blockRange)
|
|
openFence = nil
|
|
} else {
|
|
textStorage.addAttribute(.font, value: monoFont, range: lineRange)
|
|
}
|
|
}
|
|
|
|
lineStart = NSMaxRange(lineRange)
|
|
}
|
|
|
|
return fencedRanges
|
|
}
|
|
|
|
private func isInsideFencedBlock(_ lineRange: NSRange, fencedRanges: [NSRange]) -> Bool {
|
|
for fenced in fencedRanges {
|
|
if lineRange.location >= fenced.location && NSMaxRange(lineRange) <= NSMaxRange(fenced) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// MARK: - Inline Code
|
|
|
|
private func highlightInlineCode(textStorage: NSTextStorage, lineRange: NSRange, palette: CatppuccinPalette, baseFont: NSFont) {
|
|
guard let regex = try? NSRegularExpression(pattern: "`([^`]+)`") else { return }
|
|
let monoFont = NSFont.monospacedSystemFont(ofSize: baseFont.pointSize, weight: .regular)
|
|
let matches = regex.matches(in: textStorage.string, range: lineRange)
|
|
for match in matches {
|
|
let fullRange = match.range
|
|
let openTick = NSRange(location: fullRange.location, length: 1)
|
|
let closeTick = NSRange(location: fullRange.location + fullRange.length - 1, length: 1)
|
|
let contentRange = NSRange(location: fullRange.location + 1, length: fullRange.length - 2)
|
|
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: openTick)
|
|
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: closeTick)
|
|
if contentRange.length > 0 {
|
|
textStorage.addAttribute(.font, value: monoFont, range: contentRange)
|
|
textStorage.addAttribute(.backgroundColor, value: palette.surface0, range: contentRange)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Strikethrough
|
|
|
|
private func highlightStrikethrough(textStorage: NSTextStorage, lineRange: NSRange, palette: CatppuccinPalette) {
|
|
guard let regex = try? NSRegularExpression(pattern: "~~(.+?)~~") else { return }
|
|
let matches = regex.matches(in: textStorage.string, range: lineRange)
|
|
for match in matches {
|
|
let fullRange = match.range
|
|
let openMarker = NSRange(location: fullRange.location, length: 2)
|
|
let closeMarker = NSRange(location: fullRange.location + fullRange.length - 2, length: 2)
|
|
let contentRange = NSRange(location: fullRange.location + 2, length: fullRange.length - 4)
|
|
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: openMarker)
|
|
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: closeMarker)
|
|
if contentRange.length > 0 {
|
|
textStorage.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: contentRange)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Footnotes
|
|
|
|
private func highlightFootnoteRefs(textStorage: NSTextStorage, lineRange: NSRange, palette: CatppuccinPalette) {
|
|
// Inline refs: [^label] (but not definitions which start line with [^label]:)
|
|
guard let regex = try? NSRegularExpression(pattern: "\\[\\^[^\\]]+\\](?!:)") else { return }
|
|
let matches = regex.matches(in: textStorage.string, range: lineRange)
|
|
for match in matches {
|
|
textStorage.addAttribute(.foregroundColor, value: palette.blue, range: match.range)
|
|
textStorage.addAttribute(.superscript, value: 1, range: match.range)
|
|
}
|
|
}
|
|
|
|
private func highlightFootnoteDefinition(textStorage: NSTextStorage, lineRange: NSRange, palette: CatppuccinPalette) {
|
|
guard let regex = try? NSRegularExpression(pattern: "^\\[\\^[^\\]]+\\]:") else { return }
|
|
let nsText = textStorage.string as NSString
|
|
let lineContent = nsText.substring(with: lineRange)
|
|
let localRange = NSRange(location: 0, length: (lineContent as NSString).length)
|
|
let matches = regex.matches(in: lineContent, range: localRange)
|
|
for match in matches {
|
|
let absRange = NSRange(location: lineRange.location + match.range.location, length: match.range.length)
|
|
textStorage.addAttribute(.foregroundColor, value: palette.lavender, range: absRange)
|
|
}
|
|
}
|
|
|
|
// MARK: - Tables
|
|
|
|
private func highlightTableLine(_ trimmed: String, lineRange: NSRange, textStorage: NSTextStorage, palette: CatppuccinPalette, baseFont: NSFont, isHeader: Bool, isSeparator: Bool) {
|
|
let monoFont = NSFont.monospacedSystemFont(ofSize: baseFont.pointSize, weight: .regular)
|
|
let boldMono = NSFontManager.shared.convert(monoFont, toHaveTrait: .boldFontMask)
|
|
|
|
textStorage.addAttribute(.font, value: monoFont, range: lineRange)
|
|
|
|
if isSeparator {
|
|
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: lineRange)
|
|
return
|
|
}
|
|
|
|
if isHeader {
|
|
textStorage.addAttribute(.font, value: boldMono, range: lineRange)
|
|
}
|
|
|
|
// Mute pipe delimiters
|
|
guard let pipeRegex = try? NSRegularExpression(pattern: "\\|") else { return }
|
|
let matches = pipeRegex.matches(in: textStorage.string, range: lineRange)
|
|
for match in matches {
|
|
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: match.range)
|
|
}
|
|
}
|
|
|
|
// MARK: - Lists and Horizontal Rules
|
|
|
|
private func highlightOrderedList(_ trimmed: String, lineRange: NSRange, line: String, textStorage: NSTextStorage, palette: CatppuccinPalette) {
|
|
guard let regex = try? NSRegularExpression(pattern: "^(\\s*)(\\d+\\.)( )") else { return }
|
|
let nsLine = line as NSString
|
|
let localRange = NSRange(location: 0, length: nsLine.length)
|
|
guard let match = regex.firstMatch(in: line, range: localRange) else { return }
|
|
let markerRange = match.range(at: 2)
|
|
let absMarker = NSRange(location: lineRange.location + markerRange.location, length: markerRange.length)
|
|
textStorage.addAttribute(.foregroundColor, value: palette.blue, range: absMarker)
|
|
|
|
applyListIndent(line: line, lineRange: lineRange, textStorage: textStorage)
|
|
}
|
|
|
|
private func highlightUnorderedList(_ trimmed: String, lineRange: NSRange, line: String, textStorage: NSTextStorage, palette: CatppuccinPalette) {
|
|
guard let regex = try? NSRegularExpression(pattern: "^(\\s*)([-*+])( )") else { return }
|
|
let nsLine = line as NSString
|
|
let localRange = NSRange(location: 0, length: nsLine.length)
|
|
guard let match = regex.firstMatch(in: line, range: localRange) else { return }
|
|
let bulletRange = match.range(at: 2)
|
|
let absBullet = NSRange(location: lineRange.location + bulletRange.location, length: bulletRange.length)
|
|
textStorage.addAttribute(.foregroundColor, value: palette.blue, range: absBullet)
|
|
|
|
applyListIndent(line: line, lineRange: lineRange, textStorage: textStorage)
|
|
}
|
|
|
|
private func highlightTaskList(_ trimmed: String, lineRange: NSRange, line: String, textStorage: NSTextStorage, palette: CatppuccinPalette) {
|
|
let checked: Bool
|
|
if trimmed.hasPrefix("- [ ] ") || trimmed.hasPrefix("* [ ] ") || trimmed.hasPrefix("+ [ ] ") {
|
|
checked = false
|
|
} else if trimmed.hasPrefix("- [x] ") || trimmed.hasPrefix("* [x] ") || trimmed.hasPrefix("+ [x] ") ||
|
|
trimmed.hasPrefix("- [X] ") || trimmed.hasPrefix("* [X] ") || trimmed.hasPrefix("+ [X] ") {
|
|
checked = true
|
|
} else {
|
|
return
|
|
}
|
|
|
|
// Find the checkbox in the actual line
|
|
guard let cbRegex = try? NSRegularExpression(pattern: "\\[[ xX]\\]") else { return }
|
|
guard let cbMatch = cbRegex.firstMatch(in: textStorage.string, range: lineRange) else { return }
|
|
let cbRange = cbMatch.range
|
|
|
|
if checked {
|
|
textStorage.addAttribute(.foregroundColor, value: palette.green, range: cbRange)
|
|
let afterCb = NSRange(location: cbRange.location + cbRange.length, length: NSMaxRange(lineRange) - (cbRange.location + cbRange.length))
|
|
if afterCb.length > 0 {
|
|
textStorage.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: afterCb)
|
|
}
|
|
} else {
|
|
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: cbRange)
|
|
}
|
|
|
|
// Bullet coloring
|
|
guard let bulletRegex = try? NSRegularExpression(pattern: "^(\\s*)([-*+])") else { return }
|
|
let nsLine = line as NSString
|
|
guard let bMatch = bulletRegex.firstMatch(in: line, range: NSRange(location: 0, length: nsLine.length)) else { return }
|
|
let bulletRange = bMatch.range(at: 2)
|
|
let absBullet = NSRange(location: lineRange.location + bulletRange.location, length: bulletRange.length)
|
|
textStorage.addAttribute(.foregroundColor, value: palette.blue, range: absBullet)
|
|
|
|
applyListIndent(line: line, lineRange: lineRange, textStorage: textStorage)
|
|
}
|
|
|
|
private func applyListIndent(line: String, lineRange: NSRange, textStorage: NSTextStorage) {
|
|
let leading = line.prefix(while: { $0 == " " || $0 == "\t" })
|
|
let spaces = leading.filter { $0 == " " }.count
|
|
let tabs = leading.filter { $0 == "\t" }.count
|
|
let level = tabs + spaces / 2
|
|
if level > 0 {
|
|
let indent = NSMutableParagraphStyle()
|
|
let px = CGFloat(level) * 20.0
|
|
indent.headIndent = px
|
|
indent.firstLineHeadIndent = px
|
|
textStorage.addAttribute(.paragraphStyle, value: indent, range: lineRange)
|
|
}
|
|
}
|
|
|
|
private func isHorizontalRule(_ trimmed: String) -> Bool {
|
|
if trimmed.isEmpty { return false }
|
|
let stripped = trimmed.replacingOccurrences(of: " ", with: "")
|
|
if stripped.count < 3 { return false }
|
|
let allDash = stripped.allSatisfy { $0 == "-" }
|
|
let allStar = stripped.allSatisfy { $0 == "*" }
|
|
let allUnderscore = stripped.allSatisfy { $0 == "_" }
|
|
return allDash || allStar || allUnderscore
|
|
}
|
|
|
|
private func findTableHeaderLines(textStorage: NSTextStorage, fencedRanges: [NSRange]) -> Set<Int> {
|
|
var headerStarts = Set<Int>()
|
|
let nsText = textStorage.string as NSString
|
|
var lineStart = 0
|
|
var prevLineStart: Int? = nil
|
|
var prevTrimmed: String? = nil
|
|
|
|
while lineStart < nsText.length {
|
|
let lineRange = nsText.lineRange(for: NSRange(location: lineStart, length: 0))
|
|
|
|
if !isInsideFencedBlock(lineRange, fencedRanges: fencedRanges) {
|
|
let line = nsText.substring(with: lineRange)
|
|
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
if trimmed.hasPrefix("|"), isTableSeparator(trimmed),
|
|
let pStart = prevLineStart, let pTrimmed = prevTrimmed,
|
|
pTrimmed.hasPrefix("|") {
|
|
headerStarts.insert(pStart)
|
|
}
|
|
|
|
prevLineStart = lineRange.location
|
|
prevTrimmed = trimmed
|
|
} else {
|
|
prevLineStart = nil
|
|
prevTrimmed = nil
|
|
}
|
|
|
|
lineStart = NSMaxRange(lineRange)
|
|
}
|
|
|
|
return headerStarts
|
|
}
|
|
|
|
private func isTableSeparator(_ trimmed: String) -> Bool {
|
|
guard trimmed.hasPrefix("|") else { return false }
|
|
let inner = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "| "))
|
|
guard !inner.isEmpty else { return false }
|
|
let cells = inner.components(separatedBy: "|")
|
|
return cells.allSatisfy { cell in
|
|
let c = cell.trimmingCharacters(in: .whitespaces)
|
|
guard let regex = try? NSRegularExpression(pattern: "^:?-{1,}:?$") else { return false }
|
|
return regex.firstMatch(in: c, range: NSRange(location: 0, length: (c as NSString).length)) != nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Markdown Links
|
|
|
|
private func highlightLinks(textStorage: NSTextStorage, palette: CatppuccinPalette, fencedRanges: [NSRange]) {
|
|
guard let regex = try? NSRegularExpression(pattern: "(?<!!)\\[([^\\]]+)\\]\\(([^)]+)\\)") else { return }
|
|
let text = textStorage.string
|
|
let fullRange = NSRange(location: 0, length: (text as NSString).length)
|
|
let matches = regex.matches(in: text, range: fullRange)
|
|
for match in matches {
|
|
if isInsideFencedBlock(match.range, fencedRanges: fencedRanges) { continue }
|
|
if isInsideInlineCode(match.range, in: textStorage) { continue }
|
|
|
|
let openBracket = NSRange(location: match.range.location, length: 1)
|
|
let textRange = match.range(at: 1)
|
|
let closeBracketAndUrl = NSRange(
|
|
location: textRange.location + textRange.length,
|
|
length: match.range.length - textRange.length - 1
|
|
)
|
|
|
|
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: openBracket)
|
|
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: closeBracketAndUrl)
|
|
|
|
if textRange.length > 0 {
|
|
let urlStr = (text as NSString).substring(with: match.range(at: 2))
|
|
textStorage.addAttributes([
|
|
.foregroundColor: palette.blue,
|
|
.underlineStyle: NSUnderlineStyle.single.rawValue,
|
|
.link: urlStr
|
|
], range: textRange)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Markdown Images
|
|
|
|
private func highlightImages(textStorage: NSTextStorage, palette: CatppuccinPalette, fencedRanges: [NSRange]) {
|
|
guard let regex = try? NSRegularExpression(pattern: "!\\[([^\\]]*)\\]\\(([^)]+)\\)") else { return }
|
|
let text = textStorage.string
|
|
let fullRange = NSRange(location: 0, length: (text as NSString).length)
|
|
let matches = regex.matches(in: text, range: fullRange)
|
|
for match in matches {
|
|
if isInsideFencedBlock(match.range, fencedRanges: fencedRanges) { continue }
|
|
if isInsideInlineCode(match.range, in: textStorage) { continue }
|
|
|
|
let bang = NSRange(location: match.range.location, length: 1)
|
|
let openBracket = NSRange(location: match.range.location + 1, length: 1)
|
|
let altRange = match.range(at: 1)
|
|
let closeBracketParen = NSRange(
|
|
location: altRange.location + altRange.length,
|
|
length: 2
|
|
)
|
|
let urlRange = match.range(at: 2)
|
|
let closeParen = NSRange(
|
|
location: match.range.location + match.range.length - 1,
|
|
length: 1
|
|
)
|
|
|
|
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: bang)
|
|
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: openBracket)
|
|
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: closeBracketParen)
|
|
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: closeParen)
|
|
|
|
if altRange.length > 0 {
|
|
textStorage.addAttribute(.foregroundColor, value: palette.green, range: altRange)
|
|
}
|
|
if urlRange.length > 0 {
|
|
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: urlRange)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Autolinks
|
|
|
|
private let autolinkDetector: NSDataDetector? = {
|
|
try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
|
}()
|
|
|
|
private func highlightAutolinks(textStorage: NSTextStorage, palette: CatppuccinPalette, fencedRanges: [NSRange]) {
|
|
guard let detector = autolinkDetector else { return }
|
|
let text = textStorage.string
|
|
let fullRange = NSRange(location: 0, length: (text as NSString).length)
|
|
|
|
let inlineCodeRanges = collectInlineCodeRanges(in: textStorage)
|
|
let linkAttrRanges = collectLinkAttributeRanges(in: textStorage)
|
|
|
|
let matches = detector.matches(in: text, range: fullRange)
|
|
for match in matches {
|
|
guard let url = match.url else { continue }
|
|
if isInsideFencedBlock(match.range, fencedRanges: fencedRanges) { continue }
|
|
if rangeOverlapsAny(match.range, ranges: inlineCodeRanges) { continue }
|
|
if rangeOverlapsAny(match.range, ranges: linkAttrRanges) { continue }
|
|
|
|
textStorage.addAttributes([
|
|
.foregroundColor: palette.blue,
|
|
.underlineStyle: NSUnderlineStyle.single.rawValue,
|
|
.link: url.absoluteString
|
|
], range: match.range)
|
|
}
|
|
}
|
|
|
|
private func isInsideInlineCode(_ range: NSRange, in textStorage: NSTextStorage) -> Bool {
|
|
guard let regex = try? NSRegularExpression(pattern: "`[^`]+`") else { return false }
|
|
let fullRange = NSRange(location: 0, length: (textStorage.string as NSString).length)
|
|
let matches = regex.matches(in: textStorage.string, range: fullRange)
|
|
for m in matches {
|
|
if m.range.location <= range.location && NSMaxRange(m.range) >= NSMaxRange(range) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
private func collectInlineCodeRanges(in textStorage: NSTextStorage) -> [NSRange] {
|
|
guard let regex = try? NSRegularExpression(pattern: "`[^`]+`") else { return [] }
|
|
let fullRange = NSRange(location: 0, length: (textStorage.string as NSString).length)
|
|
return regex.matches(in: textStorage.string, range: fullRange).map { $0.range }
|
|
}
|
|
|
|
private func collectLinkAttributeRanges(in textStorage: NSTextStorage) -> [NSRange] {
|
|
var ranges: [NSRange] = []
|
|
let fullRange = NSRange(location: 0, length: textStorage.length)
|
|
textStorage.enumerateAttribute(.link, in: fullRange) { value, range, _ in
|
|
if value != nil { ranges.append(range) }
|
|
}
|
|
return ranges
|
|
}
|
|
|
|
private func rangeOverlapsAny(_ range: NSRange, ranges: [NSRange]) -> Bool {
|
|
for r in ranges {
|
|
if NSIntersectionRange(range, r).length > 0 { return true }
|
|
}
|
|
return false
|
|
}
|
|
|
|
// MARK: - Image Cache & Path Resolution
|
|
|
|
private let imageCacheDir: URL = {
|
|
let dir = FileManager.default.homeDirectoryForCurrentUser
|
|
.appendingPathComponent(".swiftly/images", isDirectory: true)
|
|
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
|
return dir
|
|
}()
|
|
|
|
private func resolveLocalImagePath(_ rawPath: String) -> String? {
|
|
if rawPath.hasPrefix("http://") || rawPath.hasPrefix("https://") { return nil }
|
|
let expanded: String
|
|
if rawPath.hasPrefix("~/") {
|
|
expanded = (rawPath as NSString).expandingTildeInPath
|
|
} else if rawPath.hasPrefix("/") {
|
|
expanded = rawPath
|
|
} else if rawPath.hasPrefix("file://") {
|
|
expanded = URL(string: rawPath)?.path ?? rawPath
|
|
} else {
|
|
expanded = rawPath
|
|
}
|
|
return expanded
|
|
}
|
|
|
|
// MARK: - LineNumberTextView
|
|
|
|
class LineNumberTextView: NSTextView {
|
|
override func drawInsertionPoint(in rect: NSRect, color: NSColor, turnedOn flag: Bool) {
|
|
var widened = rect
|
|
widened.size.width = 2
|
|
super.drawInsertionPoint(in: widened, color: color, turnedOn: flag)
|
|
}
|
|
|
|
// MARK: - Paste (image from clipboard)
|
|
|
|
override func paste(_ sender: Any?) {
|
|
let pb = NSPasteboard.general
|
|
let imageTypes: [NSPasteboard.PasteboardType] = [.tiff, .png]
|
|
|
|
if let imageType = imageTypes.first(where: { pb.data(forType: $0) != nil }),
|
|
let data = pb.data(forType: imageType) {
|
|
if let image = NSImage(data: data), let pngData = image.tiffRepresentation,
|
|
let bitmap = NSBitmapImageRep(data: pngData),
|
|
let png = bitmap.representation(using: .png, properties: [:]) {
|
|
let uuid = UUID().uuidString
|
|
let path = imageCacheDir.appendingPathComponent("\(uuid).png")
|
|
do {
|
|
try png.write(to: path)
|
|
let markdown = ".png)"
|
|
insertText(markdown, replacementRange: selectedRange())
|
|
return
|
|
} catch {}
|
|
}
|
|
}
|
|
super.paste(sender)
|
|
}
|
|
|
|
// MARK: - Drag and Drop
|
|
|
|
override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
|
|
if sender.draggingPasteboard.canReadObject(forClasses: [NSURL.self], options: [
|
|
.urlReadingFileURLsOnly: true
|
|
]) {
|
|
return .copy
|
|
}
|
|
return super.draggingEntered(sender)
|
|
}
|
|
|
|
override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
|
|
guard let urls = sender.draggingPasteboard.readObjects(forClasses: [NSURL.self], options: [
|
|
.urlReadingFileURLsOnly: true
|
|
]) as? [URL] else {
|
|
return super.performDragOperation(sender)
|
|
}
|
|
|
|
let imageExts: Set<String> = ["png", "jpg", "jpeg", "gif", "bmp", "tiff", "webp"]
|
|
var insertions: [String] = []
|
|
|
|
for url in urls {
|
|
let ext = url.pathExtension.lowercased()
|
|
if imageExts.contains(ext) {
|
|
let uuid = UUID().uuidString
|
|
let dest = imageCacheDir.appendingPathComponent("\(uuid).\(ext)")
|
|
do {
|
|
try FileManager.default.copyItem(at: url, to: dest)
|
|
insertions.append(".\(ext))")
|
|
} catch {
|
|
insertions.append(")")
|
|
}
|
|
} else {
|
|
insertions.append("[\(url.lastPathComponent)](\(url.absoluteString))")
|
|
}
|
|
}
|
|
|
|
if !insertions.isEmpty {
|
|
let text = insertions.joined(separator: "\n")
|
|
insertText(text, replacementRange: selectedRange())
|
|
return true
|
|
}
|
|
return super.performDragOperation(sender)
|
|
}
|
|
}
|
|
|
|
class LineNumberRulerView: NSRulerView {
|
|
var evalResults: [Int: String] = [:]
|
|
|
|
private weak var editorTextView: NSTextView?
|
|
|
|
init(textView: NSTextView) {
|
|
self.editorTextView = textView
|
|
super.init(scrollView: textView.enclosingScrollView!, orientation: .verticalRuler)
|
|
self.clientView = textView
|
|
self.ruleThickness = 50
|
|
|
|
NotificationCenter.default.addObserver(
|
|
self,
|
|
selector: #selector(textDidChange(_:)),
|
|
name: NSText.didChangeNotification,
|
|
object: textView
|
|
)
|
|
}
|
|
|
|
required init(coder: NSCoder) {
|
|
fatalError()
|
|
}
|
|
|
|
@objc private func textDidChange(_ notification: Notification) {
|
|
needsDisplay = true
|
|
}
|
|
|
|
override func drawHashMarksAndLabels(in rect: NSRect) {
|
|
guard let tv = editorTextView,
|
|
let layoutManager = tv.layoutManager,
|
|
let textContainer = tv.textContainer else { return }
|
|
|
|
let palette = Theme.current
|
|
|
|
palette.mantle.setFill()
|
|
rect.fill()
|
|
|
|
let visibleRect = scrollView!.contentView.bounds
|
|
let visibleGlyphRange = layoutManager.glyphRange(
|
|
forBoundingRect: visibleRect, in: textContainer
|
|
)
|
|
let visibleCharRange = layoutManager.characterRange(
|
|
forGlyphRange: visibleGlyphRange, actualGlyphRange: nil
|
|
)
|
|
|
|
let text = tv.string as NSString
|
|
var lineNumber = 1
|
|
var index = 0
|
|
while index < visibleCharRange.location {
|
|
if text.character(at: index) == 0x0A { lineNumber += 1 }
|
|
index += 1
|
|
}
|
|
|
|
let attrs: [NSAttributedString.Key: Any] = [
|
|
.font: Theme.gutterFont,
|
|
.foregroundColor: palette.overlay0
|
|
]
|
|
let resultAttrs: [NSAttributedString.Key: Any] = [
|
|
.font: Theme.gutterFont,
|
|
.foregroundColor: palette.teal
|
|
]
|
|
|
|
var charIndex = visibleCharRange.location
|
|
while charIndex < NSMaxRange(visibleCharRange) {
|
|
let lineRange = text.lineRange(for: NSRange(location: charIndex, length: 0))
|
|
let glyphRange = layoutManager.glyphRange(forCharacterRange: lineRange, actualCharacterRange: nil)
|
|
var lineRect = layoutManager.boundingRect(forGlyphRange: glyphRange, in: textContainer)
|
|
lineRect.origin.y += tv.textContainerInset.height - visibleRect.origin.y
|
|
|
|
if let result = evalResults[lineNumber - 1] {
|
|
let resultStr = NSAttributedString(string: "→ \(result)", attributes: resultAttrs)
|
|
let resultSize = resultStr.size()
|
|
let resultPoint = NSPoint(
|
|
x: ruleThickness - resultSize.width - 4,
|
|
y: lineRect.origin.y
|
|
)
|
|
resultStr.draw(at: resultPoint)
|
|
} else {
|
|
let numStr = NSAttributedString(string: "\(lineNumber)", attributes: attrs)
|
|
let size = numStr.size()
|
|
let point = NSPoint(
|
|
x: ruleThickness - size.width - 8,
|
|
y: lineRect.origin.y
|
|
)
|
|
numStr.draw(at: point)
|
|
}
|
|
|
|
lineNumber += 1
|
|
charIndex = NSMaxRange(lineRange)
|
|
}
|
|
}
|
|
}
|