ThinCaretTextEditor.swift 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  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. _ = layoutManager
  48. hideSystemInsertionIndicator()
  49. }
  50. override func becomeFirstResponder() -> Bool {
  51. let becameFirstResponder = super.becomeFirstResponder()
  52. hideSystemInsertionIndicator()
  53. return becameFirstResponder
  54. }
  55. override func layout() {
  56. super.layout()
  57. hideSystemInsertionIndicator()
  58. }
  59. override func drawInsertionPoint(in rect: NSRect, color: NSColor, turnedOn flag: Bool) {
  60. var caretRect = rect
  61. caretRect.size.width = caretWidth
  62. super.drawInsertionPoint(in: caretRect, color: color, turnedOn: flag)
  63. }
  64. override func setNeedsDisplay(_ rect: NSRect, avoidAdditionalLayout flag: Bool) {
  65. var invalidRect = rect
  66. invalidRect.size.width += caretWidth - 1
  67. super.setNeedsDisplay(invalidRect, avoidAdditionalLayout: flag)
  68. }
  69. override func draw(_ dirtyRect: NSRect) {
  70. drawPlaceholderIfNeeded()
  71. super.draw(dirtyRect)
  72. }
  73. override func didChangeText() {
  74. super.didChangeText()
  75. needsDisplay = true
  76. }
  77. private func hideSystemInsertionIndicator() {
  78. hideInsertionIndicators(in: self)
  79. }
  80. private func hideInsertionIndicators(in view: NSView) {
  81. for subview in view.subviews {
  82. if #available(macOS 14.0, *),
  83. let indicator = subview as? NSTextInsertionIndicator {
  84. indicator.displayMode = .hidden
  85. }
  86. hideInsertionIndicators(in: subview)
  87. }
  88. }
  89. private func drawPlaceholderIfNeeded() {
  90. guard string.isEmpty, !placeholder.isEmpty else { return }
  91. let font = font ?? NSFont.systemFont(ofSize: AppTheme.formFieldFontSize)
  92. let paragraphStyle = NSMutableParagraphStyle()
  93. paragraphStyle.lineBreakMode = .byWordWrapping
  94. let attributes: [NSAttributedString.Key: Any] = [
  95. .font: font,
  96. .foregroundColor: placeholderColor,
  97. .paragraphStyle: paragraphStyle
  98. ]
  99. let horizontalInset = textContainerInset.width + (textContainer?.lineFragmentPadding ?? 0)
  100. let verticalInset = textContainerInset.height
  101. let drawRect = NSRect(
  102. x: horizontalInset,
  103. y: verticalInset,
  104. width: bounds.width - horizontalInset - textContainerInset.width,
  105. height: bounds.height - verticalInset - textContainerInset.height
  106. )
  107. placeholder.draw(
  108. with: drawRect,
  109. options: [.usesLineFragmentOrigin, .usesFontLeading],
  110. attributes: attributes
  111. )
  112. }
  113. }
  114. struct ThinCaretTextEditor: NSViewRepresentable {
  115. @Binding var text: String
  116. var placeholder: String = ""
  117. var fontSize: CGFloat = AppTheme.formFieldFontSize
  118. var isEditable: Bool = true
  119. func makeCoordinator() -> Coordinator {
  120. Coordinator(parent: self)
  121. }
  122. func makeNSView(context: Context) -> NSScrollView {
  123. let scrollView = TextEditorScrollView()
  124. scrollView.hasVerticalScroller = true
  125. scrollView.hasHorizontalScroller = false
  126. scrollView.autohidesScrollers = true
  127. scrollView.borderType = .noBorder
  128. scrollView.drawsBackground = false
  129. scrollView.focusRingType = .none
  130. let textView = ThinCaretTextView()
  131. textView.isEditable = isEditable
  132. textView.isSelectable = true
  133. textView.isRichText = false
  134. textView.allowsUndo = true
  135. textView.drawsBackground = false
  136. textView.backgroundColor = .clear
  137. textView.focusRingType = .none
  138. textView.isVerticallyResizable = true
  139. textView.isHorizontallyResizable = false
  140. textView.autoresizingMask = [.width]
  141. textView.textContainer?.lineFragmentPadding = 0
  142. textView.textContainer?.lineBreakMode = .byWordWrapping
  143. textView.textContainer?.widthTracksTextView = true
  144. textView.textContainer?.containerSize = NSSize(
  145. width: 0,
  146. height: CGFloat.greatestFiniteMagnitude
  147. )
  148. textView.textContainerInset = NSSize(
  149. width: AppTheme.textEditorHorizontalInset,
  150. height: AppTheme.textEditorVerticalInset
  151. )
  152. textView.isAutomaticQuoteSubstitutionEnabled = false
  153. textView.isAutomaticDashSubstitutionEnabled = false
  154. textView.isAutomaticTextReplacementEnabled = false
  155. textView.font = NSFont.systemFont(ofSize: fontSize)
  156. textView.textColor = NSColor(AppTheme.textPrimary)
  157. textView.insertionPointColor = NSColor(AppTheme.textPrimary)
  158. textView.placeholder = placeholder
  159. textView.placeholderColor = NSColor(AppTheme.textTertiary.opacity(0.7))
  160. textView.delegate = context.coordinator
  161. textView.string = text
  162. scrollView.documentView = textView
  163. context.coordinator.textView = textView
  164. return scrollView
  165. }
  166. func updateNSView(_ scrollView: NSScrollView, context: Context) {
  167. guard let textView = scrollView.documentView as? ThinCaretTextView else { return }
  168. textView.isEditable = isEditable
  169. textView.placeholder = placeholder
  170. textView.placeholderColor = NSColor(AppTheme.textTertiary.opacity(0.7))
  171. textView.textContainerInset = NSSize(
  172. width: AppTheme.textEditorHorizontalInset,
  173. height: AppTheme.textEditorVerticalInset
  174. )
  175. if textView.string != text {
  176. textView.string = text
  177. textView.needsDisplay = true
  178. }
  179. textView.textColor = NSColor(AppTheme.textPrimary)
  180. textView.insertionPointColor = NSColor(AppTheme.textPrimary)
  181. textView.font = NSFont.systemFont(ofSize: fontSize)
  182. if let scrollView = scrollView as? TextEditorScrollView {
  183. scrollView.syncTextContainerWidthIfNeeded()
  184. }
  185. }
  186. final class Coordinator: NSObject, NSTextViewDelegate {
  187. var parent: ThinCaretTextEditor
  188. weak var textView: NSTextView?
  189. init(parent: ThinCaretTextEditor) {
  190. self.parent = parent
  191. }
  192. func textDidChange(_ notification: Notification) {
  193. guard let textView = notification.object as? NSTextView else { return }
  194. parent.text = textView.string
  195. textView.needsDisplay = true
  196. if let scrollView = textView.enclosingScrollView as? TextEditorScrollView {
  197. scrollView.syncTextContainerWidthIfNeeded()
  198. }
  199. }
  200. }
  201. }