Bläddra i källkod

Add Punctuation Checker and Words Count pages matching the grammar checker layout.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 månad sedan
förälder
incheckning
5aaf4e11e4

+ 29 - 0
gramora/ViewModels/PunctuationCheckerViewModel.swift

@@ -0,0 +1,29 @@
+import AppKit
+import Combine
+import Foundation
+
+@MainActor
+final class PunctuationCheckerViewModel: ObservableObject {
+    static let maxCharacters = 10_000
+
+    @Published var text: String = ""
+
+    var characterCount: Int { text.count }
+
+    var characterCountLabel: String {
+        "\(characterCount)/\(Self.maxCharacters)"
+    }
+
+    func pasteText() {
+        guard let clipboard = NSPasteboard.general.string(forType: .string) else { return }
+        text = String(clipboard.prefix(Self.maxCharacters))
+    }
+
+    func clearText() {
+        text = ""
+    }
+
+    func checkPunctuation() {
+        // Punctuation check service integration point
+    }
+}

+ 55 - 0
gramora/ViewModels/WordsCountViewModel.swift

@@ -0,0 +1,55 @@
+import AppKit
+import Combine
+import Foundation
+
+@MainActor
+final class WordsCountViewModel: ObservableObject {
+    static let maxCharacters = 50_000
+
+    @Published var text: String = ""
+
+    var characterCount: Int { text.count }
+
+    var characterCountLabel: String {
+        "\(characterCount)/\(Self.maxCharacters)"
+    }
+
+    var wordCount: Int {
+        let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmed.isEmpty else { return 0 }
+        return trimmed
+            .components(separatedBy: .whitespacesAndNewlines)
+            .filter { !$0.isEmpty }
+            .count
+    }
+
+    var wordCountLabel: String {
+        "\(wordCount) Words Count"
+    }
+
+    var sentenceCount: Int {
+        let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmed.isEmpty else { return 0 }
+        let sentences = trimmed.components(separatedBy: CharacterSet(charactersIn: ".!?"))
+            .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+            .filter { !$0.isEmpty }
+        return max(sentences.count, 1)
+    }
+
+    var paragraphCount: Int {
+        let paragraphs = text
+            .components(separatedBy: .newlines)
+            .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+            .filter { !$0.isEmpty }
+        return paragraphs.count
+    }
+
+    func pasteText() {
+        guard let clipboard = NSPasteboard.general.string(forType: .string) else { return }
+        text = String(clipboard.prefix(Self.maxCharacters))
+    }
+
+    func clearText() {
+        text = ""
+    }
+}

+ 4 - 0
gramora/Views/MainView.swift

@@ -58,6 +58,10 @@ struct MainView: View {
             ParaphrasingView()
             ParaphrasingView()
         case .languageTranslator:
         case .languageTranslator:
             LanguageTranslatorView()
             LanguageTranslatorView()
+        case .punctuationChecker:
+            PunctuationCheckerView()
+        case .wordsCount:
+            WordsCountView()
         default:
         default:
             PlaceholderView(destination: viewModel.selectedDestination)
             PlaceholderView(destination: viewModel.selectedDestination)
         }
         }

+ 134 - 0
gramora/Views/PunctuationCheckerView.swift

@@ -0,0 +1,134 @@
+import SwiftUI
+
+struct PunctuationCheckerView: View {
+    @StateObject private var viewModel = PunctuationCheckerViewModel()
+
+    var body: some View {
+        GeometryReader { proxy in
+            let dynamicHorizontalPadding = min(max(proxy.size.width * 0.045, AppTheme.contentPadding), 52)
+            let dynamicTopPadding = min(max(proxy.size.height * 0.05, 24), 44)
+            let dynamicBottomPadding = min(max(proxy.size.height * 0.06, 28), 56)
+            let dynamicEditorHeight = min(max(proxy.size.height * 0.43, 260), 560)
+
+            ScrollView(.vertical, showsIndicators: false) {
+                VStack(alignment: .leading, spacing: 0) {
+                    header
+                        .padding(.bottom, 28)
+
+                    contentCard(editorHeight: dynamicEditorHeight)
+                        .padding(.bottom, 24)
+
+                    GradientButton(title: "Check Punctuation") {
+                        viewModel.checkPunctuation()
+                    }
+                }
+                .frame(maxWidth: 1200, alignment: .topLeading)
+                .frame(maxWidth: .infinity, alignment: .topLeading)
+                .padding(.horizontal, dynamicHorizontalPadding)
+                .padding(.top, dynamicTopPadding)
+                .padding(.bottom, dynamicBottomPadding)
+            }
+        }
+        .onChange(of: viewModel.text) { newValue in
+            if newValue.count > PunctuationCheckerViewModel.maxCharacters {
+                viewModel.text = String(newValue.prefix(PunctuationCheckerViewModel.maxCharacters))
+            }
+        }
+    }
+
+    private var header: some View {
+        HStack(alignment: .top) {
+            VStack(alignment: .leading, spacing: 8) {
+                Text("Punctuation Checker")
+                    .font(.system(size: 30, weight: .bold))
+                    .foregroundStyle(AppTheme.textPrimary)
+
+                HStack(spacing: 0) {
+                    Text("Perfect your ")
+                        .foregroundStyle(AppTheme.textSecondary)
+                    Text("punctuation")
+                        .fontWeight(.semibold)
+                        .foregroundStyle(AppTheme.teal)
+                    Text(". Write with ")
+                        .foregroundStyle(AppTheme.textSecondary)
+                    Text("clarity")
+                        .fontWeight(.semibold)
+                        .foregroundStyle(AppTheme.teal)
+                    Text(".")
+                        .foregroundStyle(AppTheme.textSecondary)
+                }
+                .font(.system(size: 15))
+            }
+
+            Spacer()
+
+            IconButton {
+                SettingsGearIcon()
+            } action: {}
+            .accessibilityLabel("Settings")
+        }
+    }
+
+    private func contentCard(editorHeight: CGFloat) -> some View {
+        VStack(alignment: .leading, spacing: 16) {
+            Image("YourContentLogo")
+                .resizable()
+                .aspectRatio(contentMode: .fit)
+                .frame(height: 52, alignment: .topLeading)
+                .frame(height: 46, alignment: .topLeading)
+                .clipped()
+                .accessibilityLabel("Your Content")
+
+            ZStack(alignment: .bottomTrailing) {
+                ThinCaretTextEditor(text: $viewModel.text)
+                    .font(.system(size: 14))
+                    .foregroundStyle(AppTheme.textPrimary)
+                    .frame(minHeight: editorHeight, idealHeight: editorHeight)
+                    .background(Color.white)
+                    .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
+                    .overlay(
+                        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))
+                    .foregroundStyle(AppTheme.textMuted)
+                    .padding(.trailing, 16)
+                    .padding(.bottom, 12)
+            }
+
+            HStack(spacing: 8) {
+                ToolbarButton(title: "Paste Text", iconName: "doc.on.clipboard") {
+                    viewModel.pasteText()
+                }
+
+                ToolbarButton(title: "Upload File", iconName: "square.and.arrow.up") {}
+
+                Spacer()
+
+                ClearButton {
+                    viewModel.clearText()
+                }
+            }
+        }
+        .padding(22)
+        .background(AppTheme.cardBackground)
+        .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
+        .overlay(
+            RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)
+                .stroke(AppTheme.border, lineWidth: 1)
+        )
+        .shadow(color: AppTheme.cardShadow, radius: 16, y: 6)
+    }
+}

+ 166 - 0
gramora/Views/WordsCountView.swift

@@ -0,0 +1,166 @@
+import SwiftUI
+
+struct WordsCountView: View {
+    @StateObject private var viewModel = WordsCountViewModel()
+
+    var body: some View {
+        GeometryReader { proxy in
+            let dynamicHorizontalPadding = min(max(proxy.size.width * 0.045, AppTheme.contentPadding), 52)
+            let dynamicTopPadding = min(max(proxy.size.height * 0.05, 24), 44)
+            let dynamicBottomPadding = min(max(proxy.size.height * 0.06, 28), 56)
+
+            VStack(alignment: .leading, spacing: 0) {
+                header
+                    .padding(.bottom, 28)
+
+                contentCard
+                    .frame(maxWidth: 1200, maxHeight: .infinity, alignment: .topLeading)
+                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+            }
+            .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+            .padding(.horizontal, dynamicHorizontalPadding)
+            .padding(.top, dynamicTopPadding)
+            .padding(.bottom, dynamicBottomPadding)
+        }
+        .onChange(of: viewModel.text) { newValue in
+            if newValue.count > WordsCountViewModel.maxCharacters {
+                viewModel.text = String(newValue.prefix(WordsCountViewModel.maxCharacters))
+            }
+        }
+    }
+
+    private var header: some View {
+        HStack(alignment: .top) {
+            VStack(alignment: .leading, spacing: 8) {
+                Text("Words Count")
+                    .font(.system(size: 30, weight: .bold))
+                    .foregroundStyle(AppTheme.textPrimary)
+
+                HStack(spacing: 0) {
+                    Text("Count ")
+                        .foregroundStyle(AppTheme.textSecondary)
+                    Text("every word")
+                        .fontWeight(.semibold)
+                        .foregroundStyle(AppTheme.teal)
+                    Text(". Write with ")
+                        .foregroundStyle(AppTheme.textSecondary)
+                    Text("precision")
+                        .fontWeight(.semibold)
+                        .foregroundStyle(AppTheme.teal)
+                    Text(".")
+                        .foregroundStyle(AppTheme.textSecondary)
+                }
+                .font(.system(size: 15))
+            }
+
+            Spacer()
+
+            IconButton {
+                SettingsGearIcon()
+            } action: {}
+            .accessibilityLabel("Settings")
+        }
+    }
+
+    private var contentCard: some View {
+        VStack(alignment: .leading, spacing: 16) {
+            Image("YourContentLogo")
+                .resizable()
+                .aspectRatio(contentMode: .fit)
+                .frame(height: 52, alignment: .topLeading)
+                .frame(height: 46, alignment: .topLeading)
+                .clipped()
+                .accessibilityLabel("Your Content")
+
+            Text(viewModel.wordCountLabel)
+                .font(.system(size: 18, weight: .semibold))
+                .foregroundStyle(AppTheme.textPrimary)
+
+            statsRow
+
+            ZStack(alignment: .bottomTrailing) {
+                ThinCaretTextEditor(text: $viewModel.text)
+                    .font(.system(size: 14))
+                    .foregroundStyle(AppTheme.textPrimary)
+                    .frame(maxWidth: .infinity, maxHeight: .infinity)
+                    .frame(minHeight: 96)
+                    .background(Color.white)
+                    .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
+                    .overlay(
+                        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))
+                    .foregroundStyle(AppTheme.textMuted)
+                    .padding(.trailing, 16)
+                    .padding(.bottom, 12)
+            }
+            .frame(maxHeight: .infinity)
+
+            HStack(spacing: 8) {
+                ToolbarButton(title: "Paste Text", iconName: "doc.on.clipboard") {
+                    viewModel.pasteText()
+                }
+
+                ToolbarButton(title: "Upload File", iconName: "square.and.arrow.up") {}
+
+                Spacer()
+
+                ClearButton {
+                    viewModel.clearText()
+                }
+            }
+        }
+        .frame(maxHeight: .infinity, alignment: .top)
+        .padding(22)
+        .background(AppTheme.cardBackground)
+        .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
+        .overlay(
+            RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)
+                .stroke(AppTheme.border, lineWidth: 1)
+        )
+        .shadow(color: AppTheme.cardShadow, radius: 16, y: 6)
+    }
+
+    private var statsRow: some View {
+        HStack(spacing: 12) {
+            StatBadge(title: "Words", value: "\(viewModel.wordCount)")
+            StatBadge(title: "Sentences", value: "\(viewModel.sentenceCount)")
+            StatBadge(title: "Paragraphs", value: "\(viewModel.paragraphCount)")
+            StatBadge(title: "Characters", value: "\(viewModel.characterCount)")
+        }
+    }
+}
+
+private struct StatBadge: View {
+    let title: String
+    let value: String
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 4) {
+            Text(value)
+                .font(.system(size: 16, weight: .semibold))
+                .foregroundStyle(AppTheme.teal)
+
+            Text(title)
+                .font(.system(size: 11))
+                .foregroundStyle(AppTheme.textMuted)
+        }
+        .padding(.horizontal, 14)
+        .padding(.vertical, 10)
+        .background(AppTheme.tealLight)
+        .clipShape(RoundedRectangle(cornerRadius: 10))
+    }
+}