add syntax highlighting and markdown rendering

This commit is contained in:
jess 2026-04-04 22:10:09 -07:00
parent 328a73bc0c
commit 23fa977ce1
1 changed files with 253 additions and 2 deletions

View File

@ -32,7 +32,7 @@ struct EditorTextView: NSViewRepresentable {
textView.isEditable = true textView.isEditable = true
textView.isSelectable = true textView.isSelectable = true
textView.allowsUndo = true textView.allowsUndo = true
textView.isRichText = false textView.isRichText = true
textView.usesFindBar = true textView.usesFindBar = true
textView.isIncrementalSearchingEnabled = true textView.isIncrementalSearchingEnabled = true
textView.font = Theme.editorFont textView.font = Theme.editorFont
@ -74,6 +74,12 @@ struct EditorTextView: NSViewRepresentable {
context.coordinator.textView = textView context.coordinator.textView = textView
context.coordinator.rulerView = ruler context.coordinator.rulerView = ruler
if let ts = textView.textStorage {
ts.beginEditing()
applySyntaxHighlighting(to: ts)
ts.endEditing()
}
return scrollView return scrollView
} }
@ -82,6 +88,11 @@ struct EditorTextView: NSViewRepresentable {
if textView.string != text { if textView.string != text {
let selectedRanges = textView.selectedRanges let selectedRanges = textView.selectedRanges
textView.string = text textView.string = text
if let ts = textView.textStorage {
ts.beginEditing()
applySyntaxHighlighting(to: ts)
ts.endEditing()
}
textView.selectedRanges = selectedRanges textView.selectedRanges = selectedRanges
} }
textView.font = Theme.editorFont textView.font = Theme.editorFont
@ -105,8 +116,13 @@ struct EditorTextView: NSViewRepresentable {
} }
func textDidChange(_ notification: Notification) { func textDidChange(_ notification: Notification) {
guard let tv = textView else { return } guard let tv = textView, let ts = tv.textStorage else { return }
parent.text = tv.string parent.text = tv.string
let sel = tv.selectedRanges
ts.beginEditing()
applySyntaxHighlighting(to: ts)
ts.endEditing()
tv.selectedRanges = sel
rulerView?.needsDisplay = true rulerView?.needsDisplay = true
} }
@ -123,6 +139,241 @@ struct EditorTextView: NSViewRepresentable {
} }
} }
// MARK: - Syntax Highlighting
private let syntaxKeywords: Set<String> = [
"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 nsText = text as NSString
var lineStart = 0
while lineStart < nsText.length {
let lineRange = nsText.lineRange(for: NSRange(location: lineStart, length: 0))
let line = nsText.substring(with: lineRange)
let trimmed = line.trimmingCharacters(in: .whitespaces)
if highlightMarkdownLine(trimmed, lineRange: lineRange, textStorage: textStorage, baseFont: baseFont, palette: palette) {
lineStart = NSMaxRange(lineRange)
continue
}
highlightCodeLine(line, lineRange: lineRange, textStorage: textStorage, syn: syn)
lineStart = NSMaxRange(lineRange)
}
highlightBlockComments(textStorage: textStorage, syn: syn, baseFont: baseFont)
}
private func highlightMarkdownLine(_ trimmed: String, lineRange: NSRange, textStorage: NSTextStorage, baseFont: NSFont, palette: CatppuccinPalette) -> 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 h2Font = NSFont.systemFont(ofSize: 18, weight: .bold)
textStorage.addAttribute(.font, value: h2Font, 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)
}
}
return true
}
if trimmed.hasPrefix("> ") {
textStorage.addAttribute(.foregroundColor, value: palette.overlay2, range: lineRange)
let indent = NSMutableParagraphStyle()
indent.headIndent = 20
indent.firstLineHeadIndent = 20
textStorage.addAttribute(.paragraphStyle, value: indent, range: lineRange)
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: .italicTrait)
let boldFont = NSFontManager.shared.convert(baseFont, toHaveTrait: .boldTrait)
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: .boldTrait, font: boldFont)
// Italic markers *text*
highlightInlineMarkdown(textStorage: textStorage, lineRange: lineRange, pattern: "(?<!\\*)\\*(?!\\*)(.+?)(?<!\\*)\\*(?!\\*)", markerLen: 1, trait: .italicTrait, font: italicFont)
}
private func highlightInlineMarkdown(textStorage: NSTextStorage, lineRange: NSRange, pattern: String, markerLen: Int, trait: NSFontTraitMask, font: NSFont) {
let palette = Theme.current
guard let regex = try? NSRegularExpression(pattern: pattern) else { return }
let matches = regex.matches(in: textStorage.string, range: lineRange)
for match in matches {
let fullRange = match.range
let openMarker = NSRange(location: fullRange.location, length: markerLen)
let closeMarker = NSRange(location: fullRange.location + fullRange.length - markerLen, length: markerLen)
let contentRange = NSRange(location: fullRange.location + markerLen, length: fullRange.length - markerLen * 2)
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: openMarker)
textStorage.addAttribute(.foregroundColor, value: palette.overlay0, range: closeMarker)
if contentRange.length > 0 {
textStorage.addAttribute(.font, value: font, range: contentRange)
}
}
}
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: .italicTrait)
guard let regex = try? NSRegularExpression(pattern: "/\\*.*?\\*/", options: .dotMatchesLineSeparators) else { return }
let fullRange = NSRange(location: 0, length: nsText.length)
let matches = regex.matches(in: text, range: fullRange)
for match in matches {
textStorage.addAttributes([
.foregroundColor: syn.comment,
.font: italicFont
], range: match.range)
}
}
// MARK: - LineNumberTextView
class LineNumberTextView: NSTextView { class LineNumberTextView: NSTextView {
override func drawInsertionPoint(in rect: NSRect, color: NSColor, turnedOn flag: Bool) { override func drawInsertionPoint(in rect: NSRect, color: NSColor, turnedOn flag: Bool) {
var widened = rect var widened = rect