Ariver
2026-05-22 8478c55e513bbbb4b1676d1d9207f7feb2509465
Apptag/AppGridItem.swift
@@ -10,16 +10,16 @@
    var sourceTag: String? = nil
    var dragModeActive: Bool = false
    var onDragModeChange: ((Bool) -> Void)? = nil
    var onBubbleHover: ((AppInfo, CGRect, Bool) -> Void)? = nil
    var onBubbleHover: ((AppInfo, CGRect, AppBubbleHoverEvent) -> Void)? = nil
    var onEditNote: ((AppInfo, CGRect) -> Void)? = nil
    var bubbleDisabled: Bool = false
    var showUncommonAppBubbles: Bool = AppDefaults.showUncommonAppBubbles
    var itemID: String
    var dragResetToken: Int = 0
    @Binding var hoveredAppItemID: String?
    let onSelect: () -> Void
    @State private var interactionFrame: CGRect = .zero
    @AppStorage("showUncommonAppBubbles") private var showUncommonAppBubbles = AppDefaults.showUncommonAppBubbles
    @State private var isHovered = false
    static let hoverScale: CGFloat = 1.22
    static let labelHeight: CGFloat = 14
@@ -37,7 +37,6 @@
    private var iconSlotSize: CGFloat { iconSize * Self.hoverScale }
    private var labelWidth: CGFloat { iconSize + 20 }
    private var labelHeight: CGFloat { Self.labelHeight }
    private var isHovered: Bool { hoveredAppItemID == itemID }
    var body: some View {
        VStack(spacing: 6) {
@@ -46,6 +45,7 @@
                    icon: app.icon,
                    iconSize: iconSize,
                    payload: "\(app.path.path)\n\(sourceTag ?? "")",
                    resetToken: dragResetToken,
                    onLongPress: { onDragModeChange?(true) },
                    onDragEnd: { onDragModeChange?(false) },
                    onClick: onSelect
@@ -71,20 +71,19 @@
        .padding(.horizontal, 4)
        .frame(width: Self.stableWidth(iconSize: iconSize), height: Self.stableHeight(iconSize: iconSize))
        .background(
            AppGridItemHoverTracker { hovering, frame in
            AppHoverTrackingView { hovering, frame in
                interactionFrame = frame
                if bubbleDisabled || dragModeActive {
                    setHoverState(false)
                    onBubbleHover?(app, frame, false)
                    onBubbleHover?(app, frame, .exited)
                    return
                }
                if hovering {
                    setHoverState(true)
                    guard shouldShowAppBubble else { return }
                    onBubbleHover?(app, frame, hovering)
                    onBubbleHover?(app, frame, .entered(canShowBubble: shouldShowAppBubble))
                } else if isHovered {
                    setHoverState(false)
                    onBubbleHover?(app, frame, hovering)
                    onBubbleHover?(app, frame, .exited)
                } else {
                    setHoverState(false)
                }
@@ -98,23 +97,22 @@
        }
        .opacity(dragModeActive ? 0.92 : 1)
        .animation(.easeOut(duration: 0.08), value: dragModeActive)
        .id(dragResetToken)
        .onChange(of: dragModeActive) { _, active in
            if active {
                setHoverState(false)
                onBubbleHover?(app, interactionFrame, false)
                onBubbleHover?(app, interactionFrame, .exited)
            }
        }
        .onChange(of: bubbleDisabled) { _, disabled in
            if disabled {
                setHoverState(false)
                onBubbleHover?(app, interactionFrame, false)
                onBubbleHover?(app, interactionFrame, .exited)
            }
        }
        .onDisappear {
            if hoveredAppItemID == itemID {
                hoveredAppItemID = nil
                onBubbleHover?(app, interactionFrame, false)
            if isHovered {
                isHovered = false
                onBubbleHover?(app, interactionFrame, .exited)
            }
        }
    }
@@ -128,31 +126,33 @@
    }
    private func setHoverState(_ hovering: Bool) {
        guard isHovered != hovering else { return }
        withAnimation(hovering ? Self.hoverInAnimation : Self.hoverOutAnimation) {
            if hovering {
                hoveredAppItemID = itemID
            } else if hoveredAppItemID == itemID {
                hoveredAppItemID = nil
            }
            isHovered = hovering
        }
    }
}
private struct AppGridItemHoverTracker: NSViewRepresentable {
enum AppBubbleHoverEvent {
    case entered(canShowBubble: Bool)
    case exited
}
struct AppHoverTrackingView: NSViewRepresentable {
    let onHover: (Bool, CGRect) -> Void
    func makeNSView(context: Context) -> HoverTrackingNSView {
        let view = HoverTrackingNSView()
    func makeNSView(context: Context) -> AppHoverTrackingNSView {
        let view = AppHoverTrackingNSView()
        view.onHover = onHover
        return view
    }
    func updateNSView(_ view: HoverTrackingNSView, context: Context) {
    func updateNSView(_ view: AppHoverTrackingNSView, context: Context) {
        view.onHover = onHover
    }
}
private final class HoverTrackingNSView: NSView {
final class AppHoverTrackingNSView: NSView {
    var onHover: ((Bool, CGRect) -> Void)?
    override func updateTrackingAreas() {
@@ -306,6 +306,7 @@
    let icon: NSImage
    let iconSize: CGFloat
    let payload: String
    let resetToken: Int
    let onLongPress: () -> Void
    let onDragEnd: () -> Void
    let onClick: () -> Void
@@ -315,6 +316,7 @@
        view.image = icon
        view.iconSize = iconSize
        view.payload = payload
        view.resetToken = resetToken
        view.onLongPress = onLongPress
        view.onDragEnd = onDragEnd
        view.onClick = onClick
@@ -322,13 +324,21 @@
    }
    func updateNSView(_ view: DragIconNSView, context: Context) {
        let imageChanged = view.image !== icon
        let sizeChanged = view.iconSize != iconSize
        view.image = icon
        view.iconSize = iconSize
        view.payload = payload
        if view.resetToken != resetToken {
            view.resetToken = resetToken
            view.resetInteractionState()
        }
        view.onLongPress = onLongPress
        view.onDragEnd = onDragEnd
        view.onClick = onClick
        view.needsDisplay = true
        if imageChanged || sizeChanged {
            view.needsDisplay = true
        }
    }
}
@@ -336,6 +346,7 @@
    var image: NSImage = NSImage()
    var iconSize: CGFloat = 56
    var payload: String = ""
    var resetToken = 0
    var onLongPress: (() -> Void)?
    var onDragEnd: (() -> Void)?
    var onClick: (() -> Void)?
@@ -345,6 +356,14 @@
    private var isLongPressActive = false
    private var longPressWorkItem: DispatchWorkItem?
    override var isFlipped: Bool { true }
    func resetInteractionState() {
        longPressWorkItem?.cancel()
        longPressWorkItem = nil
        mouseDownEvent = nil
        didStartDrag = false
        isLongPressActive = false
    }
    override func draw(_ dirtyRect: NSRect) {
        super.draw(dirtyRect)
@@ -411,6 +430,7 @@
        didStartDrag = false
        isLongPressActive = false
        mouseDownEvent = nil
        longPressWorkItem = nil
    }
    private func makeDragImage() -> NSImage {