|
|
@@ -0,0 +1,242 @@
|
|
|
+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()
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|