Ariver
2026-06-24 c1d6cfc2b59b2ce4f1ebf235397cdf43a4292996
src/Apptag/AppGridCollectionView.swift
@@ -38,8 +38,8 @@
}
enum AppGridUsageTipsMetrics {
    static let barHeight: CGFloat = 84
    static let reservedHeight: CGFloat = 128
    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
@@ -51,6 +51,7 @@
    let displayMode: String
    let iconSize: CGFloat
    let showNames: Bool
    let appGridTheme: AppGridTheme
    let bubbleDisabled: Bool
    let showUncommonAppBubbles: Bool
    let highlightedGroupName: String?
@@ -90,6 +91,7 @@
            displayMode: displayMode,
            iconSize: iconSize,
            showNames: showNames,
            appGridTheme: appGridTheme,
            bubbleDisabled: bubbleDisabled,
            showUncommonAppBubbles: showUncommonAppBubbles,
            highlightedGroupName: highlightedGroupName,
@@ -121,6 +123,7 @@
        var displayMode = AppDefaults.displayMode
        var iconSize: CGFloat = AppDefaults.iconSize
        var showNames = true
        var appGridTheme = AppGridTheme.fallback
        private var externalBubbleDisabled = false
        private var scrollBubbleDisabled = false
        var showUncommonAppBubbles = AppDefaults.showUncommonAppBubbles
@@ -161,6 +164,7 @@
            displayMode: String,
            iconSize: CGFloat,
            showNames: Bool,
            appGridTheme: AppGridTheme,
            bubbleDisabled: Bool,
            showUncommonAppBubbles: Bool,
            highlightedGroupName: String?,
@@ -188,6 +192,7 @@
            self.displayMode = displayMode
            self.iconSize = iconSize
            self.showNames = showNames
            self.appGridTheme = appGridTheme
            self.externalBubbleDisabled = bubbleDisabled
            self.showUncommonAppBubbles = showUncommonAppBubbles
            self.highlightedGroupName = highlightedGroupName
@@ -218,6 +223,7 @@
                displayMode: displayMode,
                iconSize: iconSize,
                showNames: showNames,
                appGridTheme: appGridTheme,
                showUncommonAppBubbles: showUncommonAppBubbles,
                bottomContentPadding: self.bottomContentPadding,
                contentRevision: contentRevision
@@ -393,6 +399,7 @@
            displayMode: String,
            iconSize: CGFloat,
            showNames: Bool,
            appGridTheme: AppGridTheme,
            showUncommonAppBubbles: Bool,
            bottomContentPadding: CGFloat,
            contentRevision: Int
@@ -405,6 +412,7 @@
                displayMode,
                "\(Int(iconSize.rounded()))",
                showNames ? "names" : "nonames",
                "theme=\(appGridTheme.rawValue)",
                showUncommonAppBubbles ? "uncommon" : "allbubbles",
                "bottom=\(Int(bottomContentPadding.rounded()))",
                colorPart,
@@ -477,6 +485,7 @@
    func applyCoordinatorUpdate() {
        guard let coordinator else { return }
        applyAppearance()
        if coordinator.needsReload {
            coordinator.needsReload = false
            collectionView.reloadData()
@@ -766,6 +775,12 @@
        }
        return didScroll
    }
    private func applyAppearance() {
        let usesDarkGlass = coordinator?.appGridTheme.usesDarkGlass == true
        layer?.backgroundColor = NSColor.clear.cgColor
        collectionView.appearance = usesDarkGlass ? NSAppearance(named: .darkAqua) : nil
    }
}
private final class AppGridUsageTipsShieldView: NSView {
@@ -943,44 +958,41 @@
        let paddingRight: CGFloat = 16
        let iconSize: CGFloat = 24
        let iconTextGap: CGFloat = 10
        let titleDetailGap: CGFloat = 22
        let detailControlsGap: CGFloat = 24
        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 fullTitleWidth = ceil(titleLabel.attributedStringValue.size().width)
        let centerY = visualFrame.midY
        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: centerY - iconSize / 2,
            y: visualFrame.midY - iconSize / 2,
            width: iconSize,
            height: iconSize
        )
        let titleX = iconView.frame.maxX + iconTextGap
        let buttonsWidth = buttonSize * 2 + buttonGap
        let trailingWidth = paddingRight + buttonsWidth + detailControlsGap
        let availableTextWidth = max(1, visualFrame.maxX - titleX - titleDetailGap - trailingWidth)
        let minimumReadableDetailWidth: CGFloat = 220
        let titleWidth: CGFloat
        if availableTextWidth >= fullTitleWidth + minimumReadableDetailWidth {
            titleWidth = fullTitleWidth
        } else if availableTextWidth >= fullTitleWidth {
            titleWidth = fullTitleWidth
        } else {
            titleWidth = max(1, availableTextWidth)
        }
        titleLabel.frame = NSRect(
            x: titleX,
            y: visualFrame.minY,
            y: textBlockY,
            width: titleWidth,
            height: visualFrame.height
            height: titleHeight
        )
        let buttonY = visualFrame.minY + 10
        let buttonsGroupMinX = visualFrame.maxX - paddingRight - buttonsWidth
        let controlGroupHeight = buttonSize + buttonDotsGap + dotsHeight
        let buttonY = visualFrame.minY + max(0, (visualFrame.height - controlGroupHeight) / 2)
        nextButton.frame = NSRect(
            x: buttonsGroupMinX + buttonSize + buttonGap,
            y: buttonY,
@@ -995,18 +1007,18 @@
        )
        dotsView.frame = NSRect(
            x: buttonsGroupMinX + (buttonsWidth - dotsWidth) / 2,
            y: previousButton.frame.maxY + 6,
            y: previousButton.frame.maxY + buttonDotsGap,
            width: dotsWidth,
            height: 10
            height: dotsHeight
        )
        let detailX = titleLabel.frame.maxX + titleDetailGap
        let detailRight = previousButton.frame.minX - detailControlsGap
        let detailX = titleX
        let detailRight = textRight
        detailScrollView.frame = NSRect(
            x: detailX,
            y: visualFrame.minY,
            y: titleLabel.frame.maxY + lineGap,
            width: max(1, detailRight - detailX),
            height: visualFrame.height
            height: detailHeight
        )
        layoutDetailLabel()
    }
@@ -1048,10 +1060,10 @@
    func preferredWidth(maxAvailableWidth: CGFloat) -> CGFloat {
        let padding: CGFloat = 22 + 16
        let fixedWidth: CGFloat = 24 + 10 + 22 + 24 + 48 + 6 + 48
        let fixedWidth: CGFloat = 24 + 10 + 24 + 48 + 6 + 48
        let titleWidth = ceil(titleLabel.attributedStringValue.size().width)
        let detailWidth = ceil(detailLabel.attributedStringValue.size().width)
        let contentWidth = padding + fixedWidth + titleWidth + detailWidth
        let detailWidth = measuredDetailLineWidth()
        let contentWidth = padding + fixedWidth + max(titleWidth, detailWidth)
        let preferredWidth = max(AppGridUsageTipsMetrics.minWidth, contentWidth)
        return min(maxAvailableWidth, preferredWidth)
    }
@@ -1081,7 +1093,7 @@
        addSubview(iconView)
        configureLabel(titleLabel, font: titleFont, lineBreakMode: .byClipping)
        configureLabel(detailLabel, font: detailFont, lineBreakMode: .byClipping)
        configureDetailLabel()
        detailScrollView.drawsBackground = false
        detailScrollView.hasVerticalScroller = false
@@ -1111,7 +1123,21 @@
        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() {
@@ -1180,9 +1206,17 @@
    }
    private func layoutDetailLabel() {
        let textWidth = detailLabel.attributedStringValue.size().width
        let width = max(detailScrollView.contentView.bounds.width, ceil(textWidth) + 18)
        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 {
@@ -1198,8 +1232,10 @@
    private func formattedTipDetail(_ text: String) -> String {
        text
            .replacingOccurrences(of: " > ", with: " -> ")
            .replacingOccurrences(of: ">", with: "->")
            .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() {
@@ -1397,6 +1433,7 @@
            x += 5 + spacing
        }
    }
}
private final class AppGridContainerCollectionLayout: NSCollectionViewLayout {
@@ -1961,15 +1998,24 @@
        if displayStyle.usesCardSurface {
            let rect = bounds.insetBy(dx: 0.5, dy: 0.5)
            let path = NSBezierPath(roundedRect: rect, xRadius: 14, yRadius: 14)
            cardSurfaceColor().setFill()
            path.fill()
            if coordinator.isColoredContainerMode || isColorlessActive {
                tagColor.withAlphaComponent(0.30).setFill()
            if coordinator.appGridTheme.usesDarkGlass {
                drawDarkCardSurface(
                    path: path,
                    tagColor: tagColor,
                    isActive: isHovered || isNavigationHighlighted,
                    isTinted: coordinator.isColoredContainerMode || isColorlessActive
                )
            } else {
                cardSurfaceColor().setFill()
                path.fill()
                if coordinator.isColoredContainerMode || isColorlessActive {
                    tagColor.withAlphaComponent(0.30).setFill()
                    path.fill()
                }
                NSColor.labelColor.withAlphaComponent(0.08).setStroke()
                path.lineWidth = 1
                path.stroke()
            }
            NSColor.labelColor.withAlphaComponent(0.08).setStroke()
            path.lineWidth = 1
            path.stroke()
        }
        drawHeader(title: group.name, displayStyle: displayStyle)
@@ -2189,6 +2235,17 @@
            && (isHovered || isNavigationHighlighted)
        let shouldShadow = coordinator.displayStyle.usesCardSurface
            && ((coordinator.isColoredContainerMode && (isHovered || isNavigationHighlighted)) || isColorlessActive)
        if coordinator.appGridTheme.usesDarkGlass && coordinator.displayStyle.usesCardSurface {
            let tagColor = TagColor.nsColor(for: coordinator.tagColors[group?.name ?? ""] ?? 0)
            let active = isHovered || isNavigationHighlighted
            layer?.shadowColor = active
                ? tagColor.withAlphaComponent(0.82).cgColor
                : NSColor.black.withAlphaComponent(0.72).cgColor
            layer?.shadowOpacity = active ? 0.50 : 0.28
            layer?.shadowRadius = active ? 24 : 14
            layer?.shadowOffset = NSSize(width: 0, height: active ? -8 : -4)
            return
        }
        layer?.shadowColor = NSColor.black.cgColor
        layer?.shadowOpacity = shouldShadow ? 0.22 : 0
        layer?.shadowRadius = shouldShadow ? 8 : 0
@@ -2224,6 +2281,40 @@
        return NSColor.white.withAlphaComponent(0.62)
    }
    private func drawDarkCardSurface(
        path: NSBezierPath,
        tagColor: NSColor,
        isActive: Bool,
        isTinted: Bool
    ) {
        NSGraphicsContext.saveGraphicsState()
        if isActive {
            let glow = NSShadow()
            glow.shadowColor = tagColor.withAlphaComponent(0.36)
            glow.shadowBlurRadius = 28
            glow.shadowOffset = NSSize(width: 0, height: -8)
            glow.set()
        }
        NSColor(calibratedRed: 0.12, green: 0.18, blue: 0.25, alpha: 0.58).setFill()
        path.fill()
        NSGraphicsContext.restoreGraphicsState()
        if isTinted {
            tagColor.withAlphaComponent(isActive ? 0.18 : 0.10).setFill()
            path.fill()
        }
        NSColor.white.withAlphaComponent(isActive ? 0.08 : 0.045).setFill()
        path.fill()
        let strokeColor = isActive
            ? tagColor.withAlphaComponent(0.72)
            : NSColor.white.withAlphaComponent(0.18)
        strokeColor.setStroke()
        path.lineWidth = isActive ? 1.4 : 1.0
        path.stroke()
    }
    private func drawHeader(title: String, displayStyle: AppGridCollectionDisplayMode) {
        let horizontalInset = displayStyle.usesCardSurface ? AppGridCollectionMetrics.cardPadding : 0
        let verticalInset = displayStyle.usesCardSurface ? AppGridCollectionMetrics.cardPadding : 0
@@ -2233,9 +2324,13 @@
            width: max(1, bounds.width - horizontalInset * 2),
            height: AppGridCollectionMetrics.headerHeight
        )
        let isDarkGrid = coordinator?.appGridTheme.usesDarkGlass == true
        let headerTextColor = isDarkGrid
            ? NSColor.white.withAlphaComponent(0.74)
            : NSColor.secondaryLabelColor
        let attributes: [NSAttributedString.Key: Any] = [
            .font: NSFont.systemFont(ofSize: 18, weight: .semibold),
            .foregroundColor: NSColor.secondaryLabelColor,
            .foregroundColor: headerTextColor,
            .paragraphStyle: centeredParagraph(lineBreak: .byTruncatingMiddle)
        ]
        let titleSize = title.size(withAttributes: attributes)
@@ -2247,7 +2342,9 @@
            height: headerRect.height - 6
        )
        let lineY = headerRect.midY
        NSColor.secondaryLabelColor.withAlphaComponent(0.25).setStroke()
        (isDarkGrid ? NSColor.white : NSColor.secondaryLabelColor)
            .withAlphaComponent(isDarkGrid ? 0.16 : 0.25)
            .setStroke()
        let leftLine = NSBezierPath()
        leftLine.move(to: NSPoint(x: headerRect.minX, y: lineY))
        leftLine.line(to: NSPoint(x: max(headerRect.minX, titleRect.minX - 2), y: lineY))