Переглянути джерело

Fix input text disappearing after results appear in text tools.

Stabilize scroll layouts and editor sizing so source text stays visible on summarizer, paraphrasing, and punctuation checker pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 місяць тому
батько
коміт
e1a372e910

+ 1 - 0
gramora/Utilities/AppTheme.swift

@@ -33,6 +33,7 @@ enum AppTheme {
     static let inputCornerRadius: CGFloat = 12
     static let textEditorHorizontalInset: CGFloat = 16
     static let textEditorVerticalInset: CGFloat = 18
+    static let compactTextEditorHeight: CGFloat = 240
 
     static let background = Color(red: 0.973, green: 0.980, blue: 0.984)
     static let sidebarBackground = Color.white

+ 31 - 0
gramora/Utilities/View+ScrollSafeTextEditor.swift

@@ -0,0 +1,31 @@
+import SwiftUI
+
+extension View {
+    @ViewBuilder
+    func scrollSafeTextEditorFrame(compact: Bool) -> some View {
+        if compact {
+            frame(maxWidth: .infinity)
+                .frame(height: AppTheme.compactTextEditorHeight)
+        } else {
+            frame(maxWidth: .infinity, maxHeight: .infinity)
+        }
+    }
+
+    @ViewBuilder
+    func scrollSafeEditorSectionFrame(compact: Bool) -> some View {
+        if compact {
+            frame(maxWidth: .infinity)
+        } else {
+            frame(maxWidth: .infinity, maxHeight: .infinity)
+        }
+    }
+
+    @ViewBuilder
+    func scrollSafeContentCardFrame(compact: Bool) -> some View {
+        if compact {
+            frame(maxWidth: .infinity, alignment: .topLeading)
+        } else {
+            frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+        }
+    }
+}

+ 36 - 1
gramora/Views/Components/ThinCaretTextEditor.swift

@@ -1,6 +1,33 @@
 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 else { return }
+
+        let width = contentView.bounds.width
+        guard width > 1 else { return }
+
+        container.containerSize = NSSize(width: width, height: .greatestFiniteMagnitude)
+        textView.setFrameSize(NSSize(width: width, height: textView.frame.height))
+    }
+}
+
 private final class ThinCaretTextView: NSTextView {
     private let caretWidth: CGFloat = 1
     private let caretHeightBoost: CGFloat = 2
@@ -82,7 +109,7 @@ struct ThinCaretTextEditor: NSViewRepresentable {
     }
 
     func makeNSView(context: Context) -> NSScrollView {
-        let scrollView = NSScrollView()
+        let scrollView = TextEditorScrollView()
         scrollView.hasVerticalScroller = true
         scrollView.hasHorizontalScroller = false
         scrollView.autohidesScrollers = true
@@ -137,6 +164,14 @@ struct ThinCaretTextEditor: NSViewRepresentable {
 
         if textView.string != text {
             textView.string = text
+            textView.needsDisplay = true
+        }
+
+        textView.textColor = NSColor(AppTheme.textPrimary)
+        textView.font = NSFont.systemFont(ofSize: 14)
+
+        if let scrollView = scrollView as? TextEditorScrollView {
+            scrollView.syncTextContainerWidthIfNeeded()
         }
     }
 

+ 36 - 16
gramora/Views/ParaphrasingView.swift

@@ -3,13 +3,17 @@ import SwiftUI
 struct ParaphrasingView: View {
     @StateObject private var viewModel = ParaphrasingViewModel()
 
+    private var showsOutputLayout: Bool {
+        !viewModel.paraphrasedText.isEmpty || viewModel.isParaphrasing
+    }
+
     var body: some View {
         GeometryReader { proxy in
             let dynamicHorizontalPadding = min(max(proxy.size.width * 0.045, AppTheme.contentPadding), 52)
             let dynamicBottomPadding = min(max(proxy.size.height * 0.04, 20), 40)
 
             Group {
-                if viewModel.paraphrasedText.isEmpty {
+                if !showsOutputLayout {
                     VStack(alignment: .leading, spacing: 0) {
                         header
                             .padding(.bottom, 24)
@@ -107,19 +111,13 @@ struct ParaphrasingView: View {
     }
 
     private var contentCard: some View {
-        VStack(alignment: .leading, spacing: AppTheme.contentCardSpacing) {
+        let compactEditor = showsOutputLayout
+
+        return VStack(alignment: .leading, spacing: AppTheme.contentCardSpacing) {
             YourContentHeader()
 
             VStack(alignment: .leading, spacing: 6) {
-                ThinCaretTextEditor(
-                    text: $viewModel.text,
-                    placeholder: "Type or paste your text here and press the paraphrase button...",
-                    maxWords: ParaphrasingViewModel.maxWords,
-                    onWordLimitReached: viewModel.notifyWordLimitReached
-                )
-                    .font(.system(size: 14))
-                    .foregroundStyle(AppTheme.textPrimary)
-                    .frame(maxWidth: .infinity, maxHeight: .infinity)
+                textEditor
                     .background(Color.white)
                     .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
                     .overlay(
@@ -132,7 +130,7 @@ struct ParaphrasingView: View {
                     .foregroundStyle(AppTheme.textMuted)
                     .frame(maxWidth: .infinity, alignment: .trailing)
             }
-            .frame(maxWidth: .infinity, maxHeight: .infinity)
+            .scrollSafeEditorSectionFrame(compact: compactEditor)
 
             HStack(spacing: 8) {
                 ToolbarButton(title: "Paste Text", iconName: "doc.on.clipboard") {
@@ -151,7 +149,7 @@ struct ParaphrasingView: View {
             }
         }
         .inputContentCardPadding()
-        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+        .scrollSafeContentCardFrame(compact: compactEditor)
         .background(AppTheme.cardBackground)
         .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
         .overlay(
@@ -161,6 +159,18 @@ struct ParaphrasingView: View {
         .shadow(color: AppTheme.cardShadow, radius: 16, y: 6)
     }
 
+    private var textEditor: some View {
+        ThinCaretTextEditor(
+            text: $viewModel.text,
+            placeholder: "Type or paste your text here and press the paraphrase button...",
+            maxWords: ParaphrasingViewModel.maxWords,
+            onWordLimitReached: viewModel.notifyWordLimitReached
+        )
+        .font(.system(size: 14))
+        .foregroundStyle(AppTheme.textPrimary)
+        .scrollSafeTextEditorFrame(compact: showsOutputLayout)
+    }
+
     private var outputCard: some View {
         VStack(alignment: .leading, spacing: 12) {
             HStack {
@@ -173,11 +183,21 @@ struct ParaphrasingView: View {
                 ToolbarButton(title: "Copy", iconName: "doc.on.doc") {
                     viewModel.copyParaphrasedText()
                 }
+                .disabled(viewModel.paraphrasedText.isEmpty)
+                .opacity(viewModel.paraphrasedText.isEmpty ? 0.55 : 1)
             }
 
-            Text(viewModel.paraphrasedText)
-                .font(.system(size: 14))
-                .foregroundStyle(AppTheme.textPrimary)
+            Group {
+                if viewModel.isParaphrasing && viewModel.paraphrasedText.isEmpty {
+                    Text("Paraphrasing...")
+                        .font(.system(size: 14))
+                        .foregroundStyle(AppTheme.textMuted)
+                } else {
+                    Text(viewModel.paraphrasedText)
+                        .font(.system(size: 14))
+                        .foregroundStyle(AppTheme.textPrimary)
+                }
+            }
                 .frame(maxWidth: .infinity, alignment: .leading)
                 .padding(16)
                 .background(AppTheme.tealLight.opacity(0.35))

+ 36 - 16
gramora/Views/PunctuationCheckerView.swift

@@ -3,13 +3,17 @@ import SwiftUI
 struct PunctuationCheckerView: View {
     @StateObject private var viewModel = PunctuationCheckerViewModel()
 
+    private var showsOutputLayout: Bool {
+        !viewModel.correctedText.isEmpty || viewModel.isChecking
+    }
+
     var body: some View {
         GeometryReader { proxy in
             let dynamicHorizontalPadding = min(max(proxy.size.width * 0.045, AppTheme.contentPadding), 52)
             let dynamicBottomPadding = min(max(proxy.size.height * 0.04, 20), 40)
 
             Group {
-                if viewModel.correctedText.isEmpty {
+                if !showsOutputLayout {
                     VStack(alignment: .leading, spacing: 0) {
                         header
                             .padding(.bottom, 24)
@@ -107,19 +111,13 @@ struct PunctuationCheckerView: View {
     }
 
     private var contentCard: some View {
-        VStack(alignment: .leading, spacing: AppTheme.contentCardSpacing) {
+        let compactEditor = showsOutputLayout
+
+        return VStack(alignment: .leading, spacing: AppTheme.contentCardSpacing) {
             YourContentHeader()
 
             VStack(alignment: .leading, spacing: 6) {
-                ThinCaretTextEditor(
-                    text: $viewModel.text,
-                    placeholder: "Type or paste your text here and press the check button...",
-                    maxWords: PunctuationCheckerViewModel.maxWords,
-                    onWordLimitReached: viewModel.notifyWordLimitReached
-                )
-                    .font(.system(size: 14))
-                    .foregroundStyle(AppTheme.textPrimary)
-                    .frame(maxWidth: .infinity, maxHeight: .infinity)
+                textEditor
                     .background(Color.white)
                     .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
                     .overlay(
@@ -132,7 +130,7 @@ struct PunctuationCheckerView: View {
                     .foregroundStyle(AppTheme.textMuted)
                     .frame(maxWidth: .infinity, alignment: .trailing)
             }
-            .frame(maxWidth: .infinity, maxHeight: .infinity)
+            .scrollSafeEditorSectionFrame(compact: compactEditor)
 
             HStack(spacing: 8) {
                 ToolbarButton(title: "Paste Text", iconName: "doc.on.clipboard") {
@@ -151,7 +149,7 @@ struct PunctuationCheckerView: View {
             }
         }
         .inputContentCardPadding()
-        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+        .scrollSafeContentCardFrame(compact: compactEditor)
         .background(AppTheme.cardBackground)
         .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
         .overlay(
@@ -161,6 +159,18 @@ struct PunctuationCheckerView: View {
         .shadow(color: AppTheme.cardShadow, radius: 16, y: 6)
     }
 
+    private var textEditor: some View {
+        ThinCaretTextEditor(
+            text: $viewModel.text,
+            placeholder: "Type or paste your text here and press the check button...",
+            maxWords: PunctuationCheckerViewModel.maxWords,
+            onWordLimitReached: viewModel.notifyWordLimitReached
+        )
+        .font(.system(size: 14))
+        .foregroundStyle(AppTheme.textPrimary)
+        .scrollSafeTextEditorFrame(compact: showsOutputLayout)
+    }
+
     private var outputCard: some View {
         VStack(alignment: .leading, spacing: 12) {
             HStack {
@@ -173,11 +183,21 @@ struct PunctuationCheckerView: View {
                 ToolbarButton(title: "Copy", iconName: "doc.on.doc") {
                     viewModel.copyCorrectedText()
                 }
+                .disabled(viewModel.correctedText.isEmpty)
+                .opacity(viewModel.correctedText.isEmpty ? 0.55 : 1)
             }
 
-            Text(viewModel.correctedText)
-                .font(.system(size: 14))
-                .foregroundStyle(AppTheme.textPrimary)
+            Group {
+                if viewModel.isChecking && viewModel.correctedText.isEmpty {
+                    Text("Checking punctuation...")
+                        .font(.system(size: 14))
+                        .foregroundStyle(AppTheme.textMuted)
+                } else {
+                    Text(viewModel.correctedText)
+                        .font(.system(size: 14))
+                        .foregroundStyle(AppTheme.textPrimary)
+                }
+            }
                 .frame(maxWidth: .infinity, alignment: .leading)
                 .padding(16)
                 .background(AppTheme.tealLight.opacity(0.35))

+ 36 - 16
gramora/Views/SummarizerView.swift

@@ -3,13 +3,17 @@ import SwiftUI
 struct SummarizerView: View {
     @StateObject private var viewModel = SummarizerViewModel()
 
+    private var showsOutputLayout: Bool {
+        !viewModel.summary.isEmpty || viewModel.isSummarizing
+    }
+
     var body: some View {
         GeometryReader { proxy in
             let dynamicHorizontalPadding = min(max(proxy.size.width * 0.045, AppTheme.contentPadding), 52)
             let dynamicBottomPadding = min(max(proxy.size.height * 0.04, 20), 40)
 
             Group {
-                if viewModel.summary.isEmpty {
+                if !showsOutputLayout {
                     VStack(alignment: .leading, spacing: 0) {
                         header
                             .padding(.bottom, 24)
@@ -107,19 +111,13 @@ struct SummarizerView: View {
     }
 
     private var contentCard: some View {
-        VStack(alignment: .leading, spacing: AppTheme.contentCardSpacing) {
+        let compactEditor = showsOutputLayout
+
+        return VStack(alignment: .leading, spacing: AppTheme.contentCardSpacing) {
             YourContentHeader()
 
             VStack(alignment: .leading, spacing: 6) {
-                ThinCaretTextEditor(
-                    text: $viewModel.text,
-                    placeholder: "Type or paste your text here and press the summarize button...",
-                    maxWords: SummarizerViewModel.maxWords,
-                    onWordLimitReached: viewModel.notifyWordLimitReached
-                )
-                    .font(.system(size: 14))
-                    .foregroundStyle(AppTheme.textPrimary)
-                    .frame(maxWidth: .infinity, maxHeight: .infinity)
+                textEditor
                     .background(Color.white)
                     .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
                     .overlay(
@@ -132,7 +130,7 @@ struct SummarizerView: View {
                     .foregroundStyle(AppTheme.textMuted)
                     .frame(maxWidth: .infinity, alignment: .trailing)
             }
-            .frame(maxWidth: .infinity, maxHeight: .infinity)
+            .scrollSafeEditorSectionFrame(compact: compactEditor)
 
             HStack(spacing: 8) {
                 ToolbarButton(title: "Paste Text", iconName: "doc.on.clipboard") {
@@ -151,7 +149,7 @@ struct SummarizerView: View {
             }
         }
         .inputContentCardPadding()
-        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+        .scrollSafeContentCardFrame(compact: compactEditor)
         .background(AppTheme.cardBackground)
         .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
         .overlay(
@@ -161,6 +159,18 @@ struct SummarizerView: View {
         .shadow(color: AppTheme.cardShadow, radius: 16, y: 6)
     }
 
+    private var textEditor: some View {
+        ThinCaretTextEditor(
+            text: $viewModel.text,
+            placeholder: "Type or paste your text here and press the summarize button...",
+            maxWords: SummarizerViewModel.maxWords,
+            onWordLimitReached: viewModel.notifyWordLimitReached
+        )
+        .font(.system(size: 14))
+        .foregroundStyle(AppTheme.textPrimary)
+        .scrollSafeTextEditorFrame(compact: showsOutputLayout)
+    }
+
     private var outputCard: some View {
         VStack(alignment: .leading, spacing: 12) {
             HStack {
@@ -173,11 +183,21 @@ struct SummarizerView: View {
                 ToolbarButton(title: "Copy", iconName: "doc.on.doc") {
                     viewModel.copySummary()
                 }
+                .disabled(viewModel.summary.isEmpty)
+                .opacity(viewModel.summary.isEmpty ? 0.55 : 1)
             }
 
-            Text(viewModel.summary)
-                .font(.system(size: 14))
-                .foregroundStyle(AppTheme.textPrimary)
+            Group {
+                if viewModel.isSummarizing && viewModel.summary.isEmpty {
+                    Text("Summarizing...")
+                        .font(.system(size: 14))
+                        .foregroundStyle(AppTheme.textMuted)
+                } else {
+                    Text(viewModel.summary)
+                        .font(.system(size: 14))
+                        .foregroundStyle(AppTheme.textPrimary)
+                }
+            }
                 .frame(maxWidth: .infinity, alignment: .leading)
                 .padding(16)
                 .background(AppTheme.tealLight.opacity(0.35))