| | |
| | | } |
| | | |
| | | 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 |
| | |
| | | let displayMode: String |
| | | let iconSize: CGFloat |
| | | let showNames: Bool |
| | | let appGridTheme: AppGridTheme |
| | | let bubbleDisabled: Bool |
| | | let showUncommonAppBubbles: Bool |
| | | let highlightedGroupName: String? |
| | |
| | | displayMode: displayMode, |
| | | iconSize: iconSize, |
| | | showNames: showNames, |
| | | appGridTheme: appGridTheme, |
| | | bubbleDisabled: bubbleDisabled, |
| | | showUncommonAppBubbles: showUncommonAppBubbles, |
| | | highlightedGroupName: highlightedGroupName, |
| | |
| | | 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 |
| | |
| | | displayMode: String, |
| | | iconSize: CGFloat, |
| | | showNames: Bool, |
| | | appGridTheme: AppGridTheme, |
| | | bubbleDisabled: Bool, |
| | | showUncommonAppBubbles: Bool, |
| | | highlightedGroupName: String?, |
| | |
| | | self.displayMode = displayMode |
| | | self.iconSize = iconSize |
| | | self.showNames = showNames |
| | | self.appGridTheme = appGridTheme |
| | | self.externalBubbleDisabled = bubbleDisabled |
| | | self.showUncommonAppBubbles = showUncommonAppBubbles |
| | | self.highlightedGroupName = highlightedGroupName |
| | |
| | | displayMode: displayMode, |
| | | iconSize: iconSize, |
| | | showNames: showNames, |
| | | appGridTheme: appGridTheme, |
| | | showUncommonAppBubbles: showUncommonAppBubbles, |
| | | bottomContentPadding: self.bottomContentPadding, |
| | | contentRevision: contentRevision |
| | |
| | | displayMode: String, |
| | | iconSize: CGFloat, |
| | | showNames: Bool, |
| | | appGridTheme: AppGridTheme, |
| | | showUncommonAppBubbles: Bool, |
| | | bottomContentPadding: CGFloat, |
| | | contentRevision: Int |
| | |
| | | displayMode, |
| | | "\(Int(iconSize.rounded()))", |
| | | showNames ? "names" : "nonames", |
| | | "theme=\(appGridTheme.rawValue)", |
| | | showUncommonAppBubbles ? "uncommon" : "allbubbles", |
| | | "bottom=\(Int(bottomContentPadding.rounded()))", |
| | | colorPart, |
| | |
| | | |
| | | func applyCoordinatorUpdate() { |
| | | guard let coordinator else { return } |
| | | applyAppearance() |
| | | if coordinator.needsReload { |
| | | coordinator.needsReload = false |
| | | collectionView.reloadData() |
| | |
| | | } |
| | | 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 { |
| | |
| | | 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, |
| | |
| | | ) |
| | | 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() |
| | | } |
| | |
| | | |
| | | 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) |
| | | } |
| | |
| | | addSubview(iconView) |
| | | |
| | | configureLabel(titleLabel, font: titleFont, lineBreakMode: .byClipping) |
| | | configureLabel(detailLabel, font: detailFont, lineBreakMode: .byClipping) |
| | | configureDetailLabel() |
| | | |
| | | detailScrollView.drawsBackground = false |
| | | detailScrollView.hasVerticalScroller = 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() { |
| | |
| | | } |
| | | |
| | | 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 { |
| | |
| | | |
| | | 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() { |
| | |
| | | x += 5 + spacing |
| | | } |
| | | } |
| | | |
| | | } |
| | | |
| | | private final class AppGridContainerCollectionLayout: NSCollectionViewLayout { |
| | |
| | | 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) |
| | |
| | | && (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 |
| | |
| | | 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 |
| | |
| | | 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) |
| | |
| | | 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)) |