Переглянути джерело

Pin tiles: sort to top + show pin badge

Pinned apps now sort above unpinned (keeping existing drag order), and pinned tiles display a top-left pin indicator opposite the menu button.

Made-with: Cursor
huzaifahayat12 4 місяців тому
батько
коміт
7ab6621814
2 змінених файлів з 264 додано та 73 видалено
  1. 242 73
      google_apps/AppTileView.swift
  2. 22 0
      google_apps/LauncherRootView.swift

+ 242 - 73
google_apps/AppTileView.swift

@@ -7,87 +7,76 @@ struct AppTileView: View {
 
     @State private var isHovering = false
     @State private var isPressing = false
+    @AppStorage("pinnedTileIDsData") private var pinnedTileIDsData = ""
+    @AppStorage("statusBarAppIDsData") private var statusBarAppIDsData = ""
+    @AppStorage("widgetAppIDsData") private var widgetAppIDsData = ""
     private let tileCornerRadius: CGFloat = 14
 
     var body: some View {
-        Button(action: onTap) {
-            VStack(spacing: 11) {
-                AppIconView(app: app, size: 64)
-                    .shadow(
-                        color: .black.opacity(isHovering ? 0.34 : 0.2),
-                        radius: isHovering ? 14 : 8,
-                        x: 0,
-                        y: isHovering ? 10 : 5
-                    )
-                    .scaleEffect(isPressing ? 0.96 : 1.0)
-                    .offset(y: isHovering ? -1 : 0)
-
-                Text(app.name)
-                    .font(.system(size: 13, weight: .medium))
-                    .foregroundStyle(.white.opacity(isHovering ? 1.0 : 0.95))
-                    .lineLimit(1)
-                    .truncationMode(.tail)
-                    .frame(maxWidth: .infinity)
-                    .offset(y: isHovering ? -0.5 : 0)
+        ZStack(alignment: .topTrailing) {
+            Button(action: onTap) {
+                tileContent
             }
-            .padding(.horizontal, 7)
-            .padding(.vertical, 10)
-            .background(
-                RoundedRectangle(cornerRadius: tileCornerRadius, style: .continuous)
-                    .fill(
-                        LinearGradient(
-                            colors: isHovering
-                                ? [Color.white.opacity(0.16), Color.white.opacity(0.06)]
-                                : [Color.white.opacity(0.03), Color.white.opacity(0.015)],
-                            startPoint: .topLeading,
-                            endPoint: .bottomTrailing
-                        )
-                    )
-            )
-            .overlay(
-                // Soft spotlight makes hover feel richer without harsh contrast.
-                RoundedRectangle(cornerRadius: tileCornerRadius, style: .continuous)
-                    .fill(
-                        RadialGradient(
-                            colors: isHovering
-                                ? [Color.white.opacity(0.16), Color.clear]
-                                : [Color.clear, Color.clear],
-                            center: .top,
-                            startRadius: 0,
-                            endRadius: 80
-                        )
-                    )
-                    .allowsHitTesting(false)
-            )
-            .overlay(
-                RoundedRectangle(cornerRadius: tileCornerRadius, style: .continuous)
-                    .stroke(
-                        isHovering ? Color.white.opacity(0.3) : Color.white.opacity(0.06),
-                        lineWidth: isHovering ? 1.3 : 1
+            .buttonStyle(.plain)
+
+            if isPinned {
+                Image(systemName: "pin.fill")
+                    .font(.system(size: 12, weight: .semibold))
+                    .foregroundStyle(.white.opacity(0.92))
+                    .frame(width: 26, height: 26)
+                    .background(
+                        Circle()
+                            .fill(Color.black.opacity(isHovering ? 0.35 : 0.2))
                     )
-            )
-            .overlay(
-                RoundedRectangle(cornerRadius: tileCornerRadius, style: .continuous)
-                    .inset(by: 1)
-                    .stroke(
-                        isHovering ? Color.white.opacity(0.12) : Color.clear,
-                        lineWidth: 1
+                    .overlay(
+                        Circle()
+                            .stroke(Color.white.opacity(isHovering ? 0.18 : 0.12), lineWidth: 1)
                     )
+                    .shadow(color: .black.opacity(0.22), radius: 10, x: 0, y: 6)
+                    .padding(8)
+                    .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
                     .allowsHitTesting(false)
-            )
-            .scaleEffect(isPressing ? 0.985 : (isHovering ? 1.02 : 1.0))
-            .offset(y: isPressing ? 0 : (isHovering ? -2 : 0))
-            .shadow(
-                color: .black.opacity(isHovering ? 0.22 : 0),
-                radius: isHovering ? 18 : 0,
-                x: 0,
-                y: isHovering ? 12 : 0
-            )
-            .contentShape(RoundedRectangle(cornerRadius: tileCornerRadius, style: .continuous))
-            .animation(.spring(response: 0.24, dampingFraction: 0.88), value: isHovering)
-            .animation(.easeOut(duration: 0.12), value: isPressing)
+            }
+
+            if !app.isCreateNew {
+                Menu {
+                    Button(isPinned ? "Unpin" : "Pin") { togglePin() }
+                    Divider()
+                    Button("Add to StatusBar") { addToStatusBar() }
+                    Button("Add to desktop") { addToDesktop() }
+                    Button("Add Widget") { addWidget() }
+                } label: {
+                    Image(systemName: "ellipsis")
+                        .font(.system(size: 13, weight: .semibold))
+                        .rotationEffect(.degrees(90))
+                        .foregroundStyle(.white.opacity(isHovering ? 0.92 : 0.75))
+                        .frame(width: 26, height: 26)
+                        .background(
+                            Circle()
+                                .fill(Color.black.opacity(isHovering ? 0.35 : 0.2))
+                        )
+                        .overlay(
+                            Circle()
+                                .stroke(Color.white.opacity(isHovering ? 0.18 : 0.12), lineWidth: 1)
+                        )
+                        .shadow(color: .black.opacity(0.25), radius: 10, x: 0, y: 6)
+                }
+                .menuStyle(.borderlessButton)
+                .modifier(HideMenuIndicatorIfAvailable())
+                .padding(8)
+                .opacity(isHovering ? 1 : 0.0)
+                .animation(.easeOut(duration: 0.12), value: isHovering)
+            }
+        }
+        .contextMenu {
+            if !app.isCreateNew {
+                Button(isPinned ? "Unpin" : "Pin") { togglePin() }
+                Divider()
+                Button("Add to StatusBar") { addToStatusBar() }
+                Button("Add to desktop") { addToDesktop() }
+                Button("Add Widget") { addWidget() }
+            }
         }
-        .buttonStyle(.plain)
         .onHover { hovering in
             isHovering = hovering
             if !hovering {
@@ -95,6 +84,186 @@ struct AppTileView: View {
             }
         }
     }
+
+    private var tileContent: some View {
+        VStack(spacing: 11) {
+            AppIconView(app: app, size: 64)
+                .shadow(
+                    color: .black.opacity(isHovering ? 0.34 : 0.2),
+                    radius: isHovering ? 14 : 8,
+                    x: 0,
+                    y: isHovering ? 10 : 5
+                )
+                .scaleEffect(isPressing ? 0.96 : 1.0)
+                .offset(y: isHovering ? -1 : 0)
+
+            Text(app.name)
+                .font(.system(size: 13, weight: .medium))
+                .foregroundStyle(.white.opacity(isHovering ? 1.0 : 0.95))
+                .lineLimit(1)
+                .truncationMode(.tail)
+                .frame(maxWidth: .infinity)
+                .offset(y: isHovering ? -0.5 : 0)
+        }
+        .padding(.horizontal, 7)
+        .padding(.vertical, 10)
+        .background(
+            RoundedRectangle(cornerRadius: tileCornerRadius, style: .continuous)
+                .fill(
+                    LinearGradient(
+                        colors: isHovering
+                            ? [Color.white.opacity(0.16), Color.white.opacity(0.06)]
+                            : [Color.white.opacity(0.03), Color.white.opacity(0.015)],
+                        startPoint: .topLeading,
+                        endPoint: .bottomTrailing
+                    )
+                )
+        )
+        .overlay(
+            // Soft spotlight makes hover feel richer without harsh contrast.
+            RoundedRectangle(cornerRadius: tileCornerRadius, style: .continuous)
+                .fill(
+                    RadialGradient(
+                        colors: isHovering
+                            ? [Color.white.opacity(0.16), Color.clear]
+                            : [Color.clear, Color.clear],
+                        center: .top,
+                        startRadius: 0,
+                        endRadius: 80
+                    )
+                )
+                .allowsHitTesting(false)
+        )
+        .overlay(
+            RoundedRectangle(cornerRadius: tileCornerRadius, style: .continuous)
+                .stroke(
+                    isHovering ? Color.white.opacity(0.3) : Color.white.opacity(0.06),
+                    lineWidth: isHovering ? 1.3 : 1
+                )
+        )
+        .overlay(
+            RoundedRectangle(cornerRadius: tileCornerRadius, style: .continuous)
+                .inset(by: 1)
+                .stroke(
+                    isHovering ? Color.white.opacity(0.12) : Color.clear,
+                    lineWidth: 1
+                )
+                .allowsHitTesting(false)
+        )
+        .scaleEffect(isPressing ? 0.985 : (isHovering ? 1.02 : 1.0))
+        .offset(y: isPressing ? 0 : (isHovering ? -2 : 0))
+        .shadow(
+            color: .black.opacity(isHovering ? 0.22 : 0),
+            radius: isHovering ? 18 : 0,
+            x: 0,
+            y: isHovering ? 12 : 0
+        )
+        .contentShape(RoundedRectangle(cornerRadius: tileCornerRadius, style: .continuous))
+        .animation(.spring(response: 0.24, dampingFraction: 0.88), value: isHovering)
+        .animation(.easeOut(duration: 0.12), value: isPressing)
+    }
+
+    private var isPinned: Bool {
+        pinnedIDs.contains(app.id)
+    }
+
+    private var pinnedIDs: Set<UUID> {
+        decodeUUIDSet(from: pinnedTileIDsData)
+    }
+
+    private func togglePin() {
+        var updated = pinnedIDs
+        if updated.contains(app.id) {
+            updated.remove(app.id)
+        } else {
+            updated.insert(app.id)
+        }
+        pinnedTileIDsData = encodeUUIDSet(updated)
+    }
+
+    private func addToStatusBar() {
+        var updated = decodeUUIDSet(from: statusBarAppIDsData)
+        updated.insert(app.id)
+        statusBarAppIDsData = encodeUUIDSet(updated)
+        showAlert(title: "Added", message: "Added “\(app.name)” to Status Bar shortcuts.")
+    }
+
+    private func addWidget() {
+        var updated = decodeUUIDSet(from: widgetAppIDsData)
+        updated.insert(app.id)
+        widgetAppIDsData = encodeUUIDSet(updated)
+        showAlert(title: "Added", message: "Added “\(app.name)” to Widgets.")
+    }
+
+    private func addToDesktop() {
+        guard let url = app.webURL else {
+            showAlert(title: "Unavailable", message: "This tile doesn’t have a web link to create a desktop shortcut.")
+            return
+        }
+
+        guard let desktopURL = FileManager.default.urls(for: .desktopDirectory, in: .userDomainMask).first else {
+            showAlert(title: "Error", message: "Couldn’t find your Desktop folder.")
+            return
+        }
+
+        let fileName = sanitizedFileName(app.name).isEmpty ? "Shortcut" : sanitizedFileName(app.name)
+        let targetURL = desktopURL.appendingPathComponent("\(fileName).webloc")
+
+        let payload: [String: Any] = ["URL": url.absoluteString]
+        do {
+            let data = try PropertyListSerialization.data(fromPropertyList: payload, format: .xml, options: 0)
+            try data.write(to: targetURL, options: .atomic)
+            showAlert(title: "Created", message: "Desktop shortcut created: \(targetURL.lastPathComponent)")
+        } catch {
+            showAlert(title: "Error", message: "Couldn’t create the desktop shortcut.")
+        }
+    }
+
+    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 ""
+        }
+    }
+
+    private func sanitizedFileName(_ input: String) -> String {
+        let invalid = CharacterSet(charactersIn: "/:\\?%*|\"<>")
+        let cleaned = input.components(separatedBy: invalid).joined(separator: " ")
+        return cleaned.trimmingCharacters(in: .whitespacesAndNewlines)
+            .replacingOccurrences(of: "  +", with: " ", options: .regularExpression)
+    }
+
+    private func showAlert(title: String, message: String) {
+        let alert = NSAlert()
+        alert.messageText = title
+        alert.informativeText = message
+        alert.addButton(withTitle: "OK")
+        alert.alertStyle = .informational
+        alert.runModal()
+    }
+}
+
+private struct HideMenuIndicatorIfAvailable: ViewModifier {
+    func body(content: Content) -> some View {
+        if #available(macOS 13.0, *) {
+            content.menuIndicator(.hidden)
+        } else {
+            content
+        }
+    }
 }
 
 private struct AppIconView: View {

+ 22 - 0
google_apps/LauncherRootView.swift

@@ -18,6 +18,7 @@ struct LauncherRootView: View {
     @State private var layoutMode: LayoutMode = .grid
     @AppStorage("customLauncherAppsData") private var customAppsData = ""
     @AppStorage("launcherAppOrderData") private var appOrderData = ""
+    @AppStorage("pinnedTileIDsData") private var pinnedTileIDsData = ""
 
     private var apps: [LauncherApp] {
         LauncherApp.sampleApps + customApps + [LauncherApp.createNewTile]
@@ -41,8 +42,19 @@ struct LauncherRootView: View {
         let rankByID = Dictionary(
             uniqueKeysWithValues: appOrder.enumerated().map { ($0.element, $0.offset) }
         )
+        let pinnedIDs = decodeUUIDSet(from: pinnedTileIDsData)
 
         return apps.sorted { lhs, rhs in
+            if lhs.isCreateNew != rhs.isCreateNew {
+                return rhs.isCreateNew
+            }
+
+            let leftPinned = pinnedIDs.contains(lhs.id)
+            let rightPinned = pinnedIDs.contains(rhs.id)
+            if leftPinned != rightPinned {
+                return leftPinned
+            }
+
             let leftRank = rankByID[lhs.id] ?? Int.max
             let rightRank = rankByID[rhs.id] ?? Int.max
 
@@ -291,6 +303,16 @@ struct LauncherRootView: View {
             // Ignore persistence failure and keep local order.
         }
     }
+
+    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 struct PersistedCustomApp: Codable {