import SwiftUI import AppKit // MARK: - MarkdownLayoutManager class MarkdownLayoutManager: NSLayoutManager { struct BlockRange { let range: NSRange let kind: BlockKind } enum BlockKind { case codeBlock case blockquote case horizontalRule case checkbox(checked: Bool) case tableBlock(columns: Int) } var blockRanges: [BlockRange] = [] override func drawBackground(forGlyphRange glyphsToShow: NSRange, at origin: NSPoint) { super.drawBackground(forGlyphRange: glyphsToShow, at: origin) guard let textContainer = textContainers.first else { return } for block in blockRanges { let glyphRange = self.glyphRange(forCharacterRange: block.range, actualCharacterRange: nil) guard NSIntersectionRange(glyphRange, glyphsToShow).length > 0 else { continue } switch block.kind { case .codeBlock: drawCodeBlockBackground(glyphRange: glyphRange, origin: origin, container: textContainer) case .blockquote: drawBlockquoteBorder(glyphRange: glyphRange, origin: origin, container: textContainer) case .horizontalRule: drawHorizontalRule(glyphRange: glyphRange, origin: origin, container: textContainer) case .checkbox, .tableBlock: break } } } override func drawGlyphs(forGlyphRange glyphsToShow: NSRange, at origin: NSPoint) { guard let textContainer = textContainers.first else { super.drawGlyphs(forGlyphRange: glyphsToShow, at: origin) return } var skipRanges: [NSRange] = [] for block in blockRanges { guard case .checkbox(let checked) = block.kind else { continue } let glyphRange = self.glyphRange(forCharacterRange: block.range, actualCharacterRange: nil) guard NSIntersectionRange(glyphRange, glyphsToShow).length > 0 else { continue } skipRanges.append(glyphRange) drawCheckbox(checked: checked, glyphRange: glyphRange, origin: origin, container: textContainer) } if skipRanges.isEmpty { super.drawGlyphs(forGlyphRange: glyphsToShow, at: origin) return } skipRanges.sort { $0.location < $1.location } var cursor = glyphsToShow.location for skip in skipRanges { if cursor < skip.location { super.drawGlyphs(forGlyphRange: NSRange(location: cursor, length: skip.location - cursor), at: origin) } cursor = NSMaxRange(skip) } if cursor < NSMaxRange(glyphsToShow) { super.drawGlyphs(forGlyphRange: NSRange(location: cursor, length: NSMaxRange(glyphsToShow) - cursor), at: origin) } } private func drawCodeBlockBackground(glyphRange: NSRange, origin: NSPoint, container: NSTextContainer) { var rect = boundingRect(forGlyphRange: glyphRange, in: container) rect.origin.x = origin.x + 4 rect.origin.y += origin.y - 4 rect.size.width = container.containerSize.width - 8 rect.size.height += 8 let path = NSBezierPath(roundedRect: rect, xRadius: 6, yRadius: 6) Theme.current.surface0.setFill() path.fill() Theme.current.surface1.setStroke() path.lineWidth = 1 path.stroke() } private func drawBlockquoteBorder(glyphRange: NSRange, origin: NSPoint, container: NSTextContainer) { var rect = boundingRect(forGlyphRange: glyphRange, in: container) rect.origin.x = origin.x rect.origin.y += origin.y rect.size.width = container.containerSize.width let bgRect = NSRect(x: rect.origin.x + 8, y: rect.origin.y, width: rect.size.width - 16, height: rect.size.height) Theme.current.surface0.withAlphaComponent(0.3).setFill() bgRect.fill() let barRect = NSRect(x: origin.x + 8, y: rect.origin.y, width: 3, height: rect.size.height) Theme.current.lavender.setFill() barRect.fill() } private func drawHorizontalRule(glyphRange: NSRange, origin: NSPoint, container: NSTextContainer) { let rect = boundingRect(forGlyphRange: glyphRange, in: container) let y = rect.origin.y + origin.y + rect.size.height / 2 let path = NSBezierPath() path.move(to: NSPoint(x: origin.x + 8, y: y)) path.line(to: NSPoint(x: origin.x + container.containerSize.width - 8, y: y)) path.lineWidth = 1 Theme.current.overlay0.setStroke() path.stroke() } private func drawCheckbox(checked: Bool, glyphRange: NSRange, origin: NSPoint, container: NSTextContainer) { let rect = boundingRect(forGlyphRange: glyphRange, in: container) let size: CGFloat = 14 let x = rect.origin.x + origin.x let y = rect.origin.y + origin.y + (rect.size.height - size) / 2 let boxRect = NSRect(x: x, y: y, width: size, height: size) let path = NSBezierPath(roundedRect: boxRect, xRadius: 3, yRadius: 3) if checked { Theme.current.green.setFill() path.fill() let check = NSBezierPath() check.move(to: NSPoint(x: x + 3, y: y + size / 2)) check.line(to: NSPoint(x: x + size * 0.4, y: y + 3)) check.line(to: NSPoint(x: x + size - 3, y: y + size - 3)) check.lineWidth = 2 check.lineCapStyle = .round check.lineJoinStyle = .round NSColor.white.setStroke() check.stroke() } else { Theme.current.overlay0.setStroke() path.lineWidth = 1.5 path.stroke() } } private func drawTableBorders(glyphRange: NSRange, columns: Int, origin: NSPoint, container: NSTextContainer) { guard columns > 0 else { return } var rect = boundingRect(forGlyphRange: glyphRange, in: container) rect.origin.x = origin.x + 4 rect.origin.y += origin.y rect.size.width = container.containerSize.width - 8 let outerPath = NSBezierPath(rect: rect) outerPath.lineWidth = 1 Theme.current.surface2.setStroke() outerPath.stroke() guard let ts = textStorage else { return } let charRange = characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil) let text = ts.string as NSString let tableText = text.substring(with: charRange) let lines = tableText.components(separatedBy: "\n").filter { !$0.isEmpty } var charOffset = charRange.location for (i, line) in lines.enumerated() { let lineLen = (line as NSString).length if i > 0 { let lineGlyphRange = self.glyphRange(forCharacterRange: NSRange(location: charOffset, length: 1), actualCharacterRange: nil) let lineRect = boundingRect(forGlyphRange: lineGlyphRange, in: container) let y = lineRect.origin.y + origin.y let rowLine = NSBezierPath() rowLine.move(to: NSPoint(x: rect.origin.x, y: y)) rowLine.line(to: NSPoint(x: rect.origin.x + rect.size.width, y: y)) rowLine.lineWidth = 0.5 Theme.current.surface2.setStroke() rowLine.stroke() } charOffset += lineLen + 1 } if let firstLine = lines.first { let nsFirstLine = firstLine as NSString var pipeOffsets: [Int] = [] for i in 0.. 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 let cellHeight: CGFloat = 26 private let cellPadding: CGFloat = 4 private let headerHeight: CGFloat = 28 init(table: ParsedTable, width: CGFloat) { self.table = table super.init(frame: .zero) wantsLayer = true layer?.backgroundColor = Theme.current.base.cgColor layer?.cornerRadius = 4 layer?.borderWidth = 1 layer?.borderColor = Theme.current.surface2.cgColor buildGrid(width: width) } required init?(coder: NSCoder) { fatalError() } private func buildGrid(width: CGFloat) { subviews.forEach { $0.removeFromSuperview() } cellFields = [] let colCount = table.headers.count guard colCount > 0 else { return } let colWidth = (width - CGFloat(colCount + 1)) / CGFloat(colCount) let totalRows = 1 + table.rows.count let totalHeight = headerHeight + CGFloat(table.rows.count) * cellHeight frame.size = NSSize(width: width, height: totalHeight) // Header row let headerBg = NSView(frame: NSRect(x: 0, y: totalHeight - headerHeight, width: width, height: headerHeight)) headerBg.wantsLayer = true headerBg.layer?.backgroundColor = Theme.current.surface0.cgColor addSubview(headerBg) var headerFields: [NSTextField] = [] for (col, header) in table.headers.enumerated() { let x = CGFloat(col) * colWidth + CGFloat(col + 1) let field = makeCell(text: header, frame: NSRect(x: x, y: totalHeight - headerHeight + 2, width: colWidth, height: headerHeight - 4), isHeader: true, row: -1, col: col) addSubview(field) headerFields.append(field) } cellFields.append(headerFields) // Data rows for (rowIdx, row) in table.rows.enumerated() { var rowFields: [NSTextField] = [] let y = totalHeight - headerHeight - CGFloat(rowIdx + 1) * cellHeight for (col, cell) in row.enumerated() where col < colCount { let x = CGFloat(col) * colWidth + CGFloat(col + 1) let field = makeCell(text: cell, frame: NSRect(x: x, y: y + 2, width: colWidth, height: cellHeight - 4), isHeader: false, row: rowIdx, col: col) addSubview(field) rowFields.append(field) } while rowFields.count < colCount { let col = rowFields.count let x = CGFloat(col) * colWidth + CGFloat(col + 1) let field = makeCell(text: "", frame: NSRect(x: x, y: y + 2, width: colWidth, height: cellHeight - 4), isHeader: false, row: rowIdx, col: col) addSubview(field) rowFields.append(field) } cellFields.append(rowFields) } // Grid lines for i in 1.. NSTextField { let field = NSTextField(frame: frame) field.stringValue = text field.isEditable = true field.isBordered = false field.drawsBackground = false field.font = isHeader ? NSFontManager.shared.convert(Theme.editorFont, toHaveTrait: .boldFontMask) : Theme.editorFont field.textColor = Theme.current.text field.focusRingType = .none field.cell?.truncatesLastVisibleLine = true field.tag = (row + 1) * 1000 + col field.delegate = self if let align = table.alignments[safe: col] { switch align { case .left: field.alignment = .left case .center: field.alignment = .center case .right: field.alignment = .right } } return field } func controlTextDidEndEditing(_ obj: Notification) { guard let field = obj.object as? NSTextField else { return } let tag = field.tag let row = tag / 1000 - 1 let col = tag % 1000 if row == -1 { guard col < table.headers.count else { return } table.headers[col] = field.stringValue } else { guard row < table.rows.count, col < table.rows[row].count else { return } table.rows[row][col] = field.stringValue } onTableChanged?(table) if let movement = obj.userInfo?["NSTextMovement"] as? Int, movement == NSTabTextMovement { let nextCol = col + 1 let nextRow = row + (nextCol >= table.headers.count ? 1 : 0) let actualCol = nextCol % table.headers.count let actualRow = nextRow let fieldRow = actualRow + 1 if fieldRow < cellFields.count, actualCol < cellFields[fieldRow].count { window?.makeFirstResponder(cellFields[fieldRow][actualCol]) } } } func control(_ control: NSControl, textView tv: NSTextView, doCommandBy commandSelector: Selector) -> Bool { if commandSelector == #selector(NSResponder.insertNewline(_:)) { guard let field = control as? NSTextField else { return false } let tag = field.tag let row = tag / 1000 - 1 let col = tag % 1000 _ = row; _ = col table.rows.append(Array(repeating: "", count: table.headers.count)) onTableChanged?(table) let width = frame.width buildGrid(width: width) needsLayout = true return true } return false } } private extension Array { subscript(safe index: Int) -> Element? { indices.contains(index) ? self[index] : nil } } // MARK: - File Embed Attachment Cell class FileEmbedCell: NSTextAttachmentCell { let filePath: String let fileName: String private let fileIcon: NSImage init(filePath: String) { self.filePath = filePath self.fileName = (filePath as NSString).lastPathComponent self.fileIcon = NSWorkspace.shared.icon(forFile: filePath) self.fileIcon.size = NSSize(width: 16, height: 16) super.init() } required init(coder: NSCoder) { fatalError() } override func cellSize() -> NSSize { let textSize = (fileName as NSString).size(withAttributes: [.font: Theme.editorFont]) return NSSize(width: 16 + 8 + textSize.width + 16, height: max(24, textSize.height + 8)) } override func draw(withFrame cellFrame: NSRect, in controlView: NSView?) { let path = NSBezierPath(roundedRect: cellFrame.insetBy(dx: 1, dy: 1), xRadius: 4, yRadius: 4) Theme.current.surface1.setFill() path.fill() Theme.current.surface2.setStroke() path.lineWidth = 1 path.stroke() let iconRect = NSRect(x: cellFrame.origin.x + 6, y: cellFrame.origin.y + (cellFrame.height - 16) / 2, width: 16, height: 16) fileIcon.draw(in: iconRect) let textRect = NSRect(x: iconRect.maxX + 4, y: cellFrame.origin.y + (cellFrame.height - 16) / 2, width: cellFrame.width - 30, height: 16) let attrs: [NSAttributedString.Key: Any] = [ .font: Theme.editorFont, .foregroundColor: Theme.current.text ] fileName.draw(in: textRect, withAttributes: attrs) } override func wantsToTrackMouse() -> Bool { true } override func trackMouse(with theEvent: NSEvent, in cellFrame: NSRect, of controlView: NSView?, untilMouseUp flag: Bool) -> Bool { if theEvent.clickCount >= 1 { NSWorkspace.shared.open(URL(fileURLWithPath: filePath)) } return true } } // MARK: - Title View struct TitleView: View { @Binding var titleLine: String @State private var isEditing = false @State private var editText = "" var onCommitAndFocusEditor: (() -> Void)? private var displayTitle: String { let stripped = titleLine.trimmingCharacters(in: .whitespaces) if stripped.hasPrefix("# ") { return String(stripped.dropFirst(2)) } return stripped } var body: some View { HStack { if isEditing { TitleTextField( text: $editText, onCommit: { commitEdit() }, onEscape: { commitEdit() } ) .font(.system(size: 24, weight: .bold)) .foregroundColor(Color(ns: Theme.current.text)) .padding(.horizontal, 58) .padding(.top, 16) .padding(.bottom, 8) } else { Text(displayTitle.isEmpty ? "Untitled" : displayTitle) .font(.system(size: 24, weight: .bold)) .foregroundColor(displayTitle.isEmpty ? Color(ns: Theme.current.overlay0) : Color(ns: Theme.current.text)) .frame(maxWidth: .infinity, alignment: .leading) .padding(.horizontal, 58) .padding(.top, 16) .padding(.bottom, 8) .contentShape(Rectangle()) .onTapGesture(count: 2) { editText = titleLine isEditing = true } } Spacer() } .background(Color(ns: Theme.current.base)) .onReceive(NotificationCenter.default.publisher(for: .focusTitle)) { _ in editText = titleLine isEditing = true } } private func commitEdit() { titleLine = editText isEditing = false onCommitAndFocusEditor?() } } struct TitleTextField: NSViewRepresentable { @Binding var text: String var onCommit: () -> Void var onEscape: () -> Void func makeCoordinator() -> Coordinator { Coordinator(self) } func makeNSView(context: Context) -> NSTextField { let field = NSTextField() field.isBordered = false field.drawsBackground = false field.font = NSFont.systemFont(ofSize: 24, weight: .bold) field.textColor = Theme.current.text field.focusRingType = .none field.stringValue = text field.delegate = context.coordinator field.cell?.lineBreakMode = .byTruncatingTail DispatchQueue.main.async { field.window?.makeFirstResponder(field) field.currentEditor()?.selectAll(nil) } return field } func updateNSView(_ field: NSTextField, context: Context) { if field.stringValue != text { field.stringValue = text } } class Coordinator: NSObject, NSTextFieldDelegate { var parent: TitleTextField init(_ parent: TitleTextField) { self.parent = parent } func controlTextDidChange(_ obj: Notification) { guard let field = obj.object as? NSTextField else { return } parent.text = field.stringValue } func control(_ control: NSControl, textView: NSTextView, doCommandBy sel: Selector) -> Bool { if sel == #selector(NSResponder.insertNewline(_:)) { parent.onCommit() return true } if sel == #selector(NSResponder.cancelOperation(_:)) { parent.onEscape() return true } return false } } } // MARK: - Editor View struct EditorView: View { @ObservedObject var state: AppState @State private var titleIsEditing = false private var titleBinding: Binding { Binding( get: { let lines = state.documentText.components(separatedBy: "\n") return lines.first ?? "" }, set: { newTitle in let lines = state.documentText.components(separatedBy: "\n") var rest = Array(lines.dropFirst()) if rest.isEmpty && state.documentText.isEmpty { rest = [] } state.documentText = ([newTitle] + rest).joined(separator: "\n") } ) } 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 { VStack(spacing: 0) { TitleView( titleLine: titleBinding, onCommitAndFocusEditor: { NotificationCenter.default.post(name: .focusEditor, object: nil) } ) EditorTextView( text: bodyBinding, evalResults: offsetEvalResults(state.evalResults), onEvaluate: { state.evaluate() }, onBackspaceAtStart: { NotificationCenter.default.post(name: .focusTitle, object: nil) } ) } } private func offsetEvalResults(_ results: [Int: String]) -> [Int: String] { var shifted: [Int: String] = [:] for (key, val) in results where key > 0 { shifted[key - 1] = val } return shifted } } struct EditorTextView: NSViewRepresentable { @Binding var text: String var evalResults: [Int: String] 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) ts.endEditing() } textView.typingAttributes = [ .font: Theme.editorFont, .foregroundColor: Theme.current.text ] updateBlockRanges(for: textView) DispatchQueue.main.async { context.coordinator.triggerImageUpdate() context.coordinator.triggerTableUpdate() } return scrollView } func updateNSView(_ scrollView: NSScrollView, context: Context) { guard let textView = scrollView.documentView as? LineNumberTextView else { return } if textView.string != text { let selectedRanges = textView.selectedRanges textView.string = text if let ts = textView.textStorage { ts.beginEditing() applySyntaxHighlighting(to: ts) ts.endEditing() } textView.selectedRanges = selectedRanges updateBlockRanges(for: textView) } textView.backgroundColor = Theme.current.base textView.insertionPointColor = Theme.current.text textView.typingAttributes = [ .font: Theme.editorFont, .foregroundColor: Theme.current.text ] 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 focusObserver: NSObjectProtocol? private var settingsObserver: NSObjectProtocol? init(_ parent: EditorTextView) { self.parent = parent super.init() focusObserver = 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)) } settingsObserver = 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) ts.endEditing() tv.needsDisplay = true } } deinit { if let obs = focusObserver { NotificationCenter.default.removeObserver(obs) } if let obs = settingsObserver { 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) ts.endEditing() 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(_:)) { textView.insertNewlineIgnoringFieldEditor(nil) 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, clickedOnLink link: Any, at charIndex: Int) -> Bool { var urlString: String? if let url = link as? URL { urlString = url.absoluteString } else if let str = link as? String { urlString = str } guard let str = urlString, let url = URL(string: str) else { return false } NSWorkspace.shared.open(url) return true } func triggerImageUpdate() { updateInlineImages() } func triggerTableUpdate() { updateEmbeddedTables() } // MARK: - Embedded Tables private func updateEmbeddedTables() { guard let tv = textView, let lm = tv.layoutManager as? MarkdownLayoutManager, let tc = tv.textContainer else { return } for tableView in embeddedTableViews { tableView.removeFromSuperview() } embeddedTableViews.removeAll() let text = tv.string as NSString for block in lm.blockRanges { guard case .tableBlock = block.kind else { continue } guard let parsed = parseMarkdownTable(from: text, range: block.range) else { continue } let glyphRange = lm.glyphRange(forCharacterRange: block.range, actualCharacterRange: nil) var rect = lm.boundingRect(forGlyphRange: glyphRange, in: tc) rect.origin.x += tv.textContainerInset.width rect.origin.y += tv.textContainerInset.height let tableWidth = tc.containerSize.width - 8 let tableView = MarkdownTableView(table: parsed, width: tableWidth) tableView.frame.origin = NSPoint(x: rect.origin.x + 4, y: rect.origin.y) tableView.textView = tv tableView.onTableChanged = { [weak self] updatedTable in self?.applyTableEdit(updatedTable) } tv.addSubview(tableView) embeddedTableViews.append(tableView) } } private func applyTableEdit(_ table: ParsedTable) { guard let tv = textView, let ts = tv.textStorage else { return } let newMarkdown = rebuildTableMarkdown(table) let range = table.sourceRange guard NSMaxRange(range) <= ts.length else { return } isUpdatingTables = true let sel = tv.selectedRanges ts.beginEditing() ts.replaceCharacters(in: range, with: newMarkdown) applySyntaxHighlighting(to: ts) ts.endEditing() tv.selectedRanges = sel parent.text = tv.string updateBlockRanges(for: tv) isUpdatingTables = false } private static let imageRegex: NSRegularExpression? = { try? NSRegularExpression(pattern: "!\\[[^\\]]*\\]\\(([^)]+)\\)") }() private func updateInlineImages() { guard let tv = textView, let ts = tv.textStorage else { return } guard let regex = Coordinator.imageRegex else { return } let text = ts.string let nsText = text as NSString let fullRange = NSRange(location: 0, length: nsText.length) let existingAttachmentRanges = findExistingImageAttachments(in: ts) let matches = regex.matches(in: text, range: fullRange) var resolvedPaths: [String] = [] for match in matches { let urlRange = match.range(at: 1) let rawPath = nsText.substring(with: urlRange) if let resolved = resolveLocalImagePath(rawPath), FileManager.default.fileExists(atPath: resolved) { resolvedPaths.append(resolved) } } let neededSet = Set(resolvedPaths) let existingSet = Set(existingAttachmentRanges.map { $0.1 }) if neededSet == existingSet { return } isUpdatingImages = true ts.beginEditing() for (range, _) in existingAttachmentRanges.reversed() { ts.deleteCharacters(in: range) } let recalcText = ts.string let recalcNS = recalcText as NSString let recalcFull = NSRange(location: 0, length: recalcNS.length) let recalcMatches = regex.matches(in: recalcText, range: recalcFull) var offset = 0 for match in recalcMatches { let urlRange = NSRange(location: match.range(at: 1).location + offset, length: match.range(at: 1).length) let rawPath = recalcNS.substring(with: NSRange(location: urlRange.location, length: urlRange.length)) guard let resolved = resolveLocalImagePath(rawPath), FileManager.default.fileExists(atPath: resolved), let image = NSImage(contentsOfFile: resolved) else { continue } let maxWidth: CGFloat = min(600, tv.bounds.width - 40) let ratio = image.size.width > maxWidth ? maxWidth / image.size.width : 1.0 let displaySize = NSSize( width: image.size.width * ratio, height: image.size.height * ratio ) image.size = displaySize let attachment = NSTextAttachment() let cell = NSTextAttachmentCell(imageCell: image) attachment.attachmentCell = cell let attachStr = NSMutableAttributedString(string: "\n") attachStr.append(NSAttributedString(attachment: attachment)) attachStr.addAttribute(.toolTip, value: resolved, range: NSRange(location: 1, length: 1)) let lineEnd = NSMaxRange(match.range) + offset let insertAt = min(lineEnd, ts.length) ts.insert(attachStr, at: insertAt) offset += attachStr.length } ts.endEditing() isUpdatingImages = false } private func findExistingImageAttachments(in textStorage: NSTextStorage) -> [(NSRange, String)] { var results: [(NSRange, String)] = [] let fullRange = NSRange(location: 0, length: textStorage.length) textStorage.enumerateAttribute(.attachment, in: fullRange) { value, range, _ in if value is NSTextAttachment { var extRange = range if extRange.location > 0 { let prev = NSRange(location: extRange.location - 1, length: 1) let ch = (textStorage.string as NSString).substring(with: prev) if ch == "\n" { extRange = NSRange(location: prev.location, length: extRange.length + 1) } } let tip = textStorage.attribute(.toolTip, at: range.location, effectiveRange: nil) as? String ?? "" results.append((extRange, tip)) } } return results } } } // MARK: - Block Range Detection private let checkboxPattern: NSRegularExpression? = { try? NSRegularExpression(pattern: "\\[[ xX]\\]") }() func updateBlockRanges(for textView: NSTextView) { guard let lm = textView.layoutManager as? MarkdownLayoutManager else { return } let text = textView.string as NSString guard text.length > 0 else { lm.blockRanges = [] return } var blocks: [MarkdownLayoutManager.BlockRange] = [] var lineStart = 0 var openFence: Int? = nil var blockquoteStart: Int? = nil var blockquoteEnd: Int = 0 var tableStart: Int? = nil var tableEnd: Int = 0 var tableColumns: Int = 0 while lineStart < text.length { let lineRange = text.lineRange(for: NSRange(location: lineStart, length: 0)) let line = text.substring(with: lineRange) let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) if openFence == nil && trimmed.hasPrefix("```") { openFence = lineRange.location } else if openFence != nil && trimmed == "```" { let range = NSRange(location: openFence!, length: NSMaxRange(lineRange) - openFence!) blocks.append(.init(range: range, kind: .codeBlock)) openFence = nil lineStart = NSMaxRange(lineRange) continue } if openFence != nil { lineStart = NSMaxRange(lineRange) continue } if trimmed.hasPrefix("> ") || trimmed == ">" { if blockquoteStart == nil { blockquoteStart = lineRange.location } blockquoteEnd = NSMaxRange(lineRange) } else { if let start = blockquoteStart { blocks.append(.init(range: NSRange(location: start, length: blockquoteEnd - start), kind: .blockquote)) blockquoteStart = nil } } if isHorizontalRule(trimmed) && openFence == nil { blocks.append(.init(range: lineRange, kind: .horizontalRule)) } // Task list checkboxes let taskPrefixes = ["- [ ] ", "- [x] ", "- [X] ", "* [ ] ", "* [x] ", "* [X] ", "+ [ ] ", "+ [x] ", "+ [X] "] let strippedLine = trimmed for prefix in taskPrefixes { if strippedLine.hasPrefix(prefix) { let checked = prefix.contains("x") || prefix.contains("X") if let regex = checkboxPattern, let match = regex.firstMatch(in: text as String, range: lineRange) { blocks.append(.init(range: match.range, kind: .checkbox(checked: checked))) } break } } if trimmed.hasPrefix("|") { if tableStart == nil { tableStart = lineRange.location tableColumns = trimmed.filter({ $0 == "|" }).count - 1 if tableColumns < 1 { tableColumns = 1 } } tableEnd = NSMaxRange(lineRange) } else { if let start = tableStart { blocks.append(.init(range: NSRange(location: start, length: tableEnd - start), kind: .tableBlock(columns: tableColumns))) tableStart = nil tableColumns = 0 } } lineStart = NSMaxRange(lineRange) } if let start = blockquoteStart { blocks.append(.init(range: NSRange(location: start, length: blockquoteEnd - start), kind: .blockquote)) } if let start = tableStart { blocks.append(.init(range: NSRange(location: start, length: tableEnd - start), kind: .tableBlock(columns: tableColumns))) } lm.blockRanges = blocks let fullRange = NSRange(location: 0, length: text.length) lm.invalidateDisplay(forCharacterRange: fullRange) } // MARK: - Syntax Highlighting private let syntaxKeywords: Set = [ "let", "fn", "if", "else", "for", "map", "cast", "plot", "sch" ] private let syntaxOperatorChars = CharacterSet(charactersIn: "+-*/=^<>!(){}[]:,") func applySyntaxHighlighting(to textStorage: NSTextStorage) { let text = textStorage.string let fullRange = NSRange(location: 0, length: (text as NSString).length) let palette = Theme.current let syn = Theme.syntax let baseFont = Theme.editorFont let baseAttrs: [NSAttributedString.Key: Any] = [ .font: baseFont, .foregroundColor: palette.text ] textStorage.setAttributes(baseAttrs, range: fullRange) let fencedRanges = highlightFencedCodeBlocks(textStorage: textStorage, palette: palette, baseFont: baseFont) let tableHeaderLines = findTableHeaderLines(textStorage: textStorage, fencedRanges: fencedRanges) let nsText = text as NSString var lineStart = 0 while lineStart < nsText.length { let lineRange = nsText.lineRange(for: NSRange(location: lineStart, length: 0)) if isInsideFencedBlock(lineRange, fencedRanges: fencedRanges) { lineStart = NSMaxRange(lineRange) continue } let line = nsText.substring(with: lineRange) let trimmed = line.trimmingCharacters(in: .whitespaces) let isTableHeader = tableHeaderLines.contains(lineRange.location) if highlightMarkdownLine(trimmed, line: line, lineRange: lineRange, textStorage: textStorage, baseFont: baseFont, palette: palette, isTableHeader: isTableHeader) { highlightInlineCode(textStorage: textStorage, lineRange: lineRange, palette: palette, baseFont: baseFont) highlightStrikethrough(textStorage: textStorage, lineRange: lineRange, palette: palette) highlightFootnoteRefs(textStorage: textStorage, lineRange: lineRange, palette: palette) lineStart = NSMaxRange(lineRange) continue } if isCordialLine(trimmed) { highlightCodeLine(line, lineRange: lineRange, textStorage: textStorage, syn: syn) } highlightInlineCode(textStorage: textStorage, lineRange: lineRange, palette: palette, baseFont: baseFont) highlightStrikethrough(textStorage: textStorage, lineRange: lineRange, palette: palette) highlightFootnoteRefs(textStorage: textStorage, lineRange: lineRange, palette: palette) lineStart = NSMaxRange(lineRange) } highlightBlockComments(textStorage: textStorage, syn: syn, baseFont: baseFont) highlightLinks(textStorage: textStorage, palette: palette, fencedRanges: fencedRanges) highlightImages(textStorage: textStorage, palette: palette, fencedRanges: fencedRanges) highlightAutolinks(textStorage: textStorage, palette: palette, fencedRanges: fencedRanges) } private func highlightMarkdownLine(_ trimmed: String, line: String, lineRange: NSRange, textStorage: NSTextStorage, baseFont: NSFont, palette: CatppuccinPalette, isTableHeader: Bool = false) -> Bool { if trimmed.hasPrefix("### ") { let hashRange = (textStorage.string as NSString).range(of: "###", range: lineRange) if hashRange.location != NSNotFound { textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: hashRange) let contentStart = hashRange.location + hashRange.length let contentRange = NSRange(location: contentStart, length: NSMaxRange(lineRange) - contentStart) if contentRange.length > 0 { let h3Font = NSFont.systemFont(ofSize: 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: 18, 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: 22, 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 } // Eval prefix if trimmed.hasPrefix("/=") { let prefixRange = (textStorage.string as NSString).range(of: "/=", range: lineRange) if prefixRange.location != NSNotFound { textStorage.addAttribute(.foregroundColor, value: syn.operator, range: prefixRange) } } highlightCodeTokens(nsLine, inRange: NSRange(location: 0, length: nsLine.length), lineOffset: lineRange.location, textStorage: textStorage, syn: syn) // Bold markers **text** highlightInlineMarkdown(textStorage: textStorage, lineRange: lineRange, pattern: "\\*\\*(.+?)\\*\\*", markerLen: 2, trait: .boldFontMask, font: boldFont) // Italic markers *text* highlightInlineMarkdown(textStorage: textStorage, lineRange: lineRange, pattern: "(? 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) while !scanner.isAtEnd { let pos = scanner.currentIndex // String literal if scanner.scanString("\"") != nil { let startIdx = sub.distance(from: sub.startIndex, to: pos) var strContent = "\"" var escaped = false while !scanner.isAtEnd { let ch = sub[scanner.currentIndex] strContent.append(ch) scanner.currentIndex = sub.index(after: scanner.currentIndex) if escaped { escaped = false continue } if ch == "\\" { escaped = true; continue } if ch == "\"" { break } } let absRange = NSRange(location: lineOffset + range.location + startIdx, length: strContent.count) textStorage.addAttribute(.foregroundColor, value: syn.string, range: absRange) continue } // Number if let numStr = scanner.scanCharacters(from: digitChars) { let startIdx = sub.distance(from: sub.startIndex, to: pos) let absRange = NSRange(location: lineOffset + range.location + startIdx, length: numStr.count) textStorage.addAttribute(.foregroundColor, value: syn.number, range: absRange) continue } // Identifier / keyword if let ident = scanner.scanCharacters(from: identChars) { if let first = ident.unicodeScalars.first, identStart.contains(first) { let startIdx = sub.distance(from: sub.startIndex, to: pos) let absRange = NSRange(location: lineOffset + range.location + startIdx, length: ident.count) if syntaxKeywords.contains(ident) { textStorage.addAttribute(.foregroundColor, value: syn.keyword, range: absRange) } } continue } // Operator if let op = scanner.scanCharacters(from: syntaxOperatorChars) { let startIdx = sub.distance(from: sub.startIndex, to: pos) let absRange = NSRange(location: lineOffset + range.location + startIdx, length: op.count) textStorage.addAttribute(.foregroundColor, value: syn.operator, range: absRange) continue } // Skip unrecognized character scanner.currentIndex = sub.index(after: scanner.currentIndex) } } private func highlightBlockComments(textStorage: NSTextStorage, syn: Theme.SyntaxColors, baseFont: NSFont) { let text = textStorage.string let nsText = text as NSString let italicFont = NSFontManager.shared.convert(baseFont, toHaveTrait: .italicFontMask) 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) var fencedRanges: [NSRange] = [] var lineStart = 0 var openFence: Int? = nil while lineStart < nsText.length { let lineRange = nsText.lineRange(for: NSRange(location: lineStart, length: 0)) let line = nsText.substring(with: lineRange) let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) if openFence == nil { if trimmed.hasPrefix("```") { openFence = lineRange.location // Mute the fence line textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: lineRange) textStorage.addAttribute(.font, value: monoFont, range: lineRange) // Language identifier after ``` if trimmed.count > 3 { let langStart = (nsText as NSString).range(of: "```", range: lineRange) if langStart.location != NSNotFound { let after = langStart.location + langStart.length let langRange = NSRange(location: after, length: NSMaxRange(lineRange) - after) if langRange.length > 0 { textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: langRange) } } } } } else { if trimmed == "```" { // Close fence textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: lineRange) textStorage.addAttribute(.font, value: monoFont, range: lineRange) let blockRange = NSRange(location: openFence!, length: NSMaxRange(lineRange) - openFence!) fencedRanges.append(blockRange) openFence = nil } else { textStorage.addAttribute(.font, value: monoFont, range: lineRange) 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) { let monoFont = NSFont.monospacedSystemFont(ofSize: baseFont.pointSize, weight: .regular) let boldMono = NSFontManager.shared.convert(monoFont, toHaveTrait: .boldFontMask) textStorage.addAttribute(.font, value: monoFont, range: lineRange) textStorage.addAttribute(.foregroundColor, value: palette.text, range: lineRange) if isSeparator { textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: lineRange) return } if isHeader { textStorage.addAttribute(.font, value: boldMono, range: lineRange) } // Mute pipe delimiters guard let pipeRegex = try? NSRegularExpression(pattern: "\\|") else { return } let matches = pipeRegex.matches(in: textStorage.string, range: lineRange) for match in matches { textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: match.range) } } // MARK: - Lists and Horizontal Rules private func highlightOrderedList(_ trimmed: String, lineRange: NSRange, line: String, textStorage: NSTextStorage, palette: CatppuccinPalette) { guard let regex = try? NSRegularExpression(pattern: "^(\\s*)(\\d+\\.)( )") else { return } let nsLine = line as NSString let localRange = NSRange(location: 0, length: nsLine.length) guard let match = regex.firstMatch(in: line, range: localRange) else { return } let markerRange = match.range(at: 2) let absMarker = NSRange(location: lineRange.location + markerRange.location, length: markerRange.length) textStorage.addAttribute(.foregroundColor, value: palette.blue, range: absMarker) applyListIndent(line: line, lineRange: lineRange, textStorage: textStorage) } private func highlightUnorderedList(_ trimmed: String, lineRange: NSRange, line: String, textStorage: NSTextStorage, palette: CatppuccinPalette) { guard let regex = try? NSRegularExpression(pattern: "^(\\s*)([-*+])( )") else { return } let nsLine = line as NSString let localRange = NSRange(location: 0, length: nsLine.length) guard let match = regex.firstMatch(in: line, range: localRange) else { return } let bulletRange = match.range(at: 2) let absBullet = NSRange(location: lineRange.location + bulletRange.location, length: bulletRange.length) textStorage.addAttribute(.foregroundColor, value: palette.blue, range: absBullet) applyListIndent(line: line, lineRange: lineRange, textStorage: textStorage) } private func highlightTaskList(_ trimmed: String, lineRange: NSRange, line: String, textStorage: NSTextStorage, palette: CatppuccinPalette) { let checked: Bool if trimmed.hasPrefix("- [ ] ") || trimmed.hasPrefix("* [ ] ") || trimmed.hasPrefix("+ [ ] ") { checked = false } else if trimmed.hasPrefix("- [x] ") || trimmed.hasPrefix("* [x] ") || trimmed.hasPrefix("+ [x] ") || trimmed.hasPrefix("- [X] ") || trimmed.hasPrefix("* [X] ") || trimmed.hasPrefix("+ [X] ") { checked = true } else { return } // Find the checkbox in the actual line guard let cbRegex = try? NSRegularExpression(pattern: "\\[[ xX]\\]") else { return } guard let cbMatch = cbRegex.firstMatch(in: textStorage.string, range: lineRange) else { return } let cbRange = cbMatch.range if checked { textStorage.addAttribute(.foregroundColor, value: palette.green, range: cbRange) let afterCb = NSRange(location: cbRange.location + cbRange.length, length: NSMaxRange(lineRange) - (cbRange.location + cbRange.length)) if afterCb.length > 0 { textStorage.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: afterCb) } } else { textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: cbRange) } // Bullet coloring guard let bulletRegex = try? NSRegularExpression(pattern: "^(\\s*)([-*+])") else { return } let nsLine = line as NSString guard let bMatch = bulletRegex.firstMatch(in: line, range: NSRange(location: 0, length: nsLine.length)) else { return } let bulletRange = bMatch.range(at: 2) let absBullet = NSRange(location: lineRange.location + bulletRange.location, length: bulletRange.length) textStorage.addAttribute(.foregroundColor, value: palette.blue, range: absBullet) applyListIndent(line: line, lineRange: lineRange, textStorage: textStorage) } private func applyListIndent(line: String, lineRange: NSRange, textStorage: NSTextStorage) { let leading = line.prefix(while: { $0 == " " || $0 == "\t" }) let spaces = leading.filter { $0 == " " }.count let tabs = leading.filter { $0 == "\t" }.count let level = tabs + spaces / 2 if level > 0 { let indent = NSMutableParagraphStyle() let px = CGFloat(level) * 20.0 indent.headIndent = px indent.firstLineHeadIndent = px textStorage.addAttribute(.paragraphStyle, value: indent, range: lineRange) } } private func 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("=") } 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 var evalResults: [Int: String] = [:] 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) 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 = UserDefaults.standard.string(forKey: "lineIndicatorMode") ?? "on" 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 result = evalResults[lineNumber - 1] { let resultStr = NSAttributedString(string: "\u{2192} \(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: - Paste (image from clipboard) override func paste(_ sender: Any?) { let pb = NSPasteboard.general let imageTypes: [NSPasteboard.PasteboardType] = [.tiff, .png] if let imageType = imageTypes.first(where: { pb.data(forType: $0) != nil }), let data = pb.data(forType: imageType) { if let image = NSImage(data: data), let pngData = image.tiffRepresentation, let bitmap = NSBitmapImageRep(data: pngData), let png = bitmap.representation(using: .png, properties: [:]) { let uuid = UUID().uuidString let path = imageCacheDir.appendingPathComponent("\(uuid).png") do { try png.write(to: path) let markdown = "![image](~/.swiftly/images/\(uuid).png)" insertText(markdown, replacementRange: selectedRange()) return } catch {} } } super.paste(sender) } // MARK: - Drag and Drop override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation { if sender.draggingPasteboard.canReadObject(forClasses: [NSURL.self], options: [ .urlReadingFileURLsOnly: true ]) { return .copy } return super.draggingEntered(sender) } override func performDragOperation(_ sender: NSDraggingInfo) -> Bool { guard let urls = sender.draggingPasteboard.readObjects(forClasses: [NSURL.self], options: [ .urlReadingFileURLsOnly: true ]) as? [URL] else { return super.performDragOperation(sender) } let imageExts: Set = ["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) } }