Ariver
2026-06-19 39e759d015af5d82f387b57a8d137bdeb844d430
src/Apptag/AppGridCollectionView.swift
@@ -1,5 +1,6 @@
import SwiftUI
import AppKit
import Carbon.HIToolbox
fileprivate enum AppGridCollectionDisplayMode: Equatable {
    case flat
@@ -27,6 +28,21 @@
    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 = 136
    static let reservedHeight: CGFloat = 176
    static let bottomMargin: CGFloat = 20
    static let horizontalInset: CGFloat = 24
    static let minWidth: CGFloat = 640
}
struct AppGridCollectionView: NSViewRepresentable {
@@ -38,6 +54,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
@@ -50,6 +70,7 @@
    let onGroupActivate: (String) -> Void
    let onScrollActivity: () -> Void
    let onDragModeChange: (Bool) -> Void
    let onUsageTipsHoverChange: (Bool) -> Void
    func makeCoordinator() -> Coordinator {
        Coordinator()
@@ -62,6 +83,7 @@
    }
    func updateNSView(_ view: AppGridCollectionHostView, context: Context) {
        let selectedUsageTipIndexBinding = $selectedUsageTipIndex
        context.coordinator.update(
            groups: groups,
            tagColors: tagColors,
@@ -71,6 +93,10 @@
            bubbleDisabled: bubbleDisabled,
            showUncommonAppBubbles: showUncommonAppBubbles,
            highlightedGroupName: highlightedGroupName,
            bottomContentPadding: bottomContentPadding,
            usageTipsVisible: usageTipsVisible,
            usageTips: usageTips,
            selectedUsageTipIndex: selectedUsageTipIndex,
            contentRevision: contentRevision,
            scrollTargetID: scrollTargetID,
            scrollRequestToken: scrollRequestToken,
@@ -82,7 +108,9 @@
            onReorderApps: onReorderApps,
            onGroupActivate: onGroupActivate,
            onScrollActivity: onScrollActivity,
            onDragModeChange: onDragModeChange
            onDragModeChange: onDragModeChange,
            onUsageTipIndexChange: { selectedUsageTipIndexBinding.wrappedValue = $0 },
            onUsageTipsHoverChange: onUsageTipsHoverChange
        )
        view.applyCoordinatorUpdate()
    }
@@ -97,6 +125,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
@@ -113,6 +146,8 @@
        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 = ""
@@ -129,6 +164,10 @@
            bubbleDisabled: Bool,
            showUncommonAppBubbles: Bool,
            highlightedGroupName: String?,
            bottomContentPadding: CGFloat,
            usageTipsVisible: Bool,
            usageTips: [AppGridUsageTip],
            selectedUsageTipIndex: Int,
            contentRevision: Int,
            scrollTargetID: String?,
            scrollRequestToken: Int,
@@ -140,7 +179,9 @@
            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
@@ -150,6 +191,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
@@ -162,6 +207,11 @@
            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,
@@ -169,6 +219,7 @@
                iconSize: iconSize,
                showNames: showNames,
                showUncommonAppBubbles: showUncommonAppBubbles,
                bottomContentPadding: self.bottomContentPadding,
                contentRevision: contentRevision
            )
            if nextSignature != contentSignature {
@@ -225,6 +276,9 @@
            if scrollBubbleDisabled {
                reasons.insert(.scroll)
            }
            if usageTipsBubbleDisabled {
                reasons.insert(.usageTipsHover)
            }
            return reasons
        }
@@ -237,6 +291,22 @@
            guard scrollBubbleDisabled != disabled else { return false }
            scrollBubbleDisabled = disabled
            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) {
@@ -282,10 +352,16 @@
                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? {
@@ -308,7 +384,8 @@
                  let lastReorderScreenFrame
            else { return false }
            return lastReorderScreenFrame.insetBy(dx: -44, dy: -44).contains(screenPoint)
            let guardOutset = AppGridCollectionMetrics.reorderEmptyDropCancelOutset
            return lastReorderScreenFrame.insetBy(dx: -guardOutset, dy: -guardOutset).contains(screenPoint)
        }
        private static func signature(
@@ -317,6 +394,7 @@
            iconSize: CGFloat,
            showNames: Bool,
            showUncommonAppBubbles: Bool,
            bottomContentPadding: CGFloat,
            contentRevision: Int
        ) -> String {
            let colorPart = tagColors
@@ -328,9 +406,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)
        }
    }
}
@@ -350,12 +434,16 @@
    private let scrollView = AppGridScrollView()
    private let collectionView = NSCollectionView()
    private let gridLayout = AppGridContainerCollectionLayout()
    private let usageTipsShieldView = AppGridUsageTipsShieldView()
    private let usageTipsView = AppGridUsageTipsNSView()
    private weak var coordinator: AppGridCollectionView.Coordinator?
    private var scrollObserver: NSObjectProtocol?
    private var lastLayoutSize: NSSize = .zero
    private var lastReportedBoundsOrigin: NSPoint?
    private var scrollUnfreezeWorkItem: DispatchWorkItem?
    private var scrollActivityIsActive = false
    private var usageTipsEventRegionIsClaimed = false
    private var usageTipsMouseMonitor: Any?
    override var isFlipped: Bool { true }
@@ -373,6 +461,7 @@
        if let scrollObserver {
            NotificationCenter.default.removeObserver(scrollObserver)
        }
        removeUsageTipsMouseMonitor()
        scrollUnfreezeWorkItem?.cancel()
        coordinator?.cancelAppIconDrag()
        AppDragCoordinator.shared.unregisterEmptyDropTarget(id: emptyDropTargetID)
@@ -381,6 +470,7 @@
    func configure(coordinator: AppGridCollectionView.Coordinator) {
        self.coordinator = coordinator
        gridLayout.coordinator = coordinator
        usageTipsView.coordinator = coordinator
        collectionView.dataSource = coordinator
        collectionView.delegate = coordinator
    }
@@ -407,11 +497,14 @@
                }
            }
        }
        usageTipsView.applyCoordinatorState()
        positionUsageTipsView()
    }
    override func layout() {
        super.layout()
        scrollView.frame = bounds
        positionUsageTipsView()
        if lastLayoutSize != bounds.size {
            lastLayoutSize = bounds.size
            gridLayout.invalidateLayout()
@@ -421,12 +514,23 @@
    override func viewDidMoveToWindow() {
        super.viewDidMoveToWindow()
        if window == nil {
            removeUsageTipsMouseMonitor()
            AppDragCoordinator.shared.cancelDrag()
            coordinator?.cancelAppIconDrag()
            AppDragCoordinator.shared.unregisterEmptyDropTarget(id: emptyDropTargetID)
        } else {
            installUsageTipsMouseMonitor()
            AppDragCoordinator.shared.registerEmptyDropTarget(id: emptyDropTargetID, view: self)
        }
    }
    override func hitTest(_ point: NSPoint) -> NSView? {
        if !usageTipsView.isHidden,
           usageTipsView.frame.contains(point) {
            let tipsPoint = usageTipsView.convert(point, from: self)
            return usageTipsView.hitTest(tipsPoint) ?? usageTipsView
        }
        return super.hitTest(point)
    }
    func performEmptyDrop(path: String, source: String, screenPoint: NSPoint, copy: Bool) {
@@ -465,6 +569,16 @@
        scrollView.borderType = .noBorder
        scrollView.documentView = collectionView
        addSubview(scrollView)
        usageTipsShieldView.isHidden = true
        usageTipsShieldView.onClaim = { [weak self] in
            self?.claimUsageTipsEventRegion()
        }
        usageTipsShieldView.onRelease = { [weak self] in
            self?.releaseUsageTipsEventRegion()
        }
        usageTipsView.isHidden = true
        addSubview(usageTipsShieldView, positioned: .above, relativeTo: scrollView)
        addSubview(usageTipsView, positioned: .above, relativeTo: usageTipsShieldView)
        scrollView.contentView.postsBoundsChangedNotifications = true
        scrollObserver = NotificationCenter.default.addObserver(
@@ -477,6 +591,70 @@
            else { return }
            self.handleScrollActivity()
        }
    }
    private func positionUsageTipsView() {
        guard let coordinator,
              coordinator.usageTipsVisible
        else {
            usageTipsView.isHidden = true
            usageTipsShieldView.isHidden = true
            usageTipsView.clearHoverState()
            releaseUsageTipsEventRegion()
            usageTipsView.frame = .zero
            usageTipsShieldView.frame = .zero
            return
        }
        let inset = min(AppGridUsageTipsMetrics.horizontalInset, max(0, bounds.width / 4))
        let availableWidth = max(1, bounds.width - inset * 2)
        let width = usageTipsView.preferredWidth(maxAvailableWidth: availableWidth)
        let height = AppGridUsageTipsMetrics.barHeight
        usageTipsShieldView.isHidden = false
        usageTipsShieldView.frame = usageTipsEventRegion()
        usageTipsView.configureVisualLayout(
            width: width,
            height: height,
            bottomMargin: AppGridUsageTipsMetrics.bottomMargin
        )
        usageTipsView.isHidden = false
        usageTipsView.frame = usageTipsEventRegion()
    }
    fileprivate func relayoutUsageTipsAfterContentChange() {
        positionUsageTipsView()
        usageTipsView.needsLayout = true
    }
    private func usageTipsEventRegion() -> NSRect {
        guard let coordinator,
              coordinator.usageTipsVisible
        else { return .zero }
        let height = min(bounds.height, AppGridUsageTipsMetrics.reservedHeight)
        return NSRect(
            x: 0,
            y: max(0, bounds.height - height),
            width: bounds.width,
            height: height
        )
    }
    private func claimUsageTipsEventRegion() {
        usageTipsEventRegionIsClaimed = true
        window?.makeFirstResponder(usageTipsView)
        if coordinator?.setUsageTipsBubbleDisabled(true) == true {
            refreshVisibleRuntimeState()
        }
        coordinator?.onUsageTipsHoverChange(true)
    }
    private func releaseUsageTipsEventRegion() {
        guard usageTipsEventRegionIsClaimed else { return }
        usageTipsEventRegionIsClaimed = false
        if coordinator?.setUsageTipsBubbleDisabled(false) == true {
            refreshVisibleRuntimeState()
        }
        coordinator?.onUsageTipsHoverChange(false)
    }
    private func handleScrollActivity() {
@@ -512,6 +690,62 @@
        }
    }
    fileprivate func refreshVisibleRuntimeStateForUsageTips() {
        refreshVisibleRuntimeState()
    }
    func handleUsageTipsMouseDown(_ event: NSEvent) -> Bool {
        handleUsageTipsMouseEvent(event, triggerButtons: true)
    }
    private func handleUsageTipsMouseEvent(_ event: NSEvent, triggerButtons: Bool) -> Bool {
        let point = usageTipsHostPoint(for: event)
        guard !usageTipsView.isHidden,
              usageTipsView.frame.contains(point)
        else { return false }
        return usageTipsView.handleMouseEventFromHost(event, triggerButtons: triggerButtons)
    }
    private func installUsageTipsMouseMonitor() {
        guard usageTipsMouseMonitor == nil else { return }
        usageTipsMouseMonitor = NSEvent.addLocalMonitorForEvents(
            matching: [
                .leftMouseDown,
                .leftMouseUp,
                .rightMouseDown,
                .rightMouseUp,
                .otherMouseDown,
                .otherMouseUp
            ]
        ) { [weak self] event in
            guard let self,
                  self.window != nil,
                  self.handleUsageTipsMouseEvent(
                    event,
                    triggerButtons: event.type == .leftMouseDown
                  )
            else { return event }
            return nil
        }
    }
    private func usageTipsHostPoint(for event: NSEvent) -> NSPoint {
        if event.window === window {
            return convert(event.locationInWindow, from: nil)
        }
        guard let window else { return .zero }
        let windowPoint = window.convertPoint(fromScreen: NSEvent.mouseLocation)
        return convert(windowPoint, from: nil)
    }
    private func removeUsageTipsMouseMonitor() {
        if let usageTipsMouseMonitor {
            NSEvent.removeMonitor(usageTipsMouseMonitor)
            self.usageTipsMouseMonitor = nil
        }
    }
    private func replayPointerHover() {
        guard let window else { return }
        let windowPoint = window.convertPoint(fromScreen: NSEvent.mouseLocation)
@@ -531,6 +765,658 @@
            lastReportedBoundsOrigin = origin
        }
        return didScroll
    }
}
private final class AppGridUsageTipsShieldView: NSView {
    var onClaim: (() -> Void)?
    var onRelease: (() -> Void)?
    private var trackingAreaRef: NSTrackingArea?
    override var isFlipped: Bool { true }
    override var acceptsFirstResponder: Bool { false }
    override var mouseDownCanMoveWindow: Bool { false }
    override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
        true
    }
    override func updateTrackingAreas() {
        super.updateTrackingAreas()
        if let trackingAreaRef {
            removeTrackingArea(trackingAreaRef)
        }
        let area = NSTrackingArea(
            rect: .zero,
            options: [.mouseEnteredAndExited, .mouseMoved, .activeAlways, .inVisibleRect],
            owner: self,
            userInfo: nil
        )
        addTrackingArea(area)
        trackingAreaRef = area
    }
    override func hitTest(_ point: NSPoint) -> NSView? {
        guard !isHidden, bounds.contains(point) else { return nil }
        onClaim?()
        return self
    }
    override func mouseEntered(with event: NSEvent) { onClaim?() }
    override func mouseMoved(with event: NSEvent) { onClaim?() }
    override func mouseDragged(with event: NSEvent) { onClaim?() }
    override func mouseExited(with event: NSEvent) { onRelease?() }
    override func mouseDown(with event: NSEvent) {
        Diagnostics.log("usageTips.shield.mouseDown")
        onClaim?()
    }
    override func mouseUp(with event: NSEvent) {}
    override func rightMouseDown(with event: NSEvent) { onClaim?() }
    override func rightMouseUp(with event: NSEvent) {}
    override func otherMouseDown(with event: NSEvent) { onClaim?() }
    override func otherMouseUp(with event: NSEvent) {}
    override func scrollWheel(with event: NSEvent) { onClaim?() }
}
private final class AppGridUsageTipsNSView: NSView {
    weak var coordinator: AppGridCollectionView.Coordinator?
    private let backgroundView = NSVisualEffectView()
    private let iconView = AppGridDecorativeImageView()
    private let titleLabel = NSTextField(labelWithString: "")
    private let detailScrollView = NSScrollView()
    private let detailLabel = NSTextField(labelWithString: "")
    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
    private var visualWidth: CGFloat = AppGridUsageTipsMetrics.minWidth
    private var visualHeight: CGFloat = AppGridUsageTipsMetrics.barHeight
    private var visualBottomMargin: CGFloat = AppGridUsageTipsMetrics.bottomMargin
    private let titleFont = NSFont.systemFont(ofSize: 24, weight: .semibold)
    private let detailFont = NSFont.systemFont(ofSize: 24, weight: .regular)
    override var isFlipped: Bool { true }
    override var acceptsFirstResponder: Bool { true }
    override var mouseDownCanMoveWindow: Bool { false }
    override init(frame frameRect: NSRect) {
        super.init(frame: frameRect)
        setup()
    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setup()
    }
    override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
        true
    }
    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, .mouseMoved, .activeAlways, .inVisibleRect],
            owner: self,
            userInfo: nil
        )
        addTrackingArea(area)
        trackingAreaRef = area
    }
    override func hitTest(_ point: NSPoint) -> NSView? {
        guard !isHidden, bounds.contains(point) else { return nil }
        setPointerInside(true)
        let hitView = super.hitTest(point)
        if hitView == nil || hitView === backgroundView || hitView?.isDescendant(of: backgroundView) == true {
            return self
        }
        return hitView
    }
    override func mouseEntered(with event: NSEvent) {
        claimInteractionFocus()
    }
    override func mouseMoved(with event: NSEvent) {
        claimInteractionFocus()
    }
    override func mouseExited(with event: NSEvent) {
        setPointerInside(false)
    }
    override func mouseDown(with event: NSEvent) {
        Diagnostics.log("usageTips.hud.mouseDown")
        if routeButtonClickIfNeeded(event) {
            return
        }
        claimInteractionFocus()
    }
    override func mouseDragged(with event: NSEvent) {
        claimInteractionFocus()
    }
    override func mouseUp(with event: NSEvent) {}
    override func rightMouseDown(with event: NSEvent) { claimInteractionFocus() }
    override func rightMouseUp(with event: NSEvent) {}
    override func otherMouseDown(with event: NSEvent) { claimInteractionFocus() }
    override func otherMouseUp(with event: NSEvent) {}
    override func keyDown(with event: NSEvent) {
        switch Int(event.keyCode) {
        case kVK_LeftArrow:
            selectUsageTip(offset: -1)
        case kVK_RightArrow:
            selectUsageTip(offset: 1)
        default:
            super.keyDown(with: event)
        }
    }
    override func scrollWheel(with event: NSEvent) {
        detailScrollView.scrollWheel(with: event)
    }
    override func layout() {
        super.layout()
        let visualFrame = currentVisualFrame()
        backgroundView.frame = visualFrame
        let paddingLeft: CGFloat = 22
        let paddingRight: CGFloat = 16
        let iconSize: CGFloat = 24
        let iconTextGap: CGFloat = 10
        let controlZoneWidth: CGFloat = 176
        let buttonGap: CGFloat = 6
        let buttonSize: CGFloat = 48
        let buttonDotsGap: CGFloat = 6
        let dotsHeight: CGFloat = 10
        let dotsWidth = dotsView.preferredWidth
        let titleX = visualFrame.minX + paddingLeft + iconSize + iconTextGap
        let buttonsWidth = buttonSize * 2 + buttonGap
        let buttonsGroupMinX = visualFrame.maxX - paddingRight - buttonsWidth
        let textRight = visualFrame.maxX - controlZoneWidth
        let availableTextWidth = max(1, textRight - titleX)
        let titleWidth = availableTextWidth
        let titleHeight: CGFloat = 32
        let detailHeight: CGFloat = 68
        let lineGap: CGFloat = 8
        let textBlockHeight = titleHeight + lineGap + detailHeight
        let textBlockY = visualFrame.minY + max(0, (visualFrame.height - textBlockHeight) / 2)
        iconView.frame = NSRect(
            x: visualFrame.minX + paddingLeft,
            y: visualFrame.midY - iconSize / 2,
            width: iconSize,
            height: iconSize
        )
        titleLabel.frame = NSRect(
            x: titleX,
            y: textBlockY,
            width: titleWidth,
            height: titleHeight
        )
        let controlGroupHeight = buttonSize + buttonDotsGap + dotsHeight
        let buttonY = visualFrame.minY + max(0, (visualFrame.height - controlGroupHeight) / 2)
        nextButton.frame = NSRect(
            x: buttonsGroupMinX + buttonSize + buttonGap,
            y: buttonY,
            width: buttonSize,
            height: buttonSize
        )
        previousButton.frame = NSRect(
            x: buttonsGroupMinX,
            y: buttonY,
            width: buttonSize,
            height: buttonSize
        )
        dotsView.frame = NSRect(
            x: buttonsGroupMinX + (buttonsWidth - dotsWidth) / 2,
            y: previousButton.frame.maxY + buttonDotsGap,
            width: dotsWidth,
            height: dotsHeight
        )
        let detailX = titleX
        let detailRight = textRight
        detailScrollView.frame = NSRect(
            x: detailX,
            y: titleLabel.frame.maxY + lineGap,
            width: max(1, detailRight - detailX),
            height: detailHeight
        )
        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 = formattedTipDetail(tr(tip.detailKey))
        previousButton.buttonAccessibilityLabel = tr("usageTips.previous")
        nextButton.buttonAccessibilityLabel = tr("usageTips.next")
        dotsView.configure(count: coordinator.usageTips.count, selectedIndex: safeIndex)
        updateColors()
        needsLayout = true
        enclosingHostView?.relayoutUsageTipsAfterContentChange()
    }
    func clearHoverState() {
        guard isPointerInside else { return }
        setPointerInside(false)
    }
    func configureVisualLayout(width: CGFloat, height: CGFloat, bottomMargin: CGFloat) {
        visualWidth = max(1, width)
        visualHeight = max(1, height)
        visualBottomMargin = max(0, bottomMargin)
        needsLayout = true
    }
    func preferredWidth(maxAvailableWidth: CGFloat) -> CGFloat {
        let padding: CGFloat = 22 + 16
        let fixedWidth: CGFloat = 24 + 10 + 24 + 48 + 6 + 48
        let titleWidth = ceil(titleLabel.attributedStringValue.size().width)
        let detailWidth = measuredDetailLineWidth()
        let contentWidth = padding + fixedWidth + max(titleWidth, detailWidth)
        let preferredWidth = max(AppGridUsageTipsMetrics.minWidth, contentWidth)
        return min(maxAvailableWidth, preferredWidth)
    }
    private func setup() {
        wantsLayer = true
        layer?.shadowColor = NSColor.black.cgColor
        layer?.shadowOpacity = 0.14
        layer?.shadowRadius = 12
        layer?.shadowOffset = NSSize(width: 0, height: -2)
        backgroundView.material = .popover
        backgroundView.blendingMode = .withinWindow
        backgroundView.state = .active
        backgroundView.wantsLayer = true
        backgroundView.layer?.cornerRadius = 10
        backgroundView.layer?.masksToBounds = true
        backgroundView.layer?.borderWidth = 1
        addSubview(backgroundView)
        let iconConfig = NSImage.SymbolConfiguration(pointSize: 24, weight: .semibold)
        iconView.image = NSImage(
            systemSymbolName: "lightbulb.fill",
            accessibilityDescription: nil
        )?.withSymbolConfiguration(iconConfig)
        iconView.imageScaling = .scaleProportionallyDown
        addSubview(iconView)
        configureLabel(titleLabel, font: titleFont, lineBreakMode: .byClipping)
        configureDetailLabel()
        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(titleLabel)
        addSubview(detailScrollView)
        addSubview(dotsView)
        addSubview(previousButton)
        addSubview(nextButton)
        setAccessibilityRole(.group)
        updateColors()
    }
    private func configureLabel(_ label: NSTextField, font: NSFont, lineBreakMode: NSLineBreakMode) {
        label.cell = AppGridCenteredTextFieldCell(textCell: "")
        label.isEditable = false
        label.isSelectable = false
        label.drawsBackground = false
        label.isBordered = false
        label.lineBreakMode = lineBreakMode
        label.maximumNumberOfLines = 1
        label.alignment = .center
        label.font = font
    }
    private func configureDetailLabel() {
        detailLabel.cell = NSTextFieldCell(textCell: "")
        detailLabel.isEditable = false
        detailLabel.isSelectable = false
        detailLabel.drawsBackground = false
        detailLabel.isBordered = false
        detailLabel.lineBreakMode = .byWordWrapping
        detailLabel.maximumNumberOfLines = 2
        detailLabel.font = detailFont
        detailLabel.cell?.usesSingleLineMode = false
        detailLabel.cell?.wraps = true
    }
    private func claimInteractionFocus() {
        setPointerInside(true)
        window?.makeFirstResponder(self)
    }
    func handleMouseEventFromHost(_ event: NSEvent, triggerButtons: Bool) -> Bool {
        let point = usageTipsPoint(for: event)
        guard bounds.contains(point) else { return false }
        if triggerButtons, routeButtonClickIfNeeded(event) {
            return true
        }
        claimInteractionFocus()
        return true
    }
    private func selectUsageTip(offset: Int) {
        coordinator?.selectUsageTip(offset: offset)
        applyCoordinatorState()
    }
    private func routeButtonClickIfNeeded(_ event: NSEvent) -> Bool {
        let point = usageTipsPoint(for: event)
        let hitOutset: CGFloat = 24
        if nextButton.frame.insetBy(dx: -hitOutset, dy: -hitOutset).contains(point) {
            Diagnostics.log("usageTips.hud.routedNext")
            selectUsageTip(offset: 1)
            return true
        }
        if previousButton.frame.insetBy(dx: -hitOutset, dy: -hitOutset).contains(point) {
            Diagnostics.log("usageTips.hud.routedPrevious")
            selectUsageTip(offset: -1)
            return true
        }
        return false
    }
    private func usageTipsPoint(for event: NSEvent) -> NSPoint {
        if event.window === window {
            return convert(event.locationInWindow, from: nil)
        }
        guard let window else { return .zero }
        let windowPoint = window.convertPoint(fromScreen: NSEvent.mouseLocation)
        return convert(windowPoint, from: nil)
    }
    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 width = max(1, detailScrollView.contentView.bounds.width)
        detailLabel.preferredMaxLayoutWidth = width
        detailLabel.frame = NSRect(x: 0, y: 0, width: width, height: detailScrollView.bounds.height)
    }
    private func measuredDetailLineWidth() -> CGFloat {
        let attributes: [NSAttributedString.Key: Any] = [.font: detailFont]
        let lineWidths = detailLabel.stringValue
            .components(separatedBy: .newlines)
            .map { NSAttributedString(string: $0, attributes: attributes).size().width }
        return ceil(lineWidths.max() ?? detailLabel.attributedStringValue.size().width)
    }
    private func currentVisualFrame() -> NSRect {
        let width = min(bounds.width, visualWidth)
        let height = min(bounds.height, visualHeight)
        return NSRect(
            x: max(0, (bounds.width - width) / 2),
            y: max(0, bounds.height - visualBottomMargin - height),
            width: width,
            height: height
        )
    }
    private func formattedTipDetail(_ text: String) -> String {
        text
            .replacingOccurrences(of: "\\s*(?:-->|->|>|→|>|,)\\s*", with: "\n", options: .regularExpression)
            .replacingOccurrences(of: "[ \\t]{2,}", with: " ", options: .regularExpression)
            .replacingOccurrences(of: "\\n{2,}", with: "\n", options: .regularExpression)
            .trimmingCharacters(in: .whitespacesAndNewlines)
    }
    private func updateColors() {
        backgroundView.layer?.borderColor = NSColor.separatorColor.withAlphaComponent(0.35).cgColor
        iconView.contentTintColor = .systemYellow
        titleLabel.textColor = .secondaryLabelColor
        detailLabel.textColor = .labelColor
        previousButton.tintColor = .labelColor
        nextButton.tintColor = .labelColor
        dotsView.needsDisplay = true
    }
}
private final class AppGridCenteredTextFieldCell: NSTextFieldCell {
    override func drawingRect(forBounds rect: NSRect) -> NSRect {
        var drawingRect = super.drawingRect(forBounds: rect)
        let textHeight = cellSize(forBounds: rect).height
        drawingRect.origin.y = rect.origin.y + max(0, (rect.height - textHeight) / 2)
        drawingRect.size.height = min(rect.height, textHeight + 2)
        return drawingRect
    }
}
private final class AppGridDecorativeImageView: NSImageView {
    override func hitTest(_ point: NSPoint) -> NSView? {
        nil
    }
    override func isAccessibilityElement() -> Bool {
        false
    }
}
private final class AppGridUsageTipIconButton: NSView {
    var action: (() -> Void)?
    var buttonAccessibilityLabel: String = "" {
        didSet {
            setAccessibilityLabel(buttonAccessibilityLabel)
        }
    }
    var tintColor: NSColor = .labelColor {
        didSet {
            imageView.contentTintColor = tintColor
        }
    }
    private let imageView = AppGridDecorativeImageView()
    private var trackingAreaRef: NSTrackingArea?
    private var isHovered = false
    private var isPressed = false
    override var isFlipped: Bool { true }
    override var acceptsFirstResponder: Bool { false }
    override var mouseDownCanMoveWindow: Bool { false }
    init(systemImage: String) {
        super.init(frame: .zero)
        setup(systemImage: systemImage)
    }
    required init?(coder: NSCoder) { fatalError() }
    override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
        true
    }
    override func isAccessibilityElement() -> Bool {
        true
    }
    override func accessibilityRole() -> NSAccessibility.Role? {
        .button
    }
    override func accessibilityLabel() -> String? {
        buttonAccessibilityLabel
    }
    override func accessibilityPerformPress() -> Bool {
        action?()
        return true
    }
    override func layout() {
        super.layout()
        let size: CGFloat = 26
        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
        isPressed = false
        needsDisplay = true
    }
    override func mouseDown(with event: NSEvent) {
        Diagnostics.log("usageTips.button.mouseDown", [
            "label": buttonAccessibilityLabel
        ])
        isPressed = true
        needsDisplay = true
        action?()
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.10) { [weak self] in
            self?.isPressed = false
            self?.needsDisplay = true
        }
    }
    override func draw(_ dirtyRect: NSRect) {
        guard isHovered || isPressed else { return }
        tintColor.withAlphaComponent(isPressed ? 0.14 : 0.08).setFill()
        NSBezierPath(roundedRect: bounds.insetBy(dx: 1, dy: 1), xRadius: 6, yRadius: 6).fill()
    }
    private func setup(systemImage: String) {
        wantsLayer = true
        let config = NSImage.SymbolConfiguration(pointSize: 26, weight: .semibold)
        imageView.image = NSImage(
            systemSymbolName: systemImage,
            accessibilityDescription: nil
        )?.withSymbolConfiguration(config)
        imageView.imageScaling = .scaleProportionallyDown
        imageView.contentTintColor = tintColor
        addSubview(imageView)
        setAccessibilityRole(.button)
    }
}
private final class AppGridUsageTipDotsView: NSView {
    private var count = 0
    private var selectedIndex = 0
    override var isFlipped: Bool { true }
    var preferredWidth: CGFloat {
        guard count > 0 else { return 0 }
        return CGFloat(count) * 5 + CGFloat(max(0, count - 1)) * 5 + 2
    }
    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 spacing: CGFloat = 5
        let totalWidth = preferredWidth
        var x = (bounds.width - totalWidth) / 2
        let centerY = bounds.midY
        for index in 0..<count {
            let selected = index == selectedIndex
            let dotSize: CGFloat = selected ? 6 : 5
            let color = NSColor.labelColor.withAlphaComponent(selected ? 0.85 : 0.28)
            color.setFill()
            NSBezierPath(
                ovalIn: NSRect(
                    x: x,
                    y: centerY - dotSize / 2,
                    width: dotSize,
                    height: dotSize
                )
            ).fill()
            x += 5 + spacing
        }
    }
}
@@ -560,7 +1446,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] = [:]
@@ -606,22 +1493,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
@@ -651,7 +1555,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))
@@ -661,7 +1566,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
@@ -684,7 +1590,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))
@@ -694,7 +1601,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
@@ -724,7 +1632,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))
@@ -860,6 +1769,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