Ariver
2026-05-19 57abfdc2ba880be1fae4a1a184531e3228d62dae
Apptag/AppGridItem.swift
@@ -12,15 +12,19 @@
    var onDragModeChange: ((Bool) -> Void)? = nil
    var onBubbleHover: ((AppInfo, CGRect, Bool) -> Void)? = nil
    var onEditNote: ((AppInfo, CGRect) -> Void)? = nil
    var bubbleDisabled: Bool = false
    var itemID: String
    var dragResetToken: Int = 0
    @Binding var hoveredAppItemID: String?
    let onSelect: () -> Void
    @State private var isHovered = false
    @State private var wiggle = false
    @State private var globalFrame: CGRect = .zero
    @AppStorage("showUncommonAppBubbles") private var showUncommonAppBubbles = true
    @State private var interactionFrame: CGRect = .zero
    @AppStorage("showUncommonAppBubbles") private var showUncommonAppBubbles = AppDefaults.showUncommonAppBubbles
    static let hoverScale: CGFloat = 1.22
    static let labelHeight: CGFloat = 14
    private static let hoverInAnimation = Animation.easeOut(duration: 0.07)
    private static let hoverOutAnimation = Animation.easeOut(duration: 0.045)
    static func stableWidth(iconSize: CGFloat) -> CGFloat {
        iconSize * hoverScale + 8
@@ -33,6 +37,7 @@
    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) {
@@ -52,7 +57,6 @@
                    radius: isHovered ? 14 : 0,
                    y: isHovered ? 8 : 0
                )
                .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isHovered)
            }
            .frame(width: iconSlotSize, height: iconSlotSize)
@@ -61,44 +65,133 @@
                .lineLimit(1)
                .truncationMode(.tail)
                .frame(width: labelWidth, height: labelHeight)
                .opacity(showName ? 1 : (isHovered ? 0.85 : 0))
                .opacity(showName ? 1 : (shouldShowHoverName ? 0.85 : 0))
        }
        .padding(.vertical, 8)
        .padding(.horizontal, 4)
        .frame(width: Self.stableWidth(iconSize: iconSize), height: Self.stableHeight(iconSize: iconSize))
        .background(
            GeometryReader { proxy in
                Color.clear
                    .onAppear { globalFrame = proxy.frame(in: .global) }
                    .onChange(of: proxy.frame(in: .global)) { _, newFrame in
                        globalFrame = newFrame
                    }
            AppGridItemHoverTracker { hovering, frame in
                interactionFrame = frame
                if bubbleDisabled || dragModeActive {
                    setHoverState(false)
                    onBubbleHover?(app, frame, false)
                    return
                }
                if hovering {
                    setHoverState(true)
                    guard shouldShowAppBubble else { return }
                    onBubbleHover?(app, frame, hovering)
                } else if isHovered {
                    setHoverState(false)
                    onBubbleHover?(app, frame, hovering)
                } else {
                    setHoverState(false)
                }
            }
        )
        .contentShape(Rectangle())
        .onHover { hovering in
            isHovered = hovering
            if showUncommonAppBubbles && app.isUncommon {
                onBubbleHover?(app, globalFrame, hovering)
            }
        }
        .contextMenu {
            if app.isUncommon {
                Button(tr("appNote.edit")) {
                    onEditNote?(app, globalFrame)
                }
            Button(tr("appNote.edit")) {
                onEditNote?(app, interactionFrame)
            }
        }
        .rotationEffect(.degrees(dragModeActive ? (wiggle ? 2.0 : -2.0) : 0))
        .animation(
            dragModeActive
                ? .easeInOut(duration: 0.12).repeatForever(autoreverses: true)
                : .default,
            value: wiggle
        )
        .opacity(dragModeActive ? 0.92 : 1)
        .animation(.easeOut(duration: 0.08), value: dragModeActive)
        .id(dragResetToken)
        .onChange(of: dragModeActive) { _, active in
            wiggle = active
            if active {
                setHoverState(false)
                onBubbleHover?(app, interactionFrame, false)
            }
        }
        .onChange(of: bubbleDisabled) { _, disabled in
            if disabled {
                setHoverState(false)
                onBubbleHover?(app, interactionFrame, false)
            }
        }
        .onDisappear {
            if hoveredAppItemID == itemID {
                hoveredAppItemID = nil
                onBubbleHover?(app, interactionFrame, false)
            }
        }
    }
    private var shouldShowAppBubble: Bool {
        !showUncommonAppBubbles || app.isUncommon
    }
    private var shouldShowHoverName: Bool {
        !showName && !shouldShowAppBubble && isHovered
    }
    private func setHoverState(_ hovering: Bool) {
        withAnimation(hovering ? Self.hoverInAnimation : Self.hoverOutAnimation) {
            if hovering {
                hoveredAppItemID = itemID
            } else if hoveredAppItemID == itemID {
                hoveredAppItemID = nil
            }
        }
    }
}
private struct AppGridItemHoverTracker: NSViewRepresentable {
    let onHover: (Bool, CGRect) -> Void
    func makeNSView(context: Context) -> HoverTrackingNSView {
        let view = HoverTrackingNSView()
        view.onHover = onHover
        return view
    }
    func updateNSView(_ view: HoverTrackingNSView, context: Context) {
        view.onHover = onHover
    }
}
private final class HoverTrackingNSView: NSView {
    var onHover: ((Bool, CGRect) -> Void)?
    override func updateTrackingAreas() {
        super.updateTrackingAreas()
        trackingAreas.forEach(removeTrackingArea)
        addTrackingArea(
            NSTrackingArea(
                rect: .zero,
                options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect],
                owner: self,
                userInfo: nil
            )
        )
    }
    override func hitTest(_ point: NSPoint) -> NSView? {
        nil
    }
    override func mouseEntered(with event: NSEvent) {
        onHover?(true, rootLocalFrame())
    }
    override func mouseExited(with event: NSEvent) {
        onHover?(false, rootLocalFrame())
    }
    private func rootLocalFrame() -> CGRect {
        guard let contentView = window?.contentView else { return .zero }
        let rectInContent = contentView.convert(bounds, from: self)
        let y = contentView.isFlipped
            ? rectInContent.minY
            : contentView.bounds.height - rectInContent.maxY
        return CGRect(
            x: rectInContent.minX,
            y: y,
            width: rectInContent.width,
            height: rectInContent.height
        )
    }
}
@@ -112,6 +205,7 @@
    let note: String?
    let isEditing: Bool
    let placement: BubblePlacement
    let arrowOffset: CGFloat
    @Binding var draftNote: String
    var noteFocused: FocusState<Bool>.Binding
    let onCommit: () -> Void
@@ -172,6 +266,12 @@
                .shadow(color: .black.opacity(0.34), radius: 24, y: 16)
                .shadow(color: .black.opacity(0.18), radius: 8, y: 3)
        )
        .overlay(alignment: placement == .above ? .bottom : .top) {
            BubbleArrow(placement: placement)
                .fill(Color.black.opacity(0.92))
                .frame(width: 18, height: 9)
                .offset(x: arrowOffset, y: placement == .above ? 8 : -8)
        }
    }
    private var limitedDraft: Binding<String> {
@@ -182,6 +282,26 @@
    }
}
private struct BubbleArrow: Shape {
    let placement: BubblePlacement
    func path(in rect: CGRect) -> Path {
        var path = Path()
        switch placement {
        case .above:
            path.move(to: CGPoint(x: rect.minX, y: rect.minY))
            path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
            path.addLine(to: CGPoint(x: rect.midX, y: rect.maxY))
        case .below:
            path.move(to: CGPoint(x: rect.minX, y: rect.maxY))
            path.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY))
            path.addLine(to: CGPoint(x: rect.midX, y: rect.minY))
        }
        path.closeSubpath()
        return path
    }
}
private struct DraggableAppIconView: NSViewRepresentable {
    let icon: NSImage
    let iconSize: CGFloat