Просмотр исходного кода

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 месяц назад
Родитель
Сommit
e1a372e910

+ 1 - 0
gramora/Utilities/AppTheme.swift

@@ -33,6 +33,7 @@ enum AppTheme {
     static let inputCornerRadius: CGFloat = 12
     static let inputCornerRadius: CGFloat = 12
     static let textEditorHorizontalInset: CGFloat = 16
     static let textEditorHorizontalInset: CGFloat = 16
     static let textEditorVerticalInset: CGFloat = 18
     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 background = Color(red: 0.973, green: 0.980, blue: 0.984)
     static let sidebarBackground = Color.white
     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 AppKit
 import SwiftUI
 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 final class ThinCaretTextView: NSTextView {
     private let caretWidth: CGFloat = 1
     private let caretWidth: CGFloat = 1
     private let caretHeightBoost: CGFloat = 2
     private let caretHeightBoost: CGFloat = 2
@@ -82,7 +109,7 @@ struct ThinCaretTextEditor: NSViewRepresentable {
     }
     }
 
 
     func makeNSView(context: Context) -> NSScrollView {
     func makeNSView(context: Context) -> NSScrollView {
-        let scrollView = NSScrollView()
+        let scrollView = TextEditorScrollView()
         scrollView.hasVerticalScroller = true
         scrollView.hasVerticalScroller = true
         scrollView.hasHorizontalScroller = false
         scrollView.hasHorizontalScroller = false
         scrollView.autohidesScrollers = true
         scrollView.autohidesScrollers = true
@@ -137,6 +164,14 @@ struct ThinCaretTextEditor: NSViewRepresentable {
 
 
         if textView.string != text {
         if textView.string != text {
             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 {
 struct ParaphrasingView: View {
     @StateObject private var viewModel = ParaphrasingViewModel()
     @StateObject private var viewModel = ParaphrasingViewModel()
 
 
+    private var showsOutputLayout: Bool {
+        !viewModel.paraphrasedText.isEmpty || viewModel.isParaphrasing
+    }
+
     var body: some View {
     var body: some View {
         GeometryReader { proxy in
         GeometryReader { proxy in
             let dynamicHorizontalPadding = min(max(proxy.size.width * 0.045, AppTheme.contentPadding), 52)
             let dynamicHorizontalPadding = min(max(proxy.size.width * 0.045, AppTheme.contentPadding), 52)
             let dynamicBottomPadding = min(max(proxy.size.height * 0.04, 20), 40)
             let dynamicBottomPadding = min(max(proxy.size.height * 0.04, 20), 40)
 
 
             Group {
             Group {
-                if viewModel.paraphrasedText.isEmpty {
+                if !showsOutputLayout {
                     VStack(alignment: .leading, spacing: 0) {
                     VStack(alignment: .leading, spacing: 0) {
                         header
                         header
                             .padding(.bottom, 24)
                             .padding(.bottom, 24)
@@ -107,19 +111,13 @@ struct ParaphrasingView: View {
     }
     }
 
 
     private var contentCard: some View {
     private var contentCard: some View {
-        VStack(alignment: .leading, spacing: AppTheme.contentCardSpacing) {
+        let compactEditor = showsOutputLayout
+
+        return VStack(alignment: .leading, spacing: AppTheme.contentCardSpacing) {
             YourContentHeader()
             YourContentHeader()
 
 
             VStack(alignment: .leading, spacing: 6) {
             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)
                     .background(Color.white)
                     .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
                     .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
                     .overlay(
                     .overlay(
@@ -132,7 +130,7 @@ struct ParaphrasingView: View {
                     .foregroundStyle(AppTheme.textMuted)
                     .foregroundStyle(AppTheme.textMuted)
                     .frame(maxWidth: .infinity, alignment: .trailing)
                     .frame(maxWidth: .infinity, alignment: .trailing)
             }
             }
-            .frame(maxWidth: .infinity, maxHeight: .infinity)
+            .scrollSafeEditorSectionFrame(compact: compactEditor)
 
 
             HStack(spacing: 8) {
             HStack(spacing: 8) {
                 ToolbarButton(title: "Paste Text", iconName: "doc.on.clipboard") {
                 ToolbarButton(title: "Paste Text", iconName: "doc.on.clipboard") {
@@ -151,7 +149,7 @@ struct ParaphrasingView: View {
             }
             }
         }
         }
         .inputContentCardPadding()
         .inputContentCardPadding()
-        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+        .scrollSafeContentCardFrame(compact: compactEditor)
         .background(AppTheme.cardBackground)
         .background(AppTheme.cardBackground)
         .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
         .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
         .overlay(
         .overlay(
@@ -161,6 +159,18 @@ struct ParaphrasingView: View {
         .shadow(color: AppTheme.cardShadow, radius: 16, y: 6)
         .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 {
     private var outputCard: some View {
         VStack(alignment: .leading, spacing: 12) {
         VStack(alignment: .leading, spacing: 12) {
             HStack {
             HStack {
@@ -173,11 +183,21 @@ struct ParaphrasingView: View {
                 ToolbarButton(title: "Copy", iconName: "doc.on.doc") {
                 ToolbarButton(title: "Copy", iconName: "doc.on.doc") {
                     viewModel.copyParaphrasedText()
                     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)
                 .frame(maxWidth: .infinity, alignment: .leading)
                 .padding(16)
                 .padding(16)
                 .background(AppTheme.tealLight.opacity(0.35))
                 .background(AppTheme.tealLight.opacity(0.35))

+ 36 - 16
gramora/Views/PunctuationCheckerView.swift

@@ -3,13 +3,17 @@ import SwiftUI
 struct PunctuationCheckerView: View {
 struct PunctuationCheckerView: View {
     @StateObject private var viewModel = PunctuationCheckerViewModel()
     @StateObject private var viewModel = PunctuationCheckerViewModel()
 
 
+    private var showsOutputLayout: Bool {
+        !viewModel.correctedText.isEmpty || viewModel.isChecking
+    }
+
     var body: some View {
     var body: some View {
         GeometryReader { proxy in
         GeometryReader { proxy in
             let dynamicHorizontalPadding = min(max(proxy.size.width * 0.045, AppTheme.contentPadding), 52)
             let dynamicHorizontalPadding = min(max(proxy.size.width * 0.045, AppTheme.contentPadding), 52)
             let dynamicBottomPadding = min(max(proxy.size.height * 0.04, 20), 40)
             let dynamicBottomPadding = min(max(proxy.size.height * 0.04, 20), 40)
 
 
             Group {
             Group {
-                if viewModel.correctedText.isEmpty {
+                if !showsOutputLayout {
                     VStack(alignment: .leading, spacing: 0) {
                     VStack(alignment: .leading, spacing: 0) {
                         header
                         header
                             .padding(.bottom, 24)
                             .padding(.bottom, 24)
@@ -107,19 +111,13 @@ struct PunctuationCheckerView: View {
     }
     }
 
 
     private var contentCard: some View {
     private var contentCard: some View {
-        VStack(alignment: .leading, spacing: AppTheme.contentCardSpacing) {
+        let compactEditor = showsOutputLayout
+
+        return VStack(alignment: .leading, spacing: AppTheme.contentCardSpacing) {
             YourContentHeader()
             YourContentHeader()
 
 
             VStack(alignment: .leading, spacing: 6) {
             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)
                     .background(Color.white)
                     .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
                     .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
                     .overlay(
                     .overlay(
@@ -132,7 +130,7 @@ struct PunctuationCheckerView: View {
                     .foregroundStyle(AppTheme.textMuted)
                     .foregroundStyle(AppTheme.textMuted)
                     .frame(maxWidth: .infinity, alignment: .trailing)
                     .frame(maxWidth: .infinity, alignment: .trailing)
             }
             }
-            .frame(maxWidth: .infinity, maxHeight: .infinity)
+            .scrollSafeEditorSectionFrame(compact: compactEditor)
 
 
             HStack(spacing: 8) {
             HStack(spacing: 8) {
                 ToolbarButton(title: "Paste Text", iconName: "doc.on.clipboard") {
                 ToolbarButton(title: "Paste Text", iconName: "doc.on.clipboard") {
@@ -151,7 +149,7 @@ struct PunctuationCheckerView: View {
             }
             }
         }
         }
         .inputContentCardPadding()
         .inputContentCardPadding()
-        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+        .scrollSafeContentCardFrame(compact: compactEditor)
         .background(AppTheme.cardBackground)
         .background(AppTheme.cardBackground)
         .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
         .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
         .overlay(
         .overlay(
@@ -161,6 +159,18 @@ struct PunctuationCheckerView: View {
         .shadow(color: AppTheme.cardShadow, radius: 16, y: 6)
         .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 {
     private var outputCard: some View {
         VStack(alignment: .leading, spacing: 12) {
         VStack(alignment: .leading, spacing: 12) {
             HStack {
             HStack {
@@ -173,11 +183,21 @@ struct PunctuationCheckerView: View {
                 ToolbarButton(title: "Copy", iconName: "doc.on.doc") {
                 ToolbarButton(title: "Copy", iconName: "doc.on.doc") {
                     viewModel.copyCorrectedText()
                     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)
                 .frame(maxWidth: .infinity, alignment: .leading)
                 .padding(16)
                 .padding(16)
                 .background(AppTheme.tealLight.opacity(0.35))
                 .background(AppTheme.tealLight.opacity(0.35))

+ 36 - 16
gramora/Views/SummarizerView.swift

@@ -3,13 +3,17 @@ import SwiftUI
 struct SummarizerView: View {
 struct SummarizerView: View {
     @StateObject private var viewModel = SummarizerViewModel()
     @StateObject private var viewModel = SummarizerViewModel()
 
 
+    private var showsOutputLayout: Bool {
+        !viewModel.summary.isEmpty || viewModel.isSummarizing
+    }
+
     var body: some View {
     var body: some View {
         GeometryReader { proxy in
         GeometryReader { proxy in
             let dynamicHorizontalPadding = min(max(proxy.size.width * 0.045, AppTheme.contentPadding), 52)
             let dynamicHorizontalPadding = min(max(proxy.size.width * 0.045, AppTheme.contentPadding), 52)
             let dynamicBottomPadding = min(max(proxy.size.height * 0.04, 20), 40)
             let dynamicBottomPadding = min(max(proxy.size.height * 0.04, 20), 40)
 
 
             Group {
             Group {
-                if viewModel.summary.isEmpty {
+                if !showsOutputLayout {
                     VStack(alignment: .leading, spacing: 0) {
                     VStack(alignment: .leading, spacing: 0) {
                         header
                         header
                             .padding(.bottom, 24)
                             .padding(.bottom, 24)
@@ -107,19 +111,13 @@ struct SummarizerView: View {
     }
     }
 
 
     private var contentCard: some View {
     private var contentCard: some View {
-        VStack(alignment: .leading, spacing: AppTheme.contentCardSpacing) {
+        let compactEditor = showsOutputLayout
+
+        return VStack(alignment: .leading, spacing: AppTheme.contentCardSpacing) {
             YourContentHeader()
             YourContentHeader()
 
 
             VStack(alignment: .leading, spacing: 6) {
             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)
                     .background(Color.white)
                     .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
                     .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
                     .overlay(
                     .overlay(
@@ -132,7 +130,7 @@ struct SummarizerView: View {
                     .foregroundStyle(AppTheme.textMuted)
                     .foregroundStyle(AppTheme.textMuted)
                     .frame(maxWidth: .infinity, alignment: .trailing)
                     .frame(maxWidth: .infinity, alignment: .trailing)
             }
             }
-            .frame(maxWidth: .infinity, maxHeight: .infinity)
+            .scrollSafeEditorSectionFrame(compact: compactEditor)
 
 
             HStack(spacing: 8) {
             HStack(spacing: 8) {
                 ToolbarButton(title: "Paste Text", iconName: "doc.on.clipboard") {
                 ToolbarButton(title: "Paste Text", iconName: "doc.on.clipboard") {
@@ -151,7 +149,7 @@ struct SummarizerView: View {
             }
             }
         }
         }
         .inputContentCardPadding()
         .inputContentCardPadding()
-        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+        .scrollSafeContentCardFrame(compact: compactEditor)
         .background(AppTheme.cardBackground)
         .background(AppTheme.cardBackground)
         .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
         .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
         .overlay(
         .overlay(
@@ -161,6 +159,18 @@ struct SummarizerView: View {
         .shadow(color: AppTheme.cardShadow, radius: 16, y: 6)
         .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 {
     private var outputCard: some View {
         VStack(alignment: .leading, spacing: 12) {
         VStack(alignment: .leading, spacing: 12) {
             HStack {
             HStack {
@@ -173,11 +183,21 @@ struct SummarizerView: View {
                 ToolbarButton(title: "Copy", iconName: "doc.on.doc") {
                 ToolbarButton(title: "Copy", iconName: "doc.on.doc") {
                     viewModel.copySummary()
                     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)
                 .frame(maxWidth: .infinity, alignment: .leading)
                 .padding(16)
                 .padding(16)
                 .background(AppTheme.tealLight.opacity(0.35))
                 .background(AppTheme.tealLight.opacity(0.35))