6218a6ef28
- NSTextView-based syntax highlighting with regex tokenizer - Swift, C/C++, ObjC, JSON keywords, types, strings, comments - Theme-aware coloring, debounced re-highlighting - Workspace-wide search across all source files - Grouped results by file with line numbers - Scope toggle: current file vs all files - Git status badges on file tree nodes - GitService changes flow to FileNode.gitStatus - Agent file operations refresh file tree - Auto-restore last workspace on launch - All tests passing (0 errors, 0 warnings)
332 lines
14 KiB
Swift
332 lines
14 KiB
Swift
// SyntaxHighlightingTextView.swift
|
|
// CxIDE — NSViewRepresentable code editor with real-time syntax highlighting.
|
|
|
|
import SwiftUI
|
|
import AppKit
|
|
|
|
// MARK: - Syntax Highlighting Text View
|
|
|
|
struct SyntaxHighlightingTextView: NSViewRepresentable {
|
|
@Binding var text: String
|
|
let language: EditorTab.Language
|
|
let theme: IDETheme
|
|
let fontSize: CGFloat
|
|
var onTextChange: ((String) -> Void)?
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
Coordinator(self)
|
|
}
|
|
|
|
func makeNSView(context: Context) -> NSScrollView {
|
|
let scrollView = NSScrollView()
|
|
scrollView.hasVerticalScroller = true
|
|
scrollView.hasHorizontalScroller = false
|
|
scrollView.autohidesScrollers = true
|
|
scrollView.drawsBackground = false
|
|
scrollView.borderType = .noBorder
|
|
|
|
let textView = NSTextView()
|
|
textView.isEditable = true
|
|
textView.isSelectable = true
|
|
textView.isRichText = false
|
|
textView.allowsUndo = true
|
|
textView.usesFindPanel = true
|
|
textView.isAutomaticQuoteSubstitutionEnabled = false
|
|
textView.isAutomaticDashSubstitutionEnabled = false
|
|
textView.isAutomaticTextReplacementEnabled = false
|
|
textView.isAutomaticSpellingCorrectionEnabled = false
|
|
textView.isContinuousSpellCheckingEnabled = false
|
|
textView.smartInsertDeleteEnabled = false
|
|
|
|
textView.font = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular)
|
|
textView.textColor = NSColor(theme.editorForeground)
|
|
textView.backgroundColor = NSColor(theme.editorBackground)
|
|
textView.insertionPointColor = .white
|
|
textView.selectedTextAttributes = [
|
|
.backgroundColor: NSColor(theme.selectionColor)
|
|
]
|
|
|
|
textView.textContainerInset = NSSize(width: 4, height: 4)
|
|
textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
|
|
textView.minSize = NSSize(width: 0, height: 0)
|
|
textView.isVerticallyResizable = true
|
|
textView.isHorizontallyResizable = false
|
|
|
|
textView.textContainer?.containerSize = NSSize(
|
|
width: scrollView.contentSize.width,
|
|
height: CGFloat.greatestFiniteMagnitude
|
|
)
|
|
textView.textContainer?.widthTracksTextView = true
|
|
|
|
textView.delegate = context.coordinator
|
|
context.coordinator.textView = textView
|
|
|
|
scrollView.documentView = textView
|
|
|
|
// Initial content
|
|
textView.string = text
|
|
context.coordinator.applyHighlighting()
|
|
|
|
return scrollView
|
|
}
|
|
|
|
func updateNSView(_ scrollView: NSScrollView, context: Context) {
|
|
guard let textView = scrollView.documentView as? NSTextView else { return }
|
|
let coordinator = context.coordinator
|
|
|
|
// Update font if changed
|
|
let newFont = NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular)
|
|
if textView.font != newFont {
|
|
textView.font = newFont
|
|
coordinator.applyHighlighting()
|
|
}
|
|
|
|
// Update background color
|
|
textView.backgroundColor = NSColor(theme.editorBackground)
|
|
textView.insertionPointColor = .white
|
|
|
|
// Update theme reference
|
|
coordinator.parent = self
|
|
|
|
// Only update text if it changed externally (not from typing)
|
|
if textView.string != text && !coordinator.isUpdating {
|
|
coordinator.isUpdating = true
|
|
let selectedRanges = textView.selectedRanges
|
|
textView.string = text
|
|
coordinator.applyHighlighting()
|
|
textView.selectedRanges = selectedRanges
|
|
coordinator.isUpdating = false
|
|
}
|
|
}
|
|
|
|
// MARK: - Coordinator
|
|
|
|
class Coordinator: NSObject, NSTextViewDelegate {
|
|
var parent: SyntaxHighlightingTextView
|
|
weak var textView: NSTextView?
|
|
var isUpdating = false
|
|
private var highlightWorkItem: DispatchWorkItem?
|
|
|
|
init(_ parent: SyntaxHighlightingTextView) {
|
|
self.parent = parent
|
|
}
|
|
|
|
func textDidChange(_ notification: Notification) {
|
|
guard let textView = notification.object as? NSTextView, !isUpdating else { return }
|
|
isUpdating = true
|
|
parent.text = textView.string
|
|
parent.onTextChange?(textView.string)
|
|
isUpdating = false
|
|
|
|
// Debounce highlighting for performance
|
|
highlightWorkItem?.cancel()
|
|
let workItem = DispatchWorkItem { [weak self] in
|
|
DispatchQueue.main.async {
|
|
self?.applyHighlighting()
|
|
}
|
|
}
|
|
highlightWorkItem = workItem
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15, execute: workItem)
|
|
}
|
|
|
|
// MARK: - Syntax Highlighting
|
|
|
|
func applyHighlighting() {
|
|
guard let textView = textView,
|
|
let textStorage = textView.textStorage else { return }
|
|
|
|
let text = textView.string
|
|
guard !text.isEmpty else { return }
|
|
|
|
let fullRange = NSRange(location: 0, length: (text as NSString).length)
|
|
let theme = parent.theme
|
|
let font = NSFont.monospacedSystemFont(ofSize: parent.fontSize, weight: .regular)
|
|
|
|
textStorage.beginEditing()
|
|
|
|
// Reset to default
|
|
textStorage.setAttributes([
|
|
.foregroundColor: NSColor(theme.editorForeground),
|
|
.font: font
|
|
], range: fullRange)
|
|
|
|
let highlighter = SyntaxTokenizer(language: parent.language, theme: theme, font: font)
|
|
highlighter.tokenize(text: text, in: textStorage)
|
|
|
|
textStorage.endEditing()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Syntax Tokenizer
|
|
|
|
struct SyntaxTokenizer {
|
|
let language: EditorTab.Language
|
|
let theme: IDETheme
|
|
let font: NSFont
|
|
|
|
func tokenize(text: String, in storage: NSTextStorage) {
|
|
let nsText = text as NSString
|
|
|
|
// 1. Comments (must come first — overrides keywords inside comments)
|
|
highlightPattern(storage, text: nsText, pattern: "//[^\n]*", color: theme.commentColor)
|
|
highlightPattern(storage, text: nsText, pattern: "/\\*[\\s\\S]*?\\*/", color: theme.commentColor)
|
|
|
|
// 2. Strings
|
|
highlightPattern(storage, text: nsText, pattern: "\"\"\"[\\s\\S]*?\"\"\"", color: theme.stringColor)
|
|
highlightPattern(storage, text: nsText, pattern: "\"(?:[^\"\\\\]|\\\\.)*\"", color: theme.stringColor)
|
|
|
|
// 3. Numbers
|
|
highlightPattern(storage, text: nsText, pattern: "\\b\\d+(\\.\\d+)?\\b", color: theme.numberColor)
|
|
highlightPattern(storage, text: nsText, pattern: "\\b0x[0-9a-fA-F]+\\b", color: theme.numberColor)
|
|
|
|
// 4. Language-specific keywords
|
|
let keywords = keywordsForLanguage()
|
|
if !keywords.isEmpty {
|
|
let pattern = "\\b(" + keywords.joined(separator: "|") + ")\\b"
|
|
highlightPattern(storage, text: nsText, pattern: pattern, color: theme.keywordColor)
|
|
}
|
|
|
|
// 5. Types
|
|
let types = typesForLanguage()
|
|
if !types.isEmpty {
|
|
let pattern = "\\b(" + types.joined(separator: "|") + ")\\b"
|
|
highlightPattern(storage, text: nsText, pattern: pattern, color: theme.typeColor)
|
|
}
|
|
|
|
// 6. Preprocessor / annotations
|
|
switch language {
|
|
case .swift:
|
|
highlightPattern(storage, text: nsText, pattern: "@\\w+", color: theme.preprocessorColor)
|
|
highlightPattern(storage, text: nsText, pattern: "#\\w+", color: theme.preprocessorColor)
|
|
case .c, .cpp, .header, .objc:
|
|
highlightPattern(storage, text: nsText, pattern: "#\\w+[^\n]*", color: theme.preprocessorColor)
|
|
default:
|
|
break
|
|
}
|
|
|
|
// 7. Function calls: word followed by (
|
|
highlightPattern(storage, text: nsText, pattern: "\\b([a-zA-Z_]\\w*)\\s*(?=\\()", color: theme.functionColor)
|
|
|
|
// 8. Type declarations (capitalized identifiers after class/struct/enum/protocol)
|
|
highlightPattern(storage, text: nsText, pattern: "(?<=\\b(?:class|struct|enum|protocol|extension|typealias)\\s)\\w+", color: theme.typeColor)
|
|
}
|
|
|
|
private func highlightPattern(_ storage: NSTextStorage, text: NSString, pattern: String, color: Color) {
|
|
guard let regex = try? NSRegularExpression(pattern: pattern, options: []) else { return }
|
|
let fullRange = NSRange(location: 0, length: text.length)
|
|
|
|
regex.enumerateMatches(in: text as String, options: [], range: fullRange) { match, _, _ in
|
|
guard let matchRange = match?.range else { return }
|
|
storage.addAttribute(.foregroundColor, value: NSColor(color), range: matchRange)
|
|
}
|
|
}
|
|
|
|
private func keywordsForLanguage() -> [String] {
|
|
switch language {
|
|
case .swift:
|
|
return [
|
|
"import", "func", "var", "let", "class", "struct", "enum", "protocol",
|
|
"extension", "typealias", "init", "deinit", "subscript", "operator",
|
|
"if", "else", "guard", "switch", "case", "default", "for", "while",
|
|
"repeat", "do", "catch", "throw", "throws", "rethrows", "try",
|
|
"return", "break", "continue", "fallthrough", "where", "in",
|
|
"as", "is", "nil", "true", "false", "self", "Self", "super",
|
|
"async", "await", "actor", "nonisolated", "isolated", "sending",
|
|
"public", "private", "internal", "fileprivate", "open",
|
|
"static", "final", "override", "mutating", "nonmutating",
|
|
"lazy", "weak", "unowned", "inout", "some", "any",
|
|
"associatedtype", "convenience", "required", "dynamic",
|
|
"willSet", "didSet", "get", "set", "defer", "indirect",
|
|
"precedencegroup", "infix", "prefix", "postfix",
|
|
"@MainActor", "@Published", "@State", "@Binding", "@ObservedObject",
|
|
"@StateObject", "@Environment", "@EnvironmentObject", "@ViewBuilder",
|
|
"@escaping", "@autoclosure", "@discardableResult", "@available",
|
|
"@objc", "@unchecked", "Sendable", "MainActor"
|
|
]
|
|
case .c, .header:
|
|
return [
|
|
"auto", "break", "case", "char", "const", "continue", "default",
|
|
"do", "double", "else", "enum", "extern", "float", "for", "goto",
|
|
"if", "int", "long", "register", "return", "short", "signed",
|
|
"sizeof", "static", "struct", "switch", "typedef", "union",
|
|
"unsigned", "void", "volatile", "while", "inline", "restrict",
|
|
"_Bool", "_Complex", "_Imaginary",
|
|
"NULL", "true", "false"
|
|
]
|
|
case .cpp:
|
|
return [
|
|
"auto", "break", "case", "char", "const", "continue", "default",
|
|
"do", "double", "else", "enum", "extern", "float", "for", "goto",
|
|
"if", "int", "long", "register", "return", "short", "signed",
|
|
"sizeof", "static", "struct", "switch", "typedef", "union",
|
|
"unsigned", "void", "volatile", "while", "inline", "restrict",
|
|
"class", "namespace", "template", "typename", "using", "virtual",
|
|
"public", "private", "protected", "friend", "operator", "new",
|
|
"delete", "this", "throw", "try", "catch", "const_cast",
|
|
"dynamic_cast", "reinterpret_cast", "static_cast", "explicit",
|
|
"mutable", "override", "final", "noexcept", "constexpr",
|
|
"nullptr", "true", "false", "bool", "wchar_t",
|
|
"thread_local", "decltype", "alignas", "alignof",
|
|
"static_assert", "concept", "requires", "co_await", "co_return",
|
|
"co_yield", "consteval", "constinit"
|
|
]
|
|
case .objc:
|
|
return [
|
|
"auto", "break", "case", "char", "const", "continue", "default",
|
|
"do", "double", "else", "enum", "extern", "float", "for", "goto",
|
|
"if", "int", "long", "register", "return", "short", "signed",
|
|
"sizeof", "static", "struct", "switch", "typedef", "union",
|
|
"unsigned", "void", "volatile", "while", "inline",
|
|
"@interface", "@implementation", "@end", "@protocol", "@property",
|
|
"@synthesize", "@dynamic", "@optional", "@required", "@class",
|
|
"@selector", "@encode", "@try", "@catch", "@finally", "@throw",
|
|
"@autoreleasepool", "@synchronized",
|
|
"self", "super", "nil", "NULL", "YES", "NO", "true", "false",
|
|
"id", "instancetype", "SEL", "IMP", "Class", "BOOL",
|
|
"strong", "weak", "assign", "copy", "retain", "nonatomic", "atomic",
|
|
"readonly", "readwrite", "nullable", "nonnull"
|
|
]
|
|
case .json:
|
|
return ["true", "false", "null"]
|
|
case .markdown, .plaintext:
|
|
return []
|
|
}
|
|
}
|
|
|
|
private func typesForLanguage() -> [String] {
|
|
switch language {
|
|
case .swift:
|
|
return [
|
|
"Int", "Int8", "Int16", "Int32", "Int64",
|
|
"UInt", "UInt8", "UInt16", "UInt32", "UInt64",
|
|
"Float", "Double", "Float16", "Float80",
|
|
"Bool", "String", "Character", "Void", "Never",
|
|
"Optional", "Result", "Array", "Dictionary", "Set",
|
|
"AnyObject", "AnyHashable", "AnySequence", "AnyCollection",
|
|
"Data", "Date", "URL", "UUID", "Error",
|
|
"Codable", "Encodable", "Decodable", "Hashable",
|
|
"Equatable", "Comparable", "Identifiable", "CustomStringConvertible",
|
|
"Sequence", "Collection", "IteratorProtocol", "RangeReplaceableCollection",
|
|
"View", "ObservableObject", "ObservedObject", "Published",
|
|
"Color", "Image", "Text", "Button", "VStack", "HStack", "ZStack",
|
|
"List", "ForEach", "NavigationView", "NavigationLink",
|
|
"CGFloat", "CGPoint", "CGSize", "CGRect", "NSSize",
|
|
"DispatchQueue", "Task", "AsyncSequence", "AsyncStream"
|
|
]
|
|
case .cpp:
|
|
return [
|
|
"string", "vector", "map", "unordered_map", "set", "unordered_set",
|
|
"pair", "tuple", "array", "list", "deque", "queue", "stack",
|
|
"shared_ptr", "unique_ptr", "weak_ptr", "optional", "variant",
|
|
"size_t", "ptrdiff_t", "int8_t", "int16_t", "int32_t", "int64_t",
|
|
"uint8_t", "uint16_t", "uint32_t", "uint64_t",
|
|
"iostream", "istream", "ostream", "ifstream", "ofstream",
|
|
"stringstream", "istringstream", "ostringstream"
|
|
]
|
|
default:
|
|
return []
|
|
}
|
|
}
|
|
}
|