Quellcode durchsuchen

Fix chat input multi-line clipping with dynamic height growth.

Replace NSTextField with NSTextView so wrapped text renders correctly and the input card expands smoothly as content grows.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 vor 3 Wochen
Ursprung
Commit
2f68d8250f

+ 3 - 0
clone _of_clarus_ai_chat_bot/Utilities/AppTheme.swift

@@ -33,6 +33,9 @@ enum AppTheme {
     static let chatInputBackground = Color(red: 0.97, green: 0.97, blue: 0.98)
     static let chatInputHeight: CGFloat = 84
     static let chatInputToolbarHeight: CGFloat = 40
+    static let chatInputTextVerticalPadding: CGFloat = 8
+    static let chatInputMaxTextLines: Int = 6
+    static let chatInputMinTextSectionHeight: CGFloat = chatInputHeight - chatInputToolbarHeight
     static let chatToolbarPillBackground = Color(red: 0.92, green: 0.92, blue: 0.94)
     static let chatActionButtonBorder = Color(red: 0.68, green: 0.68, blue: 0.72)
     static let chatSendButtonBackground = Color(red: 0.14, green: 0.14, blue: 0.16)

+ 183 - 46
clone _of_clarus_ai_chat_bot/Views/CenteredChatTextField.swift

@@ -3,77 +3,214 @@ import SwiftUI
 
 struct CenteredChatTextField: NSViewRepresentable {
     @Binding var text: String
+    @Binding var textHeight: CGFloat
     let placeholder: String
     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([
-            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
     }
 
-    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 {
-        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
-        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
+            _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
+            }
         }
     }
 }

+ 20 - 4
clone _of_clarus_ai_chat_bot/Views/ChatInputCard.swift

@@ -2,19 +2,34 @@ import SwiftUI
 
 struct ChatInputCard: View {
     @ObservedObject var viewModel: HomeViewModel
+    @State private var textHeight: CGFloat = CenteredChatTextField.textLineHeight(for: 16)
 
-    private let inputSectionHeight: CGFloat = AppTheme.chatInputHeight - AppTheme.chatInputToolbarHeight
     private let actionButtonSize: CGFloat = 32
 
+    private var textSectionHeight: CGFloat {
+        max(
+            AppTheme.chatInputMinTextSectionHeight,
+            textHeight + AppTheme.chatInputTextVerticalPadding + 4
+        )
+    }
+
+    private var cardHeight: CGFloat {
+        textSectionHeight + AppTheme.chatInputToolbarHeight
+    }
+
     var body: some View {
         VStack(spacing: 0) {
             CenteredChatTextField(
                 text: $viewModel.promptText,
+                textHeight: $textHeight,
                 placeholder: "How can I help you",
-                fontSize: 16
+                fontSize: 16,
+                maxLines: AppTheme.chatInputMaxTextLines
             )
             .padding(.horizontal, 16)
-            .frame(height: inputSectionHeight)
+            .padding(.top, AppTheme.chatInputTextVerticalPadding)
+            .padding(.bottom, 4)
+            .frame(height: textSectionHeight, alignment: .top)
 
             HStack(spacing: 8) {
                 Button(action: {}) {
@@ -39,7 +54,8 @@ struct ChatInputCard: View {
             .frame(height: AppTheme.chatInputToolbarHeight)
         }
         .frame(maxWidth: .infinity)
-        .frame(height: AppTheme.chatInputHeight)
+        .frame(height: cardHeight)
+        .animation(.easeInOut(duration: 0.15), value: cardHeight)
         .background(
             RoundedRectangle(cornerRadius: AppTheme.chatInputCornerRadius, style: .continuous)
                 .fill(AppTheme.chatInputBackground)