3234 lines
128 KiB
Swift
3234 lines
128 KiB
Swift
import SwiftUI
|
|
import AppKit
|
|
import UniformTypeIdentifiers
|
|
|
|
// 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 {
|
|
let glyphRange = self.glyphRange(forCharacterRange: block.range, actualCharacterRange: nil)
|
|
guard NSIntersectionRange(glyphRange, glyphsToShow).length > 0 else { continue }
|
|
|
|
switch block.kind {
|
|
case .checkbox(let checked):
|
|
skipRanges.append(glyphRange)
|
|
drawCheckbox(checked: checked, glyphRange: glyphRange, origin: origin, container: textContainer)
|
|
case .horizontalRule:
|
|
skipRanges.append(glyphRange)
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
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 var columnWidths: [CGFloat] = []
|
|
private var rowHeights: [CGFloat] = []
|
|
private var tableWidth: CGFloat = 0
|
|
|
|
private let minColWidth: CGFloat = 40
|
|
private let minRowHeight: CGFloat = 24
|
|
private let defaultHeaderHeight: CGFloat = 28
|
|
private let defaultCellHeight: CGFloat = 26
|
|
private let dividerHitZone: CGFloat = 6
|
|
|
|
private let indicatorRowHeight: CGFloat = 20
|
|
private let indicatorColWidth: CGFloat = 30
|
|
private var chromeVisible = false
|
|
private var chromeContainer: NSView?
|
|
private var dragHighlightView: NSView?
|
|
private var dragHandleView: NSView?
|
|
private var focusMonitor: Any?
|
|
|
|
private enum DragMode { case none, column(Int), row(Int), move }
|
|
private var dragMode: DragMode = .none
|
|
private var dragStartPoint: NSPoint = .zero
|
|
private var dragStartSize: CGFloat = 0
|
|
|
|
init(table: ParsedTable, width: CGFloat) {
|
|
self.table = table
|
|
self.tableWidth = width
|
|
super.init(frame: .zero)
|
|
wantsLayer = true
|
|
layer?.backgroundColor = Theme.current.base.cgColor
|
|
layer?.cornerRadius = 4
|
|
initSizes(width: width)
|
|
buildGrid()
|
|
setupTrackingArea()
|
|
setupFocusMonitoring()
|
|
}
|
|
|
|
required init?(coder: NSCoder) { fatalError() }
|
|
|
|
deinit {
|
|
if let monitor = focusMonitor {
|
|
NotificationCenter.default.removeObserver(monitor)
|
|
}
|
|
}
|
|
|
|
private func initSizes(width: CGFloat) {
|
|
let colCount = table.headers.count
|
|
guard colCount > 0 else { return }
|
|
let available = width - CGFloat(colCount + 1)
|
|
let colW = available / CGFloat(colCount)
|
|
columnWidths = Array(repeating: max(colW, minColWidth), count: colCount)
|
|
|
|
rowHeights = []
|
|
rowHeights.append(defaultHeaderHeight)
|
|
for _ in 0..<table.rows.count {
|
|
rowHeights.append(defaultCellHeight)
|
|
}
|
|
}
|
|
|
|
private var totalHeight: CGFloat {
|
|
rowHeights.reduce(0, +)
|
|
}
|
|
|
|
private func columnX(for col: Int) -> CGFloat {
|
|
var x: CGFloat = 1
|
|
for i in 0..<col {
|
|
x += columnWidths[i] + 1
|
|
}
|
|
return x
|
|
}
|
|
|
|
private func rowY(for row: Int) -> CGFloat {
|
|
let th = totalHeight
|
|
var y = th
|
|
for i in 0...row {
|
|
y -= rowHeights[i]
|
|
}
|
|
return y
|
|
}
|
|
|
|
private var gridOriginX: CGFloat { chromeVisible ? indicatorColWidth : 0 }
|
|
private var gridOriginY: CGFloat { chromeVisible ? indicatorRowHeight : 0 }
|
|
|
|
private func buildGrid() {
|
|
subviews.forEach { $0.removeFromSuperview() }
|
|
chromeContainer = nil
|
|
dragHighlightView = nil
|
|
dragHandleView = nil
|
|
cellFields = []
|
|
|
|
let colCount = table.headers.count
|
|
guard colCount > 0 else { return }
|
|
let th = totalHeight
|
|
let ox = gridOriginX
|
|
let oy = gridOriginY
|
|
let gridWidth = columnX(for: colCount) + 1
|
|
let fullWidth = gridWidth + ox
|
|
let fullHeight = th + oy
|
|
|
|
frame.size = NSSize(width: fullWidth, height: fullHeight)
|
|
|
|
let headerBg = NSView(frame: NSRect(x: ox, y: th - rowHeights[0] + oy, width: gridWidth, height: rowHeights[0]))
|
|
headerBg.wantsLayer = true
|
|
headerBg.layer?.backgroundColor = Theme.current.surface0.cgColor
|
|
addSubview(headerBg)
|
|
|
|
var headerFields: [NSTextField] = []
|
|
for (col, header) in table.headers.enumerated() {
|
|
let x = columnX(for: col) + ox
|
|
let h = rowHeights[0]
|
|
let field = makeCell(text: header, frame: NSRect(x: x, y: th - h + 2 + oy, width: columnWidths[col], height: h - 4), isHeader: true, row: -1, col: col)
|
|
addSubview(field)
|
|
headerFields.append(field)
|
|
}
|
|
cellFields.append(headerFields)
|
|
|
|
for (rowIdx, row) in table.rows.enumerated() {
|
|
var rowFields: [NSTextField] = []
|
|
let y = rowY(for: rowIdx + 1) + oy
|
|
let h = rowHeights[rowIdx + 1]
|
|
for (col, cell) in row.enumerated() where col < colCount {
|
|
let x = columnX(for: col) + ox
|
|
let field = makeCell(text: cell, frame: NSRect(x: x, y: y + 2, width: columnWidths[col], height: h - 4), isHeader: false, row: rowIdx, col: col)
|
|
addSubview(field)
|
|
rowFields.append(field)
|
|
}
|
|
while rowFields.count < colCount {
|
|
let col = rowFields.count
|
|
let x = columnX(for: col) + ox
|
|
let field = makeCell(text: "", frame: NSRect(x: x, y: y + 2, width: columnWidths[col], height: h - 4), isHeader: false, row: rowIdx, col: col)
|
|
addSubview(field)
|
|
rowFields.append(field)
|
|
}
|
|
cellFields.append(rowFields)
|
|
}
|
|
|
|
let totalRows = 1 + table.rows.count
|
|
for i in 1..<colCount {
|
|
let x = columnX(for: i) - 1 + ox
|
|
let line = NSView(frame: NSRect(x: x, y: oy, width: 1, height: th))
|
|
line.wantsLayer = true
|
|
line.layer?.backgroundColor = Theme.current.surface2.cgColor
|
|
addSubview(line)
|
|
}
|
|
for i in 0..<totalRows {
|
|
let lineY = ((i == 0) ? th - rowHeights[0] : rowY(for: i)) + oy
|
|
let line = NSView(frame: NSRect(x: ox, y: lineY, width: gridWidth, height: 1))
|
|
line.wantsLayer = true
|
|
line.layer?.backgroundColor = Theme.current.surface2.cgColor
|
|
addSubview(line)
|
|
}
|
|
|
|
if chromeVisible {
|
|
layer?.borderWidth = 1
|
|
layer?.borderColor = Theme.current.surface2.cgColor
|
|
buildChrome()
|
|
} else {
|
|
layer?.borderWidth = 0
|
|
layer?.borderColor = nil
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// MARK: - Column letter helper
|
|
|
|
private func columnLetter(for index: Int) -> String {
|
|
var n = index
|
|
var result = ""
|
|
repeat {
|
|
result = String(UnicodeScalar(65 + (n % 26))!) + result
|
|
n = n / 26 - 1
|
|
} while n >= 0
|
|
return result
|
|
}
|
|
|
|
// MARK: - Chrome (indicators, handle, border — focus-only)
|
|
|
|
private func buildChrome() {
|
|
chromeContainer?.removeFromSuperview()
|
|
|
|
let container = NSView(frame: bounds)
|
|
container.wantsLayer = true
|
|
addSubview(container)
|
|
chromeContainer = container
|
|
|
|
let colCount = table.headers.count
|
|
let totalRows = 1 + table.rows.count
|
|
let topY = totalHeight + indicatorRowHeight
|
|
let indicatorBg = Theme.current.surface0
|
|
|
|
let corner = NSView(frame: NSRect(x: 0, y: topY - indicatorRowHeight, width: indicatorColWidth, height: indicatorRowHeight))
|
|
corner.wantsLayer = true
|
|
corner.layer?.backgroundColor = indicatorBg.cgColor
|
|
container.addSubview(corner)
|
|
|
|
// Drag handle dots in corner
|
|
let handle = NSView(frame: NSRect(x: 4, y: topY - indicatorRowHeight + 3, width: indicatorColWidth - 8, height: indicatorRowHeight - 6))
|
|
handle.wantsLayer = true
|
|
let handleLayer = CAShapeLayer()
|
|
let handlePath = CGMutablePath()
|
|
let dotSize: CGFloat = 2
|
|
let hW = handle.bounds.width
|
|
let hH = handle.bounds.height
|
|
for dotRow in 0..<3 {
|
|
for dotCol in 0..<2 {
|
|
let cx = (hW / 3) * CGFloat(dotCol + 1)
|
|
let cy = (hH / 4) * CGFloat(dotRow + 1)
|
|
handlePath.addEllipse(in: CGRect(x: cx - dotSize/2, y: cy - dotSize/2, width: dotSize, height: dotSize))
|
|
}
|
|
}
|
|
handleLayer.path = handlePath
|
|
handleLayer.fillColor = Theme.current.overlay1.cgColor
|
|
handle.layer?.addSublayer(handleLayer)
|
|
container.addSubview(handle)
|
|
dragHandleView = handle
|
|
|
|
for col in 0..<colCount {
|
|
let x = indicatorColWidth + columnX(for: col)
|
|
let btn = TableIndicatorButton(frame: NSRect(x: x, y: topY - indicatorRowHeight, width: columnWidths[col], height: indicatorRowHeight))
|
|
btn.label = columnLetter(for: col)
|
|
btn.bgColor = indicatorBg
|
|
btn.textColor = Theme.current.overlay2
|
|
btn.onPress = { [weak self] in self?.selectColumn(col) }
|
|
container.addSubview(btn)
|
|
}
|
|
|
|
for row in 0..<totalRows {
|
|
let y = rowY(for: row) + indicatorRowHeight
|
|
let btn = TableIndicatorButton(frame: NSRect(x: 0, y: y, width: indicatorColWidth, height: rowHeights[row]))
|
|
btn.label = "\(row + 1)"
|
|
btn.bgColor = indicatorBg
|
|
btn.textColor = Theme.current.overlay2
|
|
btn.onPress = { [weak self] in self?.selectRow(row) }
|
|
container.addSubview(btn)
|
|
}
|
|
}
|
|
|
|
private func showChrome() {
|
|
guard !chromeVisible else { return }
|
|
chromeVisible = true
|
|
buildGrid()
|
|
}
|
|
|
|
private func hideChrome() {
|
|
guard chromeVisible else { return }
|
|
chromeVisible = false
|
|
buildGrid()
|
|
}
|
|
|
|
private func setupFocusMonitoring() {
|
|
focusMonitor = NotificationCenter.default.addObserver(
|
|
forName: NSWindow.didUpdateNotification,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
self?.checkFocusState()
|
|
}
|
|
}
|
|
|
|
private func checkFocusState() {
|
|
guard let w = window else { return }
|
|
let fr = w.firstResponder
|
|
var hasFocusedCell = false
|
|
if let fieldEditor = fr as? NSTextView,
|
|
let editedCell = fieldEditor.delegate as AnyObject? {
|
|
hasFocusedCell = cellFields.joined().contains { $0 === editedCell }
|
|
} else {
|
|
hasFocusedCell = cellFields.joined().contains { $0 === fr }
|
|
}
|
|
if hasFocusedCell && !chromeVisible {
|
|
showChrome()
|
|
} else if !hasFocusedCell && chromeVisible && !mouseInside {
|
|
hideChrome()
|
|
}
|
|
}
|
|
|
|
private var mouseInside = false
|
|
|
|
// MARK: - Selection
|
|
|
|
private func selectColumn(_ col: Int) {
|
|
guard col < table.headers.count else { return }
|
|
for fieldRow in cellFields {
|
|
guard col < fieldRow.count else { continue }
|
|
fieldRow[col].selectText(nil)
|
|
}
|
|
if let first = cellFields.first, col < first.count {
|
|
window?.makeFirstResponder(first[col])
|
|
}
|
|
}
|
|
|
|
private func selectRow(_ row: Int) {
|
|
guard row < cellFields.count else { return }
|
|
let fields = cellFields[row]
|
|
for field in fields { field.selectText(nil) }
|
|
if let first = fields.first {
|
|
window?.makeFirstResponder(first)
|
|
}
|
|
}
|
|
|
|
private func selectAllCells() {
|
|
for fieldRow in cellFields {
|
|
for field in fieldRow { field.selectText(nil) }
|
|
}
|
|
if let first = cellFields.first?.first {
|
|
window?.makeFirstResponder(first)
|
|
}
|
|
}
|
|
|
|
// MARK: - Resize hit detection
|
|
|
|
private func columnDivider(at point: NSPoint) -> Int? {
|
|
let ox = gridOriginX
|
|
let colCount = table.headers.count
|
|
for i in 1..<colCount {
|
|
let divX = columnX(for: i) - 1 + ox
|
|
if abs(point.x - divX) <= dividerHitZone {
|
|
return i - 1
|
|
}
|
|
}
|
|
let lastX = columnX(for: colCount) + ox
|
|
if abs(point.x - lastX) <= dividerHitZone {
|
|
return colCount - 1
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func rowDivider(at point: NSPoint) -> Int? {
|
|
let oy = gridOriginY
|
|
let totalRows = 1 + table.rows.count
|
|
for i in 0..<totalRows {
|
|
let divY: CGFloat
|
|
if i == 0 {
|
|
divY = totalHeight - rowHeights[0] + oy
|
|
} else {
|
|
divY = rowY(for: i) + oy
|
|
}
|
|
if abs(point.y - divY) <= dividerHitZone {
|
|
return i
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func isInCorner(_ pt: NSPoint) -> Bool {
|
|
guard chromeVisible else { return false }
|
|
let topY = totalHeight + indicatorRowHeight
|
|
return pt.x < indicatorColWidth && pt.y > topY - indicatorRowHeight
|
|
}
|
|
|
|
private func isInDragHandle(_ pt: NSPoint) -> Bool {
|
|
guard chromeVisible, let hv = dragHandleView else { return false }
|
|
return hv.frame.contains(pt)
|
|
}
|
|
|
|
// MARK: - Tracking area
|
|
|
|
private func setupTrackingArea() {
|
|
for area in trackingAreas { removeTrackingArea(area) }
|
|
let area = NSTrackingArea(
|
|
rect: bounds,
|
|
options: [.mouseMoved, .mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect],
|
|
owner: self,
|
|
userInfo: nil
|
|
)
|
|
addTrackingArea(area)
|
|
}
|
|
|
|
override func mouseMoved(with event: NSEvent) {
|
|
let pt = convert(event.locationInWindow, from: nil)
|
|
if isInDragHandle(pt) {
|
|
NSCursor.openHand.set()
|
|
} else if columnDivider(at: pt) != nil {
|
|
NSCursor.resizeLeftRight.set()
|
|
} else if rowDivider(at: pt) != nil {
|
|
NSCursor.resizeUpDown.set()
|
|
} else {
|
|
NSCursor.arrow.set()
|
|
}
|
|
}
|
|
|
|
override func mouseEntered(with event: NSEvent) {
|
|
mouseInside = true
|
|
showChrome()
|
|
}
|
|
|
|
override func mouseExited(with event: NSEvent) {
|
|
NSCursor.arrow.set()
|
|
mouseInside = false
|
|
removeDragHighlight()
|
|
checkFocusState()
|
|
}
|
|
|
|
override func mouseDown(with event: NSEvent) {
|
|
if !chromeVisible {
|
|
mouseInside = true
|
|
showChrome()
|
|
}
|
|
let pt = convert(event.locationInWindow, from: nil)
|
|
|
|
if isInCorner(pt) {
|
|
selectAllCells()
|
|
return
|
|
}
|
|
|
|
if isInDragHandle(pt) {
|
|
dragMode = .move
|
|
dragStartPoint = convert(event.locationInWindow, from: nil)
|
|
NSCursor.closedHand.set()
|
|
return
|
|
}
|
|
|
|
if let col = columnDivider(at: pt) {
|
|
if event.clickCount == 2 {
|
|
autoFitColumn(col)
|
|
return
|
|
}
|
|
dragMode = .column(col)
|
|
dragStartPoint = pt
|
|
dragStartSize = columnWidths[col]
|
|
showColumnDragHighlight(col)
|
|
return
|
|
}
|
|
if let row = rowDivider(at: pt) {
|
|
if event.clickCount == 2 {
|
|
autoFitRow(row)
|
|
return
|
|
}
|
|
dragMode = .row(row)
|
|
dragStartPoint = pt
|
|
dragStartSize = rowHeights[row]
|
|
showRowDragHighlight(row)
|
|
return
|
|
}
|
|
dragMode = .none
|
|
super.mouseDown(with: event)
|
|
}
|
|
|
|
override func mouseDragged(with event: NSEvent) {
|
|
let pt = convert(event.locationInWindow, from: nil)
|
|
switch dragMode {
|
|
case .column(let col):
|
|
let delta = pt.x - dragStartPoint.x
|
|
columnWidths[col] = max(minColWidth, dragStartSize + delta)
|
|
buildGrid()
|
|
showColumnDragHighlight(col)
|
|
case .row(let row):
|
|
let delta = dragStartPoint.y - pt.y
|
|
rowHeights[row] = max(minRowHeight, dragStartSize + delta)
|
|
buildGrid()
|
|
showRowDragHighlight(row)
|
|
case .move:
|
|
let delta = NSPoint(x: pt.x - dragStartPoint.x, y: pt.y - dragStartPoint.y)
|
|
frame.origin.x += delta.x
|
|
frame.origin.y += delta.y
|
|
case .none:
|
|
super.mouseDragged(with: event)
|
|
}
|
|
}
|
|
|
|
override func mouseUp(with event: NSEvent) {
|
|
removeDragHighlight()
|
|
if case .move = dragMode {
|
|
NSCursor.openHand.set()
|
|
} else if case .none = dragMode {
|
|
super.mouseUp(with: event)
|
|
}
|
|
dragMode = .none
|
|
}
|
|
|
|
// MARK: - Resize drag indicators
|
|
|
|
private func showColumnDragHighlight(_ col: Int) {
|
|
removeDragHighlight()
|
|
let ox = gridOriginX
|
|
let oy = gridOriginY
|
|
let x = columnX(for: col + 1) - 1 + ox
|
|
let highlight = NSView(frame: NSRect(x: x - 1, y: oy, width: 3, height: totalHeight))
|
|
highlight.wantsLayer = true
|
|
highlight.layer?.backgroundColor = Theme.current.blue.withAlphaComponent(0.6).cgColor
|
|
addSubview(highlight)
|
|
dragHighlightView = highlight
|
|
}
|
|
|
|
private func showRowDragHighlight(_ row: Int) {
|
|
removeDragHighlight()
|
|
let ox = gridOriginX
|
|
let oy = gridOriginY
|
|
let colCount = table.headers.count
|
|
let gridWidth = columnX(for: colCount) + 1
|
|
let divY: CGFloat
|
|
if row == 0 {
|
|
divY = totalHeight - rowHeights[0] + oy
|
|
} else {
|
|
divY = rowY(for: row) + oy
|
|
}
|
|
let highlight = NSView(frame: NSRect(x: ox, y: divY - 1, width: gridWidth, height: 3))
|
|
highlight.wantsLayer = true
|
|
highlight.layer?.backgroundColor = Theme.current.blue.withAlphaComponent(0.6).cgColor
|
|
addSubview(highlight)
|
|
dragHighlightView = highlight
|
|
}
|
|
|
|
private func removeDragHighlight() {
|
|
dragHighlightView?.removeFromSuperview()
|
|
dragHighlightView = nil
|
|
}
|
|
|
|
// MARK: - Auto-fit
|
|
|
|
private func measureColumnWidth(_ col: Int) -> CGFloat {
|
|
let headerFont = NSFontManager.shared.convert(Theme.editorFont, toHaveTrait: .boldFontMask)
|
|
let cellFont = Theme.editorFont
|
|
|
|
var maxW: CGFloat = 0
|
|
if col < table.headers.count {
|
|
let w = (table.headers[col] as NSString).size(withAttributes: [.font: headerFont]).width
|
|
maxW = max(maxW, w)
|
|
}
|
|
for row in table.rows {
|
|
guard col < row.count else { continue }
|
|
let w = (row[col] as NSString).size(withAttributes: [.font: cellFont]).width
|
|
maxW = max(maxW, w)
|
|
}
|
|
return max(maxW + 16, minColWidth)
|
|
}
|
|
|
|
private func measureRowHeight(_ row: Int) -> CGFloat {
|
|
let font: NSFont = row == 0
|
|
? NSFontManager.shared.convert(Theme.editorFont, toHaveTrait: .boldFontMask)
|
|
: Theme.editorFont
|
|
let cells: [String] = row == 0 ? table.headers : (row - 1 < table.rows.count ? table.rows[row - 1] : [])
|
|
|
|
var maxH: CGFloat = 0
|
|
for (col, text) in cells.enumerated() where col < columnWidths.count {
|
|
let constrainedSize = NSSize(width: columnWidths[col], height: .greatestFiniteMagnitude)
|
|
let rect = (text as NSString).boundingRect(
|
|
with: constrainedSize,
|
|
options: [.usesLineFragmentOrigin],
|
|
attributes: [.font: font]
|
|
)
|
|
maxH = max(maxH, rect.height)
|
|
}
|
|
return max(maxH + 8, minRowHeight)
|
|
}
|
|
|
|
private func autoFitColumn(_ col: Int) {
|
|
guard col >= 0, col < columnWidths.count else { return }
|
|
columnWidths[col] = measureColumnWidth(col)
|
|
buildGrid()
|
|
}
|
|
|
|
private func autoFitRow(_ row: Int) {
|
|
guard row >= 0, row < rowHeights.count else { return }
|
|
rowHeights[row] = measureRowHeight(row)
|
|
buildGrid()
|
|
}
|
|
|
|
// MARK: - Export
|
|
|
|
override var acceptsFirstResponder: Bool { true }
|
|
|
|
override func keyDown(with event: NSEvent) {
|
|
if event.modifierFlags.contains(.control) && event.charactersIgnoringModifiers == "e" {
|
|
showExportDialog()
|
|
return
|
|
}
|
|
super.keyDown(with: event)
|
|
}
|
|
|
|
private func showExportDialog() {
|
|
let panel = NSSavePanel()
|
|
panel.allowedContentTypes = [
|
|
.init(filenameExtension: "md")!,
|
|
.init(filenameExtension: "csv")!
|
|
]
|
|
panel.nameFieldStringValue = "table"
|
|
panel.title = "Export Table"
|
|
panel.begin { [weak self] result in
|
|
guard result == .OK, let url = panel.url, let self = self else { return }
|
|
let ext = url.pathExtension.lowercased()
|
|
let content: String
|
|
if ext == "csv" {
|
|
content = self.exportCSV()
|
|
} else {
|
|
content = rebuildTableMarkdown(self.table)
|
|
}
|
|
try? content.write(to: url, atomically: true, encoding: .utf8)
|
|
}
|
|
}
|
|
|
|
private func exportCSV() -> String {
|
|
var lines: [String] = []
|
|
lines.append(table.headers.map { escapeCSV($0) }.joined(separator: ","))
|
|
for row in table.rows {
|
|
var cells = row
|
|
while cells.count < table.headers.count { cells.append("") }
|
|
lines.append(cells.map { escapeCSV($0) }.joined(separator: ","))
|
|
}
|
|
return lines.joined(separator: "\n")
|
|
}
|
|
|
|
private func escapeCSV(_ value: String) -> String {
|
|
if value.contains(",") || value.contains("\"") || value.contains("\n") {
|
|
return "\"" + value.replacingOccurrences(of: "\"", with: "\"\"") + "\""
|
|
}
|
|
return value
|
|
}
|
|
|
|
// MARK: - Cell editing
|
|
|
|
func controlTextDidBeginEditing(_ obj: Notification) {
|
|
if !chromeVisible {
|
|
showChrome()
|
|
}
|
|
}
|
|
|
|
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)
|
|
|
|
rowHeights.append(defaultCellHeight)
|
|
buildGrid()
|
|
needsLayout = true
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
|
|
// MARK: - Clickable indicator for column/row headers
|
|
|
|
private class TableIndicatorButton: NSView {
|
|
var label: String = ""
|
|
var bgColor: NSColor = Theme.current.surface0
|
|
var textColor: NSColor = Theme.current.overlay2
|
|
var onPress: (() -> Void)?
|
|
|
|
override init(frame: NSRect) {
|
|
super.init(frame: frame)
|
|
wantsLayer = true
|
|
}
|
|
required init?(coder: NSCoder) { fatalError() }
|
|
|
|
override func draw(_ dirtyRect: NSRect) {
|
|
bgColor.setFill()
|
|
dirtyRect.fill()
|
|
let attrs: [NSAttributedString.Key: Any] = [
|
|
.font: NSFont.systemFont(ofSize: 10, weight: .medium),
|
|
.foregroundColor: textColor
|
|
]
|
|
let str = NSAttributedString(string: label, attributes: attrs)
|
|
let size = str.size()
|
|
let pt = NSPoint(x: (bounds.width - size.width) / 2, y: (bounds.height - size.height) / 2)
|
|
str.draw(at: pt)
|
|
}
|
|
|
|
override func mouseDown(with event: NSEvent) {
|
|
layer?.backgroundColor = Theme.current.surface1.cgColor
|
|
}
|
|
|
|
override func mouseUp(with event: NSEvent) {
|
|
layer?.backgroundColor = bgColor.cgColor
|
|
let pt = convert(event.locationInWindow, from: nil)
|
|
if bounds.contains(pt) {
|
|
onPress?()
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
// MARK: - Editor View
|
|
|
|
struct EditorView: View {
|
|
@ObservedObject var state: AppState
|
|
|
|
private var bodyBinding: Binding<String> {
|
|
Binding(
|
|
get: {
|
|
let text = state.documentText
|
|
guard let firstNewline = text.firstIndex(of: "\n") else {
|
|
return ""
|
|
}
|
|
return String(text[text.index(after: firstNewline)...])
|
|
},
|
|
set: { newBody in
|
|
let lines = state.documentText.components(separatedBy: "\n")
|
|
let title = lines.first ?? ""
|
|
state.documentText = title + "\n" + newBody
|
|
}
|
|
)
|
|
}
|
|
|
|
var body: some View {
|
|
EditorTextView(
|
|
text: bodyBinding,
|
|
evalResults: offsetEvalResults(state.evalResults),
|
|
fileFormat: state.currentFileFormat,
|
|
onEvaluate: { state.evaluate() },
|
|
onBackspaceAtStart: {
|
|
NotificationCenter.default.post(name: .focusTitle, object: nil)
|
|
}
|
|
)
|
|
.padding(.top, 4)
|
|
}
|
|
|
|
private func offsetEvalResults(_ results: [Int: EvalEntry]) -> [Int: EvalEntry] {
|
|
var shifted: [Int: EvalEntry] = [:]
|
|
for (key, val) in results where key > 0 {
|
|
shifted[key - 1] = val
|
|
}
|
|
return shifted
|
|
}
|
|
}
|
|
|
|
struct EditorTextView: NSViewRepresentable {
|
|
@Binding var text: String
|
|
var evalResults: [Int: EvalEntry]
|
|
var fileFormat: FileFormat = .markdown
|
|
var onEvaluate: () -> Void
|
|
var onBackspaceAtStart: (() -> Void)? = nil
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
Coordinator(self)
|
|
}
|
|
|
|
func makeNSView(context: Context) -> NSScrollView {
|
|
let scrollView = NSTextView.scrollableTextView()
|
|
let defaultTV = scrollView.documentView as! NSTextView
|
|
|
|
// Build LineNumberTextView reusing the default text container
|
|
let tc = defaultTV.textContainer!
|
|
tc.replaceLayoutManager(MarkdownLayoutManager())
|
|
|
|
let textView = LineNumberTextView(frame: defaultTV.frame, textContainer: tc)
|
|
textView.minSize = defaultTV.minSize
|
|
textView.maxSize = defaultTV.maxSize
|
|
textView.isVerticallyResizable = defaultTV.isVerticallyResizable
|
|
textView.isHorizontallyResizable = defaultTV.isHorizontallyResizable
|
|
textView.autoresizingMask = defaultTV.autoresizingMask
|
|
|
|
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.textContainerInset = NSSize(width: 4, height: 8)
|
|
textView.textContainer?.widthTracksTextView = false
|
|
textView.registerForDraggedTypes([.fileURL])
|
|
|
|
scrollView.documentView = textView
|
|
|
|
textView.string = text
|
|
textView.evalResults = evalResults
|
|
textView.delegate = context.coordinator
|
|
context.coordinator.textView = textView
|
|
|
|
if let ts = textView.textStorage {
|
|
ts.beginEditing()
|
|
applySyntaxHighlighting(to: ts, format: fileFormat)
|
|
ts.endEditing()
|
|
}
|
|
textView.applyEvalSpacing()
|
|
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, format: fileFormat)
|
|
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
|
|
]
|
|
|
|
textView.evalResults = evalResults
|
|
textView.needsDisplay = true
|
|
}
|
|
|
|
class Coordinator: NSObject, NSTextViewDelegate {
|
|
var parent: EditorTextView
|
|
weak var textView: NSTextView?
|
|
private var isUpdatingImages = false
|
|
private var isUpdatingTables = false
|
|
private var embeddedTableViews: [MarkdownTableView] = []
|
|
private var observers: [NSObjectProtocol] = []
|
|
|
|
init(_ parent: EditorTextView) {
|
|
self.parent = parent
|
|
super.init()
|
|
|
|
observers.append(NotificationCenter.default.addObserver(
|
|
forName: .focusEditor, object: nil, queue: .main
|
|
) { [weak self] _ in
|
|
guard let tv = self?.textView else { return }
|
|
tv.window?.makeFirstResponder(tv)
|
|
tv.setSelectedRange(NSRange(location: 0, length: 0))
|
|
})
|
|
observers.append(NotificationCenter.default.addObserver(
|
|
forName: .formatDocument, object: nil, queue: .main
|
|
) { [weak self] _ in
|
|
self?.formatCurrentDocument()
|
|
})
|
|
observers.append(NotificationCenter.default.addObserver(
|
|
forName: .settingsChanged, object: nil, queue: .main
|
|
) { [weak self] _ in
|
|
guard let tv = self?.textView, let ts = tv.textStorage else { return }
|
|
let palette = Theme.current
|
|
tv.backgroundColor = palette.base
|
|
tv.insertionPointColor = palette.text
|
|
tv.selectedTextAttributes = [.backgroundColor: palette.surface1]
|
|
tv.typingAttributes = [
|
|
.font: Theme.editorFont,
|
|
.foregroundColor: palette.text
|
|
]
|
|
ts.beginEditing()
|
|
applySyntaxHighlighting(to: ts, format: parent.fileFormat)
|
|
ts.endEditing()
|
|
(tv as? LineNumberTextView)?.applyEvalSpacing()
|
|
tv.needsDisplay = true
|
|
})
|
|
observers.append(NotificationCenter.default.addObserver(
|
|
forName: .boldSelection, object: nil, queue: .main
|
|
) { [weak self] _ in
|
|
self?.wrapSelection(with: "**")
|
|
})
|
|
observers.append(NotificationCenter.default.addObserver(
|
|
forName: .italicizeSelection, object: nil, queue: .main
|
|
) { [weak self] _ in
|
|
self?.wrapSelection(with: "*")
|
|
})
|
|
observers.append(NotificationCenter.default.addObserver(
|
|
forName: .insertTable, object: nil, queue: .main
|
|
) { [weak self] _ in
|
|
self?.insertBlankTable()
|
|
})
|
|
observers.append(NotificationCenter.default.addObserver(
|
|
forName: .smartEval, object: nil, queue: .main
|
|
) { [weak self] _ in
|
|
self?.performSmartEval()
|
|
})
|
|
}
|
|
|
|
deinit {
|
|
for obs in observers {
|
|
NotificationCenter.default.removeObserver(obs)
|
|
}
|
|
}
|
|
|
|
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, format: parent.fileFormat)
|
|
ts.endEditing()
|
|
(tv as? LineNumberTextView)?.applyEvalSpacing()
|
|
tv.typingAttributes = [
|
|
.font: Theme.editorFont,
|
|
.foregroundColor: Theme.current.text
|
|
]
|
|
tv.selectedRanges = sel
|
|
updateBlockRanges(for: tv)
|
|
tv.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(_:)) {
|
|
insertNewlineWithAutoIndent(textView)
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.parent.onEvaluate()
|
|
}
|
|
return true
|
|
}
|
|
if commandSelector == #selector(NSResponder.deleteBackward(_:)) {
|
|
if textView.selectedRange().location == 0 && textView.selectedRange().length == 0 {
|
|
parent.onBackspaceAtStart?()
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func textView(_ textView: NSTextView, shouldChangeTextIn range: NSRange, replacementString text: String?) -> Bool {
|
|
guard let text = text, text.count == 1 else { return true }
|
|
let ch = text.first!
|
|
let hasSelection = range.length > 0
|
|
|
|
// Skip over matching closer when cursor is right before it
|
|
if !hasSelection {
|
|
let closerChars: Set<Character> = ["}", ")", "]", "\"", "'"]
|
|
if closerChars.contains(ch) {
|
|
let str = textView.string as NSString
|
|
if range.location < str.length {
|
|
let next = Character(UnicodeScalar(str.character(at: range.location))!)
|
|
if next == ch {
|
|
textView.setSelectedRange(NSRange(location: range.location + 1, length: 0))
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
let pairClosers: [Character: Character] = ["{": "}", "(": ")", "[": "]"]
|
|
if let close = pairClosers[ch] {
|
|
if hasSelection {
|
|
let selected = (textView.string as NSString).substring(with: range)
|
|
textView.insertText(String(ch) + selected + String(close), replacementRange: range)
|
|
textView.setSelectedRange(NSRange(location: range.location + 1, length: selected.count))
|
|
} else {
|
|
textView.insertText(String(ch) + String(close), replacementRange: range)
|
|
textView.setSelectedRange(NSRange(location: range.location + 1, length: 0))
|
|
}
|
|
return false
|
|
}
|
|
|
|
if ch == "\"" || ch == "'" {
|
|
if hasSelection {
|
|
let selected = (textView.string as NSString).substring(with: range)
|
|
textView.insertText(String(ch) + selected + String(ch), replacementRange: range)
|
|
textView.setSelectedRange(NSRange(location: range.location + 1, length: selected.count))
|
|
} else {
|
|
textView.insertText(String(ch) + String(ch), replacementRange: range)
|
|
textView.setSelectedRange(NSRange(location: range.location + 1, length: 0))
|
|
}
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
private func formatCurrentDocument() {
|
|
guard let tv = textView else { return }
|
|
let format = parent.fileFormat
|
|
let text = tv.string
|
|
|
|
var formatted: String?
|
|
switch format {
|
|
case .json:
|
|
formatted = formatJSON(text)
|
|
default:
|
|
if format.isCode {
|
|
formatted = normalizeIndentation(text)
|
|
}
|
|
}
|
|
|
|
if let result = formatted, result != text {
|
|
let sel = tv.selectedRanges
|
|
tv.string = result
|
|
parent.text = result
|
|
if let ts = tv.textStorage {
|
|
ts.beginEditing()
|
|
applySyntaxHighlighting(to: ts, format: format)
|
|
ts.endEditing()
|
|
}
|
|
(tv as? LineNumberTextView)?.applyEvalSpacing()
|
|
tv.selectedRanges = sel
|
|
tv.needsDisplay = true
|
|
}
|
|
}
|
|
|
|
private func formatJSON(_ text: String) -> String? {
|
|
guard let data = text.data(using: .utf8),
|
|
let obj = try? JSONSerialization.jsonObject(with: data),
|
|
let pretty = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted, .sortedKeys]),
|
|
let result = String(data: pretty, encoding: .utf8) else { return nil }
|
|
return result
|
|
}
|
|
|
|
private func normalizeIndentation(_ text: String) -> String {
|
|
let lines = text.components(separatedBy: "\n")
|
|
var result: [String] = []
|
|
var depth = 0
|
|
|
|
for line in lines {
|
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
|
if trimmed.isEmpty {
|
|
result.append("")
|
|
continue
|
|
}
|
|
|
|
let opens = trimmed.filter { "{([".contains($0) }.count
|
|
let closes = trimmed.filter { "})]".contains($0) }.count
|
|
let delta = opens - closes
|
|
|
|
if delta < 0 { depth = max(0, depth + delta) }
|
|
|
|
let indent = String(repeating: " ", count: depth)
|
|
result.append(indent + trimmed)
|
|
|
|
if delta > 0 { depth += delta }
|
|
}
|
|
|
|
return result.joined(separator: "\n")
|
|
}
|
|
|
|
private func insertNewlineWithAutoIndent(_ textView: NSTextView) {
|
|
let str = textView.string as NSString
|
|
let cursor = textView.selectedRange().location
|
|
let lineRange = str.lineRange(for: NSRange(location: cursor, length: 0))
|
|
let currentLine = str.substring(with: lineRange)
|
|
|
|
var indent = ""
|
|
for c in currentLine {
|
|
if c == " " || c == "\t" { indent.append(c) }
|
|
else { break }
|
|
}
|
|
|
|
let trimmed = currentLine.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let shouldIndent = trimmed.hasSuffix("{") || trimmed.hasSuffix(":") ||
|
|
trimmed.hasSuffix("do") || trimmed.hasSuffix("then") ||
|
|
trimmed.hasSuffix("(") || trimmed.hasSuffix("[")
|
|
|
|
if shouldIndent {
|
|
indent += " "
|
|
}
|
|
|
|
let charBeforeCursor: Character? = cursor > 0 ? Character(UnicodeScalar(str.character(at: cursor - 1))!) : nil
|
|
let charAtCursor: Character? = cursor < str.length ? Character(UnicodeScalar(str.character(at: cursor))!) : nil
|
|
|
|
// Between matching pairs: insert extra newline
|
|
if let before = charBeforeCursor, let after = charAtCursor,
|
|
(before == "{" && after == "}") || (before == "(" && after == ")") || (before == "[" && after == "]") {
|
|
let baseIndent = indent.count >= 4 ? String(indent.dropLast(4)) : ""
|
|
let insertion = "\n" + indent + "\n" + baseIndent
|
|
textView.insertText(insertion, replacementRange: textView.selectedRange())
|
|
textView.setSelectedRange(NSRange(location: cursor + 1 + indent.count, length: 0))
|
|
return
|
|
}
|
|
|
|
textView.insertText("\n" + indent, replacementRange: textView.selectedRange())
|
|
}
|
|
|
|
private func wrapSelection(with wrapper: String) {
|
|
guard let tv = textView else { return }
|
|
let sel = tv.selectedRange()
|
|
guard sel.length > 0 else { return }
|
|
let str = tv.string as NSString
|
|
let selected = str.substring(with: sel)
|
|
let wrapped = wrapper + selected + wrapper
|
|
tv.insertText(wrapped, replacementRange: sel)
|
|
tv.setSelectedRange(NSRange(location: sel.location + wrapper.count, length: selected.count))
|
|
}
|
|
|
|
private func insertBlankTable() {
|
|
guard let tv = textView else { return }
|
|
let table = "| Column 1 | Column 2 | Column 3 |\n| --- | --- | --- |\n| | | |\n"
|
|
tv.insertText(table, replacementRange: tv.selectedRange())
|
|
}
|
|
|
|
private func performSmartEval() {
|
|
guard let tv = textView else { return }
|
|
let str = tv.string as NSString
|
|
let cursor = tv.selectedRange().location
|
|
let lineRange = str.lineRange(for: NSRange(location: cursor, length: 0))
|
|
let line = str.substring(with: lineRange).trimmingCharacters(in: .newlines)
|
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
|
|
|
if trimmed.hasPrefix("let ") {
|
|
if let eqIdx = trimmed.firstIndex(of: "="), eqIdx > trimmed.startIndex {
|
|
let afterLet = trimmed[trimmed.index(trimmed.startIndex, offsetBy: 4)..<eqIdx]
|
|
.trimmingCharacters(in: .whitespaces)
|
|
let insertion = "\n/= \(afterLet)\n"
|
|
let endOfLine = NSMaxRange(lineRange)
|
|
tv.insertText(insertion, replacementRange: NSRange(location: endOfLine, length: 0))
|
|
}
|
|
} else if !trimmed.isEmpty {
|
|
let lineStart = lineRange.location
|
|
let whitespacePrefix = line.prefix(while: { $0 == " " || $0 == "\t" })
|
|
let insertLoc = lineStart + whitespacePrefix.count
|
|
tv.insertText("/= ", replacementRange: NSRange(location: insertLoc, length: 0))
|
|
}
|
|
}
|
|
|
|
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, format: parent.fileFormat)
|
|
ts.endEditing()
|
|
(tv as? LineNumberTextView)?.applyEvalSpacing()
|
|
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
|
|
|
|
let fullRange = NSRange(location: 0, length: text.length)
|
|
lm.invalidateDisplay(forCharacterRange: fullRange)
|
|
}
|
|
|
|
// MARK: - Syntax Highlighting
|
|
|
|
private let syntaxKeywords: Set<String> = [
|
|
"let", "fn", "if", "else", "for", "while", "return", "mut", "in",
|
|
"map", "cast", "plot", "sch"
|
|
]
|
|
|
|
private let syntaxTypes: Set<String> = [
|
|
"bool", "int", "float", "str", "i32", "f64", "Vec", "String"
|
|
]
|
|
|
|
private let syntaxBooleans: Set<String> = ["true", "false"]
|
|
|
|
private let syntaxOperatorChars = CharacterSet(charactersIn: "+-*/=^<>!(){}[]:,.&|%")
|
|
|
|
func applySyntaxHighlighting(to textStorage: NSTextStorage, format: FileFormat = .markdown) {
|
|
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)
|
|
|
|
if format.isCode {
|
|
applyCodeFileHighlighting(to: textStorage, syn: syn, baseFont: baseFont, format: format)
|
|
return
|
|
}
|
|
|
|
let fencedRanges = highlightFencedCodeBlocks(textStorage: textStorage, palette: palette, baseFont: baseFont)
|
|
let tableHeaderLines = findTableHeaderLines(textStorage: textStorage, fencedRanges: fencedRanges)
|
|
|
|
let nsText = text as NSString
|
|
var lineStart = 0
|
|
var braceDepth = 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)
|
|
|
|
let inBlock = braceDepth > 0
|
|
|
|
if isCordialLine(trimmed) || inBlock {
|
|
let opens = trimmed.filter { $0 == "{" }.count
|
|
let closes = trimmed.filter { $0 == "}" }.count
|
|
braceDepth += opens - closes
|
|
if braceDepth < 0 { braceDepth = 0 }
|
|
highlightCodeLine(line, lineRange: lineRange, textStorage: textStorage, syn: syn)
|
|
} else 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
|
|
}
|
|
|
|
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 applyCodeFileHighlighting(to textStorage: NSTextStorage, syn: Theme.SyntaxColors, baseFont: NSFont, format: FileFormat = .unknown) {
|
|
let text = textStorage.string
|
|
|
|
if let lang = format.treeSitterLang {
|
|
let spans = RustBridge.shared.highlight(source: text, lang: lang)
|
|
if !spans.isEmpty {
|
|
applyTreeSitterHighlighting(spans: spans, textStorage: textStorage, syn: syn, baseFont: baseFont)
|
|
return
|
|
}
|
|
}
|
|
|
|
let nsText = text as NSString
|
|
var lineStart = 0
|
|
while lineStart < nsText.length {
|
|
let lineRange = nsText.lineRange(for: NSRange(location: lineStart, length: 0))
|
|
let line = nsText.substring(with: lineRange)
|
|
highlightCodeLine(line, lineRange: lineRange, textStorage: textStorage, syn: syn)
|
|
lineStart = NSMaxRange(lineRange)
|
|
}
|
|
highlightBlockComments(textStorage: textStorage, syn: syn, baseFont: baseFont)
|
|
}
|
|
|
|
private func applyTreeSitterHighlighting(spans: [RustBridge.HighlightSpan], textStorage: NSTextStorage, syn: Theme.SyntaxColors, baseFont: NSFont, offset: Int = 0) {
|
|
let palette = Theme.current
|
|
let italicFont = NSFontManager.shared.convert(baseFont, toHaveTrait: .italicFontMask)
|
|
let textLen = (textStorage.string as NSString).length
|
|
|
|
// When offset > 0, spans are byte-relative to a substring starting at `offset` chars.
|
|
// Extract that substring and convert bytes to chars within it.
|
|
let sourceStr: String
|
|
if offset > 0 {
|
|
sourceStr = (textStorage.string as NSString).substring(from: offset)
|
|
} else {
|
|
sourceStr = textStorage.string
|
|
}
|
|
let sourceBytes = Array(sourceStr.utf8)
|
|
|
|
for span in spans {
|
|
guard span.start < sourceBytes.count && span.end <= sourceBytes.count && span.start < span.end else { continue }
|
|
|
|
// Convert byte offsets to character offsets within sourceStr
|
|
let prefix = sourceStr.utf8.prefix(span.start)
|
|
let charStart = sourceStr.distance(from: sourceStr.startIndex, to: sourceStr.utf8.index(sourceStr.utf8.startIndex, offsetBy: prefix.count))
|
|
let endPrefix = sourceStr.utf8.prefix(span.end)
|
|
let charEnd = sourceStr.distance(from: sourceStr.startIndex, to: sourceStr.utf8.index(sourceStr.utf8.startIndex, offsetBy: endPrefix.count))
|
|
|
|
let absStart = offset + charStart
|
|
let absEnd = offset + charEnd
|
|
guard absStart < textLen && absEnd <= textLen && absStart < absEnd else { continue }
|
|
let range = NSRange(location: absStart, length: absEnd - absStart)
|
|
|
|
let color: NSColor
|
|
var font: NSFont? = nil
|
|
|
|
switch span.kind {
|
|
case 0: color = syn.keyword
|
|
case 1: color = syn.function
|
|
case 2: color = syn.function
|
|
case 3: color = syn.type
|
|
case 4: color = syn.type
|
|
case 5: color = syn.type
|
|
case 6: color = palette.peach
|
|
case 7: color = palette.peach
|
|
case 8: color = syn.string
|
|
case 9: color = syn.number
|
|
case 10: color = syn.comment; font = italicFont
|
|
case 11: color = palette.text
|
|
case 12: color = palette.red
|
|
case 13: color = palette.maroon
|
|
case 14: color = syn.operator
|
|
case 15: color = palette.overlay2
|
|
case 16: color = palette.overlay2
|
|
case 17: color = palette.overlay2
|
|
case 18: color = palette.teal
|
|
case 19: color = palette.red
|
|
case 20: color = palette.yellow
|
|
case 21: color = palette.sapphire
|
|
case 22: color = palette.pink
|
|
case 23: color = palette.teal
|
|
default: continue
|
|
}
|
|
|
|
textStorage.addAttribute(.foregroundColor, value: color, range: range)
|
|
if let f = font {
|
|
textStorage.addAttribute(.font, value: f, range: range)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 h3Font = NSFont.systemFont(ofSize: round(baseFont.pointSize * 1.15), weight: .bold)
|
|
textStorage.addAttribute(.font, value: h3Font, range: contentRange)
|
|
textStorage.addAttribute(.foregroundColor, value: palette.text, 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 h2Font = NSFont.systemFont(ofSize: round(baseFont.pointSize * 1.38), weight: .bold)
|
|
textStorage.addAttribute(.font, value: h2Font, range: contentRange)
|
|
textStorage.addAttribute(.foregroundColor, value: palette.text, 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: round(baseFont.pointSize * 1.69), weight: .bold)
|
|
textStorage.addAttribute(.font, value: h1Font, range: contentRange)
|
|
textStorage.addAttribute(.foregroundColor, value: palette.text, 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
|
|
}
|
|
|
|
if isHorizontalRule(trimmed) {
|
|
textStorage.addAttribute(.foregroundColor, value: palette.base, range: lineRange)
|
|
textStorage.addAttribute(.font, value: NSFont.systemFont(ofSize: 0.01), 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
|
|
}
|
|
|
|
if trimmed.hasPrefix("/=|") || trimmed.hasPrefix("/=\\") {
|
|
let prefix = trimmed.hasPrefix("/=|") ? "/=|" : "/=\\"
|
|
let prefixRange = (textStorage.string as NSString).range(of: prefix, range: lineRange)
|
|
if prefixRange.location != NSNotFound {
|
|
textStorage.addAttribute(.foregroundColor, value: syn.operator, range: prefixRange)
|
|
}
|
|
} else 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)
|
|
textStorage.addAttribute(.foregroundColor, value: palette.text, 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)
|
|
|
|
var prevIdent: String? = nil
|
|
|
|
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)
|
|
prevIdent = nil
|
|
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)
|
|
prevIdent = nil
|
|
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)
|
|
} else if syntaxBooleans.contains(ident) {
|
|
textStorage.addAttribute(.foregroundColor, value: syn.boolean, range: absRange)
|
|
} else if syntaxTypes.contains(ident) {
|
|
textStorage.addAttribute(.foregroundColor, value: syn.type, range: absRange)
|
|
} else if prevIdent == "fn" {
|
|
textStorage.addAttribute(.foregroundColor, value: syn.function, range: absRange)
|
|
} else {
|
|
// Check if followed by '(' -> function call
|
|
let remaining = sub[scanner.currentIndex...]
|
|
let trimmedRest = remaining.drop(while: { $0 == " " })
|
|
if trimmedRest.first == "(" {
|
|
textStorage.addAttribute(.foregroundColor, value: syn.function, range: absRange)
|
|
}
|
|
}
|
|
}
|
|
prevIdent = ident
|
|
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)
|
|
prevIdent = nil
|
|
continue
|
|
}
|
|
|
|
// Skip unrecognized character (preserve prevIdent across whitespace)
|
|
let ch = sub[scanner.currentIndex]
|
|
if !ch.isWhitespace { prevIdent = nil }
|
|
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)
|
|
let len = nsText.length
|
|
guard len >= 4 else { return }
|
|
|
|
var i = 0
|
|
while i < len - 1 {
|
|
if nsText.character(at: i) == 0x2F && nsText.character(at: i + 1) == 0x2A { // /*
|
|
let start = i
|
|
var depth = 1
|
|
i += 2
|
|
while i < len - 1 && depth > 0 {
|
|
let c = nsText.character(at: i)
|
|
let n = nsText.character(at: i + 1)
|
|
if c == 0x2F && n == 0x2A { // /*
|
|
depth += 1
|
|
i += 2
|
|
} else if c == 0x2A && n == 0x2F { // */
|
|
depth -= 1
|
|
i += 2
|
|
} else {
|
|
i += 1
|
|
}
|
|
}
|
|
if i > len { i = len }
|
|
let range = NSRange(location: start, length: i - start)
|
|
textStorage.addAttributes([
|
|
.foregroundColor: syn.comment,
|
|
.font: italicFont
|
|
], range: range)
|
|
} else {
|
|
i += 1
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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)
|
|
let syn = Theme.syntax
|
|
var fencedRanges: [NSRange] = []
|
|
var lineStart = 0
|
|
var openFence: Int? = nil
|
|
var fenceLang: String? = nil
|
|
var codeStart: Int = 0
|
|
|
|
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
|
|
codeStart = NSMaxRange(lineRange)
|
|
let langId = String(trimmed.dropFirst(3)).trimmingCharacters(in: .whitespaces)
|
|
fenceLang = langId.isEmpty ? nil : langId.lowercased()
|
|
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: lineRange)
|
|
textStorage.addAttribute(.font, value: monoFont, range: lineRange)
|
|
}
|
|
} else {
|
|
if trimmed == "```" {
|
|
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)
|
|
|
|
// Apply tree-sitter highlighting to the code content
|
|
let codeEnd = lineRange.location
|
|
if let lang = fenceLang, codeEnd > codeStart {
|
|
let codeRange = NSRange(location: codeStart, length: codeEnd - codeStart)
|
|
let code = nsText.substring(with: codeRange)
|
|
textStorage.addAttribute(.font, value: monoFont, range: codeRange)
|
|
let spans = RustBridge.shared.highlight(source: code, lang: lang)
|
|
if !spans.isEmpty {
|
|
applyTreeSitterHighlighting(spans: spans, textStorage: textStorage, syn: syn, baseFont: monoFont, offset: codeStart)
|
|
} else {
|
|
textStorage.addAttribute(.foregroundColor, value: palette.text, range: codeRange)
|
|
}
|
|
}
|
|
|
|
openFence = nil
|
|
fenceLang = nil
|
|
} else {
|
|
textStorage.addAttribute(.font, value: monoFont, range: lineRange)
|
|
textStorage.addAttribute(.foregroundColor, value: palette.text, 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(.foregroundColor, value: palette.text, 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) {
|
|
textStorage.addAttribute(.foregroundColor, value: palette.base, range: lineRange)
|
|
textStorage.addAttribute(.font, value: NSFont.systemFont(ofSize: 0.01), range: lineRange)
|
|
}
|
|
|
|
// 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 isCordialLine(_ trimmed: String) -> Bool {
|
|
if trimmed.isEmpty { return false }
|
|
if trimmed.hasPrefix("/=") { return true }
|
|
if trimmed.hasPrefix("//") { return true }
|
|
if trimmed.hasPrefix("/*") { return true }
|
|
if trimmed.hasPrefix("let ") { return trimmed.contains("=") }
|
|
if trimmed.hasPrefix("fn ") { return true }
|
|
if trimmed.hasPrefix("while ") || trimmed.hasPrefix("while(") { return true }
|
|
if trimmed.hasPrefix("if ") || trimmed.hasPrefix("if(") { return true }
|
|
if trimmed.hasPrefix("else ") || trimmed == "else" || trimmed.hasPrefix("else{") { return true }
|
|
if trimmed.hasPrefix("for ") { return true }
|
|
if trimmed.hasPrefix("return ") || trimmed == "return" { return true }
|
|
if trimmed == "}" || trimmed.hasPrefix("} ") { return true }
|
|
guard let eqIdx = trimmed.firstIndex(of: "=") else { return false }
|
|
if eqIdx == trimmed.startIndex { return false }
|
|
let after = trimmed.index(after: eqIdx)
|
|
if after < trimmed.endIndex && trimmed[after] == "=" { return false }
|
|
let before = trimmed[trimmed.index(before: eqIdx)]
|
|
if before == "!" || before == "<" || before == ">" { return false }
|
|
return true
|
|
}
|
|
|
|
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 {
|
|
static let gutterWidth: CGFloat = 50
|
|
static let evalLeftMargin: CGFloat = 80
|
|
|
|
var evalResults: [Int: EvalEntry] = [:] {
|
|
didSet { applyEvalSpacing() }
|
|
}
|
|
|
|
override var textContainerOrigin: NSPoint {
|
|
return NSPoint(x: LineNumberTextView.gutterWidth, y: textContainerInset.height)
|
|
}
|
|
|
|
override func setFrameSize(_ newSize: NSSize) {
|
|
super.setFrameSize(newSize)
|
|
let insetWidth = textContainerInset.width
|
|
textContainer?.size.width = newSize.width - LineNumberTextView.gutterWidth - insetWidth
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
override func drawBackground(in rect: NSRect) {
|
|
super.drawBackground(in: rect)
|
|
|
|
let origin = textContainerOrigin
|
|
let gutterRect = NSRect(x: 0, y: rect.origin.y, width: LineNumberTextView.gutterWidth, height: rect.height)
|
|
let lineMode = UserDefaults.standard.string(forKey: "lineIndicatorMode") ?? "on"
|
|
(lineMode == "off" ? Theme.current.base : Theme.current.mantle).setFill()
|
|
gutterRect.fill()
|
|
|
|
drawLineNumbers(origin: origin)
|
|
}
|
|
|
|
private func drawLineNumbers(origin: NSPoint) {
|
|
guard let lm = layoutManager, let tc = textContainer else { return }
|
|
let palette = Theme.current
|
|
let text = string as NSString
|
|
guard text.length > 0 else { return }
|
|
|
|
let lineMode = ConfigManager.shared.lineIndicatorMode
|
|
|
|
var containerVisible = visibleRect
|
|
containerVisible.origin.x -= origin.x
|
|
containerVisible.origin.y -= origin.y
|
|
let visibleGlyphs = lm.glyphRange(forBoundingRect: containerVisible, in: tc)
|
|
let visibleChars = lm.characterRange(forGlyphRange: visibleGlyphs, actualGlyphRange: nil)
|
|
|
|
var lineNumber = 1
|
|
var idx = 0
|
|
while idx < visibleChars.location {
|
|
if text.character(at: idx) == 0x0A { lineNumber += 1 }
|
|
idx += 1
|
|
}
|
|
|
|
var cursorLine = 1
|
|
if lineMode == "vim" {
|
|
let cursorPos = selectedRange().location
|
|
var ci = 0
|
|
while ci < min(cursorPos, text.length) {
|
|
if text.character(at: ci) == 0x0A { cursorLine += 1 }
|
|
ci += 1
|
|
}
|
|
}
|
|
|
|
let lineAttrs: [NSAttributedString.Key: Any] = [
|
|
.font: Theme.gutterFont,
|
|
.foregroundColor: palette.overlay0
|
|
]
|
|
let currentLineAttrs: [NSAttributedString.Key: Any] = [
|
|
.font: Theme.gutterFont,
|
|
.foregroundColor: palette.text
|
|
]
|
|
let resultAttrs: [NSAttributedString.Key: Any] = [
|
|
.font: Theme.gutterFont,
|
|
.foregroundColor: palette.teal
|
|
]
|
|
|
|
var charIndex = visibleChars.location
|
|
while charIndex < NSMaxRange(visibleChars) {
|
|
let lineRange = text.lineRange(for: NSRange(location: charIndex, length: 0))
|
|
let lineGlyphRange = lm.glyphRange(forCharacterRange: lineRange, actualCharacterRange: nil)
|
|
let lineRect = lm.boundingRect(forGlyphRange: lineGlyphRange, in: tc)
|
|
let y = lineRect.origin.y + origin.y
|
|
|
|
if lineMode != "off" {
|
|
let displayNum: Int
|
|
let attrs: [NSAttributedString.Key: Any]
|
|
if lineMode == "vim" {
|
|
if lineNumber == cursorLine {
|
|
displayNum = lineNumber
|
|
attrs = currentLineAttrs
|
|
} else {
|
|
displayNum = abs(lineNumber - cursorLine)
|
|
attrs = lineAttrs
|
|
}
|
|
} else {
|
|
displayNum = lineNumber
|
|
attrs = lineAttrs
|
|
}
|
|
let numStr = NSAttributedString(string: "\(displayNum)", attributes: attrs)
|
|
let numSize = numStr.size()
|
|
numStr.draw(at: NSPoint(x: LineNumberTextView.gutterWidth - numSize.width - 8, y: y))
|
|
}
|
|
|
|
if let entry = evalResults[lineNumber - 1] {
|
|
switch entry.format {
|
|
case .table:
|
|
drawTableResult(entry.result, lineRect: lineRect, origin: origin, resultAttrs: resultAttrs)
|
|
case .tree:
|
|
drawTreeResult(entry.result, lineRect: lineRect, origin: origin, resultAttrs: resultAttrs)
|
|
case .inline:
|
|
let resultStr = NSAttributedString(string: "\u{2192} \(entry.result)", attributes: resultAttrs)
|
|
let size = resultStr.size()
|
|
let rightEdge = visibleRect.maxX
|
|
resultStr.draw(at: NSPoint(x: rightEdge - size.width - 8, y: y))
|
|
}
|
|
}
|
|
|
|
lineNumber += 1
|
|
charIndex = NSMaxRange(lineRange)
|
|
}
|
|
}
|
|
|
|
// MARK: - Table/Tree Rendering
|
|
|
|
private func drawTableResult(_ json: String, lineRect: NSRect, origin: NSPoint, resultAttrs: [NSAttributedString.Key: Any]) {
|
|
guard let data = json.data(using: .utf8),
|
|
let parsed = try? JSONSerialization.jsonObject(with: data),
|
|
let rows = parsed as? [[Any]] else {
|
|
let fallback = NSAttributedString(string: "\u{2192} \(json.prefix(40))", attributes: resultAttrs)
|
|
let size = fallback.size()
|
|
fallback.draw(at: NSPoint(x: visibleRect.maxX - size.width - 8, y: lineRect.origin.y + origin.y))
|
|
return
|
|
}
|
|
|
|
let palette = Theme.current
|
|
let font = Theme.gutterFont
|
|
let headerAttrs: [NSAttributedString.Key: Any] = [
|
|
.font: NSFontManager.shared.convert(font, toHaveTrait: .boldFontMask),
|
|
.foregroundColor: palette.teal
|
|
]
|
|
let cellAttrs: [NSAttributedString.Key: Any] = [
|
|
.font: font,
|
|
.foregroundColor: palette.subtext0
|
|
]
|
|
let borderColor = palette.surface1
|
|
|
|
let stringRows: [[String]] = rows.map { row in
|
|
row.map { cell in
|
|
if let s = cell as? String { return s }
|
|
if let n = cell as? NSNumber { return "\(n)" }
|
|
return "\(cell)"
|
|
}
|
|
}
|
|
guard !stringRows.isEmpty else { return }
|
|
|
|
let colCount = stringRows.map(\.count).max() ?? 0
|
|
guard colCount > 0 else { return }
|
|
|
|
var colWidths = [CGFloat](repeating: 0, count: colCount)
|
|
for row in stringRows {
|
|
for (ci, cell) in row.enumerated() where ci < colCount {
|
|
let w = (cell as NSString).size(withAttributes: cellAttrs).width
|
|
colWidths[ci] = max(colWidths[ci], w)
|
|
}
|
|
}
|
|
|
|
let cellPad: CGFloat = 8
|
|
let rowHeight: CGFloat = font.pointSize + 6
|
|
let tableWidth = colWidths.reduce(0, +) + cellPad * CGFloat(colCount + 1) + CGFloat(colCount - 1)
|
|
let tableHeight = rowHeight * CGFloat(stringRows.count) + CGFloat(stringRows.count + 1)
|
|
|
|
let tableX = LineNumberTextView.evalLeftMargin
|
|
let tableY = lineRect.origin.y + origin.y + lineRect.height + 4
|
|
|
|
let tableRect = NSRect(x: tableX, y: tableY, width: tableWidth, height: tableHeight)
|
|
palette.mantle.setFill()
|
|
let path = NSBezierPath(roundedRect: tableRect, xRadius: 4, yRadius: 4)
|
|
path.fill()
|
|
borderColor.setStroke()
|
|
path.lineWidth = 0.5
|
|
path.stroke()
|
|
|
|
var cy = tableY + 2
|
|
for (ri, row) in stringRows.enumerated() {
|
|
let attrs = ri == 0 ? headerAttrs : cellAttrs
|
|
var cx = tableX + cellPad
|
|
for (ci, cell) in row.enumerated() where ci < colCount {
|
|
let str = NSAttributedString(string: cell, attributes: attrs)
|
|
str.draw(at: NSPoint(x: cx, y: cy))
|
|
cx += colWidths[ci] + cellPad
|
|
}
|
|
cy += rowHeight
|
|
|
|
if ri == 0 {
|
|
borderColor.setStroke()
|
|
let linePath = NSBezierPath()
|
|
linePath.move(to: NSPoint(x: tableX + 2, y: cy - 1))
|
|
linePath.line(to: NSPoint(x: tableX + tableWidth - 2, y: cy - 1))
|
|
linePath.lineWidth = 0.5
|
|
linePath.stroke()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func drawTreeResult(_ json: String, lineRect: NSRect, origin: NSPoint, resultAttrs: [NSAttributedString.Key: Any]) {
|
|
guard let data = json.data(using: .utf8),
|
|
let root = try? JSONSerialization.jsonObject(with: data) else {
|
|
let fallback = NSAttributedString(string: "\u{2192} \(json.prefix(40))", attributes: resultAttrs)
|
|
let size = fallback.size()
|
|
fallback.draw(at: NSPoint(x: visibleRect.maxX - size.width - 8, y: lineRect.origin.y + origin.y))
|
|
return
|
|
}
|
|
|
|
let palette = Theme.current
|
|
let font = Theme.gutterFont
|
|
let nodeAttrs: [NSAttributedString.Key: Any] = [
|
|
.font: font,
|
|
.foregroundColor: palette.teal
|
|
]
|
|
let branchAttrs: [NSAttributedString.Key: Any] = [
|
|
.font: font,
|
|
.foregroundColor: palette.overlay0
|
|
]
|
|
|
|
var lines: [(String, Int)] = []
|
|
func walk(_ node: Any, depth: Int) {
|
|
if let arr = node as? [Any] {
|
|
for (i, item) in arr.enumerated() {
|
|
let prefix = i == arr.count - 1 ? "\u{2514}" : "\u{251C}"
|
|
if item is [Any] {
|
|
lines.append(("\(prefix) [\(((item as? [Any])?.count ?? 0))]", depth))
|
|
walk(item, depth: depth + 1)
|
|
} else {
|
|
lines.append(("\(prefix) \(item)", depth))
|
|
}
|
|
}
|
|
} else {
|
|
lines.append(("\(node)", depth))
|
|
}
|
|
}
|
|
|
|
if let arr = root as? [Any] {
|
|
lines.append(("[\(arr.count)]", 0))
|
|
walk(root, depth: 1)
|
|
} else {
|
|
lines.append(("\(root)", 0))
|
|
}
|
|
|
|
let lineHeight = font.pointSize + 4
|
|
let indent: CGFloat = 14
|
|
var maxWidth: CGFloat = 0
|
|
for (text, depth) in lines {
|
|
let w = (text as NSString).size(withAttributes: nodeAttrs).width + CGFloat(depth) * indent
|
|
maxWidth = max(maxWidth, w)
|
|
}
|
|
|
|
let treeHeight = lineHeight * CGFloat(lines.count) + 4
|
|
let treeWidth = maxWidth + 16
|
|
let treeX = LineNumberTextView.evalLeftMargin
|
|
let treeY = lineRect.origin.y + origin.y + lineRect.height + 4
|
|
|
|
let treeRect = NSRect(x: treeX, y: treeY, width: treeWidth, height: treeHeight)
|
|
palette.mantle.setFill()
|
|
let path = NSBezierPath(roundedRect: treeRect, xRadius: 4, yRadius: 4)
|
|
path.fill()
|
|
palette.surface1.setStroke()
|
|
path.lineWidth = 0.5
|
|
path.stroke()
|
|
|
|
var cy = treeY + 2
|
|
for (text, depth) in lines {
|
|
let x = treeX + 8 + CGFloat(depth) * indent
|
|
let attrs = depth == 0 ? nodeAttrs : branchAttrs
|
|
let str = NSAttributedString(string: text, attributes: attrs)
|
|
str.draw(at: NSPoint(x: x, y: cy))
|
|
cy += lineHeight
|
|
}
|
|
}
|
|
|
|
// MARK: - Eval Spacing
|
|
|
|
func applyEvalSpacing() {
|
|
guard let ts = textStorage else { return }
|
|
let text = ts.string as NSString
|
|
guard text.length > 0 else { return }
|
|
|
|
ts.beginEditing()
|
|
|
|
var lineStart = 0
|
|
var lineNum = 0
|
|
while lineStart < text.length {
|
|
let lineRange = text.lineRange(for: NSRange(location: lineStart, length: 0))
|
|
if let entry = evalResults[lineNum] {
|
|
let spacing: CGFloat
|
|
switch entry.format {
|
|
case .tree:
|
|
spacing = evalTreeHeight(entry.result) + 8
|
|
case .table:
|
|
spacing = evalTableHeight(entry.result) + 8
|
|
case .inline:
|
|
spacing = 0
|
|
}
|
|
if spacing > 0 {
|
|
let para = NSMutableParagraphStyle()
|
|
if let existing = ts.attribute(.paragraphStyle, at: lineRange.location, effectiveRange: nil) as? NSParagraphStyle {
|
|
para.setParagraphStyle(existing)
|
|
}
|
|
para.paragraphSpacing = spacing
|
|
ts.addAttribute(.paragraphStyle, value: para, range: lineRange)
|
|
}
|
|
}
|
|
lineNum += 1
|
|
lineStart = NSMaxRange(lineRange)
|
|
}
|
|
|
|
ts.endEditing()
|
|
}
|
|
|
|
private func evalTreeHeight(_ json: String) -> CGFloat {
|
|
guard let data = json.data(using: .utf8),
|
|
let root = try? JSONSerialization.jsonObject(with: data) else { return 0 }
|
|
let font = Theme.gutterFont
|
|
let lineHeight = font.pointSize + 4
|
|
var count = 0
|
|
func walk(_ node: Any) {
|
|
if let arr = node as? [Any] {
|
|
for item in arr {
|
|
count += 1
|
|
if item is [Any] { walk(item) }
|
|
}
|
|
} else {
|
|
count += 1
|
|
}
|
|
}
|
|
if root is [Any] {
|
|
count = 1
|
|
walk(root)
|
|
} else {
|
|
count = 1
|
|
}
|
|
return lineHeight * CGFloat(count) + 4
|
|
}
|
|
|
|
private func evalTableHeight(_ json: String) -> CGFloat {
|
|
guard let data = json.data(using: .utf8),
|
|
let parsed = try? JSONSerialization.jsonObject(with: data),
|
|
let rows = parsed as? [[Any]] else { return 0 }
|
|
let font = Theme.gutterFont
|
|
let rowHeight = font.pointSize + 6
|
|
return rowHeight * CGFloat(rows.count) + CGFloat(rows.count + 1)
|
|
}
|
|
|
|
// MARK: - Paste
|
|
|
|
override func paste(_ sender: Any?) {
|
|
let pb = NSPasteboard.general
|
|
|
|
// Image paste
|
|
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 {}
|
|
}
|
|
}
|
|
|
|
// Smart text paste with indent adjustment
|
|
if let text = pb.string(forType: .string) {
|
|
let lines = text.components(separatedBy: "\n")
|
|
if lines.count > 1 {
|
|
let str = string as NSString
|
|
let cursor = selectedRange().location
|
|
let lineRange = str.lineRange(for: NSRange(location: cursor, length: 0))
|
|
let currentLine = str.substring(with: lineRange)
|
|
var currentIndent = ""
|
|
for c in currentLine {
|
|
if c == " " || c == "\t" { currentIndent.append(c) }
|
|
else { break }
|
|
}
|
|
|
|
var minIndent = Int.max
|
|
for line in lines where !line.trimmingCharacters(in: .whitespaces).isEmpty {
|
|
let spaces = line.prefix(while: { $0 == " " || $0 == "\t" }).count
|
|
minIndent = min(minIndent, spaces)
|
|
}
|
|
if minIndent == Int.max { minIndent = 0 }
|
|
|
|
var adjusted: [String] = []
|
|
for (i, line) in lines.enumerated() {
|
|
if line.trimmingCharacters(in: .whitespaces).isEmpty {
|
|
adjusted.append("")
|
|
} else {
|
|
let stripped = String(line.dropFirst(minIndent))
|
|
if i == 0 {
|
|
adjusted.append(stripped)
|
|
} else {
|
|
adjusted.append(currentIndent + stripped)
|
|
}
|
|
}
|
|
}
|
|
|
|
let result = adjusted.joined(separator: "\n")
|
|
insertText(result, replacementRange: selectedRange())
|
|
return
|
|
}
|
|
}
|
|
|
|
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 = ".\(ext))"
|
|
} catch {
|
|
markdown = ")"
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|