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

@@ -66,6 +66,7 @@ Use this checklist before a release or after changes to panel, pasteboard, setti
33. Confirm cards from known apps show app identity in the header tile, falling back to source initials when an app icon is unavailable. 33. Confirm cards from known apps show app identity in the header tile, falling back to source initials when an app icon is unavailable.
34. Confirm multi-file cards show a stacked file preview, while single-file cards keep the regular file layout. 34. Confirm multi-file cards show a stacked file preview, while single-file cards keep the regular file layout.
35. Confirm the shelf chrome uses one row with compact search, collection chips, and utility buttons; typing a search expands the search field without pushing cards out of view. 35. Confirm the shelf chrome uses one row with compact search, collection chips, and utility buttons; typing a search expands the search field without pushing cards out of view.
36. Copy a color swatch from a design tool and confirm it appears as a Color card, can be filtered with the Colors chip, and copies back as both a color and hex text.
## Copy And Paste ## Copy And Paste

View File

@@ -9,6 +9,7 @@ enum ClipboardItemKind: Int {
case unknown case unknown
case pdf case pdf
case audio case audio
case color
var displayName: String { var displayName: String {
switch self { switch self {
@@ -20,6 +21,7 @@ enum ClipboardItemKind: Int {
case .unknown: return "item" case .unknown: return "item"
case .pdf: return "PDF" case .pdf: return "PDF"
case .audio: return "audio" case .audio: return "audio"
case .color: return "color"
} }
} }
} }
@@ -29,7 +31,7 @@ extension ClipboardItemKind {
switch self { switch self {
case .url, .file, .image, .pdf, .audio: case .url, .file, .image, .pdf, .audio:
return true return true
case .text, .richText, .unknown: case .text, .richText, .unknown, .color:
return false return false
} }
} }
@@ -38,7 +40,7 @@ extension ClipboardItemKind {
switch self { switch self {
case .file, .image, .pdf, .audio: case .file, .image, .pdf, .audio:
return true return true
case .text, .richText, .unknown, .url: case .text, .richText, .unknown, .url, .color:
return false return false
} }
} }
@@ -47,7 +49,7 @@ extension ClipboardItemKind {
switch self { switch self {
case .url, .image, .pdf, .audio, .richText: case .url, .image, .pdf, .audio, .richText:
return true return true
case .text, .file, .unknown: case .text, .file, .unknown, .color:
return false return false
} }
} }
@@ -62,8 +64,9 @@ enum ClipboardSortMode: Int {
case pinned case pinned
case files case files
case audio 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 { var title: String {
switch self { switch self {
@@ -75,6 +78,7 @@ enum ClipboardSortMode: Int {
case .pinned: return "Pinned" case .pinned: return "Pinned"
case .files: return "Files" case .files: return "Files"
case .audio: return "Audio" case .audio: return "Audio"
case .colors: return "Colors"
} }
} }
} }
@@ -146,6 +150,7 @@ struct ClipboardItem {
case .unknown: return "unknown" case .unknown: return "unknown"
case .pdf: return "pdf document" case .pdf: return "pdf document"
case .audio: return "audio sound" 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: case .file:
return filePreviewThumbnail(for: item.payload) return filePreviewThumbnail(for: item.payload)
case .text, .unknown, .audio, .richText: case .text, .unknown, .audio, .richText, .color:
return nil return nil
} }
} }
@@ -191,6 +191,9 @@ final class ClipboardCacheService {
let text = item.payload.clipboardTrimmed.isEmpty ? item.displayText : item.payload let text = item.payload.clipboardTrimmed.isEmpty ? item.displayText : item.payload
guard !text.clipboardTrimmed.isEmpty else { return nil } guard !text.clipboardTrimmed.isEmpty else { return nil }
return writeTemporaryCopy(data: Data(text.utf8), id: item.id, fileExtension: "txt") 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: case .url:
guard let data = webLocationData(for: item.payload) else { return nil } guard let data = webLocationData(for: item.payload) else { return nil }
return writeTemporaryCopy(data: data, id: item.id, fileExtension: "webloc") 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) 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) { if isIgnored(.image), hasImage(on: pasteboard) {
reportReadFailureStatus(ignoredKindMessage(.image)) reportReadFailureStatus(ignoredKindMessage(.image))
return nil return nil
@@ -407,6 +416,10 @@ final class ClipboardMonitorService {
pasteboard.data(forType: .sound) != nil pasteboard.data(forType: .sound) != nil
} }
private func hasColor(on pasteboard: NSPasteboard) -> Bool {
NSColor(from: pasteboard) != nil
}
private func hasFileItems(on pasteboard: NSPasteboard) -> Bool { private func hasFileItems(on pasteboard: NSPasteboard) -> Bool {
guard let urls = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL], !urls.isEmpty else { guard let urls = pasteboard.readObjects(forClasses: [NSURL.self], options: nil) as? [URL], !urls.isEmpty else {
return false 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? { private func itemFromRichText(_ pasteboard: NSPasteboard, sourceApp: String?, sourceBundleId: String?) -> ClipboardItem? {
guard let data = pasteboard.data(forType: .rtf), guard let data = pasteboard.data(forType: .rtf),
let attributed = NSAttributedString(rtf: data, documentAttributes: nil) let attributed = NSAttributedString(rtf: data, documentAttributes: nil)

View File

@@ -130,6 +130,10 @@ final class PasteActionService {
pasteboardItem.setString(dragLabel(for: item), forType: .string) pasteboardItem.setString(dragLabel(for: item), forType: .string)
return [pasteboardItem] return [pasteboardItem]
case .color:
guard let color = ColorPayload.color(from: item.payload) else { return [] }
return [color]
case .richText: case .richText:
if let data = cacheService.data(for: item.payload) { if let data = cacheService.data(for: item.payload) {
let pasteboardItem = NSPasteboardItem() let pasteboardItem = NSPasteboardItem()
@@ -184,6 +188,13 @@ final class PasteActionService {
guard let data = cacheService.data(for: item.payload) else { return false } guard let data = cacheService.data(for: item.payload) else { return false }
board.clearContents() board.clearContents()
didWrite = board.setData(data, forType: .sound) 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: case .richText:
if let data = cacheService.data(for: item.payload) { if let data = cacheService.data(for: item.payload) {
board.clearContents() board.clearContents()
@@ -239,6 +250,8 @@ final class PasteActionService {
return nonEmptyPlainText(item.ocrText) ?? nonEmptyPlainText(item.displayText) return nonEmptyPlainText(item.ocrText) ?? nonEmptyPlainText(item.displayText)
case .pdf, .audio: case .pdf, .audio:
return nonEmptyPlainText(item.displayText) 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, 23: .images,
22: .files, 22: .files,
26: .pinned, 26: .pinned,
28: .audio 28: .audio,
25: .colors
] ]
private let viewModel: ClipboardPanelViewModel 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 .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 .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 .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 .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 .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) 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 .text: return "Text"
case .links: return "Links" case .links: return "Links"
case .images: return "Images" case .images: return "Images"
case .colors: return "Colors"
case .audio: return "Audio" case .audio: return "Audio"
case .files: return "Files" case .files: return "Files"
case .pinned: return "Pinned" case .pinned: return "Pinned"
@@ -638,6 +640,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
case .text: return "text.alignleft" case .text: return "text.alignleft"
case .links: return "link" case .links: return "link"
case .images: return "photo" case .images: return "photo"
case .colors: return "paintpalette"
case .audio: return "music.note" case .audio: return "music.note"
case .files: return "doc.fill" case .files: return "doc.fill"
case .pinned: return "pin.fill" case .pinned: return "pin.fill"
@@ -1130,6 +1133,8 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
switch viewModel.sortMode { switch viewModel.sortMode {
case .images: case .images:
return ("No images yet", "Image clips are saved when the clipboard contains image data.") 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: case .links:
return ("No links yet", "Links are detected from copied URLs.") return ("No links yet", "Links are detected from copied URLs.")
case .text: case .text:
@@ -1933,7 +1938,8 @@ private enum ClipboardItemDragPasteboard {
.png, .png,
.pdf, .pdf,
.sound, .sound,
.rtf .rtf,
.color
] ]
} }
@@ -3009,14 +3015,14 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
switch itemKind { switch itemKind {
case .url, .file, .image, .pdf, .audio: case .url, .file, .image, .pdf, .audio:
return true return true
case .text, .richText, .unknown: case .text, .richText, .unknown, .color:
return false return false
} }
} }
private var canPreview: Bool { private var canPreview: Bool {
switch itemKind { switch itemKind {
case .text, .url, .image, .richText, .file, .pdf, .audio, .unknown: case .text, .url, .image, .richText, .file, .pdf, .audio, .unknown, .color:
return true return true
} }
} }
@@ -3027,7 +3033,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
private var canPlainText: Bool { private var canPlainText: Bool {
switch itemKind { switch itemKind {
case .url, .image, .richText, .file, .pdf, .audio: case .url, .image, .richText, .file, .pdf, .audio, .color:
return true return true
case .text, .unknown: case .text, .unknown:
return false return false
@@ -3038,7 +3044,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
switch itemKind { switch itemKind {
case .file, .image, .pdf, .audio: case .file, .image, .pdf, .audio:
return true return true
case .text, .richText, .url, .unknown: case .text, .richText, .url, .unknown, .color:
return false return false
} }
} }
@@ -3613,6 +3619,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
return filePreviewView(for: item, thumbnail: thumbnail) return filePreviewView(for: item, thumbnail: thumbnail)
case .audio: case .audio:
return audioPreviewView(for: item) return audioPreviewView(for: item)
case .color:
return colorPreviewView(for: item)
case .text, .richText, .image, .unknown: case .text, .richText, .image, .unknown:
return textPreviewView(for: item) return textPreviewView(for: item)
} }
@@ -4026,6 +4034,61 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
return container 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 { private func mediaPreviewView(for item: ClipboardItem, thumbnail: NSImage) -> NSView {
let container = NSView() let container = NSView()
container.translatesAutoresizingMaskIntoConstraints = false container.translatesAutoresizingMaskIntoConstraints = false
@@ -4109,6 +4172,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
return thumbnail == nil ? "file-preview" : "file-media-preview" return thumbnail == nil ? "file-preview" : "file-media-preview"
case .audio: case .audio:
return "audio-preview" return "audio-preview"
case .color:
return "color-preview"
case .richText: case .richText:
return "rich-text-preview" return "rich-text-preview"
case .text: case .text:
@@ -4286,6 +4351,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
return audioTitle(for: item) return audioTitle(for: item)
case .image: case .image:
return imageTitle(for: item) return imageTitle(for: item)
case .color:
return ColorPayload.displayHex(from: item.payload)
default: default:
break break
} }
@@ -4314,6 +4381,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
return fileLocationText(from: item.payload, fallback: "PDF document") return fileLocationText(from: item.payload, fallback: "PDF document")
case .audio: case .audio:
return "Sound clip" return "Sound clip"
case .color:
return ColorPayload.componentSummary(from: item.payload)
case .richText: case .richText:
let text = normalized(item.displayText) let text = normalized(item.displayText)
return text.isEmpty ? "No preview available" : text return text.isEmpty ? "No preview available" : text
@@ -4345,6 +4414,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
return "PDF" return "PDF"
case .audio: case .audio:
return "Audio" return "Audio"
case .color:
return "Color"
case .image: case .image:
if item.ocrText?.clipboardTrimmed.isEmpty == false { if item.ocrText?.clipboardTrimmed.isEmpty == false {
return "OCR text" 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) return NSColor(calibratedRed: 0.55, green: 0.35, blue: 0.88, alpha: 1)
case .audio: case .audio:
return NSColor(calibratedRed: 0.93, green: 0.12, blue: 0.34, alpha: 1) 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: case .unknown:
return .systemGray return .systemGray
} }
@@ -4539,6 +4612,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
case .file: return "doc" case .file: return "doc"
case .pdf: return "doc.text.fill" case .pdf: return "doc.text.fill"
case .audio: return "music.note" case .audio: return "music.note"
case .color: return "paintpalette"
case .unknown: return "questionmark" case .unknown: return "questionmark"
} }
} }
@@ -4610,6 +4684,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
case .unknown: return "Unknown" case .unknown: return "Unknown"
case .pdf: return "PDF" case .pdf: return "PDF"
case .audio: return "Audio" case .audio: return "Audio"
case .color: return "Color"
} }
} }

View File

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

View File

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

View File

@@ -187,6 +187,35 @@ final class ClipboardMonitorServiceTests: XCTestCase {
XCTAssertEqual(NSPasteboard.general.data(forType: .sound), audioData) XCTAssertEqual(NSPasteboard.general.data(forType: .sound), audioData)
} }
func testPollNowCapturesColorAsRestorableSwatch() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
let (store, cacheService) = makeStoreAndCache(settings: settings)
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
let color = NSColor(deviceRed: 10 / 255, green: 132 / 255, blue: 255 / 255, alpha: 1)
let captured = expectation(description: "color captured")
store.observeItems { items in
if items.contains(where: { $0.kind == .color && $0.payload == "#0A84FF" }) {
captured.fulfill()
}
}
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.writeObjects([color]))
monitor.pollNowAndWait()
wait(for: [captured], timeout: 1.0)
let item = try XCTUnwrap(store.items.first(where: { $0.kind == .color }))
XCTAssertEqual(item.displayText, "#0A84FF")
XCTAssertEqual(item.payload, "#0A84FF")
XCTAssertEqual(PasteActionService(cacheService: cacheService).copy(item), .copied)
let restored = try XCTUnwrap(NSColor(from: NSPasteboard.general))
XCTAssertEqual(ColorPayload.hexString(from: restored), "#0A84FF")
XCTAssertEqual(NSPasteboard.general.string(forType: .string), "#0A84FF")
}
func testPollNowCapturesFileReference() throws { func testPollNowCapturesFileReference() throws {
let settings = SettingsModel(defaults: makeTestDefaults()) let settings = SettingsModel(defaults: makeTestDefaults())
let (store, cacheService) = makeStoreAndCache(settings: settings) let (store, cacheService) = makeStoreAndCache(settings: settings)
@@ -592,6 +621,24 @@ final class ClipboardMonitorServiceTests: XCTestCase {
XCTAssertTrue(try imageCacheFileURLs(in: baseURL).isEmpty) XCTAssertTrue(try imageCacheFileURLs(in: baseURL).isEmpty)
} }
func testIgnoredColorKindDoesNotCaptureSwatch() throws {
let settings = SettingsModel(defaults: makeTestDefaults())
settings.ignoredItemKindsRaw = [ClipboardItemKind.color.rawValue]
let (store, cacheService, _) = makeStoreCacheAndBaseURL(settings: settings)
let monitor = ClipboardMonitorService(store: store, cacheService: cacheService, settings: settings)
let color = NSColor(deviceRed: 10 / 255, green: 132 / 255, blue: 255 / 255, alpha: 1)
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
XCTAssertTrue(pasteboard.writeObjects([color]))
monitor.pollNowAndWait()
RunLoop.main.run(until: Date().addingTimeInterval(0.05))
XCTAssertTrue(store.items.isEmpty)
XCTAssertEqual(settings.captureStatusMessage, "Skipped: Color items are ignored in capture settings.")
}
func testIgnoredPDFKindDoesNotWriteAttachmentFiles() throws { func testIgnoredPDFKindDoesNotWriteAttachmentFiles() throws {
let settings = SettingsModel(defaults: makeTestDefaults()) let settings = SettingsModel(defaults: makeTestDefaults())
settings.ignoredItemKindsRaw = [ClipboardItemKind.pdf.rawValue] settings.ignoredItemKindsRaw = [ClipboardItemKind.pdf.rawValue]

View File

@@ -156,6 +156,7 @@ final class ClipboardPanelControllerTests: XCTestCase {
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 22, modifiers: [.command, .option]), .files) XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 22, modifiers: [.command, .option]), .files)
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 26, modifiers: [.command, .option]), .pinned) XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 26, modifiers: [.command, .option]), .pinned)
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 28, modifiers: [.command, .option]), .audio) XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 28, modifiers: [.command, .option]), .audio)
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 25, modifiers: [.command, .option]), .colors)
} }
func testCollectionShortcutsRequireCommandOptionSoQuickPasteKeepsCommandNumbers() { func testCollectionShortcutsRequireCommandOptionSoQuickPasteKeepsCommandNumbers() {

View File

@@ -39,6 +39,51 @@ final class ClipboardPanelViewModelTests: XCTestCase {
XCTAssertEqual(pinnedOnly.map(\.payload), ["four", "one"]) XCTAssertEqual(pinnedOnly.map(\.payload), ["four", "one"])
} }
func testComputeVisibleItemsFiltersColorClipsAndStructuredType() {
let settings = makeSettings()
let store = makeStore(settings: settings)
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: ClipboardCacheService())
let color = ClipboardItem(
id: UUID(),
kind: .color,
displayText: "#0A84FF",
payload: "#0A84FF",
payloadHash: hash("#0A84FF"),
createdAt: Date(timeIntervalSince1970: 100),
lastUsedAt: Date(timeIntervalSince1970: 100),
useCount: 0,
sourceApp: "Design Tool",
imagePath: nil,
thumbnailPath: nil
)
let text = ClipboardItem(
id: UUID(),
kind: .text,
displayText: "Color note",
payload: "Color note",
payloadHash: hash("Color note"),
createdAt: Date(timeIntervalSince1970: 200),
lastUsedAt: Date(timeIntervalSince1970: 200),
useCount: 0,
sourceApp: "Notes",
imagePath: nil,
thumbnailPath: nil
)
XCTAssertEqual(
viewModel.computeVisibleItems(from: [text, color], query: "", sortMode: .colors).map(\.payload),
["#0A84FF"]
)
XCTAssertEqual(
viewModel.computeVisibleItems(from: [text, color], query: "type:swatch", sortMode: .mostRecent).map(\.payload),
["#0A84FF"]
)
XCTAssertEqual(
viewModel.computeVisibleItems(from: [text, color], query: "hex 0a84ff", sortMode: .mostRecent).map(\.payload),
["#0A84FF"]
)
}
func testSearchMatchesIndependentTokensCaseInsensitively() { func testSearchMatchesIndependentTokensCaseInsensitively() {
let settings = makeSettings() let settings = makeSettings()
let store = makeStore(settings: settings) let store = makeStore(settings: settings)

View File

@@ -246,11 +246,11 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertEqual( XCTAssertEqual(
fixture.view.debugCollectionTitles, fixture.view.debugCollectionTitles,
["Clipboard", "Frequent", "Text", "Links", "Images", "Audio", "Files", "Pinned"] ["Clipboard", "Frequent", "Text", "Links", "Images", "Colors", "Audio", "Files", "Pinned"]
) )
XCTAssertEqual( XCTAssertEqual(
fixture.view.debugCollectionLeadingSymbols, fixture.view.debugCollectionLeadingSymbols,
["doc.on.clipboard", "chart.bar.fill", "text.alignleft", "link", "photo", "music.note", "doc.fill", "pin.fill"] ["doc.on.clipboard", "chart.bar.fill", "text.alignleft", "link", "photo", "paintpalette", "music.note", "doc.fill", "pin.fill"]
) )
XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Clipboard") XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Clipboard")
@@ -678,17 +678,18 @@ final class ClipboardPanelViewTests: XCTestCase {
let rich = makeItem(kind: .richText, text: "Rich note", store: fixture.store) let rich = makeItem(kind: .richText, text: "Rich note", store: fixture.store)
let link = makeItem(kind: .url, text: "https://example.com/releases", store: fixture.store) let link = makeItem(kind: .url, text: "https://example.com/releases", store: fixture.store)
let image = makeItem(kind: .image, text: "image payload", store: fixture.store) let image = makeItem(kind: .image, text: "image payload", store: fixture.store)
let color = makeItem(kind: .color, displayText: "#0A84FF", payload: "#0A84FF", store: fixture.store)
let audio = makeItem(kind: .audio, text: "audio payload", store: fixture.store) let audio = makeItem(kind: .audio, text: "audio payload", store: fixture.store)
let file = makeItem(kind: .file, text: "/tmp/report.pdf", store: fixture.store) let file = makeItem(kind: .file, text: "/tmp/report.pdf", store: fixture.store)
[pinned, rich, link, image, audio, file].forEach { [pinned, rich, link, image, color, audio, file].forEach {
fixture.store.upsert($0) fixture.store.upsert($0)
drainMainQueue() drainMainQueue()
} }
XCTAssertEqual(fixture.viewModel.visibleItems.count, 6) XCTAssertEqual(fixture.viewModel.visibleItems.count, 7)
XCTAssertEqual(ClipboardSortMode.allCases.map { fixture.viewModel.collectionCount(for: $0) }, [6, 6, 2, 1, 1, 1, 1, 1]) XCTAssertEqual(ClipboardSortMode.allCases.map { fixture.viewModel.collectionCount(for: $0) }, [7, 7, 2, 1, 1, 1, 1, 1, 1])
XCTAssertEqual(fixture.view.debugCollectionCounts, [6, 6, 2, 1, 1, 1, 1, 1]) XCTAssertEqual(fixture.view.debugCollectionCounts, [7, 7, 2, 1, 1, 1, 1, 1, 1])
XCTAssertEqual(fixture.view.debugCollectionCountLabelHiddenStates, Array(repeating: false, count: ClipboardSortMode.allCases.count)) XCTAssertEqual(fixture.view.debugCollectionCountLabelHiddenStates, Array(repeating: false, count: ClipboardSortMode.allCases.count))
} }
@@ -1296,6 +1297,25 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["audio-preview"]) XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["audio-preview"])
} }
func testColorCardsUseSwatchPreview() {
let fixture = makePanelFixture()
let item = makeItem(
kind: .color,
displayText: "#0A84FF",
payload: "#0A84FF",
store: fixture.store
)
fixture.store.upsert(item)
fixture.viewModel.sortMode = .colors
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugCardAccessibilityLabels, ["Color: #0A84FF"])
XCTAssertEqual(fixture.view.debugCardPreviewSummaries, ["#0A84FF|RGB 10 132 255|Color"])
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["color-preview"])
}
private func makePanelWithPanelView() -> (NSWindow, ClipboardPanelView) { private func makePanelWithPanelView() -> (NSWindow, ClipboardPanelView) {
let fixture = makePanelFixture() let fixture = makePanelFixture()
return (fixture.window, fixture.view) return (fixture.window, fixture.view)

View File

@@ -83,6 +83,30 @@ final class PasteActionServiceTests: XCTestCase {
) )
} }
func testCopyWritesColorToPasteboardWithHexFallback() throws {
let service = PasteActionService()
let item = ClipboardItem(
id: UUID(),
kind: .color,
displayText: "#0A84FF",
payload: "#0A84FF",
payloadHash: "hash",
createdAt: Date(),
lastUsedAt: Date(),
useCount: 0,
sourceApp: nil,
imagePath: nil,
thumbnailPath: nil
)
XCTAssertEqual(service.copy(item), .copied)
let restored = try XCTUnwrap(NSColor(from: NSPasteboard.general))
XCTAssertEqual(ColorPayload.hexString(from: restored), "#0A84FF")
XCTAssertEqual(NSPasteboard.general.string(forType: .string), "#0A84FF")
XCTAssertEqual(service.copyPlainText(item), .copiedPlainText)
XCTAssertEqual(NSPasteboard.general.string(forType: .string), "#0A84FF")
}
func testPasteWithoutTargetCopiesWithoutRequestingAutomaticPaste() { func testPasteWithoutTargetCopiesWithoutRequestingAutomaticPaste() {
let service = PasteActionService() let service = PasteActionService()
let item = ClipboardItem( let item = ClipboardItem(