From 8478c55e513bbbb4b1676d1d9207f7feb2509465 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Fri, 22 May 2026 00:30:57 +0800
Subject: [PATCH] Freeze 7.6.0 release candidate and move docs out of repo

---
 Apptag/AppGridItem.swift |   93 +++++++++++++++++++++++++++++++---------------
 1 files changed, 62 insertions(+), 31 deletions(-)

diff --git a/Apptag/AppGridItem.swift b/Apptag/AppGridItem.swift
index 5cda31b..c187acf 100644
--- a/Apptag/AppGridItem.swift
+++ b/Apptag/AppGridItem.swift
@@ -10,15 +10,16 @@
     var sourceTag: String? = nil
     var dragModeActive: Bool = false
     var onDragModeChange: ((Bool) -> Void)? = nil
-    var onBubbleHover: ((AppInfo, CGRect, Bool) -> Void)? = nil
+    var onBubbleHover: ((AppInfo, CGRect, AppBubbleHoverEvent) -> Void)? = nil
     var onEditNote: ((AppInfo, CGRect) -> Void)? = nil
+    var bubbleDisabled: Bool = false
+    var showUncommonAppBubbles: Bool = AppDefaults.showUncommonAppBubbles
     var itemID: String
-    @Binding var hoveredAppItemID: String?
+    var dragResetToken: Int = 0
     let onSelect: () -> Void
 
-    @State private var wiggle = false
     @State private var interactionFrame: CGRect = .zero
-    @AppStorage("showUncommonAppBubbles") private var showUncommonAppBubbles = AppDefaults.showUncommonAppBubbles
+    @State private var isHovered = false
 
     static let hoverScale: CGFloat = 1.22
     static let labelHeight: CGFloat = 14
@@ -36,7 +37,6 @@
     private var iconSlotSize: CGFloat { iconSize * Self.hoverScale }
     private var labelWidth: CGFloat { iconSize + 20 }
     private var labelHeight: CGFloat { Self.labelHeight }
-    private var isHovered: Bool { hoveredAppItemID == itemID }
 
     var body: some View {
         VStack(spacing: 6) {
@@ -45,6 +45,7 @@
                     icon: app.icon,
                     iconSize: iconSize,
                     payload: "\(app.path.path)\n\(sourceTag ?? "")",
+                    resetToken: dragResetToken,
                     onLongPress: { onDragModeChange?(true) },
                     onDragEnd: { onDragModeChange?(false) },
                     onClick: onSelect
@@ -70,15 +71,19 @@
         .padding(.horizontal, 4)
         .frame(width: Self.stableWidth(iconSize: iconSize), height: Self.stableHeight(iconSize: iconSize))
         .background(
-            AppGridItemHoverTracker { hovering, frame in
+            AppHoverTrackingView { hovering, frame in
                 interactionFrame = frame
+                if bubbleDisabled || dragModeActive {
+                    setHoverState(false)
+                    onBubbleHover?(app, frame, .exited)
+                    return
+                }
                 if hovering {
                     setHoverState(true)
-                    guard shouldShowAppBubble else { return }
-                    onBubbleHover?(app, frame, hovering)
+                    onBubbleHover?(app, frame, .entered(canShowBubble: shouldShowAppBubble))
                 } else if isHovered {
                     setHoverState(false)
-                    onBubbleHover?(app, frame, hovering)
+                    onBubbleHover?(app, frame, .exited)
                 } else {
                     setHoverState(false)
                 }
@@ -90,20 +95,24 @@
                 onEditNote?(app, interactionFrame)
             }
         }
-        .rotationEffect(.degrees(dragModeActive ? (wiggle ? 2.0 : -2.0) : 0))
-        .animation(
-            dragModeActive
-                ? .easeInOut(duration: 0.12).repeatForever(autoreverses: true)
-                : .default,
-            value: wiggle
-        )
+        .opacity(dragModeActive ? 0.92 : 1)
+        .animation(.easeOut(duration: 0.08), value: dragModeActive)
         .onChange(of: dragModeActive) { _, active in
-            wiggle = active
+            if active {
+                setHoverState(false)
+                onBubbleHover?(app, interactionFrame, .exited)
+            }
+        }
+        .onChange(of: bubbleDisabled) { _, disabled in
+            if disabled {
+                setHoverState(false)
+                onBubbleHover?(app, interactionFrame, .exited)
+            }
         }
         .onDisappear {
-            if hoveredAppItemID == itemID {
-                hoveredAppItemID = nil
-                onBubbleHover?(app, interactionFrame, false)
+            if isHovered {
+                isHovered = false
+                onBubbleHover?(app, interactionFrame, .exited)
             }
         }
     }
@@ -117,31 +126,33 @@
     }
 
     private func setHoverState(_ hovering: Bool) {
+        guard isHovered != hovering else { return }
         withAnimation(hovering ? Self.hoverInAnimation : Self.hoverOutAnimation) {
-            if hovering {
-                hoveredAppItemID = itemID
-            } else if hoveredAppItemID == itemID {
-                hoveredAppItemID = nil
-            }
+            isHovered = hovering
         }
     }
 }
 
-private struct AppGridItemHoverTracker: NSViewRepresentable {
+enum AppBubbleHoverEvent {
+    case entered(canShowBubble: Bool)
+    case exited
+}
+
+struct AppHoverTrackingView: NSViewRepresentable {
     let onHover: (Bool, CGRect) -> Void
 
-    func makeNSView(context: Context) -> HoverTrackingNSView {
-        let view = HoverTrackingNSView()
+    func makeNSView(context: Context) -> AppHoverTrackingNSView {
+        let view = AppHoverTrackingNSView()
         view.onHover = onHover
         return view
     }
 
-    func updateNSView(_ view: HoverTrackingNSView, context: Context) {
+    func updateNSView(_ view: AppHoverTrackingNSView, context: Context) {
         view.onHover = onHover
     }
 }
 
-private final class HoverTrackingNSView: NSView {
+final class AppHoverTrackingNSView: NSView {
     var onHover: ((Bool, CGRect) -> Void)?
 
     override func updateTrackingAreas() {
@@ -295,6 +306,7 @@
     let icon: NSImage
     let iconSize: CGFloat
     let payload: String
+    let resetToken: Int
     let onLongPress: () -> Void
     let onDragEnd: () -> Void
     let onClick: () -> Void
@@ -304,6 +316,7 @@
         view.image = icon
         view.iconSize = iconSize
         view.payload = payload
+        view.resetToken = resetToken
         view.onLongPress = onLongPress
         view.onDragEnd = onDragEnd
         view.onClick = onClick
@@ -311,13 +324,21 @@
     }
 
     func updateNSView(_ view: DragIconNSView, context: Context) {
+        let imageChanged = view.image !== icon
+        let sizeChanged = view.iconSize != iconSize
         view.image = icon
         view.iconSize = iconSize
         view.payload = payload
+        if view.resetToken != resetToken {
+            view.resetToken = resetToken
+            view.resetInteractionState()
+        }
         view.onLongPress = onLongPress
         view.onDragEnd = onDragEnd
         view.onClick = onClick
-        view.needsDisplay = true
+        if imageChanged || sizeChanged {
+            view.needsDisplay = true
+        }
     }
 }
 
@@ -325,6 +346,7 @@
     var image: NSImage = NSImage()
     var iconSize: CGFloat = 56
     var payload: String = ""
+    var resetToken = 0
     var onLongPress: (() -> Void)?
     var onDragEnd: (() -> Void)?
     var onClick: (() -> Void)?
@@ -334,6 +356,14 @@
     private var isLongPressActive = false
     private var longPressWorkItem: DispatchWorkItem?
     override var isFlipped: Bool { true }
+
+    func resetInteractionState() {
+        longPressWorkItem?.cancel()
+        longPressWorkItem = nil
+        mouseDownEvent = nil
+        didStartDrag = false
+        isLongPressActive = false
+    }
 
     override func draw(_ dirtyRect: NSRect) {
         super.draw(dirtyRect)
@@ -400,6 +430,7 @@
         didStartDrag = false
         isLongPressActive = false
         mouseDownEvent = nil
+        longPressWorkItem = nil
     }
 
     private func makeDragImage() -> NSImage {

--
Gitblit v1.9.3