Explorar el Código

Add widgets page and desktop widget windows

Implements a Widgets page with per-app selection and size previews, plus floating desktop widget windows with persistence.

Made-with: Cursor
huzaifahayat12 hace 3 meses
padre
commit
1d88cd840c

+ 5 - 69
google_apps/AppTileView.swift

@@ -150,7 +150,11 @@ struct AppTileView: View {
         var updated = decodeUUIDSet(from: widgetAppIDsData)
         updated.insert(app.id)
         widgetAppIDsData = encodeUUIDSet(updated)
-        showAlert(title: "Added", message: "Added “\(app.name)” to Widgets.")
+        NotificationCenter.default.post(
+            name: .openWidgetsPage,
+            object: nil,
+            userInfo: ["appID": app.id]
+        )
     }
 
     private func addToDesktop() {
@@ -224,74 +228,6 @@ private struct HideMenuIndicatorIfAvailable: ViewModifier {
     }
 }
 
-private struct AppIconView: View {
-    let app: LauncherApp
-    let size: CGFloat
-    var showAppBackground: Bool = true
-    var iconPaddingFactor: CGFloat = 0.12
-
-    var body: some View {
-        ZStack {
-            if app.isCreateNew {
-                RoundedRectangle(cornerRadius: 18, style: .continuous)
-                    .stroke(Color.white.opacity(0.35), lineWidth: 2)
-                    .background(
-                        RoundedRectangle(cornerRadius: 18, style: .continuous)
-                            .fill(Color.white.opacity(0.03))
-                    )
-                Image(systemName: "plus")
-                    .font(.system(size: size * 0.34, weight: .regular))
-                    .foregroundStyle(.white.opacity(0.9))
-            } else {
-                if showAppBackground {
-                    RoundedRectangle(cornerRadius: 18, style: .continuous)
-                        .fill(LinearGradient(colors: gradientColors, startPoint: .topLeading, endPoint: .bottomTrailing))
-                    RoundedRectangle(cornerRadius: 18, style: .continuous)
-                        .stroke(Color.white.opacity(0.08), lineWidth: 1)
-                }
-
-                if let webURL = app.webURL {
-                    WebSiteFaviconView(
-                        webURL: webURL,
-                        size: size,
-                        assetIconName: app.assetIconName,
-                        fallbackSymbolName: app.fallbackSymbolName,
-                        iconPaddingFactor: iconPaddingFactor
-                    )
-                } else if let iconImage = NSImage(named: app.assetIconName) {
-                    Image(nsImage: iconImage)
-                        .resizable()
-                        .scaledToFit()
-                        .padding(size * iconPaddingFactor)
-                } else {
-                    Image(systemName: app.fallbackSymbolName)
-                        .font(.system(size: size * 0.32, weight: .semibold))
-                        .foregroundStyle(.white)
-                        .padding(size * iconPaddingFactor)
-                }
-            }
-        }
-        .frame(width: size, height: size)
-    }
-
-    private var gradientColors: [Color] {
-        switch app.name {
-        case let name where name.contains("Gmail"):
-            return [Color.red.opacity(0.85), Color.orange.opacity(0.8)]
-        case let name where name.contains("Docs"):
-            return [Color.blue.opacity(0.9), Color.indigo.opacity(0.85)]
-        case let name where name.contains("Drive"):
-            return [Color.green.opacity(0.9), Color.blue.opacity(0.75)]
-        case let name where name.contains("YouTube"):
-            return [Color.red.opacity(0.95), Color.pink.opacity(0.75)]
-        case let name where name.contains("Maps"):
-            return [Color.green.opacity(0.85), Color.teal.opacity(0.75)]
-        default:
-            return [Color.blue.opacity(0.85), Color.cyan.opacity(0.7)]
-        }
-    }
-}
-
 struct AppDetailView: View {
     let app: LauncherApp
     @Environment(\.dismiss) private var dismiss

+ 7 - 1
google_apps/InAppBrowserWindow.swift

@@ -446,7 +446,13 @@ final class InAppBrowserWindowController: NSWindowController, NSWindowDelegate {
     }
 
     @objc private func widgetTapped() {
-        // Reserved for widget functionality.
+        let candidateTitle = siteNameLabel?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+        let matched = LauncherApp.sampleApps.first { $0.name.caseInsensitiveCompare(candidateTitle) == .orderedSame }
+        NotificationCenter.default.post(
+            name: .openWidgetsPage,
+            object: nil,
+            userInfo: matched.map { ["appID": $0.id] }
+        )
     }
 
     @objc private func togglePinOnTop() {

+ 125 - 54
google_apps/LauncherRootView.swift

@@ -3,6 +3,11 @@ import AppKit
 import UniformTypeIdentifiers
 
 struct LauncherRootView: View {
+    enum Page: Hashable {
+        case apps
+        case widgets
+    }
+
     enum LayoutMode {
         case grid
         case panel
@@ -17,10 +22,14 @@ struct LauncherRootView: View {
     @State private var draggedAppID: UUID?
     @State private var lastDropDestinationAppID: UUID?
     @State private var layoutMode: LayoutMode = .grid
+    @State private var activePage: Page = .apps
+    @State private var selectedWidgetAppID: UUID?
     @AppStorage("customLauncherAppsData") private var customAppsData = ""
     @AppStorage("launcherAppOrderData") private var appOrderData = ""
     @AppStorage("pinnedTileIDsData") private var pinnedTileIDsData = ""
 
+    @AppStorage("widgetAppIDsData") private var widgetAppIDsData = ""
+
     private var apps: [LauncherApp] {
         LauncherApp.sampleApps + customApps + [LauncherApp.createNewTile]
     }
@@ -79,9 +88,16 @@ struct LauncherRootView: View {
                 SearchHeader(
                     query: $query,
                     isPanelMode: layoutMode == .panel,
+                    activePage: activePage,
                     onToggleLayout: {
                         layoutMode = layoutMode == .grid ? .panel : .grid
                     },
+                    onOpenWidgets: {
+                        activePage = .widgets
+                    },
+                    onOpenApps: {
+                        activePage = .apps
+                    },
                     onOpenPremium: {
                         showingPremiumScreen = true
                     },
@@ -91,7 +107,7 @@ struct LauncherRootView: View {
                         }
                     }
                 )
-                    .padding(.top, 12)
+                .padding(.top, 12)
 
                 PromoBanner(
                     onCloseTap: { showingPremiumScreen = true },
@@ -100,58 +116,15 @@ struct LauncherRootView: View {
                 .padding(.bottom, 8)
                 .zIndex(1)
 
-                if filteredApps.isEmpty {
-                    VStack(spacing: 10) {
-                        Image(systemName: "magnifyingglass")
-                            .font(.system(size: 28, weight: .semibold))
-                            .foregroundStyle(.white.opacity(0.75))
-                        Text("No apps found")
-                            .font(.title3.weight(.semibold))
-                            .foregroundStyle(.white.opacity(0.9))
-                        Text("Try a different search term.")
-                            .font(.subheadline)
-                            .foregroundStyle(.white.opacity(0.7))
-                    }
-                    .frame(maxWidth: .infinity, maxHeight: .infinity)
-                } else {
-                    if layoutMode == .panel {
-                        IconPanelView(
-                            apps: filteredApps,
-                            onAppTap: { app in
-                                handleAppTap(app)
-                            },
-                            draggedAppID: $draggedAppID,
-                            lastDropDestinationAppID: $lastDropDestinationAppID,
-                            onMove: moveApp
-                        )
-                    } else {
-                        ScrollView {
-                            LazyVGrid(columns: columns, spacing: 20) {
-                                ForEach(filteredApps) { app in
-                                    AppTileView(app: app) {
-                                        handleAppTap(app)
-                                    }
-                                    .onDrag {
-                                        draggedAppID = app.id
-                                        lastDropDestinationAppID = nil
-                                        return NSItemProvider(object: app.id.uuidString as NSString)
-                                    }
-                                    .onDrop(
-                                        of: [UTType.text],
-                                        delegate: TileReorderDropDelegate(
-                                            destinationAppID: app.id,
-                                            draggedAppID: $draggedAppID,
-                                            lastDropDestinationAppID: $lastDropDestinationAppID,
-                                            onMove: moveApp
-                                        )
-                                    )
-                                }
-                            }
-                            .padding(.top, 10)
-                            .padding(.horizontal, 14)
-                            .padding(.bottom, 18)
-                        }
-                    }
+                switch activePage {
+                case .apps:
+                    appsPage
+                case .widgets:
+                    WidgetsRootView(
+                        apps: orderedApps.filter { !$0.isCreateNew },
+                        selectedAppID: $selectedWidgetAppID,
+                        widgetLibraryIDsData: $widgetAppIDsData
+                    )
                 }
             }
             .padding(.horizontal, 10)
@@ -175,6 +148,79 @@ struct LauncherRootView: View {
             loadCustomAppsFromStorage()
             loadAppOrderFromStorage()
             normalizeAppOrderAndPersist()
+
+            DesktopWidgetWindowManager.shared.restore(appProvider: { id in
+                orderedApps.first { $0.id == id }
+            })
+
+            NotificationCenter.default.addObserver(
+                forName: .openWidgetsPage,
+                object: nil,
+                queue: .main
+            ) { notification in
+                let id = notification.userInfo?["appID"] as? UUID
+                if let id {
+                    selectedWidgetAppID = id
+                }
+                activePage = .widgets
+            }
+        }
+    }
+
+    @ViewBuilder
+    private var appsPage: some View {
+        if filteredApps.isEmpty {
+            VStack(spacing: 10) {
+                Image(systemName: "magnifyingglass")
+                    .font(.system(size: 28, weight: .semibold))
+                    .foregroundStyle(.white.opacity(0.75))
+                Text("No apps found")
+                    .font(.title3.weight(.semibold))
+                    .foregroundStyle(.white.opacity(0.9))
+                Text("Try a different search term.")
+                    .font(.subheadline)
+                    .foregroundStyle(.white.opacity(0.7))
+            }
+            .frame(maxWidth: .infinity, maxHeight: .infinity)
+        } else {
+            if layoutMode == .panel {
+                IconPanelView(
+                    apps: filteredApps,
+                    onAppTap: { app in
+                        handleAppTap(app)
+                    },
+                    draggedAppID: $draggedAppID,
+                    lastDropDestinationAppID: $lastDropDestinationAppID,
+                    onMove: moveApp
+                )
+            } else {
+                ScrollView {
+                    LazyVGrid(columns: columns, spacing: 20) {
+                        ForEach(filteredApps) { app in
+                            AppTileView(app: app) {
+                                handleAppTap(app)
+                            }
+                            .onDrag {
+                                draggedAppID = app.id
+                                lastDropDestinationAppID = nil
+                                return NSItemProvider(object: app.id.uuidString as NSString)
+                            }
+                            .onDrop(
+                                of: [UTType.text],
+                                delegate: TileReorderDropDelegate(
+                                    destinationAppID: app.id,
+                                    draggedAppID: $draggedAppID,
+                                    lastDropDestinationAppID: $lastDropDestinationAppID,
+                                    onMove: moveApp
+                                )
+                            )
+                        }
+                    }
+                    .padding(.top, 10)
+                    .padding(.horizontal, 14)
+                    .padding(.bottom, 18)
+                }
+            }
         }
     }
 
@@ -356,7 +402,10 @@ private struct TileReorderDropDelegate: DropDelegate {
 private struct SearchHeader: View {
     @Binding var query: String
     let isPanelMode: Bool
+    let activePage: LauncherRootView.Page
     let onToggleLayout: () -> Void
+    let onOpenWidgets: () -> Void
+    let onOpenApps: () -> Void
     let onOpenPremium: () -> Void
     let onOpenGoogle: () -> Void
 
@@ -388,10 +437,28 @@ private struct SearchHeader: View {
                 }
                 .buttonStyle(.plain)
 
+                Button(action: {
+                    if activePage == .apps {
+                        onOpenWidgets()
+                    } else {
+                        onOpenApps()
+                    }
+                }) {
+                    Image(systemName: activePage == .apps ? "widget.small" : "square.grid.2x2")
+                }
+                .buttonStyle(.plain)
+
                 Menu {
                     Button("Clear Search") {
                         query = ""
                     }
+                    Button(activePage == .apps ? "Open Widgets" : "Open Apps") {
+                        if activePage == .apps {
+                            onOpenWidgets()
+                        } else {
+                            onOpenApps()
+                        }
+                    }
                     Button("Open Premium") {
                         onOpenPremium()
                     }
@@ -564,7 +631,11 @@ private struct PanelAppRowView: View {
         var updated = decodeUUIDSetGlobal(from: widgetAppIDsData)
         updated.insert(app.id)
         widgetAppIDsData = encodeUUIDSetGlobal(updated)
-        showAlert(title: "Added", message: "Added “\(app.name)” to Widgets.")
+        NotificationCenter.default.post(
+            name: .openWidgetsPage,
+            object: nil,
+            userInfo: ["appID": app.id]
+        )
     }
 
     private func addToDesktop() {

+ 71 - 0
google_apps/Shared/AppIconView.swift

@@ -0,0 +1,71 @@
+import SwiftUI
+import AppKit
+
+struct AppIconView: View {
+    let app: LauncherApp
+    let size: CGFloat
+    var showAppBackground: Bool = true
+    var iconPaddingFactor: CGFloat = 0.12
+
+    var body: some View {
+        ZStack {
+            if app.isCreateNew {
+                RoundedRectangle(cornerRadius: 18, style: .continuous)
+                    .stroke(Color.white.opacity(0.35), lineWidth: 2)
+                    .background(
+                        RoundedRectangle(cornerRadius: 18, style: .continuous)
+                            .fill(Color.white.opacity(0.03))
+                    )
+                Image(systemName: "plus")
+                    .font(.system(size: size * 0.34, weight: .regular))
+                    .foregroundStyle(.white.opacity(0.9))
+            } else {
+                if showAppBackground {
+                    RoundedRectangle(cornerRadius: 18, style: .continuous)
+                        .fill(LinearGradient(colors: gradientColors, startPoint: .topLeading, endPoint: .bottomTrailing))
+                    RoundedRectangle(cornerRadius: 18, style: .continuous)
+                        .stroke(Color.white.opacity(0.08), lineWidth: 1)
+                }
+
+                if let webURL = app.webURL {
+                    WebSiteFaviconView(
+                        webURL: webURL,
+                        size: size,
+                        assetIconName: app.assetIconName,
+                        fallbackSymbolName: app.fallbackSymbolName,
+                        iconPaddingFactor: iconPaddingFactor
+                    )
+                } else if let iconImage = NSImage(named: app.assetIconName) {
+                    Image(nsImage: iconImage)
+                        .resizable()
+                        .scaledToFit()
+                        .padding(size * iconPaddingFactor)
+                } else {
+                    Image(systemName: app.fallbackSymbolName)
+                        .font(.system(size: size * 0.32, weight: .semibold))
+                        .foregroundStyle(.white)
+                        .padding(size * iconPaddingFactor)
+                }
+            }
+        }
+        .frame(width: size, height: size)
+    }
+
+    private var gradientColors: [Color] {
+        switch app.name {
+        case let name where name.contains("Gmail"):
+            return [Color.red.opacity(0.85), Color.orange.opacity(0.8)]
+        case let name where name.contains("Docs"):
+            return [Color.blue.opacity(0.9), Color.indigo.opacity(0.85)]
+        case let name where name.contains("Drive"):
+            return [Color.green.opacity(0.9), Color.blue.opacity(0.75)]
+        case let name where name.contains("YouTube"):
+            return [Color.red.opacity(0.95), Color.pink.opacity(0.75)]
+        case let name where name.contains("Maps"):
+            return [Color.green.opacity(0.85), Color.teal.opacity(0.75)]
+        default:
+            return [Color.blue.opacity(0.85), Color.cyan.opacity(0.7)]
+        }
+    }
+}
+

+ 191 - 0
google_apps/Widgets/DesktopWidgetView.swift

@@ -0,0 +1,191 @@
+import SwiftUI
+
+struct DesktopWidgetView: View {
+    let app: LauncherApp
+    let size: WidgetSize
+    var isPreview: Bool = false
+
+    var body: some View {
+        ZStack {
+            background
+
+            VStack(alignment: .leading, spacing: 12) {
+                header
+
+                switch size {
+                case .small:
+                    smallBody
+                case .medium:
+                    mediumBody
+                case .large:
+                    largeBody
+                }
+
+                Spacer(minLength: 0)
+            }
+            .padding(14)
+        }
+        .cornerRadius(20)
+    }
+
+    private var background: some View {
+        ZStack {
+            LinearGradient(
+                colors: [Color.black.opacity(0.55), Color.white.opacity(0.04)],
+                startPoint: .topLeading,
+                endPoint: .bottomTrailing
+            )
+
+            RoundedRectangle(cornerRadius: 20, style: .continuous)
+                .fill(Color.white.opacity(0.03))
+        }
+        .overlay(
+            RoundedRectangle(cornerRadius: 20, style: .continuous)
+                .stroke(Color.white.opacity(0.10), lineWidth: 1)
+        )
+    }
+
+    private var header: some View {
+        HStack(spacing: 10) {
+            AppIconView(app: app, size: 30, showAppBackground: true, iconPaddingFactor: 0.1)
+            VStack(alignment: .leading, spacing: 1) {
+                Text(app.name)
+                    .font(.system(size: 13, weight: .bold))
+                    .foregroundStyle(.white.opacity(0.94))
+                    .lineLimit(1)
+                Text(app.webURL?.host ?? "Quick access")
+                    .font(.system(size: 11, weight: .semibold))
+                    .foregroundStyle(.white.opacity(0.55))
+                    .lineLimit(1)
+            }
+            Spacer()
+        }
+    }
+
+    private var smallBody: some View {
+        VStack(alignment: .leading, spacing: 10) {
+            quickActionsRow
+            openButton
+        }
+    }
+
+    private var mediumBody: some View {
+        VStack(alignment: .leading, spacing: 12) {
+            Text("Shortcuts")
+                .font(.system(size: 12, weight: .bold))
+                .foregroundStyle(.white.opacity(0.8))
+            quickActionsGrid(columns: 2)
+            openButton
+        }
+    }
+
+    private var largeBody: some View {
+        VStack(alignment: .leading, spacing: 12) {
+            Text("Shortcuts")
+                .font(.system(size: 12, weight: .bold))
+                .foregroundStyle(.white.opacity(0.8))
+            quickActionsGrid(columns: 3)
+            openButton
+        }
+    }
+
+    private var quickActionsRow: some View {
+        HStack(spacing: 10) {
+            quickChip(title: "Compose", systemImage: "square.and.pencil")
+            quickChip(title: "Inbox", systemImage: "tray")
+            quickChip(title: "Starred", systemImage: "star")
+        }
+    }
+
+    private func quickActionsGrid(columns: Int) -> some View {
+        let items: [(String, String)] = [
+            ("Compose", "square.and.pencil"),
+            ("Inbox", "tray"),
+            ("Important", "exclamationmark.circle"),
+            ("Starred", "star"),
+            ("Sent", "paperplane"),
+            ("Drafts", "doc.plaintext"),
+            ("Spam", "xmark.circle"),
+            ("Trash", "trash"),
+        ]
+
+        return LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 10), count: columns), spacing: 10) {
+            ForEach(Array(items.enumerated()), id: \.offset) { _, item in
+                quickTile(title: item.0, systemImage: item.1)
+            }
+        }
+    }
+
+    private func quickChip(title: String, systemImage: String) -> some View {
+        HStack(spacing: 6) {
+            Image(systemName: systemImage)
+                .font(.system(size: 11, weight: .semibold))
+                .foregroundStyle(.white.opacity(0.82))
+            Text(title)
+                .font(.system(size: 11, weight: .bold))
+                .foregroundStyle(.white.opacity(0.88))
+        }
+        .padding(.horizontal, 10)
+        .padding(.vertical, 8)
+        .background(
+            Capsule(style: .continuous)
+                .fill(Color.white.opacity(0.08))
+        )
+        .overlay(
+            Capsule(style: .continuous)
+                .stroke(Color.white.opacity(0.10), lineWidth: 1)
+        )
+    }
+
+    private func quickTile(title: String, systemImage: String) -> some View {
+        HStack(spacing: 10) {
+            Image(systemName: systemImage)
+                .font(.system(size: 12, weight: .semibold))
+                .foregroundStyle(.white.opacity(0.85))
+            Text(title)
+                .font(.system(size: 12, weight: .bold))
+                .foregroundStyle(.white.opacity(0.9))
+                .lineLimit(1)
+            Spacer(minLength: 0)
+        }
+        .padding(.horizontal, 12)
+        .padding(.vertical, 10)
+        .background(
+            RoundedRectangle(cornerRadius: 14, style: .continuous)
+                .fill(Color.white.opacity(0.06))
+        )
+        .overlay(
+            RoundedRectangle(cornerRadius: 14, style: .continuous)
+                .stroke(Color.white.opacity(0.08), lineWidth: 1)
+        )
+        .contentShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
+    }
+
+    private var openButton: some View {
+        Button(action: openApp) {
+            HStack {
+                Text("Open")
+                    .font(.system(size: 13, weight: .bold))
+                Spacer()
+                Image(systemName: "arrow.up.right")
+                    .font(.system(size: 12, weight: .bold))
+            }
+            .foregroundStyle(.white.opacity(0.92))
+            .padding(.horizontal, 12)
+            .padding(.vertical, 10)
+            .background(
+                RoundedRectangle(cornerRadius: 14, style: .continuous)
+                    .fill(Color.blue.opacity(0.75))
+            )
+        }
+        .buttonStyle(.plain)
+        .disabled(isPreview || app.webURL == nil)
+        .opacity((isPreview || app.webURL == nil) ? 0.65 : 1)
+    }
+
+    private func openApp() {
+        guard !isPreview, let url = app.webURL else { return }
+        InAppBrowserWindowManager.shared.open(url: url, title: app.name)
+    }
+}
+

+ 202 - 0
google_apps/Widgets/DesktopWidgetWindowManager.swift

@@ -0,0 +1,202 @@
+import AppKit
+import SwiftUI
+
+final class DesktopWidgetWindowManager {
+    static let shared = DesktopWidgetWindowManager()
+
+    private let storageKey = "widgetInstancesData"
+    private var windowsByInstanceID: [UUID: NSPanel] = [:]
+
+    private init() {}
+
+    func show(instance: WidgetInstance, appProvider: (UUID) -> LauncherApp?) {
+        guard let app = appProvider(instance.appID) else { return }
+
+        let panel = makePanel(for: instance, app: app)
+        windowsByInstanceID[instance.id] = panel
+
+        persistUpsert(instance: instance)
+
+        if let origin = instance.origin {
+            panel.setFrameOrigin(NSPoint(x: origin.x, y: origin.y))
+        } else {
+            panel.center()
+        }
+
+        panel.makeKeyAndOrderFront(nil)
+    }
+
+    func restore(appProvider: (UUID) -> LauncherApp?) {
+        let instances = loadInstances()
+        for instance in instances {
+            guard windowsByInstanceID[instance.id] == nil else { continue }
+            show(instance: instance, appProvider: appProvider)
+        }
+    }
+
+    func removeAll(forAppID appID: UUID) {
+        let instances = loadInstances()
+        let remaining = instances.filter { $0.appID != appID }
+        let removed = instances.filter { $0.appID == appID }
+
+        for instance in removed {
+            if let win = windowsByInstanceID.removeValue(forKey: instance.id) {
+                win.close()
+            }
+        }
+
+        saveInstances(remaining)
+    }
+
+    // MARK: - Panel construction
+
+    private func makePanel(for instance: WidgetInstance, app: LauncherApp) -> NSPanel {
+        let size = panelSize(for: instance.size)
+        let panel = NSPanel(
+            contentRect: NSRect(x: 0, y: 0, width: size.width, height: size.height),
+            styleMask: [.nonactivatingPanel, .titled, .fullSizeContentView],
+            backing: .buffered,
+            defer: false
+        )
+
+        panel.isFloatingPanel = true
+        panel.level = .floating
+        panel.titleVisibility = .hidden
+        panel.titlebarAppearsTransparent = true
+        panel.isOpaque = false
+        panel.backgroundColor = .clear
+        panel.isMovableByWindowBackground = true
+        panel.hidesOnDeactivate = false
+        panel.collectionBehavior = [
+            .canJoinAllSpaces,
+            .fullScreenAuxiliary,
+            .transient,
+        ]
+
+        let root = DesktopWidgetHostView(app: app, instanceID: instance.id, size: instance.size) {
+            self.close(instanceID: instance.id)
+        }
+
+        let hosting = NSHostingView(rootView: root)
+        hosting.translatesAutoresizingMaskIntoConstraints = false
+
+        let container = NSView()
+        container.translatesAutoresizingMaskIntoConstraints = false
+        container.wantsLayer = true
+        container.layer?.backgroundColor = NSColor.clear.cgColor
+        container.addSubview(hosting)
+
+        NSLayoutConstraint.activate([
+            hosting.leadingAnchor.constraint(equalTo: container.leadingAnchor),
+            hosting.trailingAnchor.constraint(equalTo: container.trailingAnchor),
+            hosting.topAnchor.constraint(equalTo: container.topAnchor),
+            hosting.bottomAnchor.constraint(equalTo: container.bottomAnchor),
+        ])
+
+        panel.contentView = container
+
+        NotificationCenter.default.addObserver(
+            forName: NSWindow.didMoveNotification,
+            object: panel,
+            queue: .main
+        ) { [weak self, weak panel] _ in
+            guard let self, let panel else { return }
+            self.persistOrigin(instanceID: instance.id, origin: panel.frame.origin)
+        }
+
+        return panel
+    }
+
+    private func panelSize(for size: WidgetSize) -> CGSize {
+        switch size {
+        case .small:
+            return CGSize(width: 220, height: 220)
+        case .medium:
+            return CGSize(width: 390, height: 220)
+        case .large:
+            return CGSize(width: 390, height: 300)
+        }
+    }
+
+    private func close(instanceID: UUID) {
+        if let win = windowsByInstanceID.removeValue(forKey: instanceID) {
+            win.close()
+        }
+        let instances = loadInstances().filter { $0.id != instanceID }
+        saveInstances(instances)
+    }
+
+    // MARK: - Persistence
+
+    private func loadInstances() -> [WidgetInstance] {
+        guard let data = UserDefaults.standard.data(forKey: storageKey) else { return [] }
+        do {
+            return try JSONDecoder().decode([WidgetInstance].self, from: data)
+        } catch {
+            return []
+        }
+    }
+
+    private func saveInstances(_ instances: [WidgetInstance]) {
+        do {
+            let data = try JSONEncoder().encode(instances)
+            UserDefaults.standard.set(data, forKey: storageKey)
+        } catch {
+            // Ignore and keep UI responsive.
+        }
+    }
+
+    private func persistUpsert(instance: WidgetInstance) {
+        var instances = loadInstances()
+        if let idx = instances.firstIndex(where: { $0.id == instance.id }) {
+            instances[idx] = instance
+        } else {
+            instances.append(instance)
+        }
+        saveInstances(instances)
+    }
+
+    private func persistOrigin(instanceID: UUID, origin: NSPoint) {
+        var instances = loadInstances()
+        guard let idx = instances.firstIndex(where: { $0.id == instanceID }) else { return }
+        instances[idx].origin = WidgetInstance.Origin(x: origin.x, y: origin.y)
+        saveInstances(instances)
+    }
+}
+
+private struct DesktopWidgetHostView: View {
+    let app: LauncherApp
+    let instanceID: UUID
+    let size: WidgetSize
+    let onClose: () -> Void
+
+    @State private var hovering = false
+
+    var body: some View {
+        ZStack(alignment: .topTrailing) {
+            DesktopWidgetView(app: app, size: size, isPreview: false)
+
+            Button(action: onClose) {
+                Image(systemName: "xmark")
+                    .font(.system(size: 11, weight: .bold))
+                    .foregroundStyle(.white.opacity(0.9))
+                    .frame(width: 26, height: 26)
+                    .background(
+                        Circle()
+                            .fill(Color.black.opacity(hovering ? 0.35 : 0.2))
+                    )
+                    .overlay(
+                        Circle()
+                            .stroke(Color.white.opacity(hovering ? 0.16 : 0.10), lineWidth: 1)
+                    )
+                    .shadow(color: .black.opacity(0.25), radius: 10, x: 0, y: 6)
+            }
+            .buttonStyle(.plain)
+            .padding(8)
+            .opacity(hovering ? 1 : 0.0)
+            .animation(.easeOut(duration: 0.12), value: hovering)
+        }
+        .onHover { hovering = $0 }
+    }
+}
+

+ 22 - 0
google_apps/Widgets/WidgetInstance.swift

@@ -0,0 +1,22 @@
+import Foundation
+import CoreGraphics
+
+struct WidgetInstance: Identifiable, Codable, Hashable {
+    struct Origin: Codable, Hashable {
+        var x: Double
+        var y: Double
+    }
+
+    let id: UUID
+    let appID: UUID
+    var size: WidgetSize
+    var origin: Origin?
+
+    init(id: UUID = UUID(), appID: UUID, size: WidgetSize, origin: Origin?) {
+        self.id = id
+        self.appID = appID
+        self.size = size
+        self.origin = origin
+    }
+}
+

+ 61 - 0
google_apps/Widgets/WidgetPreviewCard.swift

@@ -0,0 +1,61 @@
+import SwiftUI
+
+struct WidgetPreviewCard: View {
+    let app: LauncherApp
+    let size: WidgetSize
+
+    var body: some View {
+        VStack(alignment: .leading, spacing: 12) {
+            HStack(spacing: 10) {
+                AppIconView(app: app, size: 34, showAppBackground: true, iconPaddingFactor: 0.1)
+                VStack(alignment: .leading, spacing: 2) {
+                    Text(app.name)
+                        .font(.system(size: 15, weight: .bold))
+                        .foregroundStyle(.white.opacity(0.94))
+                    Text(size.title + " Widget")
+                        .font(.system(size: 12, weight: .semibold))
+                        .foregroundStyle(.white.opacity(0.6))
+                }
+                Spacer()
+            }
+
+            DesktopWidgetView(app: app, size: size, isPreview: true)
+                .frame(width: previewSize.width, height: previewSize.height)
+                .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
+                .overlay(
+                    RoundedRectangle(cornerRadius: 18, style: .continuous)
+                        .stroke(Color.white.opacity(0.10), lineWidth: 1)
+                )
+        }
+        .padding(14)
+        .background(
+            RoundedRectangle(cornerRadius: 18, style: .continuous)
+                .fill(Color.white.opacity(0.04))
+        )
+        .overlay(
+            RoundedRectangle(cornerRadius: 18, style: .continuous)
+                .stroke(Color.white.opacity(0.06), lineWidth: 1)
+        )
+    }
+
+    private var previewSize: CGSize {
+        switch size {
+        case .small:
+            return CGSize(width: 190, height: 190)
+        case .medium:
+            return CGSize(width: 340, height: 190)
+        case .large:
+            return CGSize(width: 340, height: 260)
+        }
+    }
+}
+
+#Preview {
+    ZStack {
+        Color.black
+        WidgetPreviewCard(app: LauncherApp.sampleApps[1], size: .medium)
+            .frame(width: 520, height: 360)
+            .padding()
+    }
+}
+

+ 16 - 0
google_apps/Widgets/WidgetSize.swift

@@ -0,0 +1,16 @@
+import Foundation
+
+enum WidgetSize: String, CaseIterable, Codable, Hashable {
+    case small
+    case medium
+    case large
+
+    var title: String {
+        switch self {
+        case .small: return "Small"
+        case .medium: return "Medium"
+        case .large: return "Large"
+        }
+    }
+}
+

+ 310 - 0
google_apps/Widgets/WidgetsRootView.swift

@@ -0,0 +1,310 @@
+import SwiftUI
+import AppKit
+
+extension Notification.Name {
+    static let openWidgetsPage = Notification.Name("openWidgetsPage")
+}
+
+struct WidgetsRootView: View {
+    let apps: [LauncherApp]
+    @Binding var selectedAppID: UUID?
+    @Binding var widgetLibraryIDsData: String
+
+    @State private var query = ""
+    @State private var selectedSize: WidgetSize = .small
+
+    private var filteredApps: [LauncherApp] {
+        let q = query.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !q.isEmpty else { return apps }
+        return apps.filter { $0.name.localizedCaseInsensitiveContains(q) }
+    }
+
+    private var selectedApp: LauncherApp? {
+        guard let selectedAppID else { return nil }
+        return apps.first { $0.id == selectedAppID }
+    }
+
+    var body: some View {
+        HStack(spacing: 14) {
+            sidebar
+                .frame(width: 260)
+
+            Divider()
+                .overlay(Color.white.opacity(0.08))
+
+            mainContent
+                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
+        }
+        .padding(.horizontal, 10)
+        .padding(.bottom, 12)
+    }
+
+    private var sidebar: some View {
+        VStack(alignment: .leading, spacing: 12) {
+            Text("All Widgets")
+                .font(.system(size: 16, weight: .bold))
+                .foregroundStyle(.white.opacity(0.92))
+                .padding(.top, 6)
+
+            HStack(spacing: 10) {
+                Image(systemName: "magnifyingglass")
+                    .foregroundStyle(.white.opacity(0.7))
+                TextField("Search widgets", text: $query)
+                    .textFieldStyle(.plain)
+                    .foregroundStyle(.white.opacity(0.94))
+            }
+            .padding(.horizontal, 12)
+            .padding(.vertical, 9)
+            .background(
+                RoundedRectangle(cornerRadius: 14, style: .continuous)
+                    .fill(Color.black.opacity(0.22))
+            )
+            .overlay(
+                RoundedRectangle(cornerRadius: 14, style: .continuous)
+                    .stroke(Color.white.opacity(0.08), lineWidth: 1)
+            )
+
+            ScrollView {
+                VStack(alignment: .leading, spacing: 6) {
+                    ForEach(filteredApps) { app in
+                        WidgetSidebarRow(
+                            app: app,
+                            isSelected: app.id == selectedAppID,
+                            onSelect: {
+                                selectedAppID = app.id
+                                ensureInWidgetLibrary(appID: app.id)
+                            }
+                        )
+                    }
+                }
+                .padding(.vertical, 4)
+            }
+        }
+        .padding(.horizontal, 10)
+        .padding(.vertical, 10)
+        .background(
+            RoundedRectangle(cornerRadius: 18, style: .continuous)
+                .fill(Color.white.opacity(0.03))
+        )
+        .overlay(
+            RoundedRectangle(cornerRadius: 18, style: .continuous)
+                .stroke(Color.white.opacity(0.06), lineWidth: 1)
+        )
+    }
+
+    private var mainContent: some View {
+        VStack(alignment: .leading, spacing: 14) {
+            if let selectedApp {
+                header(for: selectedApp)
+
+                HStack(spacing: 10) {
+                    Text("Widget Size")
+                        .font(.system(size: 13, weight: .semibold))
+                        .foregroundStyle(.white.opacity(0.8))
+                    Spacer()
+                    WidgetSizePicker(selected: $selectedSize)
+                }
+                .padding(.top, 4)
+
+                HStack(alignment: .top, spacing: 14) {
+                    WidgetPreviewCard(app: selectedApp, size: selectedSize)
+                        .frame(maxWidth: 560)
+                    Spacer(minLength: 0)
+                }
+
+                HStack(spacing: 10) {
+                    Button(action: {
+                        addToDesktop(app: selectedApp, size: selectedSize)
+                    }) {
+                        Text("Add to Desktop")
+                            .font(.system(size: 14, weight: .bold))
+                            .foregroundStyle(.white)
+                            .padding(.horizontal, 14)
+                            .padding(.vertical, 10)
+                            .background(
+                                RoundedRectangle(cornerRadius: 14, style: .continuous)
+                                    .fill(Color.blue.opacity(0.9))
+                            )
+                    }
+                    .buttonStyle(.plain)
+
+                    Button(action: {
+                        DesktopWidgetWindowManager.shared.removeAll(forAppID: selectedApp.id)
+                    }) {
+                        Text("Remove")
+                            .font(.system(size: 14, weight: .bold))
+                            .foregroundStyle(.white.opacity(0.9))
+                            .padding(.horizontal, 14)
+                            .padding(.vertical, 10)
+                            .background(
+                                RoundedRectangle(cornerRadius: 14, style: .continuous)
+                                    .fill(Color.white.opacity(0.08))
+                            )
+                    }
+                    .buttonStyle(.plain)
+
+                    Spacer()
+                }
+                .padding(.top, 2)
+            } else {
+                emptyState
+            }
+
+            Spacer(minLength: 0)
+        }
+        .padding(.horizontal, 12)
+        .padding(.vertical, 10)
+        .background(
+            RoundedRectangle(cornerRadius: 18, style: .continuous)
+                .fill(Color.white.opacity(0.02))
+        )
+        .overlay(
+            RoundedRectangle(cornerRadius: 18, style: .continuous)
+                .stroke(Color.white.opacity(0.06), lineWidth: 1)
+        )
+    }
+
+    private func header(for app: LauncherApp) -> some View {
+        HStack(spacing: 12) {
+            AppIconView(app: app, size: 42, showAppBackground: true, iconPaddingFactor: 0.1)
+                .shadow(color: .black.opacity(0.2), radius: 10, x: 0, y: 6)
+            VStack(alignment: .leading, spacing: 3) {
+                Text(app.name)
+                    .font(.system(size: 20, weight: .bold))
+                    .foregroundStyle(.white.opacity(0.94))
+                Text("Choose a widget size and add it to your desktop.")
+                    .font(.system(size: 12.5, weight: .medium))
+                    .foregroundStyle(.white.opacity(0.65))
+            }
+            Spacer()
+        }
+    }
+
+    private var emptyState: some View {
+        VStack(spacing: 10) {
+            Image(systemName: "widget.small")
+                .font(.system(size: 30, weight: .semibold))
+                .foregroundStyle(.white.opacity(0.75))
+            Text("Select an app to preview widgets")
+                .font(.title3.weight(.semibold))
+                .foregroundStyle(.white.opacity(0.9))
+            Text("Pick any app from the left to customize its widget.")
+                .font(.subheadline)
+                .foregroundStyle(.white.opacity(0.7))
+        }
+        .frame(maxWidth: .infinity, maxHeight: .infinity)
+    }
+
+    private func ensureInWidgetLibrary(appID: UUID) {
+        var updated = decodeUUIDSet(from: widgetLibraryIDsData)
+        updated.insert(appID)
+        widgetLibraryIDsData = encodeUUIDSet(updated)
+    }
+
+    private func addToDesktop(app: LauncherApp, size: WidgetSize) {
+        ensureInWidgetLibrary(appID: app.id)
+        let instance = WidgetInstance(appID: app.id, size: size, origin: nil)
+        DesktopWidgetWindowManager.shared.show(instance: instance, appProvider: { id in
+            apps.first { $0.id == id }
+        })
+    }
+}
+
+private struct WidgetSidebarRow: View {
+    let app: LauncherApp
+    let isSelected: Bool
+    let onSelect: () -> Void
+
+    @State private var hovering = false
+
+    var body: some View {
+        Button(action: onSelect) {
+            HStack(spacing: 10) {
+                AppIconView(app: app, size: 30, showAppBackground: false, iconPaddingFactor: 0.08)
+                Text(app.name)
+                    .font(.system(size: 13.5, weight: .semibold))
+                    .foregroundStyle(.white.opacity(0.92))
+                    .lineLimit(1)
+                Spacer(minLength: 0)
+            }
+            .padding(.horizontal, 10)
+            .padding(.vertical, 8)
+            .background(
+                RoundedRectangle(cornerRadius: 12, style: .continuous)
+                    .fill(backgroundFill)
+            )
+            .overlay(
+                RoundedRectangle(cornerRadius: 12, style: .continuous)
+                    .stroke(Color.white.opacity(isSelected ? 0.18 : 0.06), lineWidth: 1)
+            )
+        }
+        .buttonStyle(.plain)
+        .onHover { hovering = $0 }
+    }
+
+    private var backgroundFill: Color {
+        if isSelected { return Color.blue.opacity(0.18) }
+        if hovering { return Color.white.opacity(0.06) }
+        return Color.white.opacity(0.02)
+    }
+}
+
+private struct WidgetSizePicker: View {
+    @Binding var selected: WidgetSize
+
+    var body: some View {
+        HStack(spacing: 8) {
+            ForEach(WidgetSize.allCases, id: \.self) { size in
+                Button(action: { selected = size }) {
+                    Text(size.title)
+                        .font(.system(size: 12, weight: .bold))
+                        .foregroundStyle(.white.opacity(selected == size ? 0.95 : 0.75))
+                        .padding(.horizontal, 10)
+                        .padding(.vertical, 6)
+                        .background(
+                            Capsule(style: .continuous)
+                                .fill(selected == size ? Color.white.opacity(0.14) : Color.white.opacity(0.06))
+                        )
+                        .overlay(
+                            Capsule(style: .continuous)
+                                .stroke(Color.white.opacity(selected == size ? 0.16 : 0.08), lineWidth: 1)
+                        )
+                }
+                .buttonStyle(.plain)
+            }
+        }
+    }
+}
+
+private func decodeUUIDSet(from dataString: String) -> Set<UUID> {
+    guard !dataString.isEmpty, let data = dataString.data(using: .utf8) else { return [] }
+    do {
+        let strings = try JSONDecoder().decode([String].self, from: data)
+        return Set(strings.compactMap(UUID.init(uuidString:)))
+    } catch {
+        return []
+    }
+}
+
+private func encodeUUIDSet(_ set: Set<UUID>) -> String {
+    do {
+        let strings = set.map(\.uuidString).sorted()
+        let data = try JSONEncoder().encode(strings)
+        return String(decoding: data, as: UTF8.self)
+    } catch {
+        return ""
+    }
+}
+
+#Preview {
+    ZStack {
+        Color.black
+        WidgetsRootView(
+            apps: LauncherApp.sampleApps,
+            selectedAppID: .constant(LauncherApp.sampleApps.first?.id),
+            widgetLibraryIDsData: .constant("")
+        )
+        .frame(width: 980, height: 650)
+        .padding()
+    }
+}