Răsfoiți Sursa

Add Language Translator page with toolbar-style language pickers.

Introduces source/target panels, paste and file upload, and wires the sidebar destination to the new view.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 1 lună în urmă
părinte
comite
176a8f9db9

+ 28 - 0
gramora/Models/TranslationLanguage.swift

@@ -0,0 +1,28 @@
+import Foundation
+
+struct TranslationLanguage: Identifiable, Hashable {
+    let id: String
+    let name: String
+
+    static let all: [TranslationLanguage] = [
+        TranslationLanguage(id: "en", name: "English"),
+        TranslationLanguage(id: "ar", name: "Arabic"),
+        TranslationLanguage(id: "es", name: "Spanish"),
+        TranslationLanguage(id: "fr", name: "French"),
+        TranslationLanguage(id: "de", name: "German"),
+        TranslationLanguage(id: "zh", name: "Chinese"),
+        TranslationLanguage(id: "ja", name: "Japanese"),
+        TranslationLanguage(id: "ko", name: "Korean"),
+        TranslationLanguage(id: "pt", name: "Portuguese"),
+        TranslationLanguage(id: "it", name: "Italian"),
+        TranslationLanguage(id: "ru", name: "Russian"),
+        TranslationLanguage(id: "hi", name: "Hindi"),
+        TranslationLanguage(id: "tr", name: "Turkish"),
+        TranslationLanguage(id: "nl", name: "Dutch"),
+        TranslationLanguage(id: "pl", name: "Polish"),
+        TranslationLanguage(id: "ur", name: "Urdu"),
+    ]
+
+    static let defaultSource = all[0]
+    static let defaultTarget = all[1]
+}

+ 87 - 0
gramora/ViewModels/LanguageTranslatorViewModel.swift

@@ -0,0 +1,87 @@
+import AppKit
+import Combine
+import Foundation
+import UniformTypeIdentifiers
+
+@MainActor
+final class LanguageTranslatorViewModel: ObservableObject {
+    static let maxCharacters = 10_000
+
+    @Published var sourceText: String = ""
+    @Published var translatedText: String = ""
+    @Published var sourceLanguage: TranslationLanguage = .defaultSource
+    @Published var targetLanguage: TranslationLanguage = .defaultTarget
+    @Published var isTranslating = false
+
+    var characterCount: Int { sourceText.count }
+
+    var characterCountLabel: String {
+        "\(characterCount)/\(Self.maxCharacters)"
+    }
+
+    var canTranslate: Bool {
+        !sourceText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isTranslating
+    }
+
+    var sourceLanguageOptions: [TranslationLanguage] {
+        TranslationLanguage.all
+    }
+
+    var targetLanguageOptions: [TranslationLanguage] {
+        TranslationLanguage.all.filter { $0 != sourceLanguage }
+    }
+
+    func pasteText() {
+        guard let clipboard = NSPasteboard.general.string(forType: .string) else { return }
+        sourceText = String(clipboard.prefix(Self.maxCharacters))
+    }
+
+    func uploadFile() {
+        let panel = NSOpenPanel()
+        panel.title = "Choose a text file"
+        panel.allowedContentTypes = [.plainText, .text, .utf8PlainText]
+        panel.allowsMultipleSelection = false
+        panel.canChooseDirectories = false
+
+        guard panel.runModal() == .OK, let url = panel.url else { return }
+
+        do {
+            let contents = try String(contentsOf: url, encoding: .utf8)
+            sourceText = String(contents.prefix(Self.maxCharacters))
+        } catch {
+            // File read integration point
+        }
+    }
+
+    func clearText() {
+        sourceText = ""
+        translatedText = ""
+    }
+
+    func swapLanguages() {
+        let previousSource = sourceLanguage
+        sourceLanguage = targetLanguage
+        targetLanguage = previousSource
+
+        let previousSourceText = sourceText
+        sourceText = translatedText
+        translatedText = previousSourceText
+    }
+
+    func translate() {
+        let trimmed = sourceText.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmed.isEmpty else { return }
+
+        isTranslating = true
+        translatedText = ""
+
+        // Translation service integration point
+        translatedText = "[\(sourceLanguage.name) → \(targetLanguage.name)] \(trimmed)"
+        isTranslating = false
+    }
+
+    func syncTargetLanguageIfNeeded() {
+        guard targetLanguage == sourceLanguage else { return }
+        targetLanguage = TranslationLanguage.all.first { $0 != sourceLanguage } ?? .defaultTarget
+    }
+}

+ 44 - 0
gramora/Views/Components/GradientButton.swift

@@ -76,6 +76,50 @@ struct ToolbarButton: View {
     }
 }
 
+struct ToolbarMenuButton<MenuContent: View>: View {
+    let title: String
+    let iconName: String
+    @ViewBuilder let menuContent: () -> MenuContent
+
+    @State private var isHovered = false
+
+    var body: some View {
+        Menu {
+            menuContent()
+        } label: {
+            HStack(spacing: 6) {
+                Image(systemName: iconName)
+                    .font(.system(size: 12))
+
+                Text(title)
+                    .font(.system(size: 12, weight: .medium))
+
+                Image(systemName: "chevron.down")
+                    .font(.system(size: 10, weight: .semibold))
+            }
+            .foregroundStyle(isHovered ? AppTheme.toolbarForegroundHover : AppTheme.textSecondary)
+            .padding(.horizontal, 24)
+            .padding(.vertical, 7)
+            .background(isHovered ? AppTheme.toolbarBackgroundHover : Color.white)
+            .clipShape(RoundedRectangle(cornerRadius: 8))
+            .overlay(
+                RoundedRectangle(cornerRadius: 8)
+                    .stroke(isHovered ? AppTheme.toolbarBorderHover : AppTheme.border, lineWidth: 1)
+            )
+            .shadow(
+                color: isHovered ? AppTheme.toolbarShadowHover : AppTheme.toolbarShadow,
+                radius: isHovered ? 4 : 2,
+                y: isHovered ? 2 : 1
+            )
+            .scaleEffect(isHovered ? 1.02 : 1.0)
+            .animation(.easeOut(duration: 0.15), value: isHovered)
+        }
+        .menuStyle(.borderlessButton)
+        .buttonStyle(.plain)
+        .onHover { isHovered = $0 }
+    }
+}
+
 struct IconButton: View {
     let action: () -> Void
     private let iconName: String?

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

@@ -14,6 +14,8 @@ private final class ThinCaretTextView: NSTextView {
 
 struct ThinCaretTextEditor: NSViewRepresentable {
     @Binding var text: String
+    var isEditable: Bool = true
+    var onSubmit: (() -> Void)?
 
     func makeCoordinator() -> Coordinator {
         Coordinator(parent: self)
@@ -28,7 +30,7 @@ struct ThinCaretTextEditor: NSViewRepresentable {
         scrollView.drawsBackground = false
 
         let textView = ThinCaretTextView()
-        textView.isEditable = true
+        textView.isEditable = isEditable
         textView.isSelectable = true
         textView.isRichText = false
         textView.allowsUndo = true
@@ -54,6 +56,7 @@ struct ThinCaretTextEditor: NSViewRepresentable {
 
     func updateNSView(_ scrollView: NSScrollView, context: Context) {
         guard let textView = scrollView.documentView as? ThinCaretTextView else { return }
+        textView.isEditable = isEditable
         if textView.string != text {
             textView.string = text
         }
@@ -71,5 +74,17 @@ struct ThinCaretTextEditor: NSViewRepresentable {
             guard let textView = notification.object as? NSTextView else { return }
             parent.text = textView.string
         }
+
+        func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
+            guard commandSelector == #selector(NSResponder.insertNewline(_:)),
+                  let event = textView.window?.currentEvent,
+                  event.modifierFlags.contains(.command),
+                  let onSubmit = parent.onSubmit else {
+                return false
+            }
+
+            onSubmit()
+            return true
+        }
     }
 }

+ 260 - 0
gramora/Views/LanguageTranslatorView.swift

@@ -0,0 +1,260 @@
+import SwiftUI
+
+struct LanguageTranslatorView: View {
+    @StateObject private var viewModel = LanguageTranslatorViewModel()
+
+    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 dynamicPanelHeight = min(max(proxy.size.height * 0.22, 180), 280)
+
+            ScrollView(.vertical, showsIndicators: false) {
+                VStack(alignment: .leading, spacing: 0) {
+                    header
+                        .padding(.bottom, 28)
+
+                    contentCard(panelHeight: dynamicPanelHeight)
+                        .padding(.bottom, 24)
+
+                    GradientButton(title: "Translate") {
+                        viewModel.translate()
+                    }
+                    .disabled(!viewModel.canTranslate)
+                    .opacity(viewModel.canTranslate ? 1 : 0.55)
+                }
+                .frame(maxWidth: 1200, alignment: .topLeading)
+                .frame(maxWidth: .infinity, alignment: .topLeading)
+                .padding(.horizontal, dynamicHorizontalPadding)
+                .padding(.top, dynamicTopPadding)
+                .padding(.bottom, dynamicBottomPadding)
+            }
+        }
+        .onChange(of: viewModel.sourceText) { newValue in
+            if newValue.count > LanguageTranslatorViewModel.maxCharacters {
+                viewModel.sourceText = String(newValue.prefix(LanguageTranslatorViewModel.maxCharacters))
+            }
+        }
+        .onChange(of: viewModel.sourceLanguage) { _ in
+            viewModel.syncTargetLanguageIfNeeded()
+        }
+    }
+
+    private var header: some View {
+        HStack(alignment: .top) {
+            VStack(alignment: .leading, spacing: 8) {
+                Text("Language Translator")
+                    .font(.system(size: 30, weight: .bold))
+                    .foregroundStyle(AppTheme.textPrimary)
+
+                HStack(spacing: 0) {
+                    Text("Speak ")
+                        .foregroundStyle(AppTheme.textSecondary)
+                    Text("any language")
+                        .fontWeight(.semibold)
+                        .foregroundStyle(AppTheme.teal)
+                    Text(". Reach ")
+                        .foregroundStyle(AppTheme.textSecondary)
+                    Text("everyone")
+                        .fontWeight(.semibold)
+                        .foregroundStyle(AppTheme.teal)
+                    Text(".")
+                        .foregroundStyle(AppTheme.textSecondary)
+                }
+                .font(.system(size: 15))
+            }
+
+            Spacer()
+
+            IconButton {
+                SettingsGearIcon()
+            } action: {}
+            .accessibilityLabel("Settings")
+        }
+    }
+
+    private func contentCard(panelHeight: 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")
+
+            sourcePanel(height: panelHeight)
+
+            HStack(spacing: 8) {
+                ToolbarButton(title: "Paste Text", iconName: "doc.on.clipboard") {
+                    viewModel.pasteText()
+                }
+
+                ToolbarButton(title: "Upload File", iconName: "square.and.arrow.up") {
+                    viewModel.uploadFile()
+                }
+
+                Spacer()
+
+                swapLanguagesButton
+
+                ClearButton {
+                    viewModel.clearText()
+                }
+            }
+
+            targetPanel(height: panelHeight)
+
+            Text("Press ⌘ + Return to translate")
+                .font(.system(size: 11))
+                .foregroundStyle(AppTheme.textMuted)
+        }
+        .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 func sourcePanel(height: CGFloat) -> some View {
+        VStack(alignment: .leading, spacing: 12) {
+            LanguagePicker(
+                title: "From",
+                selection: $viewModel.sourceLanguage,
+                languages: viewModel.sourceLanguageOptions
+            )
+
+            ZStack(alignment: .bottomTrailing) {
+                ThinCaretTextEditor(text: $viewModel.sourceText, onSubmit: viewModel.translate)
+                    .font(.system(size: 14))
+                    .foregroundStyle(AppTheme.textPrimary)
+                    .frame(minHeight: height, idealHeight: height)
+                    .background(Color.white)
+                    .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
+                    .overlay(
+                        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))
+                    .foregroundStyle(AppTheme.textMuted)
+                    .padding(.trailing, 16)
+                    .padding(.bottom, 12)
+            }
+        }
+        .padding(16)
+        .background(AppTheme.background)
+        .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
+        .overlay(
+            RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
+                .stroke(AppTheme.border.opacity(0.6), lineWidth: 1)
+        )
+        .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
+    }
+
+    private func targetPanel(height: CGFloat) -> some View {
+        VStack(alignment: .leading, spacing: 12) {
+            LanguagePicker(
+                title: "To",
+                selection: $viewModel.targetLanguage,
+                languages: viewModel.targetLanguageOptions
+            )
+
+            ZStack(alignment: .topLeading) {
+                ThinCaretTextEditor(
+                    text: $viewModel.translatedText,
+                    isEditable: false
+                )
+                .font(.system(size: 14))
+                .foregroundStyle(AppTheme.textPrimary)
+                .frame(minHeight: height, idealHeight: height)
+                .background(Color.white)
+                .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
+                .overlay(
+                    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)
+                }
+            }
+        }
+        .padding(16)
+        .background(AppTheme.background)
+        .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
+        .overlay(
+            RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius)
+                .stroke(AppTheme.border.opacity(0.6), lineWidth: 1)
+        )
+        .clipShape(RoundedRectangle(cornerRadius: AppTheme.inputCornerRadius))
+    }
+
+    private var swapLanguagesButton: some View {
+        Button {
+            viewModel.swapLanguages()
+        } label: {
+            Image(systemName: "arrow.up.arrow.down")
+                .font(.system(size: 12, weight: .semibold))
+                .foregroundStyle(AppTheme.textSecondary)
+                .padding(.horizontal, 10)
+                .padding(.vertical, 7)
+                .background(Color.white)
+                .clipShape(RoundedRectangle(cornerRadius: 8))
+                .overlay(
+                    RoundedRectangle(cornerRadius: 8)
+                        .stroke(AppTheme.border, lineWidth: 1)
+                )
+        }
+        .buttonStyle(.plain)
+        .accessibilityLabel("Swap languages")
+    }
+}
+
+private struct LanguagePicker: View {
+    let title: String
+    @Binding var selection: TranslationLanguage
+    let languages: [TranslationLanguage]
+
+    var body: some View {
+        ToolbarMenuButton(
+            title: "\(title): \(selection.name)",
+            iconName: "globe"
+        ) {
+            ForEach(languages) { language in
+                Button {
+                    selection = language
+                } label: {
+                    HStack {
+                        Text(language.name)
+                        if selection == language {
+                            Spacer()
+                            Image(systemName: "checkmark")
+                        }
+                    }
+                }
+            }
+        }
+        .accessibilityLabel("\(title) language: \(selection.name)")
+    }
+}

+ 2 - 0
gramora/Views/MainView.swift

@@ -56,6 +56,8 @@ struct MainView: View {
             SummarizerView()
         case .paraphrasing:
             ParaphrasingView()
+        case .languageTranslator:
+            LanguageTranslatorView()
         default:
             PlaceholderView(destination: viewModel.selectedDestination)
         }