diff --git a/src/EditorView.swift b/src/EditorView.swift index 1845696..0c6bc0d 100644 --- a/src/EditorView.swift +++ b/src/EditorView.swift @@ -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 = [ + "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: "(? 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