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 |  269 +++++++++++++++++++++++++++++++++++++++++++++++++++--
 1 files changed, 256 insertions(+), 13 deletions(-)

diff --git a/Apptag/AppGridItem.swift b/Apptag/AppGridItem.swift
index 3b4cc5a..c187acf 100644
--- a/Apptag/AppGridItem.swift
+++ b/Apptag/AppGridItem.swift
@@ -10,13 +10,21 @@
     var sourceTag: String? = nil
     var dragModeActive: Bool = false
     var onDragModeChange: ((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
+    var dragResetToken: Int = 0
     let onSelect: () -> Void
 
+    @State private var interactionFrame: CGRect = .zero
     @State private var isHovered = false
-    @State private var wiggle = false
 
     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
@@ -37,6 +45,7 @@
                     icon: app.icon,
                     iconSize: iconSize,
                     payload: "\(app.path.path)\n\(sourceTag ?? "")",
+                    resetToken: dragResetToken,
                     onLongPress: { onDragModeChange?(true) },
                     onDragEnd: { onDragModeChange?(false) },
                     onClick: onSelect
@@ -48,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)
 
@@ -57,25 +65,240 @@
                 .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(
+            AppHoverTrackingView { hovering, frame in
+                interactionFrame = frame
+                if bubbleDisabled || dragModeActive {
+                    setHoverState(false)
+                    onBubbleHover?(app, frame, .exited)
+                    return
+                }
+                if hovering {
+                    setHoverState(true)
+                    onBubbleHover?(app, frame, .entered(canShowBubble: shouldShowAppBubble))
+                } else if isHovered {
+                    setHoverState(false)
+                    onBubbleHover?(app, frame, .exited)
+                } else {
+                    setHoverState(false)
+                }
+            }
+        )
         .contentShape(Rectangle())
-        .onHover { hovering in
+        .contextMenu {
+            Button(tr("appNote.edit")) {
+                onEditNote?(app, interactionFrame)
+            }
+        }
+        .opacity(dragModeActive ? 0.92 : 1)
+        .animation(.easeOut(duration: 0.08), value: dragModeActive)
+        .onChange(of: dragModeActive) { _, active in
+            if active {
+                setHoverState(false)
+                onBubbleHover?(app, interactionFrame, .exited)
+            }
+        }
+        .onChange(of: bubbleDisabled) { _, disabled in
+            if disabled {
+                setHoverState(false)
+                onBubbleHover?(app, interactionFrame, .exited)
+            }
+        }
+        .onDisappear {
+            if isHovered {
+                isHovered = false
+                onBubbleHover?(app, interactionFrame, .exited)
+            }
+        }
+    }
+
+    private var shouldShowAppBubble: Bool {
+        !showUncommonAppBubbles || app.isUncommon
+    }
+
+    private var shouldShowHoverName: Bool {
+        !showName && !shouldShowAppBubble && isHovered
+    }
+
+    private func setHoverState(_ hovering: Bool) {
+        guard isHovered != hovering else { return }
+        withAnimation(hovering ? Self.hoverInAnimation : Self.hoverOutAnimation) {
             isHovered = hovering
         }
-        .rotationEffect(.degrees(dragModeActive ? (wiggle ? 2.0 : -2.0) : 0))
-        .animation(
-            dragModeActive
-                ? .easeInOut(duration: 0.12).repeatForever(autoreverses: true)
-                : .default,
-            value: wiggle
+    }
+}
+
+enum AppBubbleHoverEvent {
+    case entered(canShowBubble: Bool)
+    case exited
+}
+
+struct AppHoverTrackingView: NSViewRepresentable {
+    let onHover: (Bool, CGRect) -> Void
+
+    func makeNSView(context: Context) -> AppHoverTrackingNSView {
+        let view = AppHoverTrackingNSView()
+        view.onHover = onHover
+        return view
+    }
+
+    func updateNSView(_ view: AppHoverTrackingNSView, context: Context) {
+        view.onHover = onHover
+    }
+}
+
+final class AppHoverTrackingNSView: 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
+            )
         )
-        .onChange(of: dragModeActive) { _, active in
-            wiggle = active
+    }
+
+    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
+        )
+    }
+}
+
+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 {
+        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)
+        )
+        .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
     }
 }
 
@@ -83,6 +306,7 @@
     let icon: NSImage
     let iconSize: CGFloat
     let payload: String
+    let resetToken: Int
     let onLongPress: () -> Void
     let onDragEnd: () -> Void
     let onClick: () -> Void
@@ -92,6 +316,7 @@
         view.image = icon
         view.iconSize = iconSize
         view.payload = payload
+        view.resetToken = resetToken
         view.onLongPress = onLongPress
         view.onDragEnd = onDragEnd
         view.onClick = onClick
@@ -99,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
+        }
     }
 }
 
@@ -113,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)?
@@ -122,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)
@@ -188,6 +430,7 @@
         didStartDrag = false
         isLongPressActive = false
         mouseDownEvent = nil
+        longPressWorkItem = nil
     }
 
     private func makeDragImage() -> NSImage {

--
Gitblit v1.9.3