WIP: add color clip support

This commit is contained in:
Akshay Kolli
2026-07-01 14:52:54 -07:00
parent d22c0c23ec
commit c9ef1d5e84
15 changed files with 377 additions and 17 deletions

View File

@@ -9,6 +9,7 @@ enum ClipboardItemKind: Int {
case unknown
case pdf
case audio
case color
var displayName: String {
switch self {
@@ -20,6 +21,7 @@ enum ClipboardItemKind: Int {
case .unknown: return "item"
case .pdf: return "PDF"
case .audio: return "audio"
case .color: return "color"
}
}
}
@@ -29,7 +31,7 @@ extension ClipboardItemKind {
switch self {
case .url, .file, .image, .pdf, .audio:
return true
case .text, .richText, .unknown:
case .text, .richText, .unknown, .color:
return false
}
}
@@ -38,7 +40,7 @@ extension ClipboardItemKind {
switch self {
case .file, .image, .pdf, .audio:
return true
case .text, .richText, .unknown, .url:
case .text, .richText, .unknown, .url, .color:
return false
}
}
@@ -47,7 +49,7 @@ extension ClipboardItemKind {
switch self {
case .url, .image, .pdf, .audio, .richText:
return true
case .text, .file, .unknown:
case .text, .file, .unknown, .color:
return false
}
}
@@ -62,8 +64,9 @@ enum ClipboardSortMode: Int {
case pinned
case files
case audio
case colors
static let allCases: [ClipboardSortMode] = [.mostRecent, .mostUsed, .text, .links, .images, .audio, .files, .pinned]
static let allCases: [ClipboardSortMode] = [.mostRecent, .mostUsed, .text, .links, .images, .colors, .audio, .files, .pinned]
var title: String {
switch self {
@@ -75,6 +78,7 @@ enum ClipboardSortMode: Int {
case .pinned: return "Pinned"
case .files: return "Files"
case .audio: return "Audio"
case .colors: return "Colors"
}
}
}
@@ -146,6 +150,7 @@ struct ClipboardItem {
case .unknown: return "unknown"
case .pdf: return "pdf document"
case .audio: return "audio sound"
case .color: return "color swatch hex"
}
}

View File

@@ -0,0 +1,75 @@
import AppKit
import Foundation
enum ColorPayload {
static func hexString(from color: NSColor) -> String? {
guard let rgb = color.usingColorSpace(.sRGB) ?? color.usingColorSpace(.deviceRGB) else {
return nil
}
let red = clampedByte(rgb.redComponent)
let green = clampedByte(rgb.greenComponent)
let blue = clampedByte(rgb.blueComponent)
let alpha = clampedByte(rgb.alphaComponent)
if alpha >= 255 {
return String(format: "#%02X%02X%02X", red, green, blue)
}
return String(format: "#%02X%02X%02X%02X", red, green, blue, alpha)
}
static func color(from payload: String) -> NSColor? {
var value = payload.clipboardTrimmed
if value.hasPrefix("#") {
value.removeFirst()
}
guard value.count == 6 || value.count == 8,
let raw = UInt32(value, radix: 16) else {
return nil
}
let hasAlpha = value.count == 8
let red = CGFloat((raw >> (hasAlpha ? 24 : 16)) & 0xFF) / 255
let green = CGFloat((raw >> (hasAlpha ? 16 : 8)) & 0xFF) / 255
let blue = CGFloat((raw >> (hasAlpha ? 8 : 0)) & 0xFF) / 255
let alpha = hasAlpha ? CGFloat(raw & 0xFF) / 255 : 1
return NSColor(deviceRed: red, green: green, blue: blue, alpha: alpha)
}
static func displayHex(from payload: String) -> String {
if let color = color(from: payload), let hex = hexString(from: color) {
return hex
}
let normalized = payload.clipboardTrimmed
return normalized.hasPrefix("#") ? normalized.uppercased() : "#\(normalized.uppercased())"
}
static func componentSummary(from payload: String) -> String {
guard let color = color(from: payload),
let rgb = color.usingColorSpace(.sRGB) ?? color.usingColorSpace(.deviceRGB) else {
return "Color"
}
let red = clampedByte(rgb.redComponent)
let green = clampedByte(rgb.greenComponent)
let blue = clampedByte(rgb.blueComponent)
let alpha = clampedByte(rgb.alphaComponent)
if alpha >= 255 {
return "RGB \(red) \(green) \(blue)"
}
return "RGBA \(red) \(green) \(blue) \(alpha)"
}
static func previewText(from payload: String) -> String {
"\(displayHex(from: payload))\n\(componentSummary(from: payload))"
}
static func contrastingTextColor(for color: NSColor) -> NSColor {
guard let rgb = color.usingColorSpace(.sRGB) ?? color.usingColorSpace(.deviceRGB) else {
return .labelColor
}
let luminance = (0.299 * rgb.redComponent) + (0.587 * rgb.greenComponent) + (0.114 * rgb.blueComponent)
return luminance > 0.62 ? NSColor.black.withAlphaComponent(0.82) : .white
}
private static func clampedByte(_ value: CGFloat) -> Int {
Int((min(1, max(0, value)) * 255).rounded())
}
}

View File

@@ -105,7 +105,7 @@ final class ClipboardCacheService {
case .file:
return filePreviewThumbnail(for: item.payload)
case .text, .unknown, .audio, .richText:
case .text, .unknown, .audio, .richText, .color:
return nil
}
}
@@ -191,6 +191,9 @@ final class ClipboardCacheService {
let text = item.payload.clipboardTrimmed.isEmpty ? item.displayText : item.payload
guard !text.clipboardTrimmed.isEmpty else { return nil }
return writeTemporaryCopy(data: Data(text.utf8), id: item.id, fileExtension: "txt")
case .color:
let text = ColorPayload.previewText(from: item.payload)
return writeTemporaryCopy(data: Data(text.utf8), id: item.id, fileExtension: "txt")
case .url:
guard let data = webLocationData(for: item.payload) else { return nil }
return writeTemporaryCopy(data: data, id: item.id, fileExtension: "webloc")

View File

@@ -195,6 +195,15 @@ final class ClipboardMonitorService {
return itemFromURL(url.url, title: url.title, sourceApp: source.name, sourceBundleId: source.bundleId, previewPasteboard: pasteboard)
}
if isIgnored(.color), hasColor(on: pasteboard) {
reportReadFailureStatus(ignoredKindMessage(.color))
return nil
}
if let colorItem = itemFromColor(pasteboard, sourceApp: source.name, sourceBundleId: source.bundleId) {
return colorItem
}
if isIgnored(.image), hasImage(on: pasteboard) {
reportReadFailureStatus(ignoredKindMessage(.image))
return nil
@@ -407,6 +416,10 @@ final class ClipboardMonitorService {
pasteboard.data(forType: .sound) != nil
}
private func hasColor(on pasteboard: NSPasteboard) -> Bool {
NSColor(from: pasteboard) != nil
}
private func hasFileItems(on pasteboard: NSPasteboard) -> Bool {
guard let urls = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL], !urls.isEmpty else {
return false
@@ -474,6 +487,30 @@ final class ClipboardMonitorService {
)
}
private func itemFromColor(_ pasteboard: NSPasteboard, sourceApp: String?, sourceBundleId: String?) -> ClipboardItem? {
guard let color = NSColor(from: pasteboard) else { return nil }
guard let hex = ColorPayload.hexString(from: color) else {
reportReadFailureStatus("Clipboard color is present but could not be decoded.")
return nil
}
return ClipboardItem(
id: UUID(),
kind: .color,
displayText: hex,
payload: hex,
payloadHash: store.hashString(hex),
createdAt: Date(),
lastUsedAt: Date(),
useCount: 1,
sourceApp: sourceApp,
imagePath: nil,
thumbnailPath: nil,
isPinned: false,
sourceAppBundleId: sourceBundleId
)
}
private func itemFromRichText(_ pasteboard: NSPasteboard, sourceApp: String?, sourceBundleId: String?) -> ClipboardItem? {
guard let data = pasteboard.data(forType: .rtf),
let attributed = NSAttributedString(rtf: data, documentAttributes: nil)

View File

@@ -130,6 +130,10 @@ final class PasteActionService {
pasteboardItem.setString(dragLabel(for: item), forType: .string)
return [pasteboardItem]
case .color:
guard let color = ColorPayload.color(from: item.payload) else { return [] }
return [color]
case .richText:
if let data = cacheService.data(for: item.payload) {
let pasteboardItem = NSPasteboardItem()
@@ -184,6 +188,13 @@ final class PasteActionService {
guard let data = cacheService.data(for: item.payload) else { return false }
board.clearContents()
didWrite = board.setData(data, forType: .sound)
case .color:
guard let color = ColorPayload.color(from: item.payload) else { return false }
board.clearContents()
didWrite = board.writeObjects([color])
if didWrite {
board.setString(ColorPayload.displayHex(from: item.payload), forType: .string)
}
case .richText:
if let data = cacheService.data(for: item.payload) {
board.clearContents()
@@ -239,6 +250,8 @@ final class PasteActionService {
return nonEmptyPlainText(item.ocrText) ?? nonEmptyPlainText(item.displayText)
case .pdf, .audio:
return nonEmptyPlainText(item.displayText)
case .color:
return nonEmptyPlainText(ColorPayload.displayHex(from: item.payload)) ?? nonEmptyPlainText(item.displayText)
}
}

View File

@@ -82,7 +82,8 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
23: .images,
22: .files,
26: .pinned,
28: .audio
28: .audio,
25: .colors
]
private let viewModel: ClipboardPanelViewModel

View File

@@ -59,6 +59,7 @@ private enum ClipboardCollectionVisuals {
case .text: return NSColor(calibratedRed: 0.96, green: 0.64, blue: 0.00, alpha: 1)
case .links: return NSColor(calibratedRed: 0.02, green: 0.47, blue: 0.98, alpha: 1)
case .images: return NSColor(calibratedRed: 1.00, green: 0.22, blue: 0.25, alpha: 1)
case .colors: return NSColor(calibratedRed: 0.00, green: 0.65, blue: 0.74, alpha: 1)
case .audio: return NSColor(calibratedRed: 0.93, green: 0.12, blue: 0.34, alpha: 1)
case .files: return NSColor(calibratedRed: 0.11, green: 0.68, blue: 0.36, alpha: 1)
case .pinned: return NSColor(calibratedRed: 0.94, green: 0.12, blue: 0.48, alpha: 1)
@@ -625,6 +626,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
case .text: return "Text"
case .links: return "Links"
case .images: return "Images"
case .colors: return "Colors"
case .audio: return "Audio"
case .files: return "Files"
case .pinned: return "Pinned"
@@ -638,6 +640,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
case .text: return "text.alignleft"
case .links: return "link"
case .images: return "photo"
case .colors: return "paintpalette"
case .audio: return "music.note"
case .files: return "doc.fill"
case .pinned: return "pin.fill"
@@ -1130,6 +1133,8 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
switch viewModel.sortMode {
case .images:
return ("No images yet", "Image clips are saved when the clipboard contains image data.")
case .colors:
return ("No colors yet", "Copied color swatches appear here.")
case .links:
return ("No links yet", "Links are detected from copied URLs.")
case .text:
@@ -1933,7 +1938,8 @@ private enum ClipboardItemDragPasteboard {
.png,
.pdf,
.sound,
.rtf
.rtf,
.color
]
}
@@ -3009,14 +3015,14 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
switch itemKind {
case .url, .file, .image, .pdf, .audio:
return true
case .text, .richText, .unknown:
case .text, .richText, .unknown, .color:
return false
}
}
private var canPreview: Bool {
switch itemKind {
case .text, .url, .image, .richText, .file, .pdf, .audio, .unknown:
case .text, .url, .image, .richText, .file, .pdf, .audio, .unknown, .color:
return true
}
}
@@ -3027,7 +3033,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
private var canPlainText: Bool {
switch itemKind {
case .url, .image, .richText, .file, .pdf, .audio:
case .url, .image, .richText, .file, .pdf, .audio, .color:
return true
case .text, .unknown:
return false
@@ -3038,7 +3044,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
switch itemKind {
case .file, .image, .pdf, .audio:
return true
case .text, .richText, .url, .unknown:
case .text, .richText, .url, .unknown, .color:
return false
}
}
@@ -3613,6 +3619,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
return filePreviewView(for: item, thumbnail: thumbnail)
case .audio:
return audioPreviewView(for: item)
case .color:
return colorPreviewView(for: item)
case .text, .richText, .image, .unknown:
return textPreviewView(for: item)
}
@@ -4026,6 +4034,61 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
return container
}
private func colorPreviewView(for item: ClipboardItem) -> NSView {
let swatchColor = ColorPayload.color(from: item.payload) ?? accentColor(for: item.kind)
let textColor = ColorPayload.contrastingTextColor(for: swatchColor)
let container = NSView()
container.wantsLayer = true
container.layer?.backgroundColor = swatchColor.cgColor
container.translatesAutoresizingMaskIntoConstraints = false
let hex = NSTextField(labelWithString: ColorPayload.displayHex(from: item.payload))
hex.font = .monospacedDigitSystemFont(ofSize: layout.isCompact ? 19 : 22, weight: .bold)
hex.textColor = textColor
hex.alignment = .center
hex.maximumNumberOfLines = 1
hex.lineBreakMode = .byTruncatingTail
hex.toolTip = hex.stringValue
let components = NSTextField(labelWithString: ColorPayload.componentSummary(from: item.payload))
components.font = .monospacedDigitSystemFont(ofSize: 11, weight: .semibold)
components.textColor = textColor.withAlphaComponent(0.72)
components.alignment = .center
components.maximumNumberOfLines = 1
components.lineBreakMode = .byTruncatingTail
components.toolTip = components.stringValue
let labels = NSStackView(views: [hex, components])
labels.orientation = .vertical
labels.alignment = .centerX
labels.spacing = 6
labels.translatesAutoresizingMaskIntoConstraints = false
let outline = NSView()
outline.wantsLayer = true
outline.layer?.cornerRadius = 16
outline.layer?.borderWidth = 1
outline.layer?.borderColor = textColor.withAlphaComponent(0.24).cgColor
outline.layer?.backgroundColor = NSColor.white.withAlphaComponent(textColor == .white ? 0.10 : 0.26).cgColor
outline.translatesAutoresizingMaskIntoConstraints = false
container.addSubview(outline)
outline.addSubview(labels)
NSLayoutConstraint.activate([
outline.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: layout.inset),
outline.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -layout.inset),
outline.topAnchor.constraint(equalTo: container.topAnchor, constant: layout.isCompact ? 24 : 28),
outline.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: layout.isCompact ? -24 : -28),
labels.leadingAnchor.constraint(equalTo: outline.leadingAnchor, constant: 12),
labels.trailingAnchor.constraint(equalTo: outline.trailingAnchor, constant: -12),
labels.centerYAnchor.constraint(equalTo: outline.centerYAnchor),
hex.widthAnchor.constraint(equalTo: labels.widthAnchor),
components.widthAnchor.constraint(equalTo: labels.widthAnchor)
])
return container
}
private func mediaPreviewView(for item: ClipboardItem, thumbnail: NSImage) -> NSView {
let container = NSView()
container.translatesAutoresizingMaskIntoConstraints = false
@@ -4109,6 +4172,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
return thumbnail == nil ? "file-preview" : "file-media-preview"
case .audio:
return "audio-preview"
case .color:
return "color-preview"
case .richText:
return "rich-text-preview"
case .text:
@@ -4286,6 +4351,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
return audioTitle(for: item)
case .image:
return imageTitle(for: item)
case .color:
return ColorPayload.displayHex(from: item.payload)
default:
break
}
@@ -4314,6 +4381,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
return fileLocationText(from: item.payload, fallback: "PDF document")
case .audio:
return "Sound clip"
case .color:
return ColorPayload.componentSummary(from: item.payload)
case .richText:
let text = normalized(item.displayText)
return text.isEmpty ? "No preview available" : text
@@ -4345,6 +4414,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
return "PDF"
case .audio:
return "Audio"
case .color:
return "Color"
case .image:
if item.ocrText?.clipboardTrimmed.isEmpty == false {
return "OCR text"
@@ -4525,6 +4596,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
return NSColor(calibratedRed: 0.55, green: 0.35, blue: 0.88, alpha: 1)
case .audio:
return NSColor(calibratedRed: 0.93, green: 0.12, blue: 0.34, alpha: 1)
case .color:
return NSColor(calibratedRed: 0.00, green: 0.65, blue: 0.74, alpha: 1)
case .unknown:
return .systemGray
}
@@ -4539,6 +4612,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
case .file: return "doc"
case .pdf: return "doc.text.fill"
case .audio: return "music.note"
case .color: return "paintpalette"
case .unknown: return "questionmark"
}
}
@@ -4610,6 +4684,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
case .unknown: return "Unknown"
case .pdf: return "PDF"
case .audio: return "Audio"
case .color: return "Color"
}
}

View File

@@ -433,6 +433,8 @@ final class ClipboardPanelViewModel {
case .audio:
guard let url = cacheService.temporaryReadableURL(for: item) else { return }
NSWorkspace.shared.open(url)
case .color:
break
default:
break
}
@@ -454,6 +456,8 @@ final class ClipboardPanelViewModel {
case .audio:
guard let url = cacheService.temporaryReadableURL(for: item) else { return }
NSWorkspace.shared.activateFileViewerSelecting([url])
case .color:
break
default:
break
}
@@ -767,6 +771,12 @@ final class ClipboardPanelViewModel {
.sorted(by: sortByUsage)
.map(\.1)
case .colors:
return collectionFiltered
.filter { $0.1.kind == .color }
.sorted(by: sortByUsage)
.map(\.1)
case .pinned:
return collectionFiltered
.filter { $0.1.isPinned }
@@ -977,6 +987,8 @@ final class ClipboardPanelViewModel {
return [.pdf]
case "audio", "sound", "music":
return [.audio]
case "color", "colors", "swatch", "swatches", "hex":
return [.color]
case "unknown", "item":
return [.unknown]
default:

View File

@@ -169,6 +169,7 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD
kindCheckbox("Text", .text),
kindCheckbox("Links", .url),
kindCheckbox("Images", .image),
kindCheckbox("Colors", .color),
kindCheckbox("Audio", .audio),
kindCheckbox("Rich text", .richText),
kindCheckbox("PDFs", .pdf),