From 052e4e2266d5efc6a53f9265ade4466d664acbd6 Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Thu, 23 Jul 2026 21:43:32 -0700 Subject: [PATCH] Use source app icons to color clip cards --- Sources/Pesty/Util/AppIconProvider.swift | 24 ++++++- Sources/Pesty/Util/SourceColor.swift | 88 +++++++++++++++++------- 2 files changed, 86 insertions(+), 26 deletions(-) diff --git a/Sources/Pesty/Util/AppIconProvider.swift b/Sources/Pesty/Util/AppIconProvider.swift index 987beb5..3535cd6 100644 --- a/Sources/Pesty/Util/AppIconProvider.swift +++ b/Sources/Pesty/Util/AppIconProvider.swift @@ -2,19 +2,41 @@ import AppKit @MainActor enum AppIconProvider { + private static let pestyBundleID = "com.greycorelabs.pesty" private static var cache: [String: NSImage] = [:] static func icon(forBundleID bundleID: String?) -> NSImage { guard let bundleID else { return generic } if let cached = cache[bundleID] { return cached } var image = generic - if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) { + if bundleID == pestyBundleID || bundleID == Bundle.main.bundleIdentifier { + image = pestyIcon() + } else if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) { image = NSWorkspace.shared.icon(forFile: url.path) } cache[bundleID] = image return image } + private static func pestyIcon() -> NSImage { + if let url = Bundle.main.url(forResource: "Pesty", withExtension: "icns"), + let icon = NSImage(contentsOf: url) { + return icon + } + let projectRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let developmentIcon = projectRoot.appending(path: "packaging/Pesty.icns") + if let icon = NSImage(contentsOf: developmentIcon) { + return icon + } + if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: pestyBundleID) { + return NSWorkspace.shared.icon(forFile: url.path) + } + return NSApp.applicationIconImage ?? generic + } + static let generic: NSImage = NSImage(systemSymbolName: "app.dashed", accessibilityDescription: nil) ?? NSImage() diff --git a/Sources/Pesty/Util/SourceColor.swift b/Sources/Pesty/Util/SourceColor.swift index 7f57273..baa6e2e 100644 --- a/Sources/Pesty/Util/SourceColor.swift +++ b/Sources/Pesty/Util/SourceColor.swift @@ -1,33 +1,71 @@ +import AppKit import SwiftUI @MainActor enum SourceColor { - private static let palette: [Color] = [ - Color(red: 0.85, green: 0.66, blue: 0.22), - Color(red: 0.34, green: 0.56, blue: 0.82), - Color(red: 0.72, green: 0.38, blue: 0.58), - Color(red: 0.27, green: 0.62, blue: 0.55), - Color(red: 0.80, green: 0.40, blue: 0.34), - Color(red: 0.45, green: 0.40, blue: 0.74), - Color(red: 0.49, green: 0.62, blue: 0.30), - Color(red: 0.84, green: 0.52, blue: 0.27), - Color(red: 0.30, green: 0.49, blue: 0.74), - Color(red: 0.62, green: 0.42, blue: 0.30), - Color(red: 0.74, green: 0.36, blue: 0.42), - Color(red: 0.40, green: 0.55, blue: 0.62) - ] - - private static let key = "appColorMap" - private static var map: [String: Int] = { - UserDefaults.standard.dictionary(forKey: key) as? [String: Int] ?? [:] - }() + private static var cache: [String: Color] = [:] + private static let fallback = Color(red: 0.02, green: 0.48, blue: 1.0) static func color(for bundleID: String?) -> Color { - guard let id = bundleID, !id.isEmpty else { return palette[0] } - if let i = map[id] { return palette[i % palette.count] } - let i = map.count % palette.count - map[id] = i - UserDefaults.standard.set(map, forKey: key) - return palette[i] + guard let id = bundleID, !id.isEmpty else { return fallback } + if let color = cache[id] { return color } + let color = dominantColor(in: AppIconProvider.icon(forBundleID: id)) ?? fallback + cache[id] = color + return color + } + + private static func dominantColor(in icon: NSImage) -> Color? { + let size = 40 + let thumbnail = NSImage(size: NSSize(width: size, height: size)) + thumbnail.lockFocus() + NSGraphicsContext.current?.imageInterpolation = .high + icon.draw(in: NSRect(x: 0, y: 0, width: size, height: size), + from: .zero, + operation: .sourceOver, + fraction: 1, + respectFlipped: true, + hints: [.interpolation: NSImageInterpolation.high]) + thumbnail.unlockFocus() + guard let data = thumbnail.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: data) else { return nil } + + var red = 0.0 + var green = 0.0 + var blue = 0.0 + var weight = 0.0 + var darkWeight = 0.0 + + for x in 0.. 0.35 else { continue } + let maximum = max(color.redComponent, color.greenComponent, color.blueComponent) + let minimum = min(color.redComponent, color.greenComponent, color.blueComponent) + let saturation = maximum == 0 ? 0 : (maximum - minimum) / maximum + let brightness = maximum + + if brightness < 0.45 { darkWeight += alpha } + guard saturation > 0.16, brightness > 0.14 else { continue } + let pixelWeight = alpha * saturation * (0.45 + 0.55 * brightness) + red += Double(color.redComponent) * pixelWeight + green += Double(color.greenComponent) * pixelWeight + blue += Double(color.blueComponent) * pixelWeight + weight += pixelWeight + } + } + + if weight == 0 { + return darkWeight > 0 ? Color(red: 0.025, green: 0.075, blue: 0.24) : fallback + } + + let main = NSColor(deviceRed: red / weight, green: green / weight, blue: blue / weight, alpha: 1) + var hue: CGFloat = 0 + var saturation: CGFloat = 0 + var brightness: CGFloat = 0 + main.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: nil) + return Color(hue: Double(hue), + saturation: min(0.99, max(0.90, Double(saturation) * 1.85)), + brightness: min(0.98, max(0.84, Double(brightness) * 1.20))) } }