From c9ef1d5e84293d32b51a8bc4cbabff0cfef6aa63 Mon Sep 17 00:00:00 2001 From: Akshay Kolli Date: Wed, 1 Jul 2026 14:52:54 -0700 Subject: [PATCH] WIP: add color clip support --- docs/SMOKE_TEST.md | 1 + sources/clipbored/models/ClipboardItem.swift | 13 ++- sources/clipbored/models/ColorPayload.swift | 75 ++++++++++++++++ .../services/ClipboardCacheService.swift | 5 +- .../services/ClipboardMonitorService.swift | 37 ++++++++ .../services/PasteActionService.swift | 13 +++ .../views/ClipboardPanelController.swift | 3 +- .../clipbored/views/ClipboardPanelView.swift | 85 +++++++++++++++++-- .../views/ClipboardPanelViewModel.swift | 12 +++ .../views/SettingsWindowController.swift | 1 + .../ClipboardMonitorServiceTests.swift | 47 ++++++++++ .../ClipboardPanelControllerTests.swift | 1 + .../ClipboardPanelViewModelTests.swift | 45 ++++++++++ .../ClipboardPanelViewTests.swift | 32 +++++-- .../PasteActionServiceTests.swift | 24 ++++++ 15 files changed, 377 insertions(+), 17 deletions(-) create mode 100644 sources/clipbored/models/ColorPayload.swift diff --git a/docs/SMOKE_TEST.md b/docs/SMOKE_TEST.md index 8e0b28d..2635122 100644 --- a/docs/SMOKE_TEST.md +++ b/docs/SMOKE_TEST.md @@ -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 diff --git a/sources/clipbored/models/ClipboardItem.swift b/sources/clipbored/models/ClipboardItem.swift index 066a39a..0980259 100644 --- a/sources/clipbored/models/ClipboardItem.swift +++ b/sources/clipbored/models/ClipboardItem.swift @@ -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" } } diff --git a/sources/clipbored/models/ColorPayload.swift b/sources/clipbored/models/ColorPayload.swift new file mode 100644 index 0000000..9304322 --- /dev/null +++ b/sources/clipbored/models/ColorPayload.swift @@ -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()) + } +} diff --git a/sources/clipbored/services/ClipboardCacheService.swift b/sources/clipbored/services/ClipboardCacheService.swift index addfc94..b9a3732 100644 --- a/sources/clipbored/services/ClipboardCacheService.swift +++ b/sources/clipbored/services/ClipboardCacheService.swift @@ -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") diff --git a/sources/clipbored/services/ClipboardMonitorService.swift b/sources/clipbored/services/ClipboardMonitorService.swift index 1820a34..1e3ae21 100644 --- a/sources/clipbored/services/ClipboardMonitorService.swift +++ b/sources/clipbored/services/ClipboardMonitorService.swift @@ -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) diff --git a/sources/clipbored/services/PasteActionService.swift b/sources/clipbored/services/PasteActionService.swift index c50086d..742763a 100644 --- a/sources/clipbored/services/PasteActionService.swift +++ b/sources/clipbored/services/PasteActionService.swift @@ -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) } } diff --git a/sources/clipbored/views/ClipboardPanelController.swift b/sources/clipbored/views/ClipboardPanelController.swift index 172cda1..311447c 100644 --- a/sources/clipbored/views/ClipboardPanelController.swift +++ b/sources/clipbored/views/ClipboardPanelController.swift @@ -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 diff --git a/sources/clipbored/views/ClipboardPanelView.swift b/sources/clipbored/views/ClipboardPanelView.swift index 01027e5..1f36fc7 100644 --- a/sources/clipbored/views/ClipboardPanelView.swift +++ b/sources/clipbored/views/ClipboardPanelView.swift @@ -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" } } diff --git a/sources/clipbored/views/ClipboardPanelViewModel.swift b/sources/clipbored/views/ClipboardPanelViewModel.swift index 211fc7c..360d44f 100644 --- a/sources/clipbored/views/ClipboardPanelViewModel.swift +++ b/sources/clipbored/views/ClipboardPanelViewModel.swift @@ -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: diff --git a/sources/clipbored/views/SettingsWindowController.swift b/sources/clipbored/views/SettingsWindowController.swift index 48fa710..0b72da5 100644 --- a/sources/clipbored/views/SettingsWindowController.swift +++ b/sources/clipbored/views/SettingsWindowController.swift @@ -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), diff --git a/tests/clipboredtests/ClipboardMonitorServiceTests.swift b/tests/clipboredtests/ClipboardMonitorServiceTests.swift index abb310f..95a7044 100644 --- a/tests/clipboredtests/ClipboardMonitorServiceTests.swift +++ b/tests/clipboredtests/ClipboardMonitorServiceTests.swift @@ -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] diff --git a/tests/clipboredtests/ClipboardPanelControllerTests.swift b/tests/clipboredtests/ClipboardPanelControllerTests.swift index 2f1e8ae..e3d7640 100644 --- a/tests/clipboredtests/ClipboardPanelControllerTests.swift +++ b/tests/clipboredtests/ClipboardPanelControllerTests.swift @@ -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() { diff --git a/tests/clipboredtests/ClipboardPanelViewModelTests.swift b/tests/clipboredtests/ClipboardPanelViewModelTests.swift index 1bed948..2c98d26 100644 --- a/tests/clipboredtests/ClipboardPanelViewModelTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewModelTests.swift @@ -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) diff --git a/tests/clipboredtests/ClipboardPanelViewTests.swift b/tests/clipboredtests/ClipboardPanelViewTests.swift index 8c1362b..3c2eb78 100644 --- a/tests/clipboredtests/ClipboardPanelViewTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewTests.swift @@ -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) diff --git a/tests/clipboredtests/PasteActionServiceTests.swift b/tests/clipboredtests/PasteActionServiceTests.swift index cc1fdbe..69f5877 100644 --- a/tests/clipboredtests/PasteActionServiceTests.swift +++ b/tests/clipboredtests/PasteActionServiceTests.swift @@ -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(