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.. 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.. 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.. 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.. CGFloat { var x: CGFloat = 1 for i in 0.. 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.. 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.. Int? { let ox = gridOriginX let colCount = table.headers.count for i in 1.. Int? { let oy = gridOriginY let totalRows = 1 + table.rows.count for i in 0.. 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 { 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 = ["}", ")", "]", "\"", "'"] 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).. 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 = [ "let", "fn", "if", "else", "for", "while", "return", "mut", "in", "map", "cast", "plot", "sch" ] private let syntaxTypes: Set = [ "bool", "int", "float", "str", "i32", "f64", "Vec", "String" ] private let syntaxBooleans: Set = ["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: "(? 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 { var headerStarts = Set() 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: "(? 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 = "![image](~/.swiftly/images/\(uuid).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 = ["png", "jpg", "jpeg", "gif", "bmp", "tiff", "webp"] guard let ts = textStorage else { return false } var didInsert = false for url in urls { let ext = url.pathExtension.lowercased() if imageExts.contains(ext) { let uuid = UUID().uuidString let dest = imageCacheDir.appendingPathComponent("\(uuid).\(ext)") let markdown: String do { try FileManager.default.copyItem(at: url, to: dest) markdown = "![image](~/.swiftly/images/\(uuid).\(ext))" } catch { markdown = "![\(url.lastPathComponent)](\(url.path))" } insertText(markdown, replacementRange: selectedRange()) didInsert = true } else { let attachment = NSTextAttachment() let cell = FileEmbedCell(filePath: url.path) attachment.attachmentCell = cell let attachStr = NSMutableAttributedString(string: "\n") attachStr.append(NSAttributedString(attachment: attachment)) let insertAt = min(selectedRange().location, ts.length) ts.insert(attachStr, at: insertAt) didInsert = true } } return didInsert || super.performDragOperation(sender) } }