Acord/src/EditorView.swift

1885 lines
77 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 checkbox(checked: Bool)
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 .checkbox, .tableBlock:
break
}
}
}
override func drawGlyphs(forGlyphRange glyphsToShow: NSRange, at origin: NSPoint) {
guard let textContainer = textContainers.first else {
super.drawGlyphs(forGlyphRange: glyphsToShow, at: origin)
return
}
var skipRanges: [NSRange] = []
for block in blockRanges {
guard case .checkbox(let checked) = block.kind else { continue }
let glyphRange = self.glyphRange(forCharacterRange: block.range, actualCharacterRange: nil)
guard NSIntersectionRange(glyphRange, glyphsToShow).length > 0 else { continue }
skipRanges.append(glyphRange)
drawCheckbox(checked: checked, glyphRange: glyphRange, origin: origin, container: textContainer)
}
if skipRanges.isEmpty {
super.drawGlyphs(forGlyphRange: glyphsToShow, at: origin)
return
}
skipRanges.sort { $0.location < $1.location }
var cursor = glyphsToShow.location
for skip in skipRanges {
if cursor < skip.location {
super.drawGlyphs(forGlyphRange: NSRange(location: cursor, length: skip.location - cursor), at: origin)
}
cursor = NSMaxRange(skip)
}
if cursor < NSMaxRange(glyphsToShow) {
super.drawGlyphs(forGlyphRange: NSRange(location: cursor, length: NSMaxRange(glyphsToShow) - cursor), at: origin)
}
}
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 drawCheckbox(checked: Bool, glyphRange: NSRange, origin: NSPoint, container: NSTextContainer) {
let rect = boundingRect(forGlyphRange: glyphRange, in: container)
let size: CGFloat = 14
let x = rect.origin.x + origin.x
let y = rect.origin.y + origin.y + (rect.size.height - size) / 2
let boxRect = NSRect(x: x, y: y, width: size, height: size)
let path = NSBezierPath(roundedRect: boxRect, xRadius: 3, yRadius: 3)
if checked {
Theme.current.green.setFill()
path.fill()
let check = NSBezierPath()
check.move(to: NSPoint(x: x + 3, y: y + size / 2))
check.line(to: NSPoint(x: x + size * 0.4, y: y + 3))
check.line(to: NSPoint(x: x + size - 3, y: y + size - 3))
check.lineWidth = 2
check.lineCapStyle = .round
check.lineJoinStyle = .round
NSColor.white.setStroke()
check.stroke()
} else {
Theme.current.overlay0.setStroke()
path.lineWidth = 1.5
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 }
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
}
if let firstLine = lines.first {
let nsFirstLine = firstLine as NSString
var pipeOffsets: [Int] = []
for i in 0..<nsFirstLine.length {
if nsFirstLine.character(at: i) == UInt16(UnicodeScalar("|").value) {
pipeOffsets.append(i)
}
}
if pipeOffsets.count > 2 {
for pi in 1..<(pipeOffsets.count - 1) {
let charPos = charRange.location + pipeOffsets[pi]
let pipeGlyph = self.glyphRange(forCharacterRange: NSRange(location: charPos, length: 1), actualCharacterRange: nil)
let pipeRect = boundingRect(forGlyphRange: pipeGlyph, in: container)
let x = pipeRect.origin.x + origin.x + pipeRect.size.width / 2
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()
}
}
if lines.count > 1 {
let firstLineRange = NSRange(location: charRange.location, length: nsFirstLine.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()
}
}
}
}
// MARK: - Interactive Table Component
enum TableAlignment {
case left, center, right
}
struct ParsedTable {
var headers: [String]
var alignments: [TableAlignment]
var rows: [[String]]
var sourceRange: NSRange
}
func parseMarkdownTable(from text: NSString, range: NSRange) -> ParsedTable? {
let tableText = text.substring(with: range)
let lines = tableText.components(separatedBy: "\n").filter { !$0.isEmpty }
guard lines.count >= 2 else { return nil }
func parseCells(_ line: String) -> [String] {
var trimmed = line.trimmingCharacters(in: .whitespaces)
if trimmed.hasPrefix("|") { trimmed = String(trimmed.dropFirst()) }
if trimmed.hasSuffix("|") { trimmed = String(trimmed.dropLast()) }
return trimmed.components(separatedBy: "|").map { $0.trimmingCharacters(in: .whitespaces) }
}
let headers = parseCells(lines[0])
guard headers.count > 0 else { return nil }
var sepIdx = 1
var alignments: [TableAlignment] = Array(repeating: .left, count: headers.count)
if sepIdx < lines.count && isTableSeparator(lines[sepIdx].trimmingCharacters(in: .whitespacesAndNewlines)) {
let sepCells = parseCells(lines[sepIdx])
for (i, cell) in sepCells.enumerated() where i < alignments.count {
let c = cell.trimmingCharacters(in: .whitespaces)
if c.hasPrefix(":") && c.hasSuffix(":") {
alignments[i] = .center
} else if c.hasSuffix(":") {
alignments[i] = .right
}
}
sepIdx += 1
}
var rows: [[String]] = []
for i in sepIdx..<lines.count {
let cells = parseCells(lines[i])
var row = cells
while row.count < headers.count { row.append("") }
if row.count > headers.count { row = Array(row.prefix(headers.count)) }
rows.append(row)
}
return ParsedTable(headers: headers, alignments: alignments, rows: rows, sourceRange: range)
}
func rebuildTableMarkdown(_ table: ParsedTable) -> String {
let colCount = table.headers.count
var colWidths = Array(repeating: 3, count: colCount)
for (i, h) in table.headers.enumerated() {
colWidths[i] = max(colWidths[i], h.count)
}
for row in table.rows {
for (i, cell) in row.enumerated() where i < colCount {
colWidths[i] = max(colWidths[i], cell.count)
}
}
func formatRow(_ cells: [String]) -> String {
var parts: [String] = []
for (i, cell) in cells.enumerated() where i < colCount {
parts.append(" " + cell.padding(toLength: colWidths[i], withPad: " ", startingAt: 0) + " ")
}
return "|" + parts.joined(separator: "|") + "|"
}
var lines: [String] = []
lines.append(formatRow(table.headers))
var sepParts: [String] = []
for i in 0..<colCount {
let w = colWidths[i]
var dash = String(repeating: "-", count: w)
switch table.alignments[i] {
case .left: dash = " " + dash + " "
case .center: dash = ":" + dash + ":"
case .right: dash = " " + dash + ":"
}
sepParts.append(dash)
}
lines.append("|" + sepParts.joined(separator: "|") + "|")
for row in table.rows {
lines.append(formatRow(row))
}
return lines.joined(separator: "\n")
}
class MarkdownTableView: NSView, NSTextFieldDelegate {
var table: ParsedTable
weak var textView: NSTextView?
var onTableChanged: ((ParsedTable) -> Void)?
private var cellFields: [[NSTextField]] = []
private let cellHeight: CGFloat = 26
private let cellPadding: CGFloat = 4
private let headerHeight: CGFloat = 28
init(table: ParsedTable, width: CGFloat) {
self.table = table
super.init(frame: .zero)
wantsLayer = true
layer?.backgroundColor = Theme.current.base.cgColor
layer?.cornerRadius = 4
layer?.borderWidth = 1
layer?.borderColor = Theme.current.surface2.cgColor
buildGrid(width: width)
}
required init?(coder: NSCoder) { fatalError() }
private func buildGrid(width: CGFloat) {
subviews.forEach { $0.removeFromSuperview() }
cellFields = []
let colCount = table.headers.count
guard colCount > 0 else { return }
let colWidth = (width - CGFloat(colCount + 1)) / CGFloat(colCount)
let totalRows = 1 + table.rows.count
let totalHeight = headerHeight + CGFloat(table.rows.count) * cellHeight
frame.size = NSSize(width: width, height: totalHeight)
// Header row
let headerBg = NSView(frame: NSRect(x: 0, y: totalHeight - headerHeight, width: width, height: headerHeight))
headerBg.wantsLayer = true
headerBg.layer?.backgroundColor = Theme.current.surface0.cgColor
addSubview(headerBg)
var headerFields: [NSTextField] = []
for (col, header) in table.headers.enumerated() {
let x = CGFloat(col) * colWidth + CGFloat(col + 1)
let field = makeCell(text: header, frame: NSRect(x: x, y: totalHeight - headerHeight + 2, width: colWidth, height: headerHeight - 4), isHeader: true, row: -1, col: col)
addSubview(field)
headerFields.append(field)
}
cellFields.append(headerFields)
// Data rows
for (rowIdx, row) in table.rows.enumerated() {
var rowFields: [NSTextField] = []
let y = totalHeight - headerHeight - CGFloat(rowIdx + 1) * cellHeight
for (col, cell) in row.enumerated() where col < colCount {
let x = CGFloat(col) * colWidth + CGFloat(col + 1)
let field = makeCell(text: cell, frame: NSRect(x: x, y: y + 2, width: colWidth, height: cellHeight - 4), isHeader: false, row: rowIdx, col: col)
addSubview(field)
rowFields.append(field)
}
while rowFields.count < colCount {
let col = rowFields.count
let x = CGFloat(col) * colWidth + CGFloat(col + 1)
let field = makeCell(text: "", frame: NSRect(x: x, y: y + 2, width: colWidth, height: cellHeight - 4), isHeader: false, row: rowIdx, col: col)
addSubview(field)
rowFields.append(field)
}
cellFields.append(rowFields)
}
// Grid lines
for i in 1..<colCount {
let x = CGFloat(i) * (colWidth + 1)
let line = NSView(frame: NSRect(x: x, y: 0, width: 1, height: totalHeight))
line.wantsLayer = true
line.layer?.backgroundColor = Theme.current.surface2.cgColor
addSubview(line)
}
for i in 0..<totalRows {
let y = totalHeight - headerHeight - CGFloat(i) * cellHeight
let line = NSView(frame: NSRect(x: 0, y: y, width: width, height: 1))
line.wantsLayer = true
line.layer?.backgroundColor = Theme.current.surface2.cgColor
addSubview(line)
}
}
private func makeCell(text: String, frame: NSRect, isHeader: Bool, row: Int, col: Int) -> NSTextField {
let field = NSTextField(frame: frame)
field.stringValue = text
field.isEditable = true
field.isBordered = false
field.drawsBackground = false
field.font = isHeader
? NSFontManager.shared.convert(Theme.editorFont, toHaveTrait: .boldFontMask)
: Theme.editorFont
field.textColor = Theme.current.text
field.focusRingType = .none
field.cell?.truncatesLastVisibleLine = true
field.tag = (row + 1) * 1000 + col
field.delegate = self
if let align = table.alignments[safe: col] {
switch align {
case .left: field.alignment = .left
case .center: field.alignment = .center
case .right: field.alignment = .right
}
}
return field
}
func controlTextDidEndEditing(_ obj: Notification) {
guard let field = obj.object as? NSTextField else { return }
let tag = field.tag
let row = tag / 1000 - 1
let col = tag % 1000
if row == -1 {
guard col < table.headers.count else { return }
table.headers[col] = field.stringValue
} else {
guard row < table.rows.count, col < table.rows[row].count else { return }
table.rows[row][col] = field.stringValue
}
onTableChanged?(table)
if let movement = obj.userInfo?["NSTextMovement"] as? Int, movement == NSTabTextMovement {
let nextCol = col + 1
let nextRow = row + (nextCol >= table.headers.count ? 1 : 0)
let actualCol = nextCol % table.headers.count
let actualRow = nextRow
let fieldRow = actualRow + 1
if fieldRow < cellFields.count, actualCol < cellFields[fieldRow].count {
window?.makeFirstResponder(cellFields[fieldRow][actualCol])
}
}
}
func control(_ control: NSControl, textView tv: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
if commandSelector == #selector(NSResponder.insertNewline(_:)) {
guard let field = control as? NSTextField else { return false }
let tag = field.tag
let row = tag / 1000 - 1
let col = tag % 1000
_ = row; _ = col
table.rows.append(Array(repeating: "", count: table.headers.count))
onTableChanged?(table)
let width = frame.width
buildGrid(width: width)
needsLayout = true
return true
}
return false
}
}
private extension Array {
subscript(safe index: Int) -> Element? {
indices.contains(index) ? self[index] : nil
}
}
// MARK: - File Embed Attachment Cell
class FileEmbedCell: NSTextAttachmentCell {
let filePath: String
let fileName: String
private let fileIcon: NSImage
init(filePath: String) {
self.filePath = filePath
self.fileName = (filePath as NSString).lastPathComponent
self.fileIcon = NSWorkspace.shared.icon(forFile: filePath)
self.fileIcon.size = NSSize(width: 16, height: 16)
super.init()
}
required init(coder: NSCoder) { fatalError() }
override func cellSize() -> NSSize {
let textSize = (fileName as NSString).size(withAttributes: [.font: Theme.editorFont])
return NSSize(width: 16 + 8 + textSize.width + 16, height: max(24, textSize.height + 8))
}
override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) {
let path = NSBezierPath(roundedRect: cellFrame.insetBy(dx: 1, dy: 1), xRadius: 4, yRadius: 4)
Theme.current.surface1.setFill()
path.fill()
Theme.current.surface2.setStroke()
path.lineWidth = 1
path.stroke()
let iconRect = NSRect(x: cellFrame.origin.x + 6, y: cellFrame.origin.y + (cellFrame.height - 16) / 2, width: 16, height: 16)
fileIcon.draw(in: iconRect)
let textRect = NSRect(x: iconRect.maxX + 4, y: cellFrame.origin.y + (cellFrame.height - 16) / 2, width: cellFrame.width - 30, height: 16)
let attrs: [NSAttributedString.Key: Any] = [
.font: Theme.editorFont,
.foregroundColor: Theme.current.text
]
fileName.draw(in: textRect, withAttributes: attrs)
}
override func wantsToTrackMouse() -> Bool { true }
override func trackMouse(with theEvent: NSEvent, in cellFrame: NSRect, of controlView: NSView?, untilMouseUp flag: Bool) -> Bool {
if theEvent.clickCount >= 1 {
NSWorkspace.shared.open(URL(fileURLWithPath: filePath))
}
return true
}
}
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.isAutomaticLinkDetectionEnabled = 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()
}
textView.typingAttributes = [
.font: Theme.editorFont,
.foregroundColor: Theme.current.text
]
updateBlockRanges(for: textView)
DispatchQueue.main.async {
context.coordinator.triggerImageUpdate()
context.coordinator.triggerTableUpdate()
}
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.backgroundColor = Theme.current.base
textView.insertionPointColor = Theme.current.text
textView.typingAttributes = [
.font: Theme.editorFont,
.foregroundColor: 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
private var isUpdatingTables = false
private var embeddedTableViews: [MarkdownTableView] = []
init(_ parent: EditorTextView) {
self.parent = parent
}
func textDidChange(_ notification: Notification) {
guard let tv = textView, let ts = tv.textStorage else { return }
if isUpdatingImages || isUpdatingTables { return }
parent.text = tv.string
let sel = tv.selectedRanges
ts.beginEditing()
applySyntaxHighlighting(to: ts)
ts.endEditing()
tv.typingAttributes = [
.font: Theme.editorFont,
.foregroundColor: Theme.current.text
]
tv.selectedRanges = sel
updateBlockRanges(for: tv)
rulerView?.needsDisplay = true
DispatchQueue.main.async { [weak self] in
self?.updateInlineImages()
self?.updateEmbeddedTables()
}
}
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 textView(_ textView: NSTextView, clickedOnLink link: Any, at charIndex: Int) -> Bool {
var urlString: String?
if let url = link as? URL {
urlString = url.absoluteString
} else if let str = link as? String {
urlString = str
}
guard let str = urlString, let url = URL(string: str) else { return false }
NSWorkspace.shared.open(url)
return true
}
func triggerImageUpdate() {
updateInlineImages()
}
func triggerTableUpdate() {
updateEmbeddedTables()
}
// MARK: - Embedded Tables
private func updateEmbeddedTables() {
guard let tv = textView, let lm = tv.layoutManager as? MarkdownLayoutManager,
let tc = tv.textContainer else { return }
for tableView in embeddedTableViews {
tableView.removeFromSuperview()
}
embeddedTableViews.removeAll()
let text = tv.string as NSString
for block in lm.blockRanges {
guard case .tableBlock = block.kind else { continue }
guard let parsed = parseMarkdownTable(from: text, range: block.range) else { continue }
let glyphRange = lm.glyphRange(forCharacterRange: block.range, actualCharacterRange: nil)
var rect = lm.boundingRect(forGlyphRange: glyphRange, in: tc)
rect.origin.x += tv.textContainerInset.width
rect.origin.y += tv.textContainerInset.height
let tableWidth = tc.containerSize.width - 8
let tableView = MarkdownTableView(table: parsed, width: tableWidth)
tableView.frame.origin = NSPoint(x: rect.origin.x + 4, y: rect.origin.y)
tableView.textView = tv
tableView.onTableChanged = { [weak self] updatedTable in
self?.applyTableEdit(updatedTable)
}
tv.addSubview(tableView)
embeddedTableViews.append(tableView)
}
}
private func applyTableEdit(_ table: ParsedTable) {
guard let tv = textView, let ts = tv.textStorage else { return }
let newMarkdown = rebuildTableMarkdown(table)
let range = table.sourceRange
guard NSMaxRange(range) <= ts.length else { return }
isUpdatingTables = true
let sel = tv.selectedRanges
ts.beginEditing()
ts.replaceCharacters(in: range, with: newMarkdown)
applySyntaxHighlighting(to: ts)
ts.endEditing()
tv.selectedRanges = sel
parent.text = tv.string
updateBlockRanges(for: tv)
isUpdatingTables = false
}
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
private let checkboxPattern: NSRegularExpression? = {
try? NSRegularExpression(pattern: "\\[[ xX]\\]")
}()
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)
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
}
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
}
}
if isHorizontalRule(trimmed) && openFence == nil {
blocks.append(.init(range: lineRange, kind: .horizontalRule))
}
// Task list checkboxes
let taskPrefixes = ["- [ ] ", "- [x] ", "- [X] ", "* [ ] ", "* [x] ", "* [X] ", "+ [ ] ", "+ [x] ", "+ [X] "]
let strippedLine = trimmed
for prefix in taskPrefixes {
if strippedLine.hasPrefix(prefix) {
let checked = prefix.contains("x") || prefix.contains("X")
if let regex = checkboxPattern,
let match = regex.firstMatch(in: text as String, range: lineRange) {
blocks.append(.init(range: match.range, kind: .checkbox(checked: checked)))
}
break
}
}
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)
}
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("> ") || trimmed == ">" {
textStorage.addAttribute(.foregroundColor, value: palette.overlay2, range: lineRange)
let indent = NSMutableParagraphStyle()
indent.headIndent = 24
indent.firstLineHeadIndent = 24
textStorage.addAttribute(.paragraphStyle, value: indent, range: lineRange)
let gtRange = (textStorage.string as NSString).range(of: ">", options: [], range: lineRange)
if gtRange.location != NSNotFound {
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: gtRange)
}
return true
}
// Horizontal rule layout manager draws the line; mute the source text
if isHorizontalRule(trimmed) {
textStorage.addAttribute(.foregroundColor, value: palette.surface0, 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 = "![image](~/.swiftly/images/\(uuid).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"]
guard let ts = textStorage else { return false }
var didInsert = false
for url in urls {
let ext = url.pathExtension.lowercased()
if imageExts.contains(ext) {
let uuid = UUID().uuidString
let dest = imageCacheDir.appendingPathComponent("\(uuid).\(ext)")
let markdown: String
do {
try FileManager.default.copyItem(at: url, to: dest)
markdown = "![image](~/.swiftly/images/\(uuid).\(ext))"
} catch {
markdown = "![\(url.lastPathComponent)](\(url.path))"
}
insertText(markdown, replacementRange: selectedRange())
didInsert = true
} else {
let attachment = NSTextAttachment()
let cell = FileEmbedCell(filePath: url.path)
attachment.attachmentCell = cell
let attachStr = NSMutableAttributedString(string: "\n")
attachStr.append(NSAttributedString(attachment: attachment))
let insertAt = min(selectedRange().location, ts.length)
ts.insert(attachStr, at: insertAt)
didInsert = true
}
}
return didInsert || 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)
}
}
}