瀏覽代碼

Fix text editor placeholder and caret alignment in ThinCaretTextEditor.

Render placeholders inside the NSTextView so they share the same insets and baseline as typed text, and tune caret height and vertical position.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 月之前
父節點
當前提交
00a48fb9f5

+ 77 - 0
gramora/Views/Components/ThinCaretTextEditor.swift

@@ -3,17 +3,75 @@ import SwiftUI
 
 private final class ThinCaretTextView: NSTextView {
     private let caretWidth: CGFloat = 1
+    private let caretHeightBoost: CGFloat = 2
+    private let caretVerticalOffset: CGFloat = 1
+
+    var placeholder: String = "" {
+        didSet {
+            guard placeholder != oldValue else { return }
+            needsDisplay = true
+        }
+    }
+
+    var placeholderColor: NSColor = .placeholderTextColor
 
     override func drawInsertionPoint(in rect: NSRect, color: NSColor, turnedOn flag: Bool) {
         var caretRect = rect
         caretRect.size.width = caretWidth
         caretRect.origin.x += (rect.width - caretWidth) / 2
+
+        if let font {
+            let caretHeight = font.ascender + abs(font.descender) + caretHeightBoost
+            caretRect.size.height = caretHeight
+            caretRect.origin.y = rect.origin.y + (rect.height - caretHeight) / 2 + caretVerticalOffset
+        }
+
         super.drawInsertionPoint(in: caretRect, color: color, turnedOn: flag)
     }
+
+    override func draw(_ dirtyRect: NSRect) {
+        super.draw(dirtyRect)
+        drawPlaceholderIfNeeded()
+    }
+
+    override func didChangeText() {
+        super.didChangeText()
+        needsDisplay = true
+    }
+
+    private func drawPlaceholderIfNeeded() {
+        guard string.isEmpty, !placeholder.isEmpty else { return }
+
+        let font = font ?? NSFont.systemFont(ofSize: 14)
+        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 isEditable: Bool = true
     var onSubmit: (() -> Void)?
 
@@ -36,7 +94,15 @@ struct ThinCaretTextEditor: NSViewRepresentable {
         textView.allowsUndo = true
         textView.drawsBackground = false
         textView.backgroundColor = .clear
+        textView.isVerticallyResizable = true
+        textView.isHorizontallyResizable = false
+        textView.autoresizingMask = [.width]
         textView.textContainer?.lineFragmentPadding = 0
+        textView.textContainer?.widthTracksTextView = true
+        textView.textContainer?.containerSize = NSSize(
+            width: 0,
+            height: CGFloat.greatestFiniteMagnitude
+        )
         textView.textContainerInset = NSSize(
             width: AppTheme.textEditorHorizontalInset,
             height: AppTheme.textEditorVerticalInset
@@ -46,6 +112,8 @@ struct ThinCaretTextEditor: NSViewRepresentable {
         textView.isAutomaticTextReplacementEnabled = false
         textView.font = NSFont.systemFont(ofSize: 14)
         textView.textColor = NSColor(AppTheme.textPrimary)
+        textView.placeholder = placeholder
+        textView.placeholderColor = NSColor(AppTheme.textMuted)
         textView.delegate = context.coordinator
         textView.string = text
 
@@ -56,7 +124,15 @@ struct ThinCaretTextEditor: NSViewRepresentable {
 
     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.textMuted)
+        textView.textContainerInset = NSSize(
+            width: AppTheme.textEditorHorizontalInset,
+            height: AppTheme.textEditorVerticalInset
+        )
+
         if textView.string != text {
             textView.string = text
         }
@@ -73,6 +149,7 @@ struct ThinCaretTextEditor: NSViewRepresentable {
         func textDidChange(_ notification: Notification) {
             guard let textView = notification.object as? NSTextView else { return }
             parent.text = textView.string
+            textView.needsDisplay = true
         }
 
         func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {

+ 8 - 20
gramora/Views/EmailWriterView.swift

@@ -174,7 +174,11 @@ struct EmailWriterView: View {
             }
 
             ZStack(alignment: .bottomTrailing) {
-                ThinCaretTextEditor(text: $viewModel.prompt, onSubmit: viewModel.generateEmail)
+                ThinCaretTextEditor(
+                    text: $viewModel.prompt,
+                    placeholder: "Describe the email you need — key points, recipient context, or a rough draft...",
+                    onSubmit: viewModel.generateEmail
+                )
                     .font(.system(size: 14))
                     .foregroundStyle(AppTheme.textPrimary)
                     .frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -185,16 +189,6 @@ struct EmailWriterView: View {
                         RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
                             .stroke(AppTheme.inputBorder, lineWidth: 1)
                     )
-                    .overlay(alignment: .topLeading) {
-                        if viewModel.prompt.isEmpty {
-                            Text("Describe the email you need — key points, recipient context, or a rough draft...")
-                                .font(.system(size: 14))
-                                .foregroundStyle(AppTheme.textMuted)
-                                .padding(.leading, AppTheme.textEditorHorizontalInset)
-                                .padding(.top, AppTheme.textEditorVerticalInset)
-                                .allowsHitTesting(false)
-                        }
-                    }
 
                 Text(viewModel.characterCountLabel)
                     .font(.system(size: 11))
@@ -233,6 +227,9 @@ struct EmailWriterView: View {
             ZStack(alignment: .topLeading) {
                 ThinCaretTextEditor(
                     text: $viewModel.generatedEmail,
+                    placeholder: viewModel.isGenerating
+                        ? "Writing your email..."
+                        : "Your generated email will appear here...",
                     isEditable: false
                 )
                 .font(.system(size: 14))
@@ -245,15 +242,6 @@ struct EmailWriterView: View {
                     RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
                         .stroke(AppTheme.inputBorder, lineWidth: 1)
                 )
-
-                if viewModel.generatedEmail.isEmpty {
-                    Text(viewModel.isGenerating ? "Writing your email..." : "Your generated email will appear here...")
-                        .font(.system(size: 14))
-                        .foregroundStyle(AppTheme.textMuted)
-                        .padding(.leading, AppTheme.textEditorHorizontalInset)
-                        .padding(.top, AppTheme.textEditorVerticalInset)
-                        .allowsHitTesting(false)
-                }
             }
             .frame(maxHeight: .infinity)
         }

+ 4 - 11
gramora/Views/GrammarCheckerView.swift

@@ -134,7 +134,10 @@ struct GrammarCheckerView: View {
                 .accessibilityLabel("Your Content")
 
             ZStack(alignment: .bottomTrailing) {
-                ThinCaretTextEditor(text: $viewModel.text)
+                ThinCaretTextEditor(
+                    text: $viewModel.text,
+                    placeholder: "Type or paste your text here and press the check button..."
+                )
                     .font(.system(size: 14))
                     .foregroundStyle(AppTheme.textPrimary)
                     .frame(minHeight: editorHeight, idealHeight: editorHeight)
@@ -144,16 +147,6 @@ struct GrammarCheckerView: View {
                         RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
                             .stroke(AppTheme.inputBorder, lineWidth: 1)
                     )
-                    .overlay(alignment: .topLeading) {
-                        if viewModel.text.isEmpty {
-                            Text("Type or paste your text here and press the check button...")
-                                .font(.system(size: 14))
-                                .foregroundStyle(AppTheme.textMuted)
-                                .padding(.leading, AppTheme.textEditorHorizontalInset)
-                                .padding(.top, AppTheme.textEditorVerticalInset)
-                                .allowsHitTesting(false)
-                        }
-                    }
 
                 Text(viewModel.characterCountLabel)
                     .font(.system(size: 11))

+ 4 - 9
gramora/Views/JournalFinderView.swift

@@ -254,17 +254,12 @@ struct JournalFinderView: View {
             fieldLabel("Abstract or Summary", optional: false)
 
             ZStack(alignment: .topLeading) {
-                ThinCaretTextEditor(text: $viewModel.abstractText)
+                ThinCaretTextEditor(
+                    text: $viewModel.abstractText,
+                    placeholder: "Paste your abstract, introduction, or manuscript summary here..."
+                )
                     .frame(height: height)
 
-                if viewModel.abstractText.isEmpty {
-                    Text("Paste your abstract, introduction, or manuscript summary here...")
-                        .font(.system(size: 15))
-                        .foregroundStyle(AppTheme.textMuted)
-                        .padding(.horizontal, AppTheme.textEditorHorizontalInset + 2)
-                        .padding(.vertical, AppTheme.textEditorVerticalInset + 2)
-                        .allowsHitTesting(false)
-                }
             }
             .background(Color.white)
             .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))

+ 8 - 20
gramora/Views/LanguageTranslatorView.swift

@@ -150,7 +150,11 @@ struct LanguageTranslatorView: View {
             }
 
             ZStack(alignment: .bottomTrailing) {
-                ThinCaretTextEditor(text: $viewModel.sourceText, onSubmit: viewModel.translate)
+                ThinCaretTextEditor(
+                    text: $viewModel.sourceText,
+                    placeholder: "Start writing...",
+                    onSubmit: viewModel.translate
+                )
                     .font(.system(size: 14))
                     .foregroundStyle(AppTheme.textPrimary)
                     .frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -161,16 +165,6 @@ struct LanguageTranslatorView: View {
                         RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
                             .stroke(AppTheme.inputBorder, lineWidth: 1)
                     )
-                    .overlay(alignment: .topLeading) {
-                        if viewModel.sourceText.isEmpty {
-                            Text("Start writing...")
-                                .font(.system(size: 14))
-                                .foregroundStyle(AppTheme.textMuted)
-                                .padding(.leading, AppTheme.textEditorHorizontalInset)
-                                .padding(.top, AppTheme.textEditorVerticalInset)
-                                .allowsHitTesting(false)
-                        }
-                    }
 
                 Text(viewModel.characterCountLabel)
                     .font(.system(size: 11))
@@ -202,6 +196,9 @@ struct LanguageTranslatorView: View {
             ZStack(alignment: .topLeading) {
                 ThinCaretTextEditor(
                     text: $viewModel.translatedText,
+                    placeholder: viewModel.isTranslating
+                        ? "Translating..."
+                        : "Translation will appear here...",
                     isEditable: false
                 )
                 .font(.system(size: 14))
@@ -214,15 +211,6 @@ struct LanguageTranslatorView: View {
                     RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
                         .stroke(AppTheme.inputBorder, lineWidth: 1)
                 )
-
-                if viewModel.translatedText.isEmpty {
-                    Text(viewModel.isTranslating ? "Translating..." : "Translation will appear here...")
-                        .font(.system(size: 14))
-                        .foregroundStyle(AppTheme.textMuted)
-                        .padding(.leading, AppTheme.textEditorHorizontalInset)
-                        .padding(.top, AppTheme.textEditorVerticalInset)
-                        .allowsHitTesting(false)
-                }
             }
             .frame(maxHeight: .infinity)
         }

+ 4 - 11
gramora/Views/ParaphrasingView.swift

@@ -93,7 +93,10 @@ struct ParaphrasingView: View {
                 .accessibilityLabel("Your Content")
 
             ZStack(alignment: .bottomTrailing) {
-                ThinCaretTextEditor(text: $viewModel.text)
+                ThinCaretTextEditor(
+                    text: $viewModel.text,
+                    placeholder: "Type or paste your text here and press the paraphrase button..."
+                )
                     .font(.system(size: 14))
                     .foregroundStyle(AppTheme.textPrimary)
                     .frame(minHeight: editorHeight, idealHeight: editorHeight)
@@ -103,16 +106,6 @@ struct ParaphrasingView: View {
                         RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
                             .stroke(AppTheme.inputBorder, lineWidth: 1)
                     )
-                    .overlay(alignment: .topLeading) {
-                        if viewModel.text.isEmpty {
-                            Text("Type or paste your text here and press the paraphrase button...")
-                                .font(.system(size: 14))
-                                .foregroundStyle(AppTheme.textMuted)
-                                .padding(.leading, AppTheme.textEditorHorizontalInset)
-                                .padding(.top, AppTheme.textEditorVerticalInset)
-                                .allowsHitTesting(false)
-                        }
-                    }
 
                 Text(viewModel.characterCountLabel)
                     .font(.system(size: 11))

+ 4 - 11
gramora/Views/PunctuationCheckerView.swift

@@ -93,7 +93,10 @@ struct PunctuationCheckerView: View {
                 .accessibilityLabel("Your Content")
 
             ZStack(alignment: .bottomTrailing) {
-                ThinCaretTextEditor(text: $viewModel.text)
+                ThinCaretTextEditor(
+                    text: $viewModel.text,
+                    placeholder: "Type or paste your text here and press the check button..."
+                )
                     .font(.system(size: 14))
                     .foregroundStyle(AppTheme.textPrimary)
                     .frame(minHeight: editorHeight, idealHeight: editorHeight)
@@ -103,16 +106,6 @@ struct PunctuationCheckerView: View {
                         RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
                             .stroke(AppTheme.inputBorder, lineWidth: 1)
                     )
-                    .overlay(alignment: .topLeading) {
-                        if viewModel.text.isEmpty {
-                            Text("Type or paste your text here and press the check button...")
-                                .font(.system(size: 14))
-                                .foregroundStyle(AppTheme.textMuted)
-                                .padding(.leading, AppTheme.textEditorHorizontalInset)
-                                .padding(.top, AppTheme.textEditorVerticalInset)
-                                .allowsHitTesting(false)
-                        }
-                    }
 
                 Text(viewModel.characterCountLabel)
                     .font(.system(size: 11))

+ 4 - 11
gramora/Views/SpellCheckerView.swift

@@ -134,7 +134,10 @@ struct SpellCheckerView: View {
                 .accessibilityLabel("Your Content")
 
             ZStack(alignment: .bottomTrailing) {
-                ThinCaretTextEditor(text: $viewModel.text)
+                ThinCaretTextEditor(
+                    text: $viewModel.text,
+                    placeholder: "Type or paste your text here and press the check button..."
+                )
                     .font(.system(size: 14))
                     .foregroundStyle(AppTheme.textPrimary)
                     .frame(minHeight: editorHeight, idealHeight: editorHeight)
@@ -144,16 +147,6 @@ struct SpellCheckerView: View {
                         RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
                             .stroke(AppTheme.inputBorder, lineWidth: 1)
                     )
-                    .overlay(alignment: .topLeading) {
-                        if viewModel.text.isEmpty {
-                            Text("Type or paste your text here and press the check button...")
-                                .font(.system(size: 14))
-                                .foregroundStyle(AppTheme.textMuted)
-                                .padding(.leading, AppTheme.textEditorHorizontalInset)
-                                .padding(.top, AppTheme.textEditorVerticalInset)
-                                .allowsHitTesting(false)
-                        }
-                    }
 
                 Text(viewModel.characterCountLabel)
                     .font(.system(size: 11))

+ 4 - 11
gramora/Views/SummarizerView.swift

@@ -93,7 +93,10 @@ struct SummarizerView: View {
                 .accessibilityLabel("Your Content")
 
             ZStack(alignment: .bottomTrailing) {
-                ThinCaretTextEditor(text: $viewModel.text)
+                ThinCaretTextEditor(
+                    text: $viewModel.text,
+                    placeholder: "Type or paste your text here and press the summarize button..."
+                )
                     .font(.system(size: 14))
                     .foregroundStyle(AppTheme.textPrimary)
                     .frame(minHeight: editorHeight, idealHeight: editorHeight)
@@ -103,16 +106,6 @@ struct SummarizerView: View {
                         RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
                             .stroke(AppTheme.inputBorder, lineWidth: 1)
                     )
-                    .overlay(alignment: .topLeading) {
-                        if viewModel.text.isEmpty {
-                            Text("Type or paste your text here and press the summarize button...")
-                                .font(.system(size: 14))
-                                .foregroundStyle(AppTheme.textMuted)
-                                .padding(.leading, AppTheme.textEditorHorizontalInset)
-                                .padding(.top, AppTheme.textEditorVerticalInset)
-                                .allowsHitTesting(false)
-                        }
-                    }
 
                 Text(viewModel.characterCountLabel)
                     .font(.system(size: 11))

+ 4 - 11
gramora/Views/WordsCountView.swift

@@ -79,7 +79,10 @@ struct WordsCountView: View {
             statsRow
 
             ZStack(alignment: .bottomTrailing) {
-                ThinCaretTextEditor(text: $viewModel.text)
+                ThinCaretTextEditor(
+                    text: $viewModel.text,
+                    placeholder: "Type or paste your text here to count words..."
+                )
                     .font(.system(size: 14))
                     .foregroundStyle(AppTheme.textPrimary)
                     .frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -90,16 +93,6 @@ struct WordsCountView: View {
                         RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
                             .stroke(AppTheme.inputBorder, lineWidth: 1)
                     )
-                    .overlay(alignment: .topLeading) {
-                        if viewModel.text.isEmpty {
-                            Text("Type or paste your text here to count words...")
-                                .font(.system(size: 14))
-                                .foregroundStyle(AppTheme.textMuted)
-                                .padding(.leading, AppTheme.textEditorHorizontalInset)
-                                .padding(.top, AppTheme.textEditorVerticalInset)
-                                .allowsHitTesting(false)
-                        }
-                    }
 
                 Text(viewModel.characterCountLabel)
                     .font(.system(size: 11))