From e1226e6ae5a75388fe5d1a0e1ed008f80fee25ed Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Sun, 17 May 2026 14:21:25 +0800
Subject: [PATCH] Release Apptag 6.0.5 build 610

---
 Apptag/AppGridItem.swift |  193 +++++++++++++++++++++++++++++++++++------------
 1 files changed, 142 insertions(+), 51 deletions(-)

diff --git a/Apptag/AppGridItem.swift b/Apptag/AppGridItem.swift
index f25f223..9fa97e2 100644
--- a/Apptag/AppGridItem.swift
+++ b/Apptag/AppGridItem.swift
@@ -10,43 +10,85 @@
     var sourceTag: String? = nil
     var dragModeActive: Bool = false
     var onDragModeChange: ((Bool) -> Void)? = nil
+    var onBubbleHover: ((AppInfo, CGRect, Bool) -> Void)? = nil
+    var onEditNote: ((AppInfo, CGRect) -> Void)? = nil
     let onSelect: () -> Void
 
     @State private var isHovered = false
     @State private var wiggle = false
+    @State private var globalFrame: CGRect = .zero
+    @AppStorage("showUncommonAppBubbles") private var showUncommonAppBubbles = true
 
-    private let hoverScale: CGFloat = 1.22
+    static let hoverScale: CGFloat = 1.22
+    static let labelHeight: CGFloat = 14
+
+    static func stableWidth(iconSize: CGFloat) -> CGFloat {
+        iconSize * hoverScale + 8
+    }
+
+    static func stableHeight(iconSize: CGFloat) -> CGFloat {
+        iconSize * hoverScale + labelHeight + 22
+    }
+
+    private var iconSlotSize: CGFloat { iconSize * Self.hoverScale }
+    private var labelWidth: CGFloat { iconSize + 20 }
+    private var labelHeight: CGFloat { Self.labelHeight }
 
     var body: some View {
         VStack(spacing: 6) {
-            DraggableAppIconView(
-                icon: app.icon,
-                iconSize: iconSize,
-                payload: "\(app.path.path)\n\(sourceTag ?? "")",
-                onHover: { isHovered = $0 },
-                onLongPress: { onDragModeChange?(true) },
-                onDragEnd: { onDragModeChange?(false) },
-                onClick: onSelect
-            )
-            .frame(width: iconSize, height: iconSize)
-            .scaleEffect(isHovered ? hoverScale : 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)
+            ZStack {
+                DraggableAppIconView(
+                    icon: app.icon,
+                    iconSize: iconSize,
+                    payload: "\(app.path.path)\n\(sourceTag ?? "")",
+                    onLongPress: { onDragModeChange?(true) },
+                    onDragEnd: { onDragModeChange?(false) },
+                    onClick: onSelect
+                )
+                .frame(width: iconSize, height: iconSize)
+                .scaleEffect(isHovered ? Self.hoverScale : 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)
+            }
+            .frame(width: iconSlotSize, height: iconSlotSize)
 
             Text(app.name)
                 .font(.system(size: 11, weight: .medium))
                 .lineLimit(1)
                 .truncationMode(.tail)
-                .frame(maxWidth: iconSize + 20)
+                .frame(width: labelWidth, height: labelHeight)
                 .opacity(showName ? 1 : (isHovered ? 0.85 : 0))
         }
         .padding(.vertical, 8)
         .padding(.horizontal, 4)
-        .contentShape(RoundedRectangle(cornerRadius: 10))
+        .frame(width: Self.stableWidth(iconSize: iconSize), height: Self.stableHeight(iconSize: iconSize))
+        .background(
+            GeometryReader { proxy in
+                Color.clear
+                    .onAppear { globalFrame = proxy.frame(in: .global) }
+                    .onChange(of: proxy.frame(in: .global)) { _, newFrame in
+                        globalFrame = newFrame
+                    }
+            }
+        )
+        .contentShape(Rectangle())
+        .onHover { hovering in
+            isHovered = hovering
+            if showUncommonAppBubbles && app.isUncommon {
+                onBubbleHover?(app, globalFrame, hovering)
+            }
+        }
+        .contextMenu {
+            if app.isUncommon {
+                Button(tr("appNote.edit")) {
+                    onEditNote?(app, globalFrame)
+                }
+            }
+        }
         .rotationEffect(.degrees(dragModeActive ? (wiggle ? 2.0 : -2.0) : 0))
         .animation(
             dragModeActive
@@ -60,11 +102,90 @@
     }
 }
 
+enum BubblePlacement {
+    case above
+    case below
+}
+
+struct AppNameBubble: View {
+    let appName: String
+    let note: String?
+    let isEditing: Bool
+    let placement: BubblePlacement
+    @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 {
+        content
+        .onExitCommand(perform: onCancel)
+    }
+
+    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(.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)
+        )
+    }
+
+    private var limitedDraft: Binding<String> {
+        Binding(
+            get: { draftNote },
+            set: { draftNote = String($0.prefix(TagDatabase.maxAppNoteLength)) }
+        )
+    }
+}
+
 private struct DraggableAppIconView: NSViewRepresentable {
     let icon: NSImage
     let iconSize: CGFloat
     let payload: String
-    let onHover: (Bool) -> Void
     let onLongPress: () -> Void
     let onDragEnd: () -> Void
     let onClick: () -> Void
@@ -74,7 +195,6 @@
         view.image = icon
         view.iconSize = iconSize
         view.payload = payload
-        view.onHover = onHover
         view.onLongPress = onLongPress
         view.onDragEnd = onDragEnd
         view.onClick = onClick
@@ -85,7 +205,6 @@
         view.image = icon
         view.iconSize = iconSize
         view.payload = payload
-        view.onHover = onHover
         view.onLongPress = onLongPress
         view.onDragEnd = onDragEnd
         view.onClick = onClick
@@ -97,7 +216,6 @@
     var image: NSImage = NSImage()
     var iconSize: CGFloat = 56
     var payload: String = ""
-    var onHover: ((Bool) -> Void)?
     var onLongPress: (() -> Void)?
     var onDragEnd: (() -> Void)?
     var onClick: (() -> Void)?
@@ -106,24 +224,7 @@
     private var didStartDrag = false
     private var isLongPressActive = false
     private var longPressWorkItem: DispatchWorkItem?
-    private var trackingAreaRef: NSTrackingArea?
-
     override var isFlipped: Bool { true }
-
-    override func updateTrackingAreas() {
-        super.updateTrackingAreas()
-        if let trackingAreaRef {
-            removeTrackingArea(trackingAreaRef)
-        }
-        let area = NSTrackingArea(
-            rect: bounds,
-            options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect],
-            owner: self,
-            userInfo: nil
-        )
-        trackingAreaRef = area
-        addTrackingArea(area)
-    }
 
     override func draw(_ dirtyRect: NSRect) {
         super.draw(dirtyRect)
@@ -134,10 +235,6 @@
             height: iconSize
         )
         image.draw(in: rect)
-    }
-
-    override func mouseEntered(with event: NSEvent) {
-        onHover?(true)
     }
 
     override func mouseDown(with event: NSEvent) {
@@ -194,12 +291,6 @@
         didStartDrag = false
         isLongPressActive = false
         mouseDownEvent = nil
-    }
-
-    override func mouseExited(with event: NSEvent) {
-        if !didStartDrag {
-            onHover?(false)
-        }
     }
 
     private func makeDragImage() -> NSImage {

--
Gitblit v1.9.3