WIP: add collection-colored card headers

This commit is contained in:
Akshay Kolli
2026-06-30 03:36:36 -07:00
parent a2404ad4f9
commit 0b8e0d6be1
4 changed files with 136 additions and 31 deletions

View File

@@ -17,7 +17,7 @@ The project is intentionally dependency-light: Swift Package Manager, AppKit, Ca
- SQLite persistence with bounded history, pinned-item retention, and encrypted app-managed payloads
- Search with independent token matching, structured filters such as `app:Safari`, `type:image`, `date:2026-06-30`, result jump-back to full history, and optional local OCR for copied images
- Sort modes for recent, most used, images, links, text, files, audio, and pinned items
- Custom named collections for organizing clips from the card Collect control, context menu, or by dragging cards onto collection chips
- Custom named collections for organizing clips from the card Collect control, context menu, or by dragging cards onto collection chips, with consistent collection colors in chips and collection card headers
- Copy and paste actions with Accessibility permission fallback
- Image thumbnail cache with byte and file-count pruning
- Configurable history length, cache limit, polling profile, ignored apps, content kinds, launch-at-login, Dock/menu-bar presence, and clear-on-quit behavior, with card-level capture rules for ignoring a source app or content type

View File

@@ -40,7 +40,7 @@ Use this checklist before a release or after changes to panel, pasteboard, setti
7. Reopen the panel, change sort segments, and confirm each segment updates results.
8. Right-click a card, choose Add to Collection > New Collection..., enter `Client Work`, and confirm a Client Work chip appears with the item count.
9. Select another card and confirm its Collect button offers Client Work as a reusable destination.
10. Select the Client Work chip and confirm the rail filters to assigned items; quit and reopen ClipBored and confirm the assignment persists.
10. Select the Client Work chip and confirm the rail filters to assigned items, cards use the Client Work name/color in their headers, and the assignment persists after quitting and reopening ClipBored.
11. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry.
12. Right-click a card, use Capture Rules to ignore its source app, copy from that app again, and confirm the new item is skipped.
13. Drag an unassigned card onto the Client Work chip and confirm the chip count increases and the card appears when Client Work is selected.

View File

@@ -40,6 +40,56 @@ private struct ClipboardItemCardLayout: Equatable {
}
}
private enum ClipboardCollectionVisuals {
private static let customPalette: [NSColor] = [
NSColor(calibratedRed: 0.98, green: 0.30, blue: 0.32, alpha: 1),
NSColor(calibratedRed: 0.96, green: 0.64, blue: 0.00, alpha: 1),
NSColor(calibratedRed: 0.04, green: 0.47, blue: 0.95, alpha: 1),
NSColor(calibratedRed: 0.18, green: 0.72, blue: 0.34, alpha: 1),
NSColor(calibratedRed: 0.55, green: 0.35, blue: 0.88, alpha: 1),
NSColor(calibratedRed: 0.93, green: 0.12, blue: 0.34, alpha: 1),
NSColor(calibratedRed: 0.10, green: 0.62, blue: 0.72, alpha: 1),
NSColor(calibratedRed: 0.74, green: 0.42, blue: 0.16, alpha: 1)
]
static func color(for mode: ClipboardSortMode) -> NSColor {
switch mode {
case .mostRecent: return .secondaryLabelColor
case .mostUsed: return NSColor(calibratedRed: 0.58, green: 0.42, blue: 0.92, 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 .images: return NSColor(calibratedRed: 1.00, green: 0.22, blue: 0.25, 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)
}
}
static func color(forCollectionNamed name: String) -> NSColor {
switch name {
case "Useful Links":
return customPalette[0]
case "Important Notes":
return customPalette[1]
case "Code Snippets":
return customPalette[2]
case "Read Later":
return customPalette[3]
default:
return customPalette[stablePaletteIndex(for: name)]
}
}
private static func stablePaletteIndex(for name: String) -> Int {
var hash: UInt64 = 1_469_598_103_934_665_603
for scalar in name.lowercased().unicodeScalars {
hash ^= UInt64(scalar.value)
hash &*= 1_099_511_628_211
}
return Int(hash % UInt64(customPalette.count))
}
}
final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
private enum Metrics {
static let actionButtonSize: CGFloat = 30
@@ -522,31 +572,11 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
}
private func collectionColor(for mode: ClipboardSortMode) -> NSColor {
switch mode {
case .mostRecent: return .secondaryLabelColor
case .mostUsed: return NSColor(calibratedRed: 0.58, green: 0.42, blue: 0.92, 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 .images: return NSColor(calibratedRed: 1.00, green: 0.22, blue: 0.25, 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)
}
ClipboardCollectionVisuals.color(for: mode)
}
private func collectionColor(forCollectionNamed name: String) -> NSColor {
switch name {
case "Useful Links":
return NSColor(calibratedRed: 0.98, green: 0.30, blue: 0.32, alpha: 1)
case "Important Notes":
return NSColor(calibratedRed: 0.96, green: 0.64, blue: 0.00, alpha: 1)
case "Code Snippets":
return NSColor(calibratedRed: 0.04, green: 0.47, blue: 0.95, alpha: 1)
case "Read Later":
return NSColor(calibratedRed: 0.18, green: 0.72, blue: 0.34, alpha: 1)
default:
return NSColor(calibratedRed: 0.52, green: 0.42, blue: 0.86, alpha: 1)
}
ClipboardCollectionVisuals.color(forCollectionNamed: name)
}
private func applyCardDensity() {
@@ -639,7 +669,8 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
collectionNames: collectionNames,
isStacked: viewModel.isItemStacked(at: index),
stackCount: viewModel.stackCount,
canShowInClipboard: viewModel.canShowVisibleItemsInClipboard
canShowInClipboard: viewModel.canShowVisibleItemsInClipboard,
selectedCollectionName: viewModel.selectedCollectionName
)
card.onSelect = { [weak self] selected in
self?.viewModel.selectItem(at: selected)
@@ -1114,6 +1145,22 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
cardViews.map(\.debugHeaderBadgeSymbol)
}
var debugFirstCardHeaderTitle: String {
cardViews.first?.debugHeaderTitle ?? ""
}
var debugFirstCardHeaderSubtitle: String {
cardViews.first?.debugHeaderSubtitle ?? ""
}
var debugFirstCardHeaderColorHex: String {
cardViews.first?.debugHeaderColorHex ?? ""
}
var debugFirstCardFooterDetailText: String {
cardViews.first?.debugFooterDetailText ?? ""
}
var debugQuickPasteBadgeTexts: [String] {
cardViews.compactMap(\.debugQuickPasteBadgeText)
}
@@ -1199,6 +1246,12 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
return viewModel.collectionNames.compactMap { customCollectionButtons[$0]?.count }
}
var debugCustomCollectionColorHexes: [String: String] {
Dictionary(uniqueKeysWithValues: viewModel.collectionNames.map { name in
(name, ClipboardItemCardView.debugHex(ClipboardCollectionVisuals.color(forCollectionNamed: name)))
})
}
var debugStackChipIsVisible: Bool {
collectionStack.arrangedSubviews.contains(stackChip)
}
@@ -1643,6 +1696,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
private let itemSourceAppName: String?
private let itemSourceAppBundleID: String?
private let itemCollectionName: String?
private let activeCollectionName: String?
private let activeCollectionColor: NSColor?
private let collectionNames: [String]
private let contentView = NSView()
private let footerDetailLabel = NSTextField(labelWithString: "")
@@ -1664,8 +1719,12 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
collectionNames: [String] = [],
isStacked: Bool = false,
stackCount: Int = 0,
canShowInClipboard: Bool = false
canShowInClipboard: Bool = false,
selectedCollectionName: String? = nil
) {
let normalizedItemCollection = ClipboardCollectionDefaults.normalizedName(item.collectionName)
let normalizedSelectedCollection = ClipboardCollectionDefaults.normalizedName(selectedCollectionName)
let activeCollection = normalizedSelectedCollection == normalizedItemCollection ? normalizedSelectedCollection : nil
self.index = index
self.itemID = item.id
self.layout = layout
@@ -1676,7 +1735,9 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
self.canShowInClipboard = canShowInClipboard
self.itemSourceAppName = Self.presentSourceText(item.sourceApp)
self.itemSourceAppBundleID = Self.presentSourceText(item.sourceAppBundleId)
self.itemCollectionName = ClipboardCollectionDefaults.normalizedName(item.collectionName)
self.itemCollectionName = normalizedItemCollection
self.activeCollectionName = activeCollection
self.activeCollectionColor = activeCollection.map(ClipboardCollectionVisuals.color(forCollectionNamed:))
self.collectionNames = collectionNames.compactMap { ClipboardCollectionDefaults.normalizedName($0) }
super.init(frame: .zero)
configure(item: item, thumbnail: thumbnail)
@@ -1806,6 +1867,9 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
private(set) var debugPreviewSummary = ""
private(set) var debugPreviewStyle = ""
private(set) var debugHeaderBadgeSymbol = ""
private(set) var debugHeaderTitle = ""
private(set) var debugHeaderSubtitle = ""
private(set) var debugHeaderColorHex = ""
var debugMenuTitles: [String] {
contextMenu().items.map { $0.isSeparatorItem ? "-" : $0.title }
@@ -1852,9 +1916,21 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
quickPasteBadgeLabel?.stringValue
}
var debugFooterDetailText: String {
footerDetailLabel.stringValue
}
var debugItemID: UUID {
itemID
}
static func debugHex(_ color: NSColor) -> String {
let rgb = color.usingColorSpace(.deviceRGB) ?? color
let red = Int((rgb.redComponent * 255).rounded())
let green = Int((rgb.greenComponent * 255).rounded())
let blue = Int((rgb.blueComponent * 255).rounded())
return String(format: "#%02X%02X%02X", red, green, blue)
}
#endif
private func contextMenu() -> NSMenu {
@@ -2242,6 +2318,9 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
debugPreviewSummary = "\(titleText(for: item))|\(previewText(for: item))|\(detailMetricText(for: item))"
debugPreviewStyle = previewStyle(for: item, thumbnail: thumbnail)
debugHeaderBadgeSymbol = headerBadgeSymbol(for: item.kind)
debugHeaderTitle = headerTitle(for: item)
debugHeaderSubtitle = headerSubtitle(for: item)
debugHeaderColorHex = Self.debugHex(headerColor(for: item))
#endif
wantsLayer = true
@@ -2302,17 +2381,17 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
private func headerView(for item: ClipboardItem) -> NSView {
let header = NSView()
header.wantsLayer = true
header.layer?.backgroundColor = accentColor(for: item.kind).cgColor
header.layer?.backgroundColor = headerColor(for: item).cgColor
header.heightAnchor.constraint(equalToConstant: layout.headerHeight).isActive = true
let kind = NSTextField(labelWithString: kindLabel(for: item.kind))
let kind = NSTextField(labelWithString: headerTitle(for: item))
kind.font = .systemFont(ofSize: layout.isCompact ? 15 : 16, weight: .bold)
kind.textColor = .white
kind.lineBreakMode = .byTruncatingTail
kind.maximumNumberOfLines = 1
kind.toolTip = kind.stringValue
let source = NSTextField(labelWithString: Self.relativeDateText(for: item.createdAt))
let source = NSTextField(labelWithString: headerSubtitle(for: item))
source.font = .systemFont(ofSize: layout.isCompact ? 10 : 11, weight: .regular)
source.textColor = NSColor.white.withAlphaComponent(0.72)
source.lineBreakMode = .byTruncatingTail
@@ -2382,6 +2461,20 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
return header
}
private func headerTitle(for item: ClipboardItem) -> String {
activeCollectionName ?? kindLabel(for: item.kind)
}
private func headerSubtitle(for item: ClipboardItem) -> String {
let relativeDate = Self.relativeDateText(for: item.createdAt)
guard activeCollectionName != nil else { return relativeDate }
return "\(kindLabel(for: item.kind)) - \(relativeDate)"
}
private func headerColor(for item: ClipboardItem) -> NSColor {
activeCollectionColor ?? accentColor(for: item.kind)
}
private func quickPasteBadge() -> NSTextField? {
guard index < 9 else { return nil }
let label = NSTextField(labelWithString: "\(index + 1)")
@@ -2839,7 +2932,9 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
source.toolTip = source.stringValue
let detailText = detailMetricText(for: item)
if let collectionName = item.collectionName?.clipboardTrimmed, !collectionName.isEmpty {
if activeCollectionName == nil,
let collectionName = item.collectionName?.clipboardTrimmed,
!collectionName.isEmpty {
footerDetailLabel.stringValue = "\(collectionName) - \(detailText)"
} else {
footerDetailLabel.stringValue = detailText

View File

@@ -203,11 +203,21 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertEqual(fixture.viewModel.statusMessage, "Added to Research Stack")
XCTAssertEqual(fixture.view.debugCustomCollectionTitles, ["Research Stack"])
XCTAssertEqual(fixture.view.debugFirstCardHeaderTitle, "Text")
XCTAssertEqual(fixture.view.debugFirstCardFooterDetailText, "Research Stack - 17 characters")
fixture.viewModel.selectCollection(named: "Research Stack")
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Collect this note"])
XCTAssertEqual(fixture.view.debugFirstCardHeaderTitle, "Research Stack")
XCTAssertEqual(fixture.view.debugFirstCardHeaderSubtitle, "Text - Just now")
XCTAssertEqual(
fixture.view.debugFirstCardHeaderColorHex,
fixture.view.debugCustomCollectionColorHexes["Research Stack"] ?? ""
)
XCTAssertEqual(fixture.view.debugFirstCardFooterDetailText, "17 characters")
}
func testSelectedCardActionsRespectSelectedKind() {