ThinCaretTextEditor.swift 10.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  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,
  18. let layoutManager = textView.layoutManager else { return }
  19. let width = contentView.bounds.width
  20. guard width > 1 else { return }
  21. textView.minSize = NSSize(width: width, height: 0)
  22. textView.maxSize = NSSize(width: width, height: .greatestFiniteMagnitude)
  23. container.lineBreakMode = .byWordWrapping
  24. if abs(textView.frame.width - width) > 0.5 {
  25. textView.setFrameSize(NSSize(width: width, height: textView.frame.height))
  26. }
  27. layoutManager.ensureLayout(for: container)
  28. let usedHeight = layoutManager.usedRect(for: container).height
  29. let insetHeight = textView.textContainerInset.height * 2
  30. let newHeight = max(usedHeight + insetHeight, contentView.bounds.height)
  31. if abs(textView.frame.height - newHeight) > 0.5 {
  32. textView.setFrameSize(NSSize(width: width, height: newHeight))
  33. }
  34. }
  35. }
  36. private final class ThinCaretTextView: NSTextView {
  37. private let caretWidth: CGFloat = 1
  38. var placeholder: String = "" {
  39. didSet {
  40. guard placeholder != oldValue else { return }
  41. needsDisplay = true
  42. }
  43. }
  44. var placeholderColor: NSColor = .placeholderTextColor
  45. override func viewDidMoveToWindow() {
  46. super.viewDidMoveToWindow()
  47. // Opt into TextKit 1 so custom caret drawing is honored on modern macOS.
  48. _ = layoutManager
  49. hideSystemInsertionIndicator()
  50. }
  51. override func becomeFirstResponder() -> Bool {
  52. let becameFirstResponder = super.becomeFirstResponder()
  53. hideSystemInsertionIndicator()
  54. return becameFirstResponder
  55. }
  56. override func layout() {
  57. super.layout()
  58. hideSystemInsertionIndicator()
  59. }
  60. override func drawInsertionPoint(in rect: NSRect, color: NSColor, turnedOn flag: Bool) {
  61. var caretRect = rect
  62. caretRect.size.width = caretWidth
  63. super.drawInsertionPoint(in: caretRect, color: color, turnedOn: flag)
  64. }
  65. override func setNeedsDisplay(_ rect: NSRect, avoidAdditionalLayout flag: Bool) {
  66. var invalidRect = rect
  67. invalidRect.size.width += caretWidth - 1
  68. super.setNeedsDisplay(invalidRect, avoidAdditionalLayout: flag)
  69. }
  70. override func draw(_ dirtyRect: NSRect) {
  71. drawPlaceholderIfNeeded()
  72. super.draw(dirtyRect)
  73. }
  74. override func didChangeText() {
  75. super.didChangeText()
  76. needsDisplay = true
  77. }
  78. private func hideSystemInsertionIndicator() {
  79. hideInsertionIndicators(in: self)
  80. }
  81. private func hideInsertionIndicators(in view: NSView) {
  82. for subview in view.subviews {
  83. if #available(macOS 14.0, *),
  84. let indicator = subview as? NSTextInsertionIndicator {
  85. indicator.displayMode = .hidden
  86. }
  87. hideInsertionIndicators(in: subview)
  88. }
  89. }
  90. private func drawPlaceholderIfNeeded() {
  91. guard string.isEmpty, !placeholder.isEmpty else { return }
  92. let font = font ?? NSFont.systemFont(ofSize: 14)
  93. let paragraphStyle = NSMutableParagraphStyle()
  94. paragraphStyle.lineBreakMode = .byWordWrapping
  95. let attributes: [NSAttributedString.Key: Any] = [
  96. .font: font,
  97. .foregroundColor: placeholderColor,
  98. .paragraphStyle: paragraphStyle
  99. ]
  100. let horizontalInset = textContainerInset.width + (textContainer?.lineFragmentPadding ?? 0)
  101. let verticalInset = textContainerInset.height
  102. let drawRect = NSRect(
  103. x: horizontalInset,
  104. y: verticalInset,
  105. width: bounds.width - horizontalInset - textContainerInset.width,
  106. height: bounds.height - verticalInset - textContainerInset.height
  107. )
  108. placeholder.draw(
  109. with: drawRect,
  110. options: [.usesLineFragmentOrigin, .usesFontLeading],
  111. attributes: attributes
  112. )
  113. }
  114. }
  115. struct ThinCaretTextEditor: NSViewRepresentable {
  116. @Binding var text: String
  117. var placeholder: String = ""
  118. var isEditable: Bool = true
  119. var maxWords: Int?
  120. var onWordLimitReached: (() -> Void)?
  121. var onSubmit: (() -> Void)?
  122. func makeCoordinator() -> Coordinator {
  123. Coordinator(parent: self)
  124. }
  125. func makeNSView(context: Context) -> NSScrollView {
  126. let scrollView = TextEditorScrollView()
  127. scrollView.hasVerticalScroller = true
  128. scrollView.hasHorizontalScroller = false
  129. scrollView.autohidesScrollers = true
  130. scrollView.borderType = .noBorder
  131. scrollView.drawsBackground = false
  132. scrollView.focusRingType = .none
  133. let textView = ThinCaretTextView()
  134. textView.isEditable = isEditable
  135. textView.isSelectable = true
  136. textView.isRichText = false
  137. textView.allowsUndo = true
  138. textView.drawsBackground = false
  139. textView.backgroundColor = .clear
  140. textView.focusRingType = .none
  141. textView.isVerticallyResizable = true
  142. textView.isHorizontallyResizable = false
  143. textView.autoresizingMask = [.width]
  144. textView.textContainer?.lineFragmentPadding = 0
  145. textView.textContainer?.lineBreakMode = .byWordWrapping
  146. textView.textContainer?.widthTracksTextView = true
  147. textView.textContainer?.containerSize = NSSize(
  148. width: 0,
  149. height: CGFloat.greatestFiniteMagnitude
  150. )
  151. textView.textContainerInset = NSSize(
  152. width: AppTheme.textEditorHorizontalInset,
  153. height: AppTheme.textEditorVerticalInset
  154. )
  155. textView.isAutomaticQuoteSubstitutionEnabled = false
  156. textView.isAutomaticDashSubstitutionEnabled = false
  157. textView.isAutomaticTextReplacementEnabled = false
  158. textView.font = NSFont.systemFont(ofSize: 14)
  159. textView.textColor = NSColor(AppTheme.textPrimary)
  160. textView.insertionPointColor = NSColor(AppTheme.textPrimary)
  161. textView.placeholder = placeholder
  162. textView.placeholderColor = NSColor(AppTheme.textMuted)
  163. textView.delegate = context.coordinator
  164. textView.string = text
  165. scrollView.documentView = textView
  166. context.coordinator.textView = textView
  167. return scrollView
  168. }
  169. func updateNSView(_ scrollView: NSScrollView, context: Context) {
  170. guard let textView = scrollView.documentView as? ThinCaretTextView else { return }
  171. textView.isEditable = isEditable
  172. textView.placeholder = placeholder
  173. textView.placeholderColor = NSColor(AppTheme.textMuted)
  174. textView.textContainerInset = NSSize(
  175. width: AppTheme.textEditorHorizontalInset,
  176. height: AppTheme.textEditorVerticalInset
  177. )
  178. if textView.string != text {
  179. textView.string = text
  180. textView.needsDisplay = true
  181. }
  182. textView.textColor = NSColor(AppTheme.textPrimary)
  183. textView.insertionPointColor = NSColor(AppTheme.textPrimary)
  184. textView.font = NSFont.systemFont(ofSize: 14)
  185. if let scrollView = scrollView as? TextEditorScrollView {
  186. scrollView.syncTextContainerWidthIfNeeded()
  187. }
  188. }
  189. final class Coordinator: NSObject, NSTextViewDelegate {
  190. var parent: ThinCaretTextEditor
  191. weak var textView: NSTextView?
  192. init(parent: ThinCaretTextEditor) {
  193. self.parent = parent
  194. super.init()
  195. NotificationCenter.default.addObserver(
  196. self,
  197. selector: #selector(applyThemeColors),
  198. name: .themeDidChange,
  199. object: nil
  200. )
  201. }
  202. deinit {
  203. NotificationCenter.default.removeObserver(self)
  204. }
  205. @objc private func applyThemeColors() {
  206. guard let textView = textView as? ThinCaretTextView else { return }
  207. textView.textColor = NSColor(AppTheme.textPrimary)
  208. textView.insertionPointColor = NSColor(AppTheme.textPrimary)
  209. textView.placeholderColor = NSColor(AppTheme.textMuted)
  210. textView.needsDisplay = true
  211. }
  212. func textDidChange(_ notification: Notification) {
  213. guard let textView = notification.object as? NSTextView else { return }
  214. parent.text = textView.string
  215. textView.needsDisplay = true
  216. if let scrollView = textView.enclosingScrollView as? TextEditorScrollView {
  217. scrollView.syncTextContainerWidthIfNeeded()
  218. }
  219. }
  220. func textView(
  221. _ textView: NSTextView,
  222. shouldChangeTextIn affectedCharRange: NSRange,
  223. replacementString: String?
  224. ) -> Bool {
  225. guard let maxWords = parent.maxWords else { return true }
  226. let current = textView.string as NSString
  227. let replacement = replacementString ?? ""
  228. let newString = current.replacingCharacters(in: affectedCharRange, with: replacement)
  229. guard newString.wordCount > maxWords, !replacement.isEmpty else { return true }
  230. NSSound.beep()
  231. parent.onWordLimitReached?()
  232. return false
  233. }
  234. func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
  235. guard commandSelector == #selector(NSResponder.insertNewline(_:)),
  236. let event = textView.window?.currentEvent,
  237. event.modifierFlags.contains(.command),
  238. let onSubmit = parent.onSubmit else {
  239. return false
  240. }
  241. onSubmit()
  242. return true
  243. }
  244. }
  245. }