Ariver
2026-06-13 1030214551f03d50a98e7a218648d89803c4c057
src/Apptag/AppGridCollectionView.swift
@@ -27,6 +27,20 @@
    static let externalInteraction = AppGridBubbleSuppressionReasons(rawValue: 1 << 0)
    static let scroll = AppGridBubbleSuppressionReasons(rawValue: 1 << 1)
    static let usageTipsHover = AppGridBubbleSuppressionReasons(rawValue: 1 << 2)
}
struct AppGridUsageTip: Equatable {
    let id: Int
    let titleKey: String
    let detailKey: String
}
enum AppGridUsageTipsMetrics {
    static let barHeight: CGFloat = 68
    static let reservedHeight: CGFloat = 104
    static let bottomMargin: CGFloat = 18
    static let horizontalInset: CGFloat = AppGridCollectionMetrics.outerPadding
}
struct AppGridCollectionView: NSViewRepresentable {
@@ -38,6 +52,10 @@
    let bubbleDisabled: Bool
    let showUncommonAppBubbles: Bool
    let highlightedGroupName: String?
    let bottomContentPadding: CGFloat
    let usageTipsVisible: Bool
    let usageTips: [AppGridUsageTip]
    @Binding var selectedUsageTipIndex: Int
    let contentRevision: Int
    let scrollTargetID: String?
    let scrollRequestToken: Int
@@ -46,9 +64,11 @@
    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
    let onUsageTipsHoverChange: (Bool) -> Void
    func makeCoordinator() -> Coordinator {
        Coordinator()
@@ -61,6 +81,7 @@
    }
    func updateNSView(_ view: AppGridCollectionHostView, context: Context) {
        let selectedUsageTipIndexBinding = $selectedUsageTipIndex
        context.coordinator.update(
            groups: groups,
            tagColors: tagColors,
@@ -70,6 +91,10 @@
            bubbleDisabled: bubbleDisabled,
            showUncommonAppBubbles: showUncommonAppBubbles,
            highlightedGroupName: highlightedGroupName,
            bottomContentPadding: bottomContentPadding,
            usageTipsVisible: usageTipsVisible,
            usageTips: usageTips,
            selectedUsageTipIndex: selectedUsageTipIndex,
            contentRevision: contentRevision,
            scrollTargetID: scrollTargetID,
            scrollRequestToken: scrollRequestToken,
@@ -78,9 +103,12 @@
            onEditNote: onEditNote,
            onDropApp: onDropApp,
            onDropOutsideGroup: onDropOutsideGroup,
            onReorderApps: onReorderApps,
            onGroupActivate: onGroupActivate,
            onScrollActivity: onScrollActivity,
            onDragModeChange: onDragModeChange
            onDragModeChange: onDragModeChange,
            onUsageTipIndexChange: { selectedUsageTipIndexBinding.wrappedValue = $0 },
            onUsageTipsHoverChange: onUsageTipsHoverChange
        )
        view.applyCoordinatorUpdate()
    }
@@ -95,6 +123,11 @@
        private var scrollBubbleDisabled = false
        var showUncommonAppBubbles = AppDefaults.showUncommonAppBubbles
        var highlightedGroupName: String?
        var bottomContentPadding: CGFloat = 0
        var usageTipsVisible = false
        var usageTips: [AppGridUsageTip] = []
        var selectedUsageTipIndex = 0
        private var usageTipsBubbleDisabled = false
        var contentRevision = 0
        var scrollTargetID: String?
        var scrollRequestToken = 0
@@ -107,9 +140,18 @@
        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 }
        var onUsageTipIndexChange: (Int) -> Void = { _ in }
        var onUsageTipsHoverChange: (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 +162,10 @@
            bubbleDisabled: Bool,
            showUncommonAppBubbles: Bool,
            highlightedGroupName: String?,
            bottomContentPadding: CGFloat,
            usageTipsVisible: Bool,
            usageTips: [AppGridUsageTip],
            selectedUsageTipIndex: Int,
            contentRevision: Int,
            scrollTargetID: String?,
            scrollRequestToken: Int,
@@ -128,9 +174,12 @@
            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
            onDragModeChange: @escaping (Bool) -> Void,
            onUsageTipIndexChange: @escaping (Int) -> Void,
            onUsageTipsHoverChange: @escaping (Bool) -> Void
        ) {
            self.groups = groups
            self.tagColors = tagColors
@@ -140,6 +189,10 @@
            self.externalBubbleDisabled = bubbleDisabled
            self.showUncommonAppBubbles = showUncommonAppBubbles
            self.highlightedGroupName = highlightedGroupName
            self.bottomContentPadding = max(0, bottomContentPadding)
            self.usageTipsVisible = usageTipsVisible && !usageTips.isEmpty
            self.usageTips = usageTips
            self.selectedUsageTipIndex = Self.clampedUsageTipIndex(selectedUsageTipIndex, tips: usageTips)
            self.contentRevision = contentRevision
            self.scrollTargetID = scrollTargetID
            self.scrollRequestToken = scrollRequestToken
@@ -148,9 +201,15 @@
            self.onEditNote = onEditNote
            self.onDropApp = onDropApp
            self.onDropOutsideGroup = onDropOutsideGroup
            self.onReorderApps = onReorderApps
            self.onGroupActivate = onGroupActivate
            self.onScrollActivity = onScrollActivity
            self.onDragModeChange = onDragModeChange
            self.onUsageTipIndexChange = onUsageTipIndexChange
            self.onUsageTipsHoverChange = onUsageTipsHoverChange
            if !self.usageTipsVisible {
                usageTipsBubbleDisabled = false
            }
            let nextSignature = Self.signature(
                tagColors: tagColors,
@@ -158,6 +217,7 @@
                iconSize: iconSize,
                showNames: showNames,
                showUncommonAppBubbles: showUncommonAppBubbles,
                bottomContentPadding: self.bottomContentPadding,
                contentRevision: contentRevision
            )
            if nextSignature != contentSignature {
@@ -214,6 +274,9 @@
            if scrollBubbleDisabled {
                reasons.insert(.scroll)
            }
            if usageTipsBubbleDisabled {
                reasons.insert(.usageTipsHover)
            }
            return reasons
        }
@@ -228,12 +291,108 @@
            return true
        }
        @discardableResult
        func setUsageTipsBubbleDisabled(_ disabled: Bool) -> Bool {
            guard usageTipsBubbleDisabled != disabled else { return false }
            usageTipsBubbleDisabled = disabled
            return true
        }
        func selectUsageTip(offset: Int) {
            guard !usageTips.isEmpty else { return }
            let count = usageTips.count
            let nextIndex = (selectedUsageTipIndex + offset + count) % count
            guard nextIndex != selectedUsageTipIndex else { return }
            selectedUsageTipIndex = nextIndex
            onUsageTipIndexChange(nextIndex)
        }
        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,9 +404,15 @@
                "\(Int(iconSize.rounded()))",
                showNames ? "names" : "nonames",
                showUncommonAppBubbles ? "uncommon" : "allbubbles",
                "bottom=\(Int(bottomContentPadding.rounded()))",
                colorPart,
                "rev=\(contentRevision)"
            ].joined(separator: "|")
        }
        private static func clampedUsageTipIndex(_ index: Int, tips: [AppGridUsageTip]) -> Int {
            guard !tips.isEmpty else { return 0 }
            return min(max(0, index), tips.count - 1)
        }
    }
}
@@ -267,6 +432,7 @@
    private let scrollView = AppGridScrollView()
    private let collectionView = NSCollectionView()
    private let gridLayout = AppGridContainerCollectionLayout()
    private let usageTipsView = AppGridUsageTipsNSView()
    private weak var coordinator: AppGridCollectionView.Coordinator?
    private var scrollObserver: NSObjectProtocol?
    private var lastLayoutSize: NSSize = .zero
@@ -291,12 +457,14 @@
            NotificationCenter.default.removeObserver(scrollObserver)
        }
        scrollUnfreezeWorkItem?.cancel()
        coordinator?.cancelAppIconDrag()
        AppDragCoordinator.shared.unregisterEmptyDropTarget(id: emptyDropTargetID)
    }
    func configure(coordinator: AppGridCollectionView.Coordinator) {
        self.coordinator = coordinator
        gridLayout.coordinator = coordinator
        usageTipsView.coordinator = coordinator
        collectionView.dataSource = coordinator
        collectionView.delegate = coordinator
    }
@@ -323,11 +491,14 @@
                }
            }
        }
        usageTipsView.applyCoordinatorState()
        positionUsageTipsView()
    }
    override func layout() {
        super.layout()
        scrollView.frame = bounds
        positionUsageTipsView()
        if lastLayoutSize != bounds.size {
            lastLayoutSize = bounds.size
            gridLayout.invalidateLayout()
@@ -337,6 +508,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 +520,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)
        }
@@ -371,6 +552,8 @@
        scrollView.borderType = .noBorder
        scrollView.documentView = collectionView
        addSubview(scrollView)
        usageTipsView.isHidden = true
        addSubview(usageTipsView)
        scrollView.contentView.postsBoundsChangedNotifications = true
        scrollObserver = NotificationCenter.default.addObserver(
@@ -385,7 +568,30 @@
        }
    }
    private func positionUsageTipsView() {
        guard let coordinator,
              coordinator.usageTipsVisible
        else {
            usageTipsView.isHidden = true
            usageTipsView.clearHoverState()
            usageTipsView.frame = .zero
            return
        }
        let inset = min(AppGridUsageTipsMetrics.horizontalInset, max(0, bounds.width / 4))
        let width = max(1, bounds.width - inset * 2)
        let height = AppGridUsageTipsMetrics.barHeight
        let y = max(0, bounds.height - AppGridUsageTipsMetrics.bottomMargin - height)
        usageTipsView.isHidden = false
        usageTipsView.frame = NSRect(x: inset, y: y, width: width, height: height)
    }
    private func handleScrollActivity() {
        if AppDragCoordinator.shared.hasActiveDrag {
            AppDragCoordinator.shared.cancelDrag()
        }
        coordinator?.cancelAppIconDrag()
        if !scrollActivityIsActive {
            scrollActivityIsActive = true
            if coordinator?.setScrollBubbleDisabled(true) == true {
@@ -413,6 +619,10 @@
        }
    }
    fileprivate func refreshVisibleRuntimeStateForUsageTips() {
        refreshVisibleRuntimeState()
    }
    private func replayPointerHover() {
        guard let window else { return }
        let windowPoint = window.convertPoint(fromScreen: NSEvent.mouseLocation)
@@ -432,6 +642,334 @@
            lastReportedBoundsOrigin = origin
        }
        return didScroll
    }
}
private final class AppGridUsageTipsNSView: NSView {
    weak var coordinator: AppGridCollectionView.Coordinator?
    private let titleBackgroundView = NSView()
    private let titleLabel = NSTextField(labelWithString: "")
    private let detailScrollView = NSScrollView()
    private let detailLabel = NSTextField(labelWithString: "")
    private let controlsView = NSView()
    private let previousButton = AppGridUsageTipIconButton(systemImage: "chevron.left")
    private let nextButton = AppGridUsageTipIconButton(systemImage: "chevron.right")
    private let dotsView = AppGridUsageTipDotsView()
    private var trackingAreaRef: NSTrackingArea?
    private var isPointerInside = false
    override var isFlipped: Bool { true }
    override var acceptsFirstResponder: Bool { false }
    override init(frame frameRect: NSRect) {
        super.init(frame: frameRect)
        setup()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setup()
    }
    override func viewDidMoveToWindow() {
        super.viewDidMoveToWindow()
        if window == nil {
            clearHoverState()
        }
    }
    override func updateTrackingAreas() {
        super.updateTrackingAreas()
        if let trackingAreaRef {
            removeTrackingArea(trackingAreaRef)
        }
        let area = NSTrackingArea(
            rect: .zero,
            options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect],
            owner: self,
            userInfo: nil
        )
        addTrackingArea(area)
        trackingAreaRef = area
    }
    override func hitTest(_ point: NSPoint) -> NSView? {
        guard !isHidden, bounds.contains(point) else { return nil }
        return super.hitTest(point) ?? self
    }
    override func mouseEntered(with event: NSEvent) {
        setPointerInside(true)
    }
    override func mouseExited(with event: NSEvent) {
        setPointerInside(false)
    }
    override func mouseDown(with event: NSEvent) {
        window?.makeKeyAndOrderFront(nil)
    }
    override func mouseUp(with event: NSEvent) {}
    override func rightMouseDown(with event: NSEvent) {}
    override func rightMouseUp(with event: NSEvent) {}
    override func scrollWheel(with event: NSEvent) {
        detailScrollView.scrollWheel(with: event)
    }
    override func layout() {
        super.layout()
        let titleWidth = min(320, max(240, bounds.width * 0.24))
        let controlsWidth: CGFloat = 120
        let detailWidth = max(1, bounds.width - titleWidth - controlsWidth)
        titleBackgroundView.frame = NSRect(x: 0, y: 0, width: titleWidth, height: bounds.height)
        titleLabel.frame = titleBackgroundView.bounds.insetBy(dx: 18, dy: 0)
        detailScrollView.frame = NSRect(x: titleWidth, y: 0, width: detailWidth, height: bounds.height)
        controlsView.frame = NSRect(x: titleWidth + detailWidth, y: 0, width: controlsWidth, height: bounds.height)
        let buttonSize: CGFloat = 34
        let buttonY: CGFloat = 9
        previousButton.frame = NSRect(x: 22, y: buttonY, width: buttonSize, height: buttonSize)
        nextButton.frame = NSRect(x: 64, y: buttonY, width: buttonSize, height: buttonSize)
        dotsView.frame = NSRect(x: 18, y: 48, width: controlsWidth - 36, height: 10)
        layoutDetailLabel()
    }
    func applyCoordinatorState() {
        guard let coordinator,
              coordinator.usageTipsVisible,
              !coordinator.usageTips.isEmpty
        else {
            isHidden = true
            clearHoverState()
            return
        }
        isHidden = false
        let safeIndex = min(max(0, coordinator.selectedUsageTipIndex), coordinator.usageTips.count - 1)
        let tip = coordinator.usageTips[safeIndex]
        titleLabel.stringValue = tr(tip.titleKey)
        detailLabel.stringValue = tr(tip.detailKey)
        previousButton.accessibilityLabel = tr("usageTips.previous")
        nextButton.accessibilityLabel = tr("usageTips.next")
        dotsView.configure(count: coordinator.usageTips.count, selectedIndex: safeIndex)
        updateColors()
        layoutDetailLabel()
    }
    func clearHoverState() {
        guard isPointerInside else { return }
        setPointerInside(false)
    }
    private func setup() {
        wantsLayer = true
        layer?.cornerRadius = 8
        layer?.masksToBounds = true
        titleBackgroundView.wantsLayer = true
        controlsView.wantsLayer = true
        titleLabel.isEditable = false
        titleLabel.isSelectable = false
        titleLabel.drawsBackground = false
        titleLabel.isBordered = false
        titleLabel.lineBreakMode = .byTruncatingTail
        titleLabel.maximumNumberOfLines = 1
        titleLabel.font = NSFont.systemFont(ofSize: 24, weight: .bold)
        detailLabel.isEditable = false
        detailLabel.isSelectable = false
        detailLabel.drawsBackground = false
        detailLabel.isBordered = false
        detailLabel.lineBreakMode = .byClipping
        detailLabel.maximumNumberOfLines = 1
        detailLabel.font = NSFont.systemFont(ofSize: 24, weight: .bold)
        detailScrollView.drawsBackground = false
        detailScrollView.hasVerticalScroller = false
        detailScrollView.hasHorizontalScroller = true
        detailScrollView.autohidesScrollers = true
        detailScrollView.borderType = .noBorder
        detailScrollView.documentView = detailLabel
        previousButton.action = { [weak self] in self?.selectUsageTip(offset: -1) }
        nextButton.action = { [weak self] in self?.selectUsageTip(offset: 1) }
        addSubview(titleBackgroundView)
        titleBackgroundView.addSubview(titleLabel)
        addSubview(detailScrollView)
        addSubview(controlsView)
        controlsView.addSubview(previousButton)
        controlsView.addSubview(nextButton)
        controlsView.addSubview(dotsView)
        setAccessibilityRole(.group)
        updateColors()
    }
    private func selectUsageTip(offset: Int) {
        coordinator?.selectUsageTip(offset: offset)
        applyCoordinatorState()
    }
    private func setPointerInside(_ inside: Bool) {
        guard isPointerInside != inside else { return }
        isPointerInside = inside
        if coordinator?.setUsageTipsBubbleDisabled(inside) == true {
            enclosingHostView?.refreshVisibleRuntimeStateForUsageTips()
        }
        coordinator?.onUsageTipsHoverChange(inside)
    }
    private var enclosingHostView: AppGridCollectionHostView? {
        var current = superview
        while let view = current {
            if let host = view as? AppGridCollectionHostView {
                return host
            }
            current = view.superview
        }
        return nil
    }
    private func layoutDetailLabel() {
        let textWidth = detailLabel.attributedStringValue.size().width
        let width = max(detailScrollView.contentView.bounds.width, ceil(textWidth) + 36)
        detailLabel.frame = NSRect(x: 18, y: 0, width: width, height: bounds.height)
    }
    private func updateColors() {
        layer?.backgroundColor = NSColor.black.withAlphaComponent(0.88).cgColor
        layer?.borderColor = NSColor.labelColor.withAlphaComponent(0.18).cgColor
        layer?.borderWidth = 1
        titleBackgroundView.layer?.backgroundColor = NSColor.controlBackgroundColor.withAlphaComponent(0.96).cgColor
        controlsView.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.88).cgColor
        titleLabel.textColor = .labelColor
        detailLabel.textColor = NSColor.white.withAlphaComponent(0.96)
    }
}
private final class AppGridUsageTipIconButton: NSView {
    var action: (() -> Void)?
    var accessibilityLabel: String = "" {
        didSet {
            setAccessibilityLabel(accessibilityLabel)
        }
    }
    private let imageView = NSImageView()
    private var trackingAreaRef: NSTrackingArea?
    private var isHovered = false
    override var isFlipped: Bool { true }
    override var acceptsFirstResponder: Bool { false }
    init(systemImage: String) {
        super.init(frame: .zero)
        setup(systemImage: systemImage)
    }
    required init?(coder: NSCoder) { fatalError() }
    override func layout() {
        super.layout()
        let size: CGFloat = 24
        imageView.frame = NSRect(
            x: (bounds.width - size) / 2,
            y: (bounds.height - size) / 2,
            width: size,
            height: size
        )
    }
    override func updateTrackingAreas() {
        super.updateTrackingAreas()
        if let trackingAreaRef {
            removeTrackingArea(trackingAreaRef)
        }
        let area = NSTrackingArea(
            rect: .zero,
            options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect],
            owner: self,
            userInfo: nil
        )
        addTrackingArea(area)
        trackingAreaRef = area
    }
    override func hitTest(_ point: NSPoint) -> NSView? {
        bounds.contains(point) ? self : nil
    }
    override func mouseEntered(with event: NSEvent) {
        isHovered = true
        needsDisplay = true
    }
    override func mouseExited(with event: NSEvent) {
        isHovered = false
        needsDisplay = true
    }
    override func mouseDown(with event: NSEvent) {
        action?()
    }
    override func draw(_ dirtyRect: NSRect) {
        guard isHovered else { return }
        NSColor.white.withAlphaComponent(0.12).setFill()
        NSBezierPath(roundedRect: bounds.insetBy(dx: 1, dy: 1), xRadius: 7, yRadius: 7).fill()
    }
    private func setup(systemImage: String) {
        wantsLayer = true
        let config = NSImage.SymbolConfiguration(pointSize: 24, weight: .bold)
        imageView.image = NSImage(
            systemSymbolName: systemImage,
            accessibilityDescription: nil
        )?.withSymbolConfiguration(config)
        imageView.imageScaling = .scaleProportionallyDown
        imageView.contentTintColor = .white
        addSubview(imageView)
        setAccessibilityRole(.button)
    }
}
private final class AppGridUsageTipDotsView: NSView {
    private var count = 0
    private var selectedIndex = 0
    override var isFlipped: Bool { true }
    func configure(count: Int, selectedIndex: Int) {
        self.count = count
        self.selectedIndex = selectedIndex
        needsDisplay = true
    }
    override func draw(_ dirtyRect: NSRect) {
        guard count > 0 else { return }
        let dotSize: CGFloat = 5
        let spacing: CGFloat = 5
        let totalWidth = CGFloat(count) * dotSize + CGFloat(max(0, count - 1)) * spacing
        var x = (bounds.width - totalWidth) / 2
        let y = (bounds.height - dotSize) / 2
        for index in 0..<count {
            let color = index == selectedIndex
                ? NSColor.controlAccentColor
                : NSColor.white.withAlphaComponent(0.58)
            color.setFill()
            NSBezierPath(
                ovalIn: NSRect(x: x, y: y, width: dotSize, height: dotSize)
            ).fill()
            x += dotSize + spacing
        }
    }
}
@@ -461,7 +999,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 +1046,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 +1108,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 +1119,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 +1143,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 +1154,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 +1185,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 +1322,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 +1406,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 +1442,7 @@
        AppDragCoordinator.shared.unregister(id: dropTargetID)
        group = nil
        coordinator = nil
        reorderInsertionIndex = nil
        isMouseInside = false
        isHovered = false
        layer?.shadowOpacity = 0
@@ -982,19 +1547,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 +1628,7 @@
            iconView.configure(
                app: app,
                sourceTag: group.name,
                sourceContainerID: group.containerID,
                iconSize: coordinator.iconSize,
                showName: coordinator.showNames,
                coordinator: coordinator
@@ -1018,6 +1636,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 +1854,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 +1879,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 +1906,7 @@
        mouseDownEvent = nil
        didStartDrag = false
        isLongPressActive = false
        sourceContainerID = ""
        longPressWorkItem = nil
    }
@@ -1293,6 +2009,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 +2031,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 +2077,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) {