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.
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.
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

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),

View File

@@ -187,6 +187,35 @@ final class ClipboardMonitorServiceTests: XCTestCase {
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 {
let settings = SettingsModel(defaults: makeTestDefaults())
let (store, cacheService) = makeStoreAndCache(settings: settings)
@@ -592,6 +621,24 @@ final class ClipboardMonitorServiceTests: XCTestCase {
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 {
let settings = SettingsModel(defaults: makeTestDefaults())
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: 26, modifiers: [.command, .option]), .pinned)
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 28, modifiers: [.command, .option]), .audio)
XCTAssertEqual(ClipboardPanelController.collectionShortcutMode(forKeyCode: 25, modifiers: [.command, .option]), .colors)
}
func testCollectionShortcutsRequireCommandOptionSoQuickPasteKeepsCommandNumbers() {

View File

@@ -39,6 +39,51 @@ final class ClipboardPanelViewModelTests: XCTestCase {
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() {
let settings = makeSettings()
let store = makeStore(settings: settings)

View File

@@ -246,11 +246,11 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertEqual(
fixture.view.debugCollectionTitles,
["Clipboard", "Frequent", "Text", "Links", "Images", "Audio", "Files", "Pinned"]
["Clipboard", "Frequent", "Text", "Links", "Images", "Colors", "Audio", "Files", "Pinned"]
)
XCTAssertEqual(
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")
@@ -678,17 +678,18 @@ final class ClipboardPanelViewTests: XCTestCase {
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 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 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)
drainMainQueue()
}
XCTAssertEqual(fixture.viewModel.visibleItems.count, 6)
XCTAssertEqual(ClipboardSortMode.allCases.map { fixture.viewModel.collectionCount(for: $0) }, [6, 6, 2, 1, 1, 1, 1, 1])
XCTAssertEqual(fixture.view.debugCollectionCounts, [6, 6, 2, 1, 1, 1, 1, 1])
XCTAssertEqual(fixture.viewModel.visibleItems.count, 7)
XCTAssertEqual(ClipboardSortMode.allCases.map { fixture.viewModel.collectionCount(for: $0) }, [7, 7, 2, 1, 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))
}
@@ -1296,6 +1297,25 @@ final class ClipboardPanelViewTests: XCTestCase {
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) {
let fixture = makePanelFixture()
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() {
let service = PasteActionService()
let item = ClipboardItem(