From 00d66fcdb928754e5a8c69a3b4ccab7832af5b54 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Mon, 18 May 2026 20:12:20 +0800
Subject: [PATCH] Stabilize menu bar item identity for Thaw

---
 Apptag/AppGridItem.swift |  167 ++++++++++++++++++++++++++++++++++++++++++++++++-------
 1 files changed, 146 insertions(+), 21 deletions(-)

diff --git a/Apptag/AppGridItem.swift b/Apptag/AppGridItem.swift
index 9fa97e2..22c204c 100644
--- a/Apptag/AppGridItem.swift
+++ b/Apptag/AppGridItem.swift
@@ -12,15 +12,19 @@
     var onDragModeChange: ((Bool) -> Void)? = nil
     var onBubbleHover: ((AppInfo, CGRect, Bool) -> Void)? = nil
     var onEditNote: ((AppInfo, CGRect) -> Void)? = nil
+    var bubbleDisabled: Bool = false
+    var itemID: String
+    @Binding var hoveredAppItemID: String?
     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
+    @State private var interactionFrame: CGRect = .zero
+    @AppStorage("showUncommonAppBubbles") private var showUncommonAppBubbles = AppDefaults.showUncommonAppBubbles
 
     static let hoverScale: CGFloat = 1.22
     static let labelHeight: CGFloat = 14
+    private static let hoverInAnimation = Animation.easeOut(duration: 0.07)
+    private static let hoverOutAnimation = Animation.easeOut(duration: 0.045)
 
     static func stableWidth(iconSize: CGFloat) -> CGFloat {
         iconSize * hoverScale + 8
@@ -33,6 +37,7 @@
     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) {
@@ -52,7 +57,6 @@
                     radius: isHovered ? 14 : 0,
                     y: isHovered ? 8 : 0
                 )
-                .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isHovered)
             }
             .frame(width: iconSlotSize, height: iconSlotSize)
 
@@ -61,32 +65,35 @@
                 .lineLimit(1)
                 .truncationMode(.tail)
                 .frame(width: labelWidth, height: labelHeight)
-                .opacity(showName ? 1 : (isHovered ? 0.85 : 0))
+                .opacity(showName ? 1 : (shouldShowHoverName ? 0.85 : 0))
         }
         .padding(.vertical, 8)
         .padding(.horizontal, 4)
         .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
-                    }
+            AppGridItemHoverTracker { hovering, frame in
+                interactionFrame = frame
+                if bubbleDisabled || dragModeActive {
+                    setHoverState(false)
+                    onBubbleHover?(app, frame, false)
+                    return
+                }
+                if hovering {
+                    setHoverState(true)
+                    guard shouldShowAppBubble else { return }
+                    onBubbleHover?(app, frame, hovering)
+                } else if isHovered {
+                    setHoverState(false)
+                    onBubbleHover?(app, frame, hovering)
+                } else {
+                    setHoverState(false)
+                }
             }
         )
         .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)
-                }
+            Button(tr("appNote.edit")) {
+                onEditNote?(app, interactionFrame)
             }
         }
         .rotationEffect(.degrees(dragModeActive ? (wiggle ? 2.0 : -2.0) : 0))
@@ -98,7 +105,98 @@
         )
         .onChange(of: dragModeActive) { _, active in
             wiggle = active
+            if active {
+                setHoverState(false)
+                onBubbleHover?(app, interactionFrame, false)
+            }
         }
+        .onChange(of: bubbleDisabled) { _, disabled in
+            if disabled {
+                setHoverState(false)
+                onBubbleHover?(app, interactionFrame, false)
+            }
+        }
+        .onDisappear {
+            if hoveredAppItemID == itemID {
+                hoveredAppItemID = nil
+                onBubbleHover?(app, interactionFrame, false)
+            }
+        }
+    }
+
+    private var shouldShowAppBubble: Bool {
+        !showUncommonAppBubbles || app.isUncommon
+    }
+
+    private var shouldShowHoverName: Bool {
+        !showName && !shouldShowAppBubble && isHovered
+    }
+
+    private func setHoverState(_ hovering: Bool) {
+        withAnimation(hovering ? Self.hoverInAnimation : Self.hoverOutAnimation) {
+            if hovering {
+                hoveredAppItemID = itemID
+            } else if hoveredAppItemID == itemID {
+                hoveredAppItemID = nil
+            }
+        }
+    }
+}
+
+private struct AppGridItemHoverTracker: NSViewRepresentable {
+    let onHover: (Bool, CGRect) -> Void
+
+    func makeNSView(context: Context) -> HoverTrackingNSView {
+        let view = HoverTrackingNSView()
+        view.onHover = onHover
+        return view
+    }
+
+    func updateNSView(_ view: HoverTrackingNSView, context: Context) {
+        view.onHover = onHover
+    }
+}
+
+private final class HoverTrackingNSView: NSView {
+    var onHover: ((Bool, CGRect) -> Void)?
+
+    override func updateTrackingAreas() {
+        super.updateTrackingAreas()
+        trackingAreas.forEach(removeTrackingArea)
+        addTrackingArea(
+            NSTrackingArea(
+                rect: .zero,
+                options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect],
+                owner: self,
+                userInfo: nil
+            )
+        )
+    }
+
+    override func hitTest(_ point: NSPoint) -> NSView? {
+        nil
+    }
+
+    override func mouseEntered(with event: NSEvent) {
+        onHover?(true, rootLocalFrame())
+    }
+
+    override func mouseExited(with event: NSEvent) {
+        onHover?(false, rootLocalFrame())
+    }
+
+    private func rootLocalFrame() -> CGRect {
+        guard let contentView = window?.contentView else { return .zero }
+        let rectInContent = contentView.convert(bounds, from: self)
+        let y = contentView.isFlipped
+            ? rectInContent.minY
+            : contentView.bounds.height - rectInContent.maxY
+        return CGRect(
+            x: rectInContent.minX,
+            y: y,
+            width: rectInContent.width,
+            height: rectInContent.height
+        )
     }
 }
 
@@ -112,6 +210,7 @@
     let note: String?
     let isEditing: Bool
     let placement: BubblePlacement
+    let arrowOffset: CGFloat
     @Binding var draftNote: String
     var noteFocused: FocusState<Bool>.Binding
     let onCommit: () -> Void
@@ -172,6 +271,12 @@
                 .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> {
@@ -182,6 +287,26 @@
     }
 }
 
+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
+    }
+}
+
 private struct DraggableAppIconView: NSViewRepresentable {
     let icon: NSImage
     let iconSize: CGFloat

--
Gitblit v1.9.3