| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242 |
- 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()
- }
- }
- }
- }
|