|
@@ -3,77 +3,214 @@ import SwiftUI
|
|
|
|
|
|
|
|
struct CenteredChatTextField: NSViewRepresentable {
|
|
struct CenteredChatTextField: NSViewRepresentable {
|
|
|
@Binding var text: String
|
|
@Binding var text: String
|
|
|
|
|
+ @Binding var textHeight: CGFloat
|
|
|
let placeholder: String
|
|
let placeholder: String
|
|
|
let fontSize: CGFloat
|
|
let fontSize: CGFloat
|
|
|
|
|
+ let maxLines: Int
|
|
|
|
|
|
|
|
- func makeNSView(context: Context) -> NSView {
|
|
|
|
|
- let container = NSView()
|
|
|
|
|
- let field = NSTextField()
|
|
|
|
|
- field.delegate = context.coordinator
|
|
|
|
|
- context.coordinator.field = field
|
|
|
|
|
- applyStyle(to: field)
|
|
|
|
|
|
|
+ func makeNSView(context: Context) -> TextViewContainer {
|
|
|
|
|
+ let container = TextViewContainer()
|
|
|
|
|
+ let textView = ChatTextView()
|
|
|
|
|
+ textView.delegate = context.coordinator
|
|
|
|
|
+ context.coordinator.textView = textView
|
|
|
|
|
+ context.coordinator.parent = self
|
|
|
|
|
+ configure(textView)
|
|
|
|
|
|
|
|
- field.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
- container.addSubview(field)
|
|
|
|
|
|
|
+ textView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
|
+ container.addSubview(textView)
|
|
|
|
|
+ container.textView = textView
|
|
|
|
|
|
|
|
NSLayoutConstraint.activate([
|
|
NSLayoutConstraint.activate([
|
|
|
- field.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
|
|
|
|
- field.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
|
|
|
|
- field.centerYAnchor.constraint(equalTo: container.centerYAnchor),
|
|
|
|
|
|
|
+ textView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
|
|
|
|
+ textView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
|
|
|
|
+ textView.topAnchor.constraint(equalTo: container.topAnchor),
|
|
|
|
|
+ textView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
|
|
])
|
|
])
|
|
|
|
|
|
|
|
|
|
+ container.onBoundsChange = { [weak coordinator = context.coordinator] width in
|
|
|
|
|
+ coordinator?.updateHeight(width: width)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
return container
|
|
return container
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- func updateNSView(_ nsView: NSView, context: Context) {
|
|
|
|
|
- guard let field = context.coordinator.field else { return }
|
|
|
|
|
- applyStyle(to: field)
|
|
|
|
|
- if field.stringValue != text {
|
|
|
|
|
- field.stringValue = text
|
|
|
|
|
|
|
+ func updateNSView(_ nsView: TextViewContainer, context: Context) {
|
|
|
|
|
+ context.coordinator.parent = self
|
|
|
|
|
+ guard let textView = context.coordinator.textView else { return }
|
|
|
|
|
+
|
|
|
|
|
+ configure(textView)
|
|
|
|
|
+ textView.placeholder = placeholder
|
|
|
|
|
+ textView.placeholderColor = NSColor(AppTheme.textPlaceholder)
|
|
|
|
|
+
|
|
|
|
|
+ if textView.string != text {
|
|
|
|
|
+ textView.string = text
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ context.coordinator.updateHeight(width: nsView.bounds.width)
|
|
|
|
|
+ textView.needsDisplay = true
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
func makeCoordinator() -> Coordinator {
|
|
|
- Coordinator(text: $text)
|
|
|
|
|
|
|
+ Coordinator(text: $text, textHeight: $textHeight)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private func applyStyle(to field: NSTextField) {
|
|
|
|
|
- field.isBezeled = false
|
|
|
|
|
- field.isBordered = false
|
|
|
|
|
- field.drawsBackground = false
|
|
|
|
|
- field.focusRingType = .none
|
|
|
|
|
- field.alignment = .left
|
|
|
|
|
- field.font = .systemFont(ofSize: fontSize)
|
|
|
|
|
- field.textColor = NSColor(AppTheme.textPrimary)
|
|
|
|
|
-
|
|
|
|
|
- let paragraphStyle = NSMutableParagraphStyle()
|
|
|
|
|
- paragraphStyle.alignment = .left
|
|
|
|
|
- field.placeholderAttributedString = NSAttributedString(
|
|
|
|
|
- string: placeholder,
|
|
|
|
|
- attributes: [
|
|
|
|
|
- .foregroundColor: NSColor(AppTheme.textPlaceholder),
|
|
|
|
|
- .font: NSFont.systemFont(ofSize: fontSize),
|
|
|
|
|
- .paragraphStyle: paragraphStyle,
|
|
|
|
|
|
|
+ private func configure(_ textView: ChatTextView) {
|
|
|
|
|
+ textView.isRichText = false
|
|
|
|
|
+ textView.importsGraphics = false
|
|
|
|
|
+ textView.drawsBackground = false
|
|
|
|
|
+ textView.isEditable = true
|
|
|
|
|
+ textView.isSelectable = true
|
|
|
|
|
+ textView.font = .systemFont(ofSize: fontSize)
|
|
|
|
|
+ textView.textColor = NSColor(AppTheme.textPrimary)
|
|
|
|
|
+ textView.insertionPointColor = NSColor(AppTheme.textPrimary)
|
|
|
|
|
+ textView.backgroundColor = .clear
|
|
|
|
|
+ textView.textContainerInset = NSSize(width: 0, height: 2)
|
|
|
|
|
+ textView.textContainer?.lineFragmentPadding = 0
|
|
|
|
|
+ textView.isVerticallyResizable = true
|
|
|
|
|
+ textView.isHorizontallyResizable = false
|
|
|
|
|
+ textView.autoresizingMask = [.width]
|
|
|
|
|
+ textView.textContainer?.widthTracksTextView = true
|
|
|
|
|
+ textView.maxLines = maxLines
|
|
|
|
|
+ textView.updateScrollBehavior()
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ static func textLineHeight(for fontSize: CGFloat) -> CGFloat {
|
|
|
|
|
+ let font = NSFont.systemFont(ofSize: fontSize)
|
|
|
|
|
+ return ceil(font.ascender - font.descender + font.leading)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ fileprivate static func measuredHeight(
|
|
|
|
|
+ for textView: NSTextView,
|
|
|
|
|
+ width: CGFloat,
|
|
|
|
|
+ fontSize: CGFloat,
|
|
|
|
|
+ maxLines: Int
|
|
|
|
|
+ ) -> CGFloat {
|
|
|
|
|
+ let lineHeight = textLineHeight(for: fontSize)
|
|
|
|
|
+ let minHeight = lineHeight
|
|
|
|
|
+ let maxHeight = lineHeight * CGFloat(maxLines)
|
|
|
|
|
+
|
|
|
|
|
+ guard width > 0,
|
|
|
|
|
+ let layoutManager = textView.layoutManager,
|
|
|
|
|
+ let textContainer = textView.textContainer else {
|
|
|
|
|
+ return minHeight
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let horizontalInset = textView.textContainerInset.width * 2 + textContainer.lineFragmentPadding * 2
|
|
|
|
|
+ let containerWidth = max(width - horizontalInset, 1)
|
|
|
|
|
+ textContainer.containerSize = NSSize(width: containerWidth, height: .greatestFiniteMagnitude)
|
|
|
|
|
+ layoutManager.ensureLayout(for: textContainer)
|
|
|
|
|
+
|
|
|
|
|
+ let usedRect = layoutManager.usedRect(for: textContainer)
|
|
|
|
|
+ let contentHeight = ceil(usedRect.height + textView.textContainerInset.height * 2)
|
|
|
|
|
+
|
|
|
|
|
+ if textView.string.isEmpty {
|
|
|
|
|
+ return minHeight
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return min(max(contentHeight, minHeight), maxHeight)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ final class ChatTextView: NSTextView {
|
|
|
|
|
+ var placeholder = ""
|
|
|
|
|
+ var placeholderColor = NSColor.placeholderTextColor
|
|
|
|
|
+ var maxLines = 6
|
|
|
|
|
+
|
|
|
|
|
+ func updateScrollBehavior() {
|
|
|
|
|
+ guard let layoutManager, let textContainer else { return }
|
|
|
|
|
+
|
|
|
|
|
+ let lineHeight = CenteredChatTextField.textLineHeight(for: font?.pointSize ?? 16)
|
|
|
|
|
+ let maxHeight = lineHeight * CGFloat(maxLines)
|
|
|
|
|
+ let width = bounds.width > 0 ? bounds.width : (superview?.bounds.width ?? 0)
|
|
|
|
|
+ let measured = CenteredChatTextField.measuredHeight(
|
|
|
|
|
+ for: self,
|
|
|
|
|
+ width: width,
|
|
|
|
|
+ fontSize: font?.pointSize ?? 16,
|
|
|
|
|
+ maxLines: maxLines
|
|
|
|
|
+ )
|
|
|
|
|
+ let isAtMax = measured >= maxHeight - 0.5
|
|
|
|
|
+
|
|
|
|
|
+ textContainer.heightTracksTextView = isAtMax
|
|
|
|
|
+ if isAtMax {
|
|
|
|
|
+ textContainer.containerSize = NSSize(
|
|
|
|
|
+ width: textContainer.containerSize.width,
|
|
|
|
|
+ height: maxHeight
|
|
|
|
|
+ )
|
|
|
|
|
+ } else {
|
|
|
|
|
+ textContainer.containerSize = NSSize(
|
|
|
|
|
+ width: textContainer.containerSize.width,
|
|
|
|
|
+ height: .greatestFiniteMagnitude
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ layoutManager.ensureLayout(for: textContainer)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ override func draw(_ dirtyRect: NSRect) {
|
|
|
|
|
+ super.draw(dirtyRect)
|
|
|
|
|
+
|
|
|
|
|
+ guard string.isEmpty, !placeholder.isEmpty else { return }
|
|
|
|
|
+
|
|
|
|
|
+ let inset = textContainerInset
|
|
|
|
|
+ let padding = textContainer?.lineFragmentPadding ?? 0
|
|
|
|
|
+ let rect = NSRect(
|
|
|
|
|
+ x: inset.width + padding,
|
|
|
|
|
+ y: inset.height,
|
|
|
|
|
+ width: bounds.width - (inset.width + padding) * 2,
|
|
|
|
|
+ height: bounds.height - inset.height * 2
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ let attributes: [NSAttributedString.Key: Any] = [
|
|
|
|
|
+ .font: font ?? NSFont.systemFont(ofSize: 16),
|
|
|
|
|
+ .foregroundColor: placeholderColor,
|
|
|
]
|
|
]
|
|
|
- )
|
|
|
|
|
- field.cell?.usesSingleLineMode = false
|
|
|
|
|
- field.cell?.wraps = true
|
|
|
|
|
- field.cell?.isScrollable = false
|
|
|
|
|
- field.maximumNumberOfLines = 3
|
|
|
|
|
- field.lineBreakMode = .byWordWrapping
|
|
|
|
|
|
|
+ placeholder.draw(with: rect, options: .usesLineFragmentOrigin, attributes: attributes)
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- final class Coordinator: NSObject, NSTextFieldDelegate {
|
|
|
|
|
|
|
+ final class TextViewContainer: NSView {
|
|
|
|
|
+ weak var textView: ChatTextView?
|
|
|
|
|
+ var onBoundsChange: ((CGFloat) -> Void)?
|
|
|
|
|
+
|
|
|
|
|
+ override func layout() {
|
|
|
|
|
+ super.layout()
|
|
|
|
|
+ textView?.updateScrollBehavior()
|
|
|
|
|
+ onBoundsChange?(bounds.width)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ final class Coordinator: NSObject, NSTextViewDelegate {
|
|
|
@Binding var text: String
|
|
@Binding var text: String
|
|
|
- weak var field: NSTextField?
|
|
|
|
|
|
|
+ @Binding var textHeight: CGFloat
|
|
|
|
|
+ weak var textView: ChatTextView?
|
|
|
|
|
+ var parent: CenteredChatTextField?
|
|
|
|
|
|
|
|
- init(text: Binding<String>) {
|
|
|
|
|
|
|
+ init(text: Binding<String>, textHeight: Binding<CGFloat>) {
|
|
|
_text = text
|
|
_text = text
|
|
|
|
|
+ _textHeight = textHeight
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- func controlTextDidChange(_ obj: Notification) {
|
|
|
|
|
- guard let field = obj.object as? NSTextField else { return }
|
|
|
|
|
- text = field.stringValue
|
|
|
|
|
|
|
+ func textDidChange(_ notification: Notification) {
|
|
|
|
|
+ guard let textView = notification.object as? ChatTextView else { return }
|
|
|
|
|
+ text = textView.string
|
|
|
|
|
+ updateHeight(width: textView.bounds.width)
|
|
|
|
|
+ textView.needsDisplay = true
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ func updateHeight(width: CGFloat) {
|
|
|
|
|
+ guard let parent, let textView else { return }
|
|
|
|
|
+
|
|
|
|
|
+ textView.updateScrollBehavior()
|
|
|
|
|
+
|
|
|
|
|
+ let measuredHeight = CenteredChatTextField.measuredHeight(
|
|
|
|
|
+ for: textView,
|
|
|
|
|
+ width: width,
|
|
|
|
|
+ fontSize: parent.fontSize,
|
|
|
|
|
+ maxLines: parent.maxLines
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ if abs(textHeight - measuredHeight) > 0.5 {
|
|
|
|
|
+ textHeight = measuredHeight
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|