0: GFM markdown rendering — fenced code blocks, inline code, tables, lists, task lists, strikethrough, footnotes, horizontal rules
This commit is contained in:
parent
94b0965be2
commit
6b5404f679
|
|
@ -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<Int> {
|
||||
var headerStarts = Set<Int>()
|
||||
let nsText = textStorage.string as NSString
|
||||
var lineStart = 0
|
||||
var prevLineStart: Int? = nil
|
||||
var prevTrimmed: String? = nil
|
||||
|
||||
while lineStart < nsText.length {
|
||||
let lineRange = nsText.lineRange(for: NSRange(location: lineStart, length: 0))
|
||||
|
||||
if !isInsideFencedBlock(lineRange, fencedRanges: fencedRanges) {
|
||||
let line = nsText.substring(with: lineRange)
|
||||
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
if trimmed.hasPrefix("|"), isTableSeparator(trimmed),
|
||||
let pStart = prevLineStart, let pTrimmed = prevTrimmed,
|
||||
pTrimmed.hasPrefix("|") {
|
||||
headerStarts.insert(pStart)
|
||||
}
|
||||
|
||||
prevLineStart = lineRange.location
|
||||
prevTrimmed = trimmed
|
||||
} else {
|
||||
prevLineStart = nil
|
||||
prevTrimmed = nil
|
||||
}
|
||||
|
||||
lineStart = NSMaxRange(lineRange)
|
||||
}
|
||||
|
||||
return headerStarts
|
||||
}
|
||||
|
||||
private func isTableSeparator(_ trimmed: String) -> Bool {
|
||||
guard trimmed.hasPrefix("|") else { return false }
|
||||
let inner = trimmed.trimmingCharacters(in: CharacterSet(charactersIn: "| "))
|
||||
guard !inner.isEmpty else { return false }
|
||||
let cells = inner.components(separatedBy: "|")
|
||||
return cells.allSatisfy { cell in
|
||||
let c = cell.trimmingCharacters(in: .whitespaces)
|
||||
guard let regex = try? NSRegularExpression(pattern: "^:?-{1,}:?$") else { return false }
|
||||
return regex.firstMatch(in: c, range: NSRange(location: 0, length: (c as NSString).length)) != nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - LineNumberTextView
|
||||
|
||||
class LineNumberTextView: NSTextView {
|
||||
|
|
|
|||
Loading…
Reference in New Issue