Эх сурвалжийг харах

Add a full Spelling Checker page with detailed results flow.

Wire spelling into main navigation and provide a back action, corrected text output, copy support, and mistake cards that fill the results layout.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 сар өмнө
parent
commit
17810ffe8b

+ 7 - 0
gramora/Models/SpellCheckModels.swift

@@ -0,0 +1,7 @@
+import Foundation
+
+struct SpellingMistake: Identifiable, Equatable {
+    let id = UUID()
+    let original: String
+    let suggestion: String
+}

+ 98 - 0
gramora/ViewModels/SpellCheckerViewModel.swift

@@ -0,0 +1,98 @@
+import AppKit
+import Combine
+import Foundation
+
+@MainActor
+final class SpellCheckerViewModel: ObservableObject {
+    static let maxCharacters = 10_000
+
+    @Published var text: String = ""
+    @Published private(set) var mistakes: [SpellingMistake] = []
+    @Published private(set) var correctedText: String = ""
+    @Published private(set) var hasResults = false
+
+    var characterCount: Int { text.count }
+
+    var characterCountLabel: String {
+        "\(characterCount)/\(Self.maxCharacters)"
+    }
+
+    var mistakeCountLabel: String {
+        let count = mistakes.count
+        return "\(count) Mistake\(count == 1 ? "" : "s")"
+    }
+
+    func pasteText() {
+        guard let clipboard = NSPasteboard.general.string(forType: .string) else { return }
+        text = String(clipboard.prefix(Self.maxCharacters))
+        resetResults()
+    }
+
+    func clearText() {
+        text = ""
+        resetResults()
+    }
+
+    func resetResults() {
+        mistakes = []
+        correctedText = ""
+        hasResults = false
+    }
+
+    func checkSpelling() {
+        let content = text.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !content.isEmpty else {
+            resetResults()
+            return
+        }
+
+        let checker = NSSpellChecker.shared
+        let language = checker.language()
+        let nsContent = content as NSString
+        var location = 0
+        var foundMistakes: [SpellingMistake] = []
+        var replacements: [(range: NSRange, suggestion: String)] = []
+
+        while location < nsContent.length {
+            let misspelledRange = checker.checkSpelling(
+                of: content,
+                startingAt: location
+            )
+
+            guard misspelledRange.location != NSNotFound else { break }
+
+            let original = nsContent.substring(with: misspelledRange)
+            let guesses = checker.guesses(
+                forWordRange: misspelledRange,
+                in: content,
+                language: language,
+                inSpellDocumentWithTag: 0
+            )
+            let suggestion = guesses?.first ?? original
+
+            foundMistakes.append(SpellingMistake(original: original, suggestion: suggestion))
+            if original != suggestion {
+                replacements.append((misspelledRange, suggestion))
+            }
+
+            let nextLocation = misspelledRange.location + max(misspelledRange.length, 1)
+            if nextLocation <= location { break }
+            location = nextLocation
+        }
+
+        let mutable = NSMutableString(string: content)
+        for replacement in replacements.reversed() {
+            mutable.replaceCharacters(in: replacement.range, with: replacement.suggestion)
+        }
+
+        mistakes = foundMistakes
+        correctedText = mutable as String
+        hasResults = true
+    }
+
+    func copyCorrectedText() {
+        guard !correctedText.isEmpty else { return }
+        NSPasteboard.general.clearContents()
+        NSPasteboard.general.setString(correctedText, forType: .string)
+    }
+}

+ 4 - 0
gramora/Views/MainView.swift

@@ -64,6 +64,10 @@ struct MainView: View {
             WordsCountView()
         case .aiEmailWriter:
             EmailWriterView()
+        case .spellingChecker:
+            SpellCheckerView {
+                viewModel.selectedDestination = .grammarChecker
+            }
         default:
             PlaceholderView(destination: viewModel.selectedDestination)
         }

+ 272 - 0
gramora/Views/SpellCheckerView.swift

@@ -0,0 +1,272 @@
+import SwiftUI
+
+struct SpellCheckerView: View {
+    @StateObject private var viewModel = SpellCheckerViewModel()
+    let onBackTapped: () -> Void
+
+    private let panelBackground = Color(red: 0.96, green: 0.97, blue: 0.98)
+
+    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)
+
+            Group {
+                if viewModel.hasResults {
+                    VStack(alignment: .leading, spacing: 0) {
+                        header
+                            .padding(.bottom, 20)
+
+                        resultsLayout
+                            .frame(maxWidth: .infinity, maxHeight: .infinity)
+                    }
+                    .frame(maxWidth: 1200, maxHeight: .infinity, alignment: .topLeading)
+                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+                    .padding(.horizontal, dynamicHorizontalPadding)
+                    .padding(.top, dynamicTopPadding)
+                    .padding(.bottom, dynamicBottomPadding)
+                } else {
+                    ScrollView(.vertical, showsIndicators: false) {
+                        VStack(alignment: .leading, spacing: 0) {
+                            header
+                                .padding(.bottom, 24)
+
+                            contentCard(editorHeight: dynamicEditorHeight)
+                                .padding(.bottom, 24)
+
+                            GradientButton(title: "Check Spelling") {
+                                viewModel.checkSpelling()
+                            }
+                        }
+                        .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 > SpellCheckerViewModel.maxCharacters {
+                viewModel.text = String(newValue.prefix(SpellCheckerViewModel.maxCharacters))
+            }
+            if viewModel.hasResults {
+                viewModel.resetResults()
+            }
+        }
+    }
+
+    private var header: some View {
+        VStack(alignment: .leading, spacing: 16) {
+            IconButton(iconName: "arrow.left") {
+                onBackTapped()
+            }
+            .accessibilityLabel("Go back")
+
+            HStack(alignment: .top) {
+                VStack(alignment: .leading, spacing: 8) {
+                    Text("Spelling Checker")
+                        .font(.system(size: 30, weight: .bold))
+                        .foregroundStyle(AppTheme.textPrimary)
+
+                    HStack(spacing: 0) {
+                        Text("Catch ")
+                            .foregroundStyle(AppTheme.textSecondary)
+                        Text("misspellings")
+                            .fontWeight(.semibold)
+                            .foregroundStyle(AppTheme.teal)
+                        Text(". Write ")
+                            .foregroundStyle(AppTheme.textSecondary)
+                        Text("clearly")
+                            .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)
+    }
+
+    private var resultsLayout: some View {
+        HStack(alignment: .top, spacing: 20) {
+            correctedContentPanel
+            spellingReportPanel
+        }
+        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
+    }
+
+    private var correctedContentPanel: some View {
+        VStack(alignment: .leading, spacing: 14) {
+            Text("Your Content")
+                .font(.system(size: 18, weight: .bold))
+                .foregroundStyle(AppTheme.textPrimary)
+
+            VStack(alignment: .leading, spacing: 0) {
+                Text(viewModel.correctedText)
+                    .font(.system(size: 14))
+                    .foregroundStyle(AppTheme.textPrimary)
+                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+                    .padding(20)
+
+                HStack {
+                    ToolbarButton(title: "Copy Text", iconName: "doc.on.doc") {
+                        viewModel.copyCorrectedText()
+                    }
+                    Spacer()
+                }
+                .padding(.horizontal, 16)
+                .padding(.bottom, 16)
+            }
+            .frame(maxHeight: .infinity)
+            .background(panelBackground)
+            .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
+            .overlay(
+                RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)
+                    .stroke(AppTheme.border, lineWidth: 1)
+            )
+        }
+        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+    }
+
+    private var spellingReportPanel: some View {
+        VStack(alignment: .leading, spacing: 14) {
+            Text("Spelling Report")
+                .font(.system(size: 18, weight: .bold))
+                .foregroundStyle(AppTheme.textPrimary)
+
+            VStack(alignment: .leading, spacing: 14) {
+                Text(viewModel.mistakeCountLabel)
+                    .font(.system(size: 15, weight: .bold))
+                    .foregroundStyle(AppTheme.textPrimary)
+
+                if viewModel.mistakes.isEmpty {
+                    Text("No spelling mistakes found.")
+                        .font(.system(size: 14))
+                        .foregroundStyle(AppTheme.textSecondary)
+                        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+                } else {
+                    ScrollView(.vertical, showsIndicators: false) {
+                        VStack(spacing: 10) {
+                            ForEach(viewModel.mistakes) { mistake in
+                                mistakeCard(mistake)
+                            }
+                        }
+                    }
+                    .frame(maxHeight: .infinity)
+                }
+            }
+            .padding(16)
+            .frame(width: 300)
+            .frame(maxHeight: .infinity, alignment: .topLeading)
+            .background(panelBackground)
+            .clipShape(RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius))
+            .overlay(
+                RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)
+                    .stroke(AppTheme.border, lineWidth: 1)
+            )
+        }
+        .frame(maxHeight: .infinity, alignment: .topLeading)
+    }
+
+    private func mistakeCard(_ mistake: SpellingMistake) -> some View {
+        VStack(alignment: .leading, spacing: 10) {
+            Text("Correct the spelling error")
+                .font(.system(size: 13, weight: .bold))
+                .foregroundStyle(AppTheme.textPrimary)
+
+            HStack(spacing: 18) {
+                Text(mistake.original)
+                    .font(.system(size: 14, weight: .medium))
+                    .foregroundStyle(AppTheme.textPrimary)
+
+                Text(mistake.suggestion)
+                    .font(.system(size: 14, weight: .semibold))
+                    .foregroundStyle(AppTheme.teal)
+            }
+        }
+        .frame(maxWidth: .infinity, alignment: .leading)
+        .padding(.horizontal, 14)
+        .padding(.vertical, 12)
+        .background(Color.white)
+        .clipShape(RoundedRectangle(cornerRadius: 10))
+        .overlay(
+            RoundedRectangle(cornerRadius: 10)
+                .stroke(AppTheme.border.opacity(0.6), lineWidth: 1)
+        )
+    }
+}