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, let layoutManager = textView.layoutManager else { return } let width = contentView.bounds.width guard width > 1 else { return } textView.minSize = NSSize(width: width, height: 0) textView.maxSize = NSSize(width: width, height: .greatestFiniteMagnitude) container.lineBreakMode = .byWordWrapping if abs(textView.frame.width - width) > 0.5 { textView.setFrameSize(NSSize(width: width, height: textView.frame.height)) } layoutManager.ensureLayout(for: container) let usedHeight = layoutManager.usedRect(for: container).height let insetHeight = textView.textContainerInset.height * 2 let newHeight = max(usedHeight + insetHeight, contentView.bounds.height) if abs(textView.frame.height - newHeight) > 0.5 { textView.setFrameSize(NSSize(width: width, height: newHeight)) } } } private final class ThinCaretTextView: NSTextView { private let caretWidth: CGFloat = 1 var placeholder: String = "" { didSet { guard placeholder != oldValue else { return } needsDisplay = true } } var placeholderColor: NSColor = .placeholderTextColor override func viewDidMoveToWindow() { super.viewDidMoveToWindow() _ = layoutManager hideSystemInsertionIndicator() } override func becomeFirstResponder() -> Bool { let becameFirstResponder = super.becomeFirstResponder() hideSystemInsertionIndicator() return becameFirstResponder } override func layout() { super.layout() hideSystemInsertionIndicator() } override func drawInsertionPoint(in rect: NSRect, color: NSColor, turnedOn flag: Bool) { var caretRect = rect caretRect.size.width = caretWidth super.drawInsertionPoint(in: caretRect, color: color, turnedOn: flag) } override func setNeedsDisplay(_ rect: NSRect, avoidAdditionalLayout flag: Bool) { var invalidRect = rect invalidRect.size.width += caretWidth - 1 super.setNeedsDisplay(invalidRect, avoidAdditionalLayout: flag) } override func draw(_ dirtyRect: NSRect) { drawPlaceholderIfNeeded() super.draw(dirtyRect) } override func didChangeText() { super.didChangeText() needsDisplay = true } private func hideSystemInsertionIndicator() { hideInsertionIndicators(in: self) } private func hideInsertionIndicators(in view: NSView) { for subview in view.subviews { if #available(macOS 14.0, *), let indicator = subview as? NSTextInsertionIndicator { indicator.displayMode = .hidden } hideInsertionIndicators(in: subview) } } private func drawPlaceholderIfNeeded() { guard string.isEmpty, !placeholder.isEmpty else { return } let font = font ?? NSFont.systemFont(ofSize: AppTheme.formFieldFontSize) 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 fontSize: CGFloat = AppTheme.formFieldFontSize var isEditable: Bool = true 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 scrollView.focusRingType = .none let textView = ThinCaretTextView() textView.isEditable = isEditable textView.isSelectable = true textView.isRichText = false textView.allowsUndo = true textView.drawsBackground = false textView.backgroundColor = .clear textView.focusRingType = .none textView.isVerticallyResizable = true textView.isHorizontallyResizable = false textView.autoresizingMask = [.width] textView.textContainer?.lineFragmentPadding = 0 textView.textContainer?.lineBreakMode = .byWordWrapping 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: fontSize) textView.textColor = NSColor(AppTheme.textPrimary) textView.insertionPointColor = NSColor(AppTheme.textPrimary) textView.placeholder = placeholder textView.placeholderColor = NSColor(AppTheme.textTertiary.opacity(0.7)) 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.textTertiary.opacity(0.7)) 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.insertionPointColor = NSColor(AppTheme.textPrimary) textView.font = NSFont.systemFont(ofSize: fontSize) 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 } func textDidChange(_ notification: Notification) { guard let textView = notification.object as? NSTextView else { return } parent.text = textView.string textView.needsDisplay = true if let scrollView = textView.enclosingScrollView as? TextEditorScrollView { scrollView.syncTextContainerWidthIfNeeded() } } } }