Files
ggbond268--mactools/Plugins/ZshConfig/Sources/ZshSyntaxHighlightingEditor.swift
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

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
}
}
}