167 lines
7.1 KiB
Swift
167 lines
7.1 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
|
|
// MARK: - ZshSyntaxHighlightingEditor
|
|
|
|
/// NSTextView-based code editor with zsh/shell syntax highlighting.
|
|
/// Supports comments, keywords, quoted strings, and variable references.
|
|
struct ZshSyntaxHighlightingEditor: NSViewRepresentable {
|
|
@Binding var text: String
|
|
var isEditable: Bool = true
|
|
/// Called whenever the user edits the content (not on programmatic updates).
|
|
var onChange: (() -> Void)? = nil
|
|
/// Increment to trigger a one-shot scroll to the bottom of the document.
|
|
var scrollToBottomID: Int = 0
|
|
|
|
func makeCoordinator() -> Coordinator { Coordinator(self) }
|
|
|
|
func makeNSView(context: Context) -> NSScrollView {
|
|
let scrollView = NSTextView.scrollableTextView()
|
|
guard let textView = scrollView.documentView as? NSTextView else { return scrollView }
|
|
|
|
scrollView.hasVerticalScroller = true
|
|
scrollView.autohidesScrollers = true
|
|
scrollView.drawsBackground = false
|
|
|
|
textView.delegate = context.coordinator
|
|
textView.isEditable = isEditable
|
|
textView.isSelectable = true
|
|
textView.allowsUndo = true
|
|
textView.drawsBackground = false
|
|
textView.backgroundColor = .clear
|
|
textView.textContainerInset = NSSize(width: 2, height: 4)
|
|
textView.isVerticallyResizable = true
|
|
textView.isHorizontallyResizable = false
|
|
textView.autoresizingMask = .width
|
|
textView.textContainer?.widthTracksTextView = true
|
|
textView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude)
|
|
textView.isAutomaticQuoteSubstitutionEnabled = false
|
|
textView.isAutomaticDashSubstitutionEnabled = false
|
|
textView.isAutomaticSpellingCorrectionEnabled = false
|
|
textView.isContinuousSpellCheckingEnabled = false
|
|
textView.isGrammarCheckingEnabled = false
|
|
textView.isAutomaticTextReplacementEnabled = false
|
|
textView.typingAttributes = Self.baseAttributes
|
|
|
|
if !text.isEmpty {
|
|
textView.string = text
|
|
Self.applyHighlighting(to: textView)
|
|
}
|
|
// Store the initial content so `updateNSView` can detect external resets.
|
|
context.coordinator.lastTextFedToBinding = text
|
|
return scrollView
|
|
}
|
|
|
|
func updateNSView(_ nsView: NSScrollView, context: Context) {
|
|
guard let textView = nsView.documentView as? NSTextView else { return }
|
|
guard !context.coordinator.isHighlighting else { return }
|
|
|
|
// Reset `textView.string` only when external input, such as loading a file or appending a
|
|
// snippet, actually changed `text`. Do not compare `textView.string != text`: `@Published`
|
|
// emits `objectWillChange` in `willSet`, so `updateNSView` can observe the old value and
|
|
// incorrectly reset the editor, clearing the undo stack.
|
|
if text != context.coordinator.lastTextFedToBinding {
|
|
context.coordinator.lastTextFedToBinding = text
|
|
let sel = textView.selectedRange()
|
|
textView.string = text
|
|
Self.applyHighlighting(to: textView)
|
|
let safeLocation = min(sel.location, (text as NSString).length)
|
|
textView.setSelectedRange(NSRange(location: safeLocation, length: 0))
|
|
}
|
|
textView.isEditable = isEditable
|
|
if scrollToBottomID != context.coordinator.lastScrollToBottomID {
|
|
context.coordinator.lastScrollToBottomID = scrollToBottomID
|
|
textView.scrollToEndOfDocument(nil)
|
|
}
|
|
}
|
|
|
|
// MARK: - Syntax Definitions
|
|
|
|
private static let baseAttributes: [NSAttributedString.Key: Any] = [
|
|
.font: NSFont.monospacedSystemFont(ofSize: 12, weight: .regular),
|
|
.foregroundColor: NSColor.labelColor
|
|
]
|
|
|
|
private static let syntaxRules: [(NSRegularExpression, NSColor)] = {
|
|
let defs: [(String, NSColor)] = [
|
|
// Double-quoted strings
|
|
(#""[^"\\]*(?:\\.[^"\\]*)*""#, .systemOrange),
|
|
// Single-quoted strings
|
|
(#"'[^']*'"#, .systemOrange),
|
|
// Variable references: $VAR or ${VAR}
|
|
(#"\$\{?[A-Za-z_][A-Za-z0-9_]*\}?"#, .systemTeal),
|
|
// zsh/shell keywords and builtins
|
|
(
|
|
#"\b(alias|export|source|function|eval|if|then|else|elif|fi|for|in|do|done|while|until|case|esac|return|local|readonly|unset|typeset|setopt|unsetopt|autoload|bindkey|compinit|zle)\b"#,
|
|
.systemBlue
|
|
),
|
|
]
|
|
return defs.compactMap { pattern, color in
|
|
(try? NSRegularExpression(pattern: pattern)).map { ($0, color) }
|
|
}
|
|
}()
|
|
|
|
private static let commentRegex: NSRegularExpression? =
|
|
try? NSRegularExpression(pattern: #"#.*$"#, options: .anchorsMatchLines)
|
|
|
|
static func applyHighlighting(to textView: NSTextView) {
|
|
guard let storage = textView.textStorage else { return }
|
|
let string = textView.string
|
|
let fullRange = NSRange(location: 0, length: (string as NSString).length)
|
|
|
|
// Syntax highlighting attributes must not enter the undo stack, or Cmd-Z would undo
|
|
// highlighting instead of text edits.
|
|
let undoManager = textView.undoManager
|
|
undoManager?.disableUndoRegistration()
|
|
defer { undoManager?.enableUndoRegistration() }
|
|
|
|
storage.beginEditing()
|
|
|
|
// Reset to base style
|
|
storage.setAttributes(baseAttributes, range: fullRange)
|
|
|
|
// Apply keyword / string / variable coloring
|
|
for (regex, color) in syntaxRules {
|
|
regex.enumerateMatches(in: string, options: [], range: fullRange) { match, _, _ in
|
|
guard let r = match?.range else { return }
|
|
storage.addAttribute(.foregroundColor, value: color, range: r)
|
|
}
|
|
}
|
|
|
|
// Comments override all other rules (applied last)
|
|
commentRegex?.enumerateMatches(in: string, options: [], range: fullRange) { match, _, _ in
|
|
guard let r = match?.range else { return }
|
|
storage.addAttribute(.foregroundColor, value: NSColor.secondaryLabelColor, range: r)
|
|
}
|
|
|
|
storage.endEditing()
|
|
|
|
// Keep typing attributes consistent so new characters start with base style
|
|
textView.typingAttributes = baseAttributes
|
|
}
|
|
|
|
// MARK: - Coordinator
|
|
|
|
final class Coordinator: NSObject, NSTextViewDelegate {
|
|
var parent: ZshSyntaxHighlightingEditor
|
|
var isHighlighting = false
|
|
var lastScrollToBottomID: Int = 0
|
|
/// Last text pushed to the binding by `textDidChange`, used to distinguish user input from external assignment.
|
|
var lastTextFedToBinding: String = ""
|
|
|
|
init(_ parent: ZshSyntaxHighlightingEditor) {
|
|
self.parent = parent
|
|
}
|
|
|
|
func textDidChange(_ notification: Notification) {
|
|
guard !isHighlighting, let textView = notification.object as? NSTextView else { return }
|
|
lastTextFedToBinding = textView.string
|
|
parent.text = textView.string
|
|
parent.onChange?()
|
|
isHighlighting = true
|
|
ZshSyntaxHighlightingEditor.applyHighlighting(to: textView)
|
|
isHighlighting = false
|
|
}
|
|
}
|
|
}
|