ThinCaretTextEditor.swift 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  1. import AppKit
  2. import SwiftUI
  3. private final class TextEditorScrollView: NSScrollView {
  4. override func layout() {
  5. super.layout()
  6. syncTextContainerWidth()
  7. }
  8. override func resizeSubviews(withOldSize oldSize: NSSize) {
  9. super.resizeSubviews(withOldSize: oldSize)
  10. syncTextContainerWidth()
  11. }
  12. fileprivate func syncTextContainerWidthIfNeeded() {
  13. syncTextContainerWidth()
  14. }
  15. private func syncTextContainerWidth() {
  16. guard let textView = documentView as? NSTextView,
  17. let container = textView.textContainer else { return }
  18. let width = contentView.bounds.width
  19. guard width > 1 else { return }
  20. container.containerSize = NSSize(width: width, height: .greatestFiniteMagnitude)
  21. textView.setFrameSize(NSSize(width: width, height: textView.frame.height))
  22. }
  23. }
  24. private final class ThinCaretTextView: NSTextView {
  25. private let caretWidth: CGFloat = 1
  26. private let caretHeightBoost: CGFloat = 2
  27. private let caretVerticalOffset: CGFloat = 1
  28. var placeholder: String = "" {
  29. didSet {
  30. guard placeholder != oldValue else { return }
  31. needsDisplay = true
  32. }
  33. }
  34. var placeholderColor: NSColor = .placeholderTextColor
  35. override func drawInsertionPoint(in rect: NSRect, color: NSColor, turnedOn flag: Bool) {
  36. var caretRect = rect
  37. caretRect.size.width = caretWidth
  38. caretRect.origin.x += (rect.width - caretWidth) / 2
  39. if let font {
  40. let caretHeight = font.ascender + abs(font.descender) + caretHeightBoost
  41. caretRect.size.height = caretHeight
  42. caretRect.origin.y = rect.origin.y + (rect.height - caretHeight) / 2 + caretVerticalOffset
  43. }
  44. super.drawInsertionPoint(in: caretRect, color: color, turnedOn: flag)
  45. }
  46. override func draw(_ dirtyRect: NSRect) {
  47. super.draw(dirtyRect)
  48. drawPlaceholderIfNeeded()
  49. }
  50. override func didChangeText() {
  51. super.didChangeText()
  52. needsDisplay = true
  53. }
  54. private func drawPlaceholderIfNeeded() {
  55. guard string.isEmpty, !placeholder.isEmpty else { return }
  56. let font = font ?? NSFont.systemFont(ofSize: 14)
  57. let paragraphStyle = NSMutableParagraphStyle()
  58. paragraphStyle.lineBreakMode = .byWordWrapping
  59. let attributes: [NSAttributedString.Key: Any] = [
  60. .font: font,
  61. .foregroundColor: placeholderColor,
  62. .paragraphStyle: paragraphStyle
  63. ]
  64. let horizontalInset = textContainerInset.width + (textContainer?.lineFragmentPadding ?? 0)
  65. let verticalInset = textContainerInset.height
  66. let drawRect = NSRect(
  67. x: horizontalInset,
  68. y: verticalInset,
  69. width: bounds.width - horizontalInset - textContainerInset.width,
  70. height: bounds.height - verticalInset - textContainerInset.height
  71. )
  72. placeholder.draw(
  73. with: drawRect,
  74. options: [.usesLineFragmentOrigin, .usesFontLeading],
  75. attributes: attributes
  76. )
  77. }
  78. }
  79. struct ThinCaretTextEditor: NSViewRepresentable {
  80. @Binding var text: String
  81. var placeholder: String = ""
  82. var isEditable: Bool = true
  83. var maxWords: Int?
  84. var onWordLimitReached: (() -> Void)?
  85. var onSubmit: (() -> Void)?
  86. func makeCoordinator() -> Coordinator {
  87. Coordinator(parent: self)
  88. }
  89. func makeNSView(context: Context) -> NSScrollView {
  90. let scrollView = TextEditorScrollView()
  91. scrollView.hasVerticalScroller = true
  92. scrollView.hasHorizontalScroller = false
  93. scrollView.autohidesScrollers = true
  94. scrollView.borderType = .noBorder
  95. scrollView.drawsBackground = false
  96. let textView = ThinCaretTextView()
  97. textView.isEditable = isEditable
  98. textView.isSelectable = true
  99. textView.isRichText = false
  100. textView.allowsUndo = true
  101. textView.drawsBackground = false
  102. textView.backgroundColor = .clear
  103. textView.isVerticallyResizable = true
  104. textView.isHorizontallyResizable = false
  105. textView.autoresizingMask = [.width]
  106. textView.textContainer?.lineFragmentPadding = 0
  107. textView.textContainer?.widthTracksTextView = true
  108. textView.textContainer?.containerSize = NSSize(
  109. width: 0,
  110. height: CGFloat.greatestFiniteMagnitude
  111. )
  112. textView.textContainerInset = NSSize(
  113. width: AppTheme.textEditorHorizontalInset,
  114. height: AppTheme.textEditorVerticalInset
  115. )
  116. textView.isAutomaticQuoteSubstitutionEnabled = false
  117. textView.isAutomaticDashSubstitutionEnabled = false
  118. textView.isAutomaticTextReplacementEnabled = false
  119. textView.font = NSFont.systemFont(ofSize: 14)
  120. textView.textColor = NSColor(AppTheme.textPrimary)
  121. textView.placeholder = placeholder
  122. textView.placeholderColor = NSColor(AppTheme.textMuted)
  123. textView.delegate = context.coordinator
  124. textView.string = text
  125. scrollView.documentView = textView
  126. context.coordinator.textView = textView
  127. return scrollView
  128. }
  129. func updateNSView(_ scrollView: NSScrollView, context: Context) {
  130. guard let textView = scrollView.documentView as? ThinCaretTextView else { return }
  131. textView.isEditable = isEditable
  132. textView.placeholder = placeholder
  133. textView.placeholderColor = NSColor(AppTheme.textMuted)
  134. textView.textContainerInset = NSSize(
  135. width: AppTheme.textEditorHorizontalInset,
  136. height: AppTheme.textEditorVerticalInset
  137. )
  138. if textView.string != text {
  139. textView.string = text
  140. textView.needsDisplay = true
  141. }
  142. textView.textColor = NSColor(AppTheme.textPrimary)
  143. textView.font = NSFont.systemFont(ofSize: 14)
  144. if let scrollView = scrollView as? TextEditorScrollView {
  145. scrollView.syncTextContainerWidthIfNeeded()
  146. }
  147. }
  148. final class Coordinator: NSObject, NSTextViewDelegate {
  149. var parent: ThinCaretTextEditor
  150. weak var textView: NSTextView?
  151. init(parent: ThinCaretTextEditor) {
  152. self.parent = parent
  153. super.init()
  154. NotificationCenter.default.addObserver(
  155. self,
  156. selector: #selector(applyThemeColors),
  157. name: .themeDidChange,
  158. object: nil
  159. )
  160. }
  161. deinit {
  162. NotificationCenter.default.removeObserver(self)
  163. }
  164. @objc private func applyThemeColors() {
  165. guard let textView = textView as? ThinCaretTextView else { return }
  166. textView.textColor = NSColor(AppTheme.textPrimary)
  167. textView.placeholderColor = NSColor(AppTheme.textMuted)
  168. textView.needsDisplay = true
  169. }
  170. func textDidChange(_ notification: Notification) {
  171. guard let textView = notification.object as? NSTextView else { return }
  172. parent.text = textView.string
  173. textView.needsDisplay = true
  174. }
  175. func textView(
  176. _ textView: NSTextView,
  177. shouldChangeTextIn affectedCharRange: NSRange,
  178. replacementString: String?
  179. ) -> Bool {
  180. guard let maxWords = parent.maxWords else { return true }
  181. let current = textView.string as NSString
  182. let replacement = replacementString ?? ""
  183. let newString = current.replacingCharacters(in: affectedCharRange, with: replacement)
  184. guard newString.wordCount > maxWords, !replacement.isEmpty else { return true }
  185. NSSound.beep()
  186. parent.onWordLimitReached?()
  187. return false
  188. }
  189. func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
  190. guard commandSelector == #selector(NSResponder.insertNewline(_:)),
  191. let event = textView.window?.currentEvent,
  192. event.modifierFlags.contains(.command),
  193. let onSubmit = parent.onSubmit else {
  194. return false
  195. }
  196. onSubmit()
  197. return true
  198. }
  199. }
  200. }