| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240 |
- import AppKit
- import SwiftUI
- private final class TextEditorScrollView: NSScrollView {
- override func layout() {
- super.layout()
- syncTextContainerWidth()
- }
- override func resizeSubviews(withOldSize oldSize: NSSize) {
- super.resizeSubviews(withOldSize: oldSize)
- syncTextContainerWidth()
- }
- fileprivate func syncTextContainerWidthIfNeeded() {
- syncTextContainerWidth()
- }
- private func syncTextContainerWidth() {
- guard let textView = documentView as? NSTextView,
- let container = textView.textContainer else { return }
- let width = contentView.bounds.width
- guard width > 1 else { return }
- container.containerSize = NSSize(width: width, height: .greatestFiniteMagnitude)
- textView.setFrameSize(NSSize(width: width, height: textView.frame.height))
- }
- }
- private final class ThinCaretTextView: NSTextView {
- private let caretWidth: CGFloat = 1
- private let caretHeightBoost: CGFloat = 2
- private let caretVerticalOffset: CGFloat = 1
- var placeholder: String = "" {
- didSet {
- guard placeholder != oldValue else { return }
- needsDisplay = true
- }
- }
- var placeholderColor: NSColor = .placeholderTextColor
- override func drawInsertionPoint(in rect: NSRect, color: NSColor, turnedOn flag: Bool) {
- var caretRect = rect
- caretRect.size.width = caretWidth
- caretRect.origin.x += (rect.width - caretWidth) / 2
- if let font {
- let caretHeight = font.ascender + abs(font.descender) + caretHeightBoost
- caretRect.size.height = caretHeight
- caretRect.origin.y = rect.origin.y + (rect.height - caretHeight) / 2 + caretVerticalOffset
- }
- super.drawInsertionPoint(in: caretRect, color: color, turnedOn: flag)
- }
- override func draw(_ dirtyRect: NSRect) {
- super.draw(dirtyRect)
- drawPlaceholderIfNeeded()
- }
- override func didChangeText() {
- super.didChangeText()
- needsDisplay = true
- }
- private func drawPlaceholderIfNeeded() {
- guard string.isEmpty, !placeholder.isEmpty else { return }
- let font = font ?? NSFont.systemFont(ofSize: 14)
- let paragraphStyle = NSMutableParagraphStyle()
- paragraphStyle.lineBreakMode = .byWordWrapping
- let attributes: [NSAttributedString.Key: Any] = [
- .font: font,
- .foregroundColor: placeholderColor,
- .paragraphStyle: paragraphStyle
- ]
- let horizontalInset = textContainerInset.width + (textContainer?.lineFragmentPadding ?? 0)
- let verticalInset = textContainerInset.height
- let drawRect = NSRect(
- x: horizontalInset,
- y: verticalInset,
- width: bounds.width - horizontalInset - textContainerInset.width,
- height: bounds.height - verticalInset - textContainerInset.height
- )
- placeholder.draw(
- with: drawRect,
- options: [.usesLineFragmentOrigin, .usesFontLeading],
- attributes: attributes
- )
- }
- }
- struct ThinCaretTextEditor: NSViewRepresentable {
- @Binding var text: String
- var placeholder: String = ""
- var isEditable: Bool = true
- var maxWords: Int?
- var onWordLimitReached: (() -> Void)?
- var onSubmit: (() -> Void)?
- func makeCoordinator() -> Coordinator {
- Coordinator(parent: self)
- }
- func makeNSView(context: Context) -> NSScrollView {
- let scrollView = TextEditorScrollView()
- scrollView.hasVerticalScroller = true
- scrollView.hasHorizontalScroller = false
- scrollView.autohidesScrollers = true
- scrollView.borderType = .noBorder
- scrollView.drawsBackground = false
- let textView = ThinCaretTextView()
- textView.isEditable = isEditable
- textView.isSelectable = true
- textView.isRichText = false
- textView.allowsUndo = true
- textView.drawsBackground = false
- textView.backgroundColor = .clear
- textView.isVerticallyResizable = true
- textView.isHorizontallyResizable = false
- textView.autoresizingMask = [.width]
- textView.textContainer?.lineFragmentPadding = 0
- textView.textContainer?.widthTracksTextView = true
- textView.textContainer?.containerSize = NSSize(
- width: 0,
- height: CGFloat.greatestFiniteMagnitude
- )
- textView.textContainerInset = NSSize(
- width: AppTheme.textEditorHorizontalInset,
- height: AppTheme.textEditorVerticalInset
- )
- textView.isAutomaticQuoteSubstitutionEnabled = false
- textView.isAutomaticDashSubstitutionEnabled = false
- textView.isAutomaticTextReplacementEnabled = false
- textView.font = NSFont.systemFont(ofSize: 14)
- textView.textColor = NSColor(AppTheme.textPrimary)
- textView.placeholder = placeholder
- textView.placeholderColor = NSColor(AppTheme.textMuted)
- textView.delegate = context.coordinator
- textView.string = text
- scrollView.documentView = textView
- context.coordinator.textView = textView
- return scrollView
- }
- func updateNSView(_ scrollView: NSScrollView, context: Context) {
- guard let textView = scrollView.documentView as? ThinCaretTextView else { return }
- textView.isEditable = isEditable
- textView.placeholder = placeholder
- textView.placeholderColor = NSColor(AppTheme.textMuted)
- textView.textContainerInset = NSSize(
- width: AppTheme.textEditorHorizontalInset,
- height: AppTheme.textEditorVerticalInset
- )
- if textView.string != text {
- textView.string = text
- textView.needsDisplay = true
- }
- textView.textColor = NSColor(AppTheme.textPrimary)
- textView.font = NSFont.systemFont(ofSize: 14)
- if let scrollView = scrollView as? TextEditorScrollView {
- scrollView.syncTextContainerWidthIfNeeded()
- }
- }
- final class Coordinator: NSObject, NSTextViewDelegate {
- var parent: ThinCaretTextEditor
- weak var textView: NSTextView?
- init(parent: ThinCaretTextEditor) {
- self.parent = parent
- super.init()
- NotificationCenter.default.addObserver(
- self,
- selector: #selector(applyThemeColors),
- name: .themeDidChange,
- object: nil
- )
- }
- deinit {
- NotificationCenter.default.removeObserver(self)
- }
- @objc private func applyThemeColors() {
- guard let textView = textView as? ThinCaretTextView else { return }
- textView.textColor = NSColor(AppTheme.textPrimary)
- textView.placeholderColor = NSColor(AppTheme.textMuted)
- textView.needsDisplay = true
- }
- func textDidChange(_ notification: Notification) {
- guard let textView = notification.object as? NSTextView else { return }
- parent.text = textView.string
- textView.needsDisplay = true
- }
- func textView(
- _ textView: NSTextView,
- shouldChangeTextIn affectedCharRange: NSRange,
- replacementString: String?
- ) -> Bool {
- guard let maxWords = parent.maxWords else { return true }
- let current = textView.string as NSString
- let replacement = replacementString ?? ""
- let newString = current.replacingCharacters(in: affectedCharRange, with: replacement)
- guard newString.wordCount > maxWords, !replacement.isEmpty else { return true }
- NSSound.beep()
- parent.onWordLimitReached?()
- return false
- }
- func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
- guard commandSelector == #selector(NSResponder.insertNewline(_:)),
- let event = textView.window?.currentEvent,
- event.modifierFlags.contains(.command),
- let onSubmit = parent.onSubmit else {
- return false
- }
- onSubmit()
- return true
- }
- }
- }
|