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.isSelectable = true
|
||||
textView.allowsUndo = true
|
||||
textView.isRichText = false
|
||||
textView.isRichText = true
|
||||
textView.usesFindBar = true
|
||||
textView.isIncrementalSearchingEnabled = true
|
||||
textView.font = Theme.editorFont
|
||||
|
|
@ -74,6 +74,12 @@ struct EditorTextView: NSViewRepresentable {
|
|||
context.coordinator.textView = textView
|
||||
context.coordinator.rulerView = ruler
|
||||
|
||||
if let ts = textView.textStorage {
|
||||
ts.beginEditing()
|
||||
applySyntaxHighlighting(to: ts)
|
||||
ts.endEditing()
|
||||
}
|
||||
|
||||
return scrollView
|
||||
}
|
||||
|
||||
|
|
@ -82,6 +88,11 @@ struct EditorTextView: NSViewRepresentable {
|
|||
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
|
||||
}
|
||||
textView.font = Theme.editorFont
|
||||
|
|
@ -105,8 +116,13 @@ struct EditorTextView: NSViewRepresentable {
|
|||
}
|
||||
|
||||
func textDidChange(_ notification: Notification) {
|
||||
guard let tv = textView else { return }
|
||||
guard let tv = textView, let ts = tv.textStorage else { return }
|
||||
parent.text = tv.string
|
||||
let sel = tv.selectedRanges
|
||||
ts.beginEditing()
|
||||
applySyntaxHighlighting(to: ts)
|
||||
ts.endEditing()
|
||||
tv.selectedRanges = sel
|
||||
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 {
|
||||
override func drawInsertionPoint(in rect: NSRect, color: NSColor, turnedOn flag: Bool) {
|
||||
var widened = rect
|
||||
|
|
|
|||
Loading…
Reference in New Issue