add syntax highlighting and markdown rendering
This commit is contained in:
parent
328a73bc0c
commit
23fa977ce1
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue