From b4a1ba5df0f073bf346c3f4407b613bf08d29f08 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Thu, 28 May 2026 19:34:57 +0800
Subject: [PATCH] Add AppKit tag navigation

---
 Apptag/AppGridItem.swift |  148 ++++++++++++++++++++++++++++++++++++++----------
 1 files changed, 116 insertions(+), 32 deletions(-)

diff --git a/Apptag/AppGridItem.swift b/Apptag/AppGridItem.swift
index b3b8141..5b39626 100644
--- a/Apptag/AppGridItem.swift
+++ b/Apptag/AppGridItem.swift
@@ -1,45 +1,129 @@
 import SwiftUI
 import AppKit
 
-// MARK: - App Grid Item (icon + name card, with Dock-like hover)
+// MARK: - App Grid Shared Types
 
-struct AppGridItem: View {
-    let app: AppInfo
-    let iconSize: CGFloat
-    var showName: Bool = true
-    let onSelect: () -> Void
+enum AppGridItemMetrics {
+    static let hoverScale: CGFloat = 1.22
+    static let labelHeight: CGFloat = 14
 
-    @State private var isHovered = false
+    static func stableWidth(iconSize: CGFloat) -> CGFloat {
+        iconSize * hoverScale + 8
+    }
+
+    static func stableHeight(iconSize: CGFloat) -> CGFloat {
+        iconSize * hoverScale + labelHeight + 22
+    }
+}
+
+enum AppBubbleHoverEvent {
+    case entered(canShowBubble: Bool)
+    case exited
+}
+
+enum BubblePlacement {
+    case above
+    case below
+}
+
+struct AppNameBubble: View {
+    let appName: String
+    let note: String?
+    let isEditing: Bool
+    let placement: BubblePlacement
+    let arrowOffset: CGFloat
+    @Binding var draftNote: String
+    var noteFocused: FocusState<Bool>.Binding
+    let onCommit: () -> Void
+    let onCancel: () -> Void
+
+    private var displayNote: String {
+        note?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
+    }
 
     var body: some View {
-        Button(action: onSelect) {
-            VStack(spacing: 6) {
-                Image(nsImage: app.icon)
-                    .resizable()
-                    .aspectRatio(contentMode: .fit)
-                    .frame(width: iconSize, height: iconSize)
-                    .scaleEffect(isHovered ? 1.22 : 1.0)
-                    .shadow(
-                        color: .black.opacity(isHovered ? 0.35 : 0),
-                        radius: isHovered ? 14 : 0,
-                        y: isHovered ? 8 : 0
-                    )
-                    .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isHovered)
+        content
+        .onExitCommand(perform: onCancel)
+    }
 
-                Text(app.name)
-                    .font(.system(size: 11, weight: .medium))
-                    .lineLimit(1)
-                    .truncationMode(.tail)
-                    .frame(maxWidth: iconSize + 20)
-                    .opacity(showName ? 1 : (isHovered ? 0.85 : 0))
+    private var content: some View {
+        VStack(spacing: isEditing || !displayNote.isEmpty ? 8 : 0) {
+            Text(appName)
+                .font(.system(size: isEditing ? 22 : 24, weight: .bold))
+                .foregroundStyle(.white)
+                .lineLimit(1)
+                .truncationMode(.middle)
+                .frame(maxWidth: .infinity)
+
+            if isEditing {
+                VStack(alignment: .trailing, spacing: 4) {
+                    TextField(tr("appNote.placeholder"), text: limitedDraft)
+                        .textFieldStyle(.plain)
+                        .font(.system(size: 15, weight: .medium))
+                        .foregroundStyle(.white)
+                        .padding(.horizontal, 12)
+                        .frame(height: 36)
+                        .background(
+                            RoundedRectangle(cornerRadius: 9)
+                                .fill(Color.white.opacity(0.12))
+                        )
+                        .focused(noteFocused)
+                        .onSubmit(onCommit)
+                    Text("\(draftNote.count) / \(TagDatabase.maxAppNoteLength)")
+                        .font(.system(size: 10, weight: .medium))
+                        .foregroundStyle(.white.opacity(0.35))
+                }
+            } else if !displayNote.isEmpty {
+                Text(displayNote)
+                    .font(.system(size: 14, weight: .medium))
+                    .lineSpacing(2)
+                    .foregroundStyle(.white.opacity(0.78))
+                    .lineLimit(4)
+                    .multilineTextAlignment(.center)
+                    .fixedSize(horizontal: false, vertical: true)
+                    .frame(maxWidth: .infinity)
             }
-            .padding(.vertical, 8)
-            .padding(.horizontal, 4)
-            .contentShape(RoundedRectangle(cornerRadius: 10))
         }
-        .buttonStyle(.plain)
-        .onHover { hovering in
-            isHovered = hovering
+        .padding(.horizontal, isEditing ? 22 : 24)
+        .padding(.vertical, isEditing ? 18 : 16)
+        .background(
+            RoundedRectangle(cornerRadius: 18, style: .continuous)
+                .fill(Color.black.opacity(0.92))
+                .shadow(color: .black.opacity(0.34), radius: 24, y: 16)
+                .shadow(color: .black.opacity(0.18), radius: 8, y: 3)
+        )
+        .overlay(alignment: placement == .above ? .bottom : .top) {
+            BubbleArrow(placement: placement)
+                .fill(Color.black.opacity(0.92))
+                .frame(width: 18, height: 9)
+                .offset(x: arrowOffset, y: placement == .above ? 8 : -8)
         }
     }
+
+    private var limitedDraft: Binding<String> {
+        Binding(
+            get: { draftNote },
+            set: { draftNote = String($0.prefix(TagDatabase.maxAppNoteLength)) }
+        )
+    }
+}
+
+private struct BubbleArrow: Shape {
+    let placement: BubblePlacement
+
+    func path(in rect: CGRect) -> Path {
+        var path = Path()
+        switch placement {
+        case .above:
+            path.move(to: CGPoint(x: rect.minX, y: rect.minY))
+            path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
+            path.addLine(to: CGPoint(x: rect.midX, y: rect.maxY))
+        case .below:
+            path.move(to: CGPoint(x: rect.minX, y: rect.maxY))
+            path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
+            path.addLine(to: CGPoint(x: rect.midX, y: rect.minY))
+        }
+        path.closeSubpath()
+        return path
+    }
 }

--
Gitblit v1.9.3