WIP: add color clip support
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
75
sources/clipbored/models/ColorPayload.swift
Normal file
75
sources/clipbored/models/ColorPayload.swift
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user