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