From 18a0fe3977175f3f7a945b300524fd2cd37fef28 Mon Sep 17 00:00:00 2001
From: Ariver <shanghai3168@gmail.com>
Date: Sat, 13 Jun 2026 15:15:47 +0800
Subject: [PATCH] Add app grid usage tips

---
 src/Apptag/AppGridCollectionView.swift |  326 ++++++++++++++++++++++++++++++++++++++++++++++++++++--
 1 files changed, 312 insertions(+), 14 deletions(-)

diff --git a/src/Apptag/AppGridCollectionView.swift b/src/Apptag/AppGridCollectionView.swift
index 54a218b..cfcd15d 100644
--- a/src/Apptag/AppGridCollectionView.swift
+++ b/src/Apptag/AppGridCollectionView.swift
@@ -38,6 +38,7 @@
     let bubbleDisabled: Bool
     let showUncommonAppBubbles: Bool
     let highlightedGroupName: String?
+    let bottomContentPadding: CGFloat
     let contentRevision: Int
     let scrollTargetID: String?
     let scrollRequestToken: Int
@@ -46,6 +47,7 @@
     let onEditNote: (AppInfo, CGRect) -> Void
     let onDropApp: (String, String, String, Bool) -> Void
     let onDropOutsideGroup: (String, String, Bool) -> Void
+    let onReorderApps: (String, [String]) -> Void
     let onGroupActivate: (String) -> Void
     let onScrollActivity: () -> Void
     let onDragModeChange: (Bool) -> Void
@@ -70,6 +72,7 @@
             bubbleDisabled: bubbleDisabled,
             showUncommonAppBubbles: showUncommonAppBubbles,
             highlightedGroupName: highlightedGroupName,
+            bottomContentPadding: bottomContentPadding,
             contentRevision: contentRevision,
             scrollTargetID: scrollTargetID,
             scrollRequestToken: scrollRequestToken,
@@ -78,6 +81,7 @@
             onEditNote: onEditNote,
             onDropApp: onDropApp,
             onDropOutsideGroup: onDropOutsideGroup,
+            onReorderApps: onReorderApps,
             onGroupActivate: onGroupActivate,
             onScrollActivity: onScrollActivity,
             onDragModeChange: onDragModeChange
@@ -95,6 +99,7 @@
         private var scrollBubbleDisabled = false
         var showUncommonAppBubbles = AppDefaults.showUncommonAppBubbles
         var highlightedGroupName: String?
+        var bottomContentPadding: CGFloat = 0
         var contentRevision = 0
         var scrollTargetID: String?
         var scrollRequestToken = 0
@@ -107,9 +112,16 @@
         var onEditNote: (AppInfo, CGRect) -> Void = { _, _ in }
         var onDropApp: (String, String, String, Bool) -> Void = { _, _, _, _ in }
         var onDropOutsideGroup: (String, String, Bool) -> Void = { _, _, _ in }
+        var onReorderApps: (String, [String]) -> Void = { _, _ in }
         var onGroupActivate: (String) -> Void = { _ in }
         var onScrollActivity: () -> Void = {}
         var onDragModeChange: (Bool) -> Void = { _ in }
+
+        private weak var activeReorderCard: AppGridGroupCardView?
+        private var activeDragPath = ""
+        private var activeDragSourceContainerID = ""
+        private var lastReorderContainerID = ""
+        private var lastReorderScreenFrame: NSRect?
 
         func update(
             groups: [TagGroup],
@@ -120,6 +132,7 @@
             bubbleDisabled: Bool,
             showUncommonAppBubbles: Bool,
             highlightedGroupName: String?,
+            bottomContentPadding: CGFloat,
             contentRevision: Int,
             scrollTargetID: String?,
             scrollRequestToken: Int,
@@ -128,6 +141,7 @@
             onEditNote: @escaping (AppInfo, CGRect) -> Void,
             onDropApp: @escaping (String, String, String, Bool) -> Void,
             onDropOutsideGroup: @escaping (String, String, Bool) -> Void,
+            onReorderApps: @escaping (String, [String]) -> Void,
             onGroupActivate: @escaping (String) -> Void,
             onScrollActivity: @escaping () -> Void,
             onDragModeChange: @escaping (Bool) -> Void
@@ -140,6 +154,7 @@
             self.externalBubbleDisabled = bubbleDisabled
             self.showUncommonAppBubbles = showUncommonAppBubbles
             self.highlightedGroupName = highlightedGroupName
+            self.bottomContentPadding = max(0, bottomContentPadding)
             self.contentRevision = contentRevision
             self.scrollTargetID = scrollTargetID
             self.scrollRequestToken = scrollRequestToken
@@ -148,6 +163,7 @@
             self.onEditNote = onEditNote
             self.onDropApp = onDropApp
             self.onDropOutsideGroup = onDropOutsideGroup
+            self.onReorderApps = onReorderApps
             self.onGroupActivate = onGroupActivate
             self.onScrollActivity = onScrollActivity
             self.onDragModeChange = onDragModeChange
@@ -158,6 +174,7 @@
                 iconSize: iconSize,
                 showNames: showNames,
                 showUncommonAppBubbles: showUncommonAppBubbles,
+                bottomContentPadding: self.bottomContentPadding,
                 contentRevision: contentRevision
             )
             if nextSignature != contentSignature {
@@ -228,12 +245,92 @@
             return true
         }
 
+        fileprivate func beginAppIconDrag(path: String, sourceContainerID: String) {
+            activeDragPath = path
+            activeDragSourceContainerID = sourceContainerID
+            lastReorderContainerID = ""
+            lastReorderScreenFrame = nil
+            clearReorderInsertion()
+            onDragModeChange(true)
+        }
+
+        fileprivate func endAppIconDrag() {
+            clearReorderInsertion()
+            activeDragPath = ""
+            activeDragSourceContainerID = ""
+            lastReorderContainerID = ""
+            lastReorderScreenFrame = nil
+            onDragModeChange(false)
+        }
+
+        fileprivate func cancelAppIconDrag() {
+            clearReorderInsertion()
+            activeDragPath = ""
+            activeDragSourceContainerID = ""
+            lastReorderContainerID = ""
+            lastReorderScreenFrame = nil
+            onDragModeChange(false)
+        }
+
+        fileprivate func setReorderInsertion(card: AppGridGroupCardView, insertionIndex: Int) {
+            if let activeReorderCard, activeReorderCard !== card {
+                activeReorderCard.clearReorderInsertion()
+            }
+            activeReorderCard = card
+            lastReorderContainerID = card.containerID
+            lastReorderScreenFrame = card.screenFrame()
+            card.setReorderInsertion(index: insertionIndex)
+        }
+
+        fileprivate func clearReorderInsertion(card: AppGridGroupCardView? = nil) {
+            if let card {
+                card.clearReorderInsertion()
+                if activeReorderCard === card {
+                    activeReorderCard = nil
+                }
+                if lastReorderContainerID == card.containerID {
+                    lastReorderContainerID = ""
+                    lastReorderScreenFrame = nil
+                }
+                return
+            }
+            activeReorderCard?.clearReorderInsertion()
+            activeReorderCard = nil
+            lastReorderContainerID = ""
+            lastReorderScreenFrame = nil
+        }
+
+        fileprivate func activeReorderPath(in containerID: String, copy: Bool) -> String? {
+            guard !copy,
+                  !activeDragPath.isEmpty,
+                  activeDragSourceContainerID == containerID
+            else { return nil }
+            return activeDragPath
+        }
+
+        fileprivate func shouldCancelEmptyDropForActiveReorder(
+            path: String,
+            screenPoint: NSPoint,
+            copy: Bool
+        ) -> Bool {
+            guard !copy,
+                  !activeDragPath.isEmpty,
+                  activeDragPath == path,
+                  activeDragSourceContainerID == lastReorderContainerID,
+                  let lastReorderScreenFrame
+            else { return false }
+
+            let guardOutset = AppGridCollectionMetrics.reorderEmptyDropCancelOutset
+            return lastReorderScreenFrame.insetBy(dx: -guardOutset, dy: -guardOutset).contains(screenPoint)
+        }
+
         private static func signature(
             tagColors: [String: Int],
             displayMode: String,
             iconSize: CGFloat,
             showNames: Bool,
             showUncommonAppBubbles: Bool,
+            bottomContentPadding: CGFloat,
             contentRevision: Int
         ) -> String {
             let colorPart = tagColors
@@ -245,6 +342,7 @@
                 "\(Int(iconSize.rounded()))",
                 showNames ? "names" : "nonames",
                 showUncommonAppBubbles ? "uncommon" : "allbubbles",
+                "bottom=\(Int(bottomContentPadding.rounded()))",
                 colorPart,
                 "rev=\(contentRevision)"
             ].joined(separator: "|")
@@ -291,6 +389,7 @@
             NotificationCenter.default.removeObserver(scrollObserver)
         }
         scrollUnfreezeWorkItem?.cancel()
+        coordinator?.cancelAppIconDrag()
         AppDragCoordinator.shared.unregisterEmptyDropTarget(id: emptyDropTargetID)
     }
 
@@ -337,6 +436,8 @@
     override func viewDidMoveToWindow() {
         super.viewDidMoveToWindow()
         if window == nil {
+            AppDragCoordinator.shared.cancelDrag()
+            coordinator?.cancelAppIconDrag()
             AppDragCoordinator.shared.unregisterEmptyDropTarget(id: emptyDropTargetID)
         } else {
             AppDragCoordinator.shared.registerEmptyDropTarget(id: emptyDropTargetID, view: self)
@@ -347,6 +448,14 @@
         guard let coordinator,
               coordinator.displayStyle != .flat
         else { return }
+        if coordinator.shouldCancelEmptyDropForActiveReorder(
+            path: path,
+            screenPoint: screenPoint,
+            copy: copy
+        ) {
+            coordinator.cancelAppIconDrag()
+            return
+        }
         DispatchQueue.main.async {
             coordinator.onDropOutsideGroup(path, source, copy)
         }
@@ -386,6 +495,11 @@
     }
 
     private func handleScrollActivity() {
+        if AppDragCoordinator.shared.hasActiveDrag {
+            AppDragCoordinator.shared.cancelDrag()
+        }
+        coordinator?.cancelAppIconDrag()
+
         if !scrollActivityIsActive {
             scrollActivityIsActive = true
             if coordinator?.setScrollBubbleDisabled(true) == true {
@@ -461,7 +575,8 @@
             groups: visibleGroups,
             contentWidth: collectionView.bounds.width,
             iconSize: iconSize,
-            displayMode: coordinator?.displayMode ?? AppDefaults.displayMode
+            displayMode: coordinator?.displayMode ?? AppDefaults.displayMode,
+            bottomContentPadding: coordinator?.bottomContentPadding ?? 0
         )
 
         var nextAttributes: [IndexPath: NSCollectionViewLayoutAttributes] = [:]
@@ -507,22 +622,39 @@
         groups: [TagGroup],
         contentWidth: CGFloat,
         iconSize: CGFloat,
-        displayMode: String
+        displayMode: String,
+        bottomContentPadding: CGFloat
     ) -> LayoutPlan {
         switch AppGridCollectionDisplayMode(displayMode) {
         case .flat:
-            return makeFlatPlan(groups: groups, contentWidth: contentWidth, iconSize: iconSize)
+            return makeFlatPlan(
+                groups: groups,
+                contentWidth: contentWidth,
+                iconSize: iconSize,
+                bottomContentPadding: bottomContentPadding
+            )
         case .masonryContainer:
-            return makeMasonryPlan(groups: groups, contentWidth: contentWidth, iconSize: iconSize)
+            return makeMasonryPlan(
+                groups: groups,
+                contentWidth: contentWidth,
+                iconSize: iconSize,
+                bottomContentPadding: bottomContentPadding
+            )
         case .gridContainer:
-            return makeGridPlan(groups: groups, contentWidth: contentWidth, iconSize: iconSize)
+            return makeGridPlan(
+                groups: groups,
+                contentWidth: contentWidth,
+                iconSize: iconSize,
+                bottomContentPadding: bottomContentPadding
+            )
         }
     }
 
     private static func makeGridPlan(
         groups: [TagGroup],
         contentWidth: CGFloat,
-        iconSize: CGFloat
+        iconSize: CGFloat,
+        bottomContentPadding: CGFloat
     ) -> LayoutPlan {
         let boundedContentWidth = max(1, contentWidth)
         let outerPadding = AppGridCollectionMetrics.outerPadding
@@ -552,7 +684,8 @@
             y += height + gap
         }
 
-        let contentHeight = rows.isEmpty ? outerPadding * 2 : y - gap + outerPadding
+        let contentHeight = (rows.isEmpty ? outerPadding * 2 : y - gap + outerPadding)
+            + max(0, bottomContentPadding)
         return LayoutPlan(
             items: items,
             contentSize: NSSize(width: boundedContentWidth, height: max(1, contentHeight))
@@ -562,7 +695,8 @@
     private static func makeFlatPlan(
         groups: [TagGroup],
         contentWidth: CGFloat,
-        iconSize: CGFloat
+        iconSize: CGFloat,
+        bottomContentPadding: CGFloat
     ) -> LayoutPlan {
         let boundedContentWidth = max(1, contentWidth)
         let outerPadding = AppGridCollectionMetrics.outerPadding
@@ -585,7 +719,8 @@
             y += height + AppGridCollectionMetrics.flatGroupGap
         }
 
-        let contentHeight = groups.isEmpty ? outerPadding * 2 : y - AppGridCollectionMetrics.flatGroupGap + outerPadding
+        let contentHeight = (groups.isEmpty ? outerPadding * 2 : y - AppGridCollectionMetrics.flatGroupGap + outerPadding)
+            + max(0, bottomContentPadding)
         return LayoutPlan(
             items: items,
             contentSize: NSSize(width: boundedContentWidth, height: max(1, contentHeight))
@@ -595,7 +730,8 @@
     private static func makeMasonryPlan(
         groups: [TagGroup],
         contentWidth: CGFloat,
-        iconSize: CGFloat
+        iconSize: CGFloat,
+        bottomContentPadding: CGFloat
     ) -> LayoutPlan {
         let boundedContentWidth = max(1, contentWidth)
         let outerPadding = AppGridCollectionMetrics.outerPadding
@@ -625,7 +761,8 @@
         }
 
         let tallest = columnHeights.max() ?? outerPadding
-        let contentHeight = groups.isEmpty ? outerPadding * 2 : tallest - gap + outerPadding
+        let contentHeight = (groups.isEmpty ? outerPadding * 2 : tallest - gap + outerPadding)
+            + max(0, bottomContentPadding)
         return LayoutPlan(
             items: items,
             contentSize: NSSize(width: boundedContentWidth, height: max(1, contentHeight))
@@ -761,6 +898,7 @@
     static let headerBottomGap: CGFloat = 6
     static let iconColumnGap: CGFloat = 6
     static let iconRowGap: CGFloat = 2
+    static let reorderEmptyDropCancelOutset: CGFloat = 4
     static let hoverScale: CGFloat = 1.22
     static let labelHeight: CGFloat = 14
 
@@ -844,9 +982,11 @@
     private weak var coordinator: AppGridCollectionView.Coordinator?
     private var group: TagGroup?
     private var iconViews: [AppGridIconNSView] = []
+    private var reorderInsertionIndex: Int?
     private var isMouseInside = false
     private var isHovered = false
     private var trackingAreaRef: NSTrackingArea?
+    var containerID: String { group?.containerID ?? "" }
 
     override var isFlipped: Bool { true }
 
@@ -878,6 +1018,7 @@
         AppDragCoordinator.shared.unregister(id: dropTargetID)
         group = nil
         coordinator = nil
+        reorderInsertionIndex = nil
         isMouseInside = false
         isHovered = false
         layer?.shadowOpacity = 0
@@ -982,19 +1123,71 @@
         }
 
         drawHeader(title: group.name, displayStyle: displayStyle)
+        drawReorderInsertionIfNeeded()
     }
 
     func performDrop(path: String, source: String, copy: Bool) {
+        performDrop(path: path, source: source, sourceContainerID: "", copy: copy)
+    }
+
+    func performDrop(path: String, source: String, sourceContainerID: String, copy: Bool) {
         guard let group else { return }
+        if sourceContainerID == group.containerID {
+            if !copy,
+               let orderedPaths = reorderedAppPaths(moving: path) {
+                coordinator?.onReorderApps(group.containerID, orderedPaths)
+            }
+            coordinator?.endAppIconDrag()
+            clearReorderInsertion()
+            return
+        }
+
         coordinator?.onDropApp(path, source, group.name, copy)
         if source != group.name || copy {
             coordinator?.onDragModeChange(false)
         }
     }
 
+    func appDragHoverChanged(active: Bool) {
+        if !active {
+            coordinator?.clearReorderInsertion(card: self)
+        }
+    }
+
+    func appDragLocationChanged(screenPoint: NSPoint, copy: Bool) {
+        guard let coordinator,
+              let group,
+              coordinator.activeReorderPath(in: group.containerID, copy: copy) != nil,
+              let window
+        else {
+            coordinator?.clearReorderInsertion(card: self)
+            return
+        }
+
+        let windowPoint = window.convertPoint(fromScreen: screenPoint)
+        let localPoint = convert(windowPoint, from: nil)
+        coordinator.setReorderInsertion(
+            card: self,
+            insertionIndex: insertionIndex(for: localPoint)
+        )
+    }
+
     func replayPointerHover(windowPoint: NSPoint) {
         setPointerInside(bounds.contains(convert(windowPoint, from: nil)))
         iconViews.forEach { $0.replayPointerHover(windowPoint: windowPoint) }
+    }
+
+    func setReorderInsertion(index: Int) {
+        let clamped = min(max(0, index), iconViews.count)
+        guard reorderInsertionIndex != clamped else { return }
+        reorderInsertionIndex = clamped
+        needsDisplay = true
+    }
+
+    func clearReorderInsertion() {
+        guard reorderInsertionIndex != nil else { return }
+        reorderInsertionIndex = nil
+        needsDisplay = true
     }
 
     private func rebuildIconViews() {
@@ -1011,6 +1204,7 @@
             iconView.configure(
                 app: app,
                 sourceTag: group.name,
+                sourceContainerID: group.containerID,
                 iconSize: coordinator.iconSize,
                 showName: coordinator.showNames,
                 coordinator: coordinator
@@ -1018,6 +1212,100 @@
             addSubview(iconView)
             return iconView
         }
+    }
+
+    private func insertionIndex(for point: NSPoint) -> Int {
+        guard !iconViews.isEmpty else { return 0 }
+
+        var bestIndex = iconViews.count
+        var bestDistance = CGFloat.greatestFiniteMagnitude
+
+        for index in 0...iconViews.count {
+            guard let candidate = insertionCandidatePoint(for: index) else { continue }
+            let dx = point.x - candidate.x
+            let dy = point.y - candidate.y
+            let distance = dx * dx + dy * dy
+            if distance < bestDistance {
+                bestDistance = distance
+                bestIndex = index
+            }
+        }
+
+        return bestIndex
+    }
+
+    private func reorderedAppPaths(moving path: String) -> [String]? {
+        guard let group,
+              let insertionIndex = reorderInsertionIndex
+        else { return nil }
+
+        var paths = group.apps.map { $0.path.path }
+        guard let sourceIndex = paths.firstIndex(of: path) else { return nil }
+
+        paths.remove(at: sourceIndex)
+        var targetIndex = insertionIndex
+        if sourceIndex < insertionIndex {
+            targetIndex -= 1
+        }
+        targetIndex = min(max(0, targetIndex), paths.count)
+        paths.insert(path, at: targetIndex)
+
+        let originalPaths = group.apps.map { $0.path.path }
+        guard paths != originalPaths else { return nil }
+        return paths
+    }
+
+    private func drawReorderInsertionIfNeeded() {
+        guard let insertionIndex = reorderInsertionIndex,
+              let rect = insertionLineRect(for: insertionIndex)
+        else { return }
+
+        NSColor.controlAccentColor.withAlphaComponent(0.92).setFill()
+        let path = NSBezierPath(roundedRect: rect, xRadius: 2, yRadius: 2)
+        path.fill()
+    }
+
+    private func insertionLineRect(for index: Int) -> NSRect? {
+        guard let candidate = insertionCandidatePoint(for: index) else { return nil }
+        let referenceFrame: NSRect
+        if iconViews.indices.contains(index) {
+            referenceFrame = iconViews[index].frame
+        } else if let lastFrame = iconViews.last?.frame {
+            referenceFrame = lastFrame
+        } else {
+            return nil
+        }
+
+        let iconSize = coordinator?.iconSize ?? CGFloat(AppDefaults.iconSize)
+        let lineHeight = max(28, min(referenceFrame.height - 8, iconSize + 18))
+        return NSRect(
+            x: candidate.x - 2,
+            y: referenceFrame.midY - lineHeight / 2,
+            width: 4,
+            height: lineHeight
+        )
+    }
+
+    private func insertionCandidatePoint(for index: Int) -> NSPoint? {
+        guard !iconViews.isEmpty else { return nil }
+
+        if index <= 0 {
+            let first = iconViews[0].frame
+            return NSPoint(x: first.minX - 3, y: first.midY)
+        }
+
+        if index >= iconViews.count {
+            let last = iconViews[iconViews.count - 1].frame
+            return NSPoint(x: last.maxX + 3, y: last.midY)
+        }
+
+        let previous = iconViews[index - 1].frame
+        let next = iconViews[index].frame
+        if abs(previous.midY - next.midY) < 4 {
+            return NSPoint(x: (previous.maxX + next.minX) / 2, y: previous.midY)
+        }
+
+        return NSPoint(x: next.minX - 3, y: next.midY)
     }
 
     private func registerDropTargetIfNeeded() {
@@ -1142,6 +1430,7 @@
     private weak var coordinator: AppGridCollectionView.Coordinator?
     private var app: AppInfo?
     private var sourceTag = ""
+    private var sourceContainerID = ""
     private var iconSize: CGFloat = AppDefaults.iconSize
     private var showName = true
     private var isMouseInside = false
@@ -1166,12 +1455,14 @@
     func configure(
         app: AppInfo,
         sourceTag: String,
+        sourceContainerID: String,
         iconSize: CGFloat,
         showName: Bool,
         coordinator: AppGridCollectionView.Coordinator
     ) {
         self.app = app
         self.sourceTag = sourceTag
+        self.sourceContainerID = sourceContainerID
         self.iconSize = iconSize
         self.showName = showName
         self.coordinator = coordinator
@@ -1191,6 +1482,7 @@
         mouseDownEvent = nil
         didStartDrag = false
         isLongPressActive = false
+        sourceContainerID = ""
         longPressWorkItem = nil
     }
 
@@ -1293,6 +1585,12 @@
 
         didStartDrag = true
         longPressWorkItem?.cancel()
+        if let app {
+            coordinator?.beginAppIconDrag(
+                path: app.path.path,
+                sourceContainerID: sourceContainerID
+            )
+        }
         AppDragCoordinator.shared.beginDrag(
             image: makeDragImage(),
             payload: dragPayload,
@@ -1309,11 +1607,11 @@
                 at: screenPoint(for: event),
                 copy: event.modifierFlags.contains(.option)
             )
-            coordinator?.onDragModeChange(false)
+            coordinator?.endAppIconDrag()
         } else if !isLongPressActive, let app {
             coordinator?.onSelectApp(app)
         } else {
-            coordinator?.onDragModeChange(false)
+            coordinator?.cancelAppIconDrag()
         }
         didStartDrag = false
         isLongPressActive = false
@@ -1355,7 +1653,7 @@
 
     private var dragPayload: String {
         guard let app else { return "" }
-        return "\(app.path.path)\n\(sourceTag)"
+        return "\(app.path.path)\n\(sourceTag)\n\(sourceContainerID)"
     }
 
     private func setHover(_ hover: Bool, notify: Bool) {

--
Gitblit v1.9.3