import AppKit
|
import Foundation
|
|
public struct SkeletonThumbnailTemplate: Equatable, Sendable {
|
public let category: AppCategory
|
public let headerHeight: CGFloat
|
public let contentRows: Int
|
public let accentStripeCount: Int
|
|
public init(category: AppCategory, headerHeight: CGFloat, contentRows: Int, accentStripeCount: Int) {
|
self.category = category
|
self.headerHeight = headerHeight
|
self.contentRows = contentRows
|
self.accentStripeCount = accentStripeCount
|
}
|
|
public static func template(for category: AppCategory) -> SkeletonThumbnailTemplate {
|
switch category {
|
case .finder:
|
return SkeletonThumbnailTemplate(category: category, headerHeight: 18, contentRows: 4, accentStripeCount: 1)
|
case .browser:
|
return SkeletonThumbnailTemplate(category: category, headerHeight: 20, contentRows: 3, accentStripeCount: 3)
|
case .terminal:
|
return SkeletonThumbnailTemplate(category: category, headerHeight: 16, contentRows: 5, accentStripeCount: 0)
|
case .editorIDE:
|
return SkeletonThumbnailTemplate(category: category, headerHeight: 16, contentRows: 6, accentStripeCount: 2)
|
case .chat:
|
return SkeletonThumbnailTemplate(category: category, headerHeight: 16, contentRows: 4, accentStripeCount: 1)
|
case .documentNotes:
|
return SkeletonThumbnailTemplate(category: category, headerHeight: 14, contentRows: 5, accentStripeCount: 0)
|
case .generic:
|
return SkeletonThumbnailTemplate(category: category, headerHeight: 16, contentRows: 3, accentStripeCount: 1)
|
}
|
}
|
}
|
|
public enum SkeletonThumbnailPalette {
|
public static let headerAlpha: CGFloat = 0.10
|
public static let contentRowAlpha: CGFloat = 0.075
|
public static let accentStripeAlpha: CGFloat = 0.28
|
public static let titleBandAlpha: CGFloat = 0.86
|
}
|
|
@MainActor
|
public final class NativeSkeletonThumbnailProvider: SkeletonThumbnailProviderProtocol {
|
public static let defaultSize = NSSize(width: 120, height: 80)
|
|
private let size: NSSize
|
|
public init(size: NSSize = NativeSkeletonThumbnailProvider.defaultSize) {
|
self.size = size
|
}
|
|
public func skeleton(for app: AlignerApp) -> NSImage {
|
drawSkeleton(template: SkeletonThumbnailTemplate.template(for: app.category), title: nil)
|
}
|
|
public func withOverlayTitle(_ title: String, for app: AlignerApp) -> NSImage {
|
drawSkeleton(template: SkeletonThumbnailTemplate.template(for: app.category), title: title)
|
}
|
|
private func drawSkeleton(template: SkeletonThumbnailTemplate, title: String?) -> NSImage {
|
let image = NSImage(size: size)
|
image.lockFocus()
|
|
let bounds = NSRect(origin: .zero, size: size)
|
NSColor.windowBackgroundColor.setFill()
|
NSBezierPath(roundedRect: bounds, xRadius: 6, yRadius: 6).fill()
|
|
drawHeader(template: template, in: bounds)
|
drawContentRows(template: template, in: bounds, reservesTitleBand: title != nil)
|
drawAccentStripes(template: template, in: bounds)
|
|
if let title, !title.isEmpty {
|
drawTitle(title, in: bounds)
|
}
|
|
image.unlockFocus()
|
return image
|
}
|
|
private func drawHeader(template: SkeletonThumbnailTemplate, in bounds: NSRect) {
|
let header = NSRect(
|
x: 0,
|
y: bounds.height - template.headerHeight,
|
width: bounds.width,
|
height: template.headerHeight
|
)
|
color(for: template.category).withAlphaComponent(SkeletonThumbnailPalette.headerAlpha).setFill()
|
NSBezierPath(roundedRect: header, xRadius: 6, yRadius: 6).fill()
|
}
|
|
private func drawContentRows(
|
template: SkeletonThumbnailTemplate,
|
in bounds: NSRect,
|
reservesTitleBand: Bool
|
) {
|
let rowHeight: CGFloat = 6
|
let rowSpacing: CGFloat = 6
|
let startY = bounds.height - template.headerHeight - 14
|
let minimumY: CGFloat = reservesTitleBand ? 24 : 8
|
let maxWidth = bounds.width - 24
|
let maximumRows = max(0, Int(floor((startY - minimumY + rowSpacing) / (rowHeight + rowSpacing))))
|
let rowsToDraw = min(template.contentRows, maximumRows)
|
|
for row in 0..<rowsToDraw {
|
let widthRatio = 1 - CGFloat(row % 3) * 0.18
|
let rect = NSRect(
|
x: 12,
|
y: startY - CGFloat(row) * (rowHeight + rowSpacing),
|
width: maxWidth * widthRatio,
|
height: rowHeight
|
)
|
NSColor.secondaryLabelColor.withAlphaComponent(SkeletonThumbnailPalette.contentRowAlpha).setFill()
|
NSBezierPath(roundedRect: rect, xRadius: 3, yRadius: 3).fill()
|
}
|
}
|
|
private func drawAccentStripes(template: SkeletonThumbnailTemplate, in bounds: NSRect) {
|
guard template.accentStripeCount > 0 else { return }
|
|
let stripeWidth: CGFloat = 8
|
for stripe in 0..<template.accentStripeCount {
|
let rect = NSRect(
|
x: 12 + CGFloat(stripe) * (stripeWidth + 4),
|
y: bounds.height - template.headerHeight + 5,
|
width: stripeWidth,
|
height: max(4, template.headerHeight - 10)
|
)
|
color(for: template.category).withAlphaComponent(SkeletonThumbnailPalette.accentStripeAlpha).setFill()
|
NSBezierPath(roundedRect: rect, xRadius: 3, yRadius: 3).fill()
|
}
|
}
|
|
private func drawTitle(_ title: String, in bounds: NSRect) {
|
let titleBand = NSRect(x: 0, y: 0, width: bounds.width, height: 24)
|
NSColor.windowBackgroundColor.withAlphaComponent(SkeletonThumbnailPalette.titleBandAlpha).setFill()
|
NSBezierPath(roundedRect: titleBand, xRadius: 6, yRadius: 6).fill()
|
|
let paragraph = NSMutableParagraphStyle()
|
paragraph.alignment = .center
|
paragraph.lineBreakMode = .byTruncatingTail
|
|
let attributes: [NSAttributedString.Key: Any] = [
|
.font: NSFont.systemFont(ofSize: 10, weight: .semibold),
|
.foregroundColor: NSColor.labelColor,
|
.paragraphStyle: paragraph
|
]
|
let rect = NSRect(x: 8, y: 6, width: bounds.width - 16, height: 14)
|
title.draw(in: rect, withAttributes: attributes)
|
}
|
|
private func color(for category: AppCategory) -> NSColor {
|
switch category {
|
case .finder:
|
return .systemBlue
|
case .browser:
|
return .systemTeal
|
case .terminal:
|
return .systemGreen
|
case .editorIDE:
|
return .systemPurple
|
case .chat:
|
return .systemMint
|
case .documentNotes:
|
return .systemYellow
|
case .generic:
|
return .tertiaryLabelColor
|
}
|
}
|
}
|