From 6b5404f679ec31569d27f62a93378ebd80dcf6b6 Mon Sep 17 00:00:00 2001 From: jess Date: Sat, 4 Apr 2026 22:30:51 -0700 Subject: [PATCH] =?UTF-8?q?0:=20GFM=20markdown=20rendering=20=E2=80=94=20f?= =?UTF-8?q?enced=20code=20blocks,=20inline=20code,=20tables,=20lists,=20ta?= =?UTF-8?q?sk=20lists,=20strikethrough,=20footnotes,=20horizontal=20rules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/EditorView.swift | 344 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 342 insertions(+), 2 deletions(-) diff --git a/src/EditorView.swift b/src/EditorView.swift index 85cb3b9..4d3d751 100644 --- a/src/EditorView.swift +++ b/src/EditorView.swift @@ -160,20 +160,36 @@ func applySyntaxHighlighting(to textStorage: NSTextStorage) { ] 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, lineRange: lineRange, textStorage: textStorage, baseFont: baseFont, palette: palette) { + if highlightMarkdownLine(trimmed, line: line, lineRange: lineRange, textStorage: textStorage, baseFont: baseFont, palette: palette, isTableHeader: isTableHeader) { + highlightInlineCode(textStorage: textStorage, lineRange: lineRange, palette: palette, baseFont: baseFont) + highlightStrikethrough(textStorage: textStorage, lineRange: lineRange, palette: palette) + highlightFootnoteRefs(textStorage: textStorage, lineRange: lineRange, palette: palette) lineStart = NSMaxRange(lineRange) continue } highlightCodeLine(line, lineRange: lineRange, textStorage: textStorage, syn: syn) + highlightInlineCode(textStorage: textStorage, lineRange: lineRange, palette: palette, baseFont: baseFont) + highlightStrikethrough(textStorage: textStorage, lineRange: lineRange, palette: palette) + highlightFootnoteRefs(textStorage: textStorage, lineRange: lineRange, palette: palette) lineStart = NSMaxRange(lineRange) } @@ -181,7 +197,7 @@ func applySyntaxHighlighting(to textStorage: NSTextStorage) { highlightBlockComments(textStorage: textStorage, syn: syn, baseFont: baseFont) } -private func highlightMarkdownLine(_ trimmed: String, lineRange: NSRange, textStorage: NSTextStorage, baseFont: NSFont, palette: CatppuccinPalette) -> Bool { +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 { @@ -219,6 +235,48 @@ private func highlightMarkdownLine(_ trimmed: String, lineRange: NSRange, textSt return true } + // Horizontal rule + if isHorizontalRule(trimmed) { + textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: lineRange) + textStorage.addAttribute(.underlineStyle, value: NSUnderlineStyle.single.rawValue, range: lineRange) + return true + } + + // Footnote definition + if trimmed.hasPrefix("[^") && trimmed.contains("]:") { + highlightFootnoteDefinition(textStorage: textStorage, lineRange: lineRange, palette: palette) + return true + } + + // Task list (check before generic unordered list) + if trimmed.hasPrefix("- [ ] ") || trimmed.hasPrefix("- [x] ") || trimmed.hasPrefix("- [X] ") || + trimmed.hasPrefix("* [ ] ") || trimmed.hasPrefix("* [x] ") || trimmed.hasPrefix("* [X] ") || + trimmed.hasPrefix("+ [ ] ") || trimmed.hasPrefix("+ [x] ") || trimmed.hasPrefix("+ [X] ") { + highlightTaskList(trimmed, lineRange: lineRange, line: line, textStorage: textStorage, palette: palette) + return true + } + + // Table rows + if trimmed.hasPrefix("|") { + let isSep = isTableSeparator(trimmed) + highlightTableLine(trimmed, lineRange: lineRange, textStorage: textStorage, palette: palette, baseFont: baseFont, isHeader: isTableHeader, isSeparator: isSep) + return true + } + + // Unordered list + if let regex = try? NSRegularExpression(pattern: "^\\s*[-*+] "), + regex.firstMatch(in: line, range: NSRange(location: 0, length: (line as NSString).length)) != nil { + highlightUnorderedList(trimmed, lineRange: lineRange, line: line, textStorage: textStorage, palette: palette) + return true + } + + // Ordered list + if let regex = try? NSRegularExpression(pattern: "^\\s*\\d+\\. "), + regex.firstMatch(in: line, range: NSRange(location: 0, length: (line as NSString).length)) != nil { + highlightOrderedList(trimmed, lineRange: lineRange, line: line, textStorage: textStorage, palette: palette) + return true + } + return false } @@ -372,6 +430,288 @@ private func highlightBlockComments(textStorage: NSTextStorage, syn: Theme.Synta } } +// 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 { + // Content inside fenced block + textStorage.addAttribute(.font, value: monoFont, range: lineRange) + textStorage.addAttribute(.backgroundColor, value: palette.surface0, range: lineRange) + } + } + + lineStart = NSMaxRange(lineRange) + } + + return fencedRanges +} + +private func isInsideFencedBlock(_ lineRange: NSRange, fencedRanges: [NSRange]) -> Bool { + for fenced in fencedRanges { + if lineRange.location >= fenced.location && NSMaxRange(lineRange) <= NSMaxRange(fenced) { + return true + } + } + return false +} + +// MARK: - Inline Code + +private func highlightInlineCode(textStorage: NSTextStorage, lineRange: NSRange, palette: CatppuccinPalette, baseFont: NSFont) { + guard let regex = try? NSRegularExpression(pattern: "`([^`]+)`") else { return } + let monoFont = NSFont.monospacedSystemFont(ofSize: baseFont.pointSize, weight: .regular) + let matches = regex.matches(in: textStorage.string, range: lineRange) + for match in matches { + let fullRange = match.range + let openTick = NSRange(location: fullRange.location, length: 1) + let closeTick = NSRange(location: fullRange.location + fullRange.length - 1, length: 1) + let contentRange = NSRange(location: fullRange.location + 1, length: fullRange.length - 2) + textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: openTick) + textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: closeTick) + if contentRange.length > 0 { + textStorage.addAttribute(.font, value: monoFont, range: contentRange) + textStorage.addAttribute(.backgroundColor, value: palette.surface0, range: contentRange) + } + } +} + +// MARK: - Strikethrough + +private func highlightStrikethrough(textStorage: NSTextStorage, lineRange: NSRange, palette: CatppuccinPalette) { + guard let regex = try? NSRegularExpression(pattern: "~~(.+?)~~") else { return } + let matches = regex.matches(in: textStorage.string, range: lineRange) + for match in matches { + let fullRange = match.range + let openMarker = NSRange(location: fullRange.location, length: 2) + let closeMarker = NSRange(location: fullRange.location + fullRange.length - 2, length: 2) + let contentRange = NSRange(location: fullRange.location + 2, length: fullRange.length - 4) + textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: openMarker) + textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: closeMarker) + if contentRange.length > 0 { + textStorage.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: contentRange) + } + } +} + +// MARK: - Footnotes + +private func highlightFootnoteRefs(textStorage: NSTextStorage, lineRange: NSRange, palette: CatppuccinPalette) { + // Inline refs: [^label] (but not definitions which start line with [^label]:) + guard let regex = try? NSRegularExpression(pattern: "\\[\\^[^\\]]+\\](?!:)") else { return } + let matches = regex.matches(in: textStorage.string, range: lineRange) + for match in matches { + textStorage.addAttribute(.foregroundColor, value: palette.blue, range: match.range) + textStorage.addAttribute(.superscript, value: 1, range: match.range) + } +} + +private func highlightFootnoteDefinition(textStorage: NSTextStorage, lineRange: NSRange, palette: CatppuccinPalette) { + guard let regex = try? NSRegularExpression(pattern: "^\\[\\^[^\\]]+\\]:") else { return } + let nsText = textStorage.string as NSString + let lineContent = nsText.substring(with: lineRange) + let localRange = NSRange(location: 0, length: (lineContent as NSString).length) + let matches = regex.matches(in: lineContent, range: localRange) + for match in matches { + let absRange = NSRange(location: lineRange.location + match.range.location, length: match.range.length) + textStorage.addAttribute(.foregroundColor, value: palette.lavender, range: absRange) + } +} + +// MARK: - Tables + +private func highlightTableLine(_ trimmed: String, lineRange: NSRange, textStorage: NSTextStorage, palette: CatppuccinPalette, baseFont: NSFont, isHeader: Bool, isSeparator: Bool) { + let monoFont = NSFont.monospacedSystemFont(ofSize: baseFont.pointSize, weight: .regular) + let boldMono = NSFontManager.shared.convert(monoFont, toHaveTrait: .boldFontMask) + + textStorage.addAttribute(.font, value: monoFont, range: lineRange) + + if isSeparator { + textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: lineRange) + return + } + + if isHeader { + textStorage.addAttribute(.font, value: boldMono, range: lineRange) + } + + // Mute pipe delimiters + guard let pipeRegex = try? NSRegularExpression(pattern: "\\|") else { return } + let matches = pipeRegex.matches(in: textStorage.string, range: lineRange) + for match in matches { + textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: match.range) + } +} + +// MARK: - Lists and Horizontal Rules + +private func highlightOrderedList(_ trimmed: String, lineRange: NSRange, line: String, textStorage: NSTextStorage, palette: CatppuccinPalette) { + guard let regex = try? NSRegularExpression(pattern: "^(\\s*)(\\d+\\.)( )") else { return } + let nsLine = line as NSString + let localRange = NSRange(location: 0, length: nsLine.length) + guard let match = regex.firstMatch(in: line, range: localRange) else { return } + let markerRange = match.range(at: 2) + let absMarker = NSRange(location: lineRange.location + markerRange.location, length: markerRange.length) + textStorage.addAttribute(.foregroundColor, value: palette.blue, range: absMarker) + + applyListIndent(line: line, lineRange: lineRange, textStorage: textStorage) +} + +private func highlightUnorderedList(_ trimmed: String, lineRange: NSRange, line: String, textStorage: NSTextStorage, palette: CatppuccinPalette) { + guard let regex = try? NSRegularExpression(pattern: "^(\\s*)([-*+])( )") else { return } + let nsLine = line as NSString + let localRange = NSRange(location: 0, length: nsLine.length) + guard let match = regex.firstMatch(in: line, range: localRange) else { return } + let bulletRange = match.range(at: 2) + let absBullet = NSRange(location: lineRange.location + bulletRange.location, length: bulletRange.length) + textStorage.addAttribute(.foregroundColor, value: palette.blue, range: absBullet) + + applyListIndent(line: line, lineRange: lineRange, textStorage: textStorage) +} + +private func highlightTaskList(_ trimmed: String, lineRange: NSRange, line: String, textStorage: NSTextStorage, palette: CatppuccinPalette) { + let checked: Bool + if trimmed.hasPrefix("- [ ] ") || trimmed.hasPrefix("* [ ] ") || trimmed.hasPrefix("+ [ ] ") { + checked = false + } else if trimmed.hasPrefix("- [x] ") || trimmed.hasPrefix("* [x] ") || trimmed.hasPrefix("+ [x] ") || + trimmed.hasPrefix("- [X] ") || trimmed.hasPrefix("* [X] ") || trimmed.hasPrefix("+ [X] ") { + checked = true + } else { + return + } + + // Find the checkbox in the actual line + guard let cbRegex = try? NSRegularExpression(pattern: "\\[[ xX]\\]") else { return } + guard let cbMatch = cbRegex.firstMatch(in: textStorage.string, range: lineRange) else { return } + let cbRange = cbMatch.range + + if checked { + textStorage.addAttribute(.foregroundColor, value: palette.green, range: cbRange) + let afterCb = NSRange(location: cbRange.location + cbRange.length, length: NSMaxRange(lineRange) - (cbRange.location + cbRange.length)) + if afterCb.length > 0 { + textStorage.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: afterCb) + } + } else { + textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: cbRange) + } + + // Bullet coloring + guard let bulletRegex = try? NSRegularExpression(pattern: "^(\\s*)([-*+])") else { return } + let nsLine = line as NSString + guard let bMatch = bulletRegex.firstMatch(in: line, range: NSRange(location: 0, length: nsLine.length)) else { return } + let bulletRange = bMatch.range(at: 2) + let absBullet = NSRange(location: lineRange.location + bulletRange.location, length: bulletRange.length) + textStorage.addAttribute(.foregroundColor, value: palette.blue, range: absBullet) + + applyListIndent(line: line, lineRange: lineRange, textStorage: textStorage) +} + +private func applyListIndent(line: String, lineRange: NSRange, textStorage: NSTextStorage) { + let leading = line.prefix(while: { $0 == " " || $0 == "\t" }) + let spaces = leading.filter { $0 == " " }.count + let tabs = leading.filter { $0 == "\t" }.count + let level = tabs + spaces / 2 + if level > 0 { + let indent = NSMutableParagraphStyle() + let px = CGFloat(level) * 20.0 + indent.headIndent = px + indent.firstLineHeadIndent = px + textStorage.addAttribute(.paragraphStyle, value: indent, range: lineRange) + } +} + +private func isHorizontalRule(_ trimmed: String) -> Bool { + if trimmed.isEmpty { return false } + let stripped = trimmed.replacingOccurrences(of: " ", with: "") + if stripped.count < 3 { return false } + let allDash = stripped.allSatisfy { $0 == "-" } + let allStar = stripped.allSatisfy { $0 == "*" } + let allUnderscore = stripped.allSatisfy { $0 == "_" } + return allDash || allStar || allUnderscore +} + +private func findTableHeaderLines(textStorage: NSTextStorage, fencedRanges: [NSRange]) -> Set { + 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: - LineNumberTextView class LineNumberTextView: NSTextView {