|
@@ -0,0 +1,257 @@
|
|
|
|
|
+import AppKit
|
|
|
|
|
+import SwiftUI
|
|
|
|
|
+
|
|
|
|
|
+private let geminiAccent = Color(red: 0.26, green: 0.55, blue: 1.0)
|
|
|
|
|
+private let geminiAccentDeep = Color(red: 0.12, green: 0.28, blue: 0.72)
|
|
|
|
|
+
|
|
|
|
|
+/// Large Gemini panel: prompt field opens Google Gemini in the in-app browser at gemini.google.com.
|
|
|
|
|
+struct GeminiDesktopWidgetView: View {
|
|
|
|
|
+ let app: LauncherApp
|
|
|
|
|
+ let mode: GeminiWidgetMode
|
|
|
|
|
+ var isPreview: Bool = false
|
|
|
|
|
+
|
|
|
|
|
+ @State private var promptText: String = ""
|
|
|
|
|
+
|
|
|
|
|
+ private var baseURL: URL { app.webURL ?? URL(string: "https://gemini.google.com")! }
|
|
|
|
|
+
|
|
|
|
|
+ var body: some View {
|
|
|
|
|
+ Group {
|
|
|
|
|
+ switch mode {
|
|
|
|
|
+ case .assistant:
|
|
|
|
|
+ assistantBody
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ .clipShape(RoundedRectangle(cornerRadius: 22, style: .continuous))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private var assistantBody: some View {
|
|
|
|
|
+ Group {
|
|
|
|
|
+ if isPreview {
|
|
|
|
|
+ previewChrome
|
|
|
|
|
+ } else {
|
|
|
|
|
+ assistantLive
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private var previewChrome: some View {
|
|
|
|
|
+ ZStack {
|
|
|
|
|
+ geminiBackground
|
|
|
|
|
+ VStack(spacing: 14) {
|
|
|
|
|
+ ZStack {
|
|
|
|
|
+ Circle()
|
|
|
|
|
+ .fill(
|
|
|
|
|
+ RadialGradient(
|
|
|
|
|
+ colors: [geminiAccent.opacity(0.45), geminiAccent.opacity(0.08)],
|
|
|
|
|
+ center: .center,
|
|
|
|
|
+ startRadius: 4,
|
|
|
|
|
+ endRadius: 36
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ .frame(width: 72, height: 72)
|
|
|
|
|
+ AppIconView(app: app, size: 48, showAppBackground: false, iconPaddingFactor: 0.08)
|
|
|
|
|
+ }
|
|
|
|
|
+ Text("Gemini")
|
|
|
|
|
+ .font(.system(size: 16, weight: .bold))
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.96))
|
|
|
|
|
+ Text("Type a prompt and send it to Gemini in the in-app browser.")
|
|
|
|
|
+ .font(.system(size: 11, weight: .medium))
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.72))
|
|
|
|
|
+ .multilineTextAlignment(.center)
|
|
|
|
|
+ .padding(.horizontal, 12)
|
|
|
|
|
+ }
|
|
|
|
|
+ .padding(18)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private var assistantLive: some View {
|
|
|
|
|
+ VStack(alignment: .leading, spacing: 0) {
|
|
|
|
|
+ headerRow
|
|
|
|
|
+ .padding(.horizontal, 16)
|
|
|
|
|
+ .padding(.top, 12)
|
|
|
|
|
+ .padding(.bottom, 10)
|
|
|
|
|
+
|
|
|
|
|
+ promptCard
|
|
|
|
|
+ .padding(.horizontal, 14)
|
|
|
|
|
+ .padding(.bottom, 12)
|
|
|
|
|
+ }
|
|
|
|
|
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
|
|
|
+ .background(geminiBackground)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private var geminiBackground: some View {
|
|
|
|
|
+ ZStack {
|
|
|
|
|
+ LinearGradient(
|
|
|
|
|
+ colors: [
|
|
|
|
|
+ Color(red: 0.11, green: 0.14, blue: 0.32),
|
|
|
|
|
+ Color(red: 0.05, green: 0.06, blue: 0.14),
|
|
|
|
|
+ ],
|
|
|
|
|
+ startPoint: .topLeading,
|
|
|
|
|
+ endPoint: .bottomTrailing
|
|
|
|
|
+ )
|
|
|
|
|
+ RadialGradient(
|
|
|
|
|
+ colors: [geminiAccent.opacity(0.28), Color.clear],
|
|
|
|
|
+ center: UnitPoint(x: 0.15, y: 0.0),
|
|
|
|
|
+ startRadius: 10,
|
|
|
|
|
+ endRadius: 200
|
|
|
|
|
+ )
|
|
|
|
|
+ RadialGradient(
|
|
|
|
|
+ colors: [geminiAccentDeep.opacity(0.22), Color.clear],
|
|
|
|
|
+ center: UnitPoint(x: 1.0, y: 0.85),
|
|
|
|
|
+ startRadius: 20,
|
|
|
|
|
+ endRadius: 180
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private var headerRow: some View {
|
|
|
|
|
+ HStack(alignment: .center, spacing: 12) {
|
|
|
|
|
+ ZStack {
|
|
|
|
|
+ Circle()
|
|
|
|
|
+ .fill(
|
|
|
|
|
+ LinearGradient(
|
|
|
|
|
+ colors: [geminiAccent.opacity(0.35), geminiAccent.opacity(0.1)],
|
|
|
|
|
+ startPoint: .topLeading,
|
|
|
|
|
+ endPoint: .bottomTrailing
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ .frame(width: 40, height: 40)
|
|
|
|
|
+ .overlay(
|
|
|
|
|
+ Circle()
|
|
|
|
|
+ .strokeBorder(Color.white.opacity(0.12), lineWidth: 1)
|
|
|
|
|
+ )
|
|
|
|
|
+ AppIconView(app: app, size: 26, showAppBackground: false, iconPaddingFactor: 0.08)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ VStack(alignment: .leading, spacing: 2) {
|
|
|
|
|
+ Text("Gemini")
|
|
|
|
|
+ .font(.system(size: 15, weight: .bold))
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.96))
|
|
|
|
|
+ Text("Google AI")
|
|
|
|
|
+ .font(.system(size: 10, weight: .semibold))
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.45))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ Spacer(minLength: 0)
|
|
|
|
|
+
|
|
|
|
|
+ Button(action: { openGeminiApp() }) {
|
|
|
|
|
+ Image(systemName: "arrow.up.right.square")
|
|
|
|
|
+ .font(.system(size: 13, weight: .semibold))
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.88))
|
|
|
|
|
+ .frame(width: 32, height: 32)
|
|
|
|
|
+ .background(
|
|
|
|
|
+ Circle()
|
|
|
|
|
+ .fill(Color.white.opacity(0.1))
|
|
|
|
|
+ .overlay(Circle().strokeBorder(Color.white.opacity(0.1), lineWidth: 1))
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ .buttonStyle(.plain)
|
|
|
|
|
+ .help("Open Gemini in browser")
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private var promptCard: some View {
|
|
|
|
|
+ VStack(alignment: .leading, spacing: 10) {
|
|
|
|
|
+ HStack {
|
|
|
|
|
+ Text("Your prompt")
|
|
|
|
|
+ .font(.system(size: 10, weight: .bold))
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.4))
|
|
|
|
|
+ .textCase(.uppercase)
|
|
|
|
|
+ Spacer(minLength: 0)
|
|
|
|
|
+ Button("Clear") {
|
|
|
|
|
+ promptText = ""
|
|
|
|
|
+ }
|
|
|
|
|
+ .font(.system(size: 11, weight: .semibold))
|
|
|
|
|
+ .foregroundStyle(.white.opacity(promptText.isEmpty ? 0.25 : 0.55))
|
|
|
|
|
+ .buttonStyle(.plain)
|
|
|
|
|
+ .disabled(promptText.isEmpty)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ ZStack(alignment: .topLeading) {
|
|
|
|
|
+ RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
|
|
|
+ .fill(Color.white.opacity(0.07))
|
|
|
|
|
+ .overlay(
|
|
|
|
|
+ RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
|
|
|
+ .strokeBorder(
|
|
|
|
|
+ LinearGradient(
|
|
|
|
|
+ colors: [Color.white.opacity(0.22), Color.white.opacity(0.06)],
|
|
|
|
|
+ startPoint: .topLeading,
|
|
|
|
|
+ endPoint: .bottomTrailing
|
|
|
|
|
+ ),
|
|
|
|
|
+ lineWidth: 1
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ TextEditor(text: $promptText)
|
|
|
|
|
+ .font(.system(size: 13, weight: .regular))
|
|
|
|
|
+ .foregroundColor(Color.white.opacity(0.95))
|
|
|
|
|
+ .padding(.horizontal, 8)
|
|
|
|
|
+ .padding(.vertical, 10)
|
|
|
|
|
+ .background(Color.clear)
|
|
|
|
|
+
|
|
|
|
|
+ if promptText.isEmpty {
|
|
|
|
|
+ Text("Ask anything…")
|
|
|
|
|
+ .font(.system(size: 13, weight: .medium))
|
|
|
|
|
+ .foregroundStyle(.white.opacity(0.35))
|
|
|
|
|
+ .padding(.leading, 14)
|
|
|
|
|
+ .padding(.top, 18)
|
|
|
|
|
+ .allowsHitTesting(false)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ .frame(minHeight: 88, maxHeight: 120)
|
|
|
|
|
+
|
|
|
|
|
+ Button(action: askInGemini) {
|
|
|
|
|
+ HStack(spacing: 8) {
|
|
|
|
|
+ Image(systemName: "sparkles")
|
|
|
|
|
+ .font(.system(size: 13, weight: .semibold))
|
|
|
|
|
+ Text("Ask in Gemini")
|
|
|
|
|
+ .font(.system(size: 13, weight: .bold))
|
|
|
|
|
+ Spacer(minLength: 0)
|
|
|
|
|
+ Image(systemName: "arrow.right.circle.fill")
|
|
|
|
|
+ .font(.system(size: 16, weight: .semibold))
|
|
|
|
|
+ .opacity(0.9)
|
|
|
|
|
+ }
|
|
|
|
|
+ .foregroundStyle(.white)
|
|
|
|
|
+ .padding(.horizontal, 14)
|
|
|
|
|
+ .padding(.vertical, 11)
|
|
|
|
|
+ .frame(maxWidth: .infinity)
|
|
|
|
|
+ .background(
|
|
|
|
|
+ RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
|
|
|
+ .fill(
|
|
|
|
|
+ LinearGradient(
|
|
|
|
|
+ colors: [geminiAccent, geminiAccentDeep],
|
|
|
|
|
+ startPoint: .leading,
|
|
|
|
|
+ endPoint: .trailing
|
|
|
|
|
+ )
|
|
|
|
|
+ )
|
|
|
|
|
+ .shadow(color: geminiAccent.opacity(0.35), radius: 10, x: 0, y: 4)
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+ .buttonStyle(.plain)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // MARK: - Actions
|
|
|
|
|
+
|
|
|
|
|
+ private func openGeminiApp(prompt: String? = nil) {
|
|
|
|
|
+ NSApp.activate(ignoringOtherApps: true)
|
|
|
|
|
+ let url = WidgetDeepLinkURL.resolved(base: baseURL, actionPath: "/app", accountSlot: 0)
|
|
|
|
|
+ let trimmed = prompt?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
|
|
|
+ InAppBrowserWindowManager.shared.open(
|
|
|
|
|
+ url: url,
|
|
|
|
|
+ title: app.name,
|
|
|
|
|
+ injectComposerPrompt: trimmed.isEmpty ? nil : trimmed
|
|
|
|
|
+ )
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// Opens `/app` and asks the in-app browser to paste your text into Gemini’s composer after load (URL query params are ignored by Gemini’s SPA).
|
|
|
|
|
+ private func askInGemini() {
|
|
|
|
|
+ let p = promptText.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
|
+ NSApp.activate(ignoringOtherApps: true)
|
|
|
|
|
+ if !p.isEmpty {
|
|
|
|
|
+ NSPasteboard.general.clearContents()
|
|
|
|
|
+ NSPasteboard.general.setString(p, forType: .string)
|
|
|
|
|
+ }
|
|
|
|
|
+ openGeminiApp(prompt: p.isEmpty ? nil : p)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|