WIP: add empty color-coded collections

This commit is contained in:
Akshay Kolli
2026-06-30 03:51:06 -07:00
parent 0b8e0d6be1
commit 16e2200244
11 changed files with 321 additions and 60 deletions

View File

@@ -437,7 +437,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
case .pollProfile:
monitor.setPaused(settings.pauseCapture)
case .status, .other:
case .status, .collections, .other:
break
case .captureStatus:
break

View File

@@ -1,7 +1,7 @@
import Foundation
final class SettingsModel {
enum Change {
enum Change: Equatable {
case maxHistoryItems
case imageCacheMaxBytes
case openShortcut
@@ -12,6 +12,7 @@ final class SettingsModel {
case pauseCapture
case pollProfile
case captureStatus
case collections
case status
case other
}
@@ -35,6 +36,8 @@ final class SettingsModel {
static let pauseCapture = "pauseCapture"
static let clearHistoryOnQuit = "clearHistoryOnQuit"
static let accessibilityNoticeShown = "accessibilityNoticeShown"
static let customCollectionNames = "customCollectionNames"
static let collectionColorHexes = "collectionColorHexes"
}
var maxHistoryItems: Int {
@@ -88,6 +91,8 @@ final class SettingsModel {
var clearHistoryOnQuit: Bool {
didSet { if oldValue != clearHistoryOnQuit { storeAndNotify(.other) } }
}
private(set) var customCollectionNames: [String]
private(set) var collectionColorHexes: [String: String]
private(set) var launchAtLoginErrorMessage: String = ""
private(set) var accessibilityPermissionStatusMessage: String = ""
private(set) var captureStatusMessage: String = ""
@@ -123,6 +128,8 @@ final class SettingsModel {
excludeSensitive = defaults.object(forKey: Keys.excludeSensitive) as? Bool ?? false
pauseCapture = defaults.object(forKey: Keys.pauseCapture) as? Bool ?? false
clearHistoryOnQuit = defaults.object(forKey: Keys.clearHistoryOnQuit) as? Bool ?? false
customCollectionNames = Self.normalizedCollectionNames(defaults.stringArray(forKey: Keys.customCollectionNames) ?? [])
collectionColorHexes = Self.normalizedCollectionColorHexes(defaults.dictionary(forKey: Keys.collectionColorHexes))
accessibilityNoticeShown = defaults.object(forKey: Keys.accessibilityNoticeShown) as? Bool ?? false
maxHistoryItems = max(AppConfiguration.minHistoryLength, min(AppConfiguration.maxHistoryLength, maxHistoryItems))
@@ -151,6 +158,8 @@ final class SettingsModel {
defaults.set(excludeSensitive, forKey: Keys.excludeSensitive)
defaults.set(pauseCapture, forKey: Keys.pauseCapture)
defaults.set(clearHistoryOnQuit, forKey: Keys.clearHistoryOnQuit)
defaults.set(customCollectionNames, forKey: Keys.customCollectionNames)
defaults.set(collectionColorHexes, forKey: Keys.collectionColorHexes)
}
func observe(_ observer: @escaping (Change) -> Void) {
@@ -204,6 +213,45 @@ final class SettingsModel {
notify(.status)
}
@discardableResult
func ensureCollection(named name: String, colorHex: String? = nil) -> String? {
guard let normalizedName = ClipboardCollectionDefaults.normalizedName(name) else { return nil }
let existingName = customCollectionNames.first {
$0.caseInsensitiveCompare(normalizedName) == .orderedSame
}
let canonicalName = existingName ?? normalizedName
var changed = false
if existingName == nil {
customCollectionNames.append(normalizedName)
changed = true
}
if let normalizedHex = Self.normalizedHexColor(colorHex),
collectionColorHexes[canonicalName] != normalizedHex {
for key in collectionColorHexes.keys where key.caseInsensitiveCompare(canonicalName) == .orderedSame && key != canonicalName {
collectionColorHexes.removeValue(forKey: key)
}
collectionColorHexes[canonicalName] = normalizedHex
changed = true
}
if changed {
storeAndNotify(.collections)
}
return normalizedName
}
func collectionColorHex(forCollectionNamed name: String) -> String? {
guard let normalizedName = ClipboardCollectionDefaults.normalizedName(name) else { return nil }
if let exact = collectionColorHexes[normalizedName] {
return exact
}
return collectionColorHexes.first { storedName, _ in
storedName.caseInsensitiveCompare(normalizedName) == .orderedSame
}?.value
}
private static func readShortcut(from value: String?) -> ShortcutBinding? {
guard let value else { return nil }
return ShortcutBinding(encoded: value)
@@ -218,4 +266,39 @@ final class SettingsModel {
maxHistoryItems = max(AppConfiguration.minHistoryLength, min(AppConfiguration.maxHistoryLength, maxHistoryItems))
imageCacheMaxBytes = max(4 * 1024 * 1024, imageCacheMaxBytes)
}
private static func normalizedCollectionNames(_ names: [String]) -> [String] {
var normalized: [String] = []
for name in names {
guard let value = ClipboardCollectionDefaults.normalizedName(name) else { continue }
guard !normalized.contains(where: { $0.caseInsensitiveCompare(value) == .orderedSame }) else { continue }
normalized.append(value)
}
return normalized
}
private static func normalizedCollectionColorHexes(_ rawValue: [String: Any]?) -> [String: String] {
guard let rawValue else { return [:] }
var normalized: [String: String] = [:]
for (name, color) in rawValue {
guard let normalizedName = ClipboardCollectionDefaults.normalizedName(name),
let hex = normalizedHexColor(color as? String) else {
continue
}
normalized[normalizedName] = hex
}
return normalized
}
private static func normalizedHexColor(_ value: String?) -> String? {
guard let value else { return nil }
var hex = value.clipboardTrimmed.uppercased()
if hex.hasPrefix("#") {
hex.removeFirst()
}
guard hex.count == 6, hex.allSatisfy({ $0.isHexDigit }) else {
return nil
}
return "#\(hex)"
}
}

View File

@@ -16,6 +16,7 @@ struct ClipboardPanelReflowPlan {
enum ClipboardPanelShortcutAction: Equatable {
case copy
case copyPlainText
case newCollection
case open
case pastePlainText
case pasteStackNext
@@ -398,6 +399,8 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
viewModel.copySelected()
case .copyPlainText:
viewModel.copySelectedPlainText()
case .newCollection:
panelView.createCollection()
case .open:
viewModel.openSelected()
case .pastePlainText:
@@ -493,6 +496,8 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
return .toggleStack
case 8:
return .copyPlainText
case 45:
return .newCollection
case 9:
return .pastePlainText
case 36:

View File

@@ -65,7 +65,14 @@ private enum ClipboardCollectionVisuals {
}
}
static func color(forCollectionNamed name: String) -> NSColor {
static func color(forCollectionNamed name: String, overrideHex: String? = nil) -> NSColor {
if let color = color(fromHex: overrideHex) {
return color
}
return defaultColor(forCollectionNamed: name)
}
static func defaultColor(forCollectionNamed name: String) -> NSColor {
switch name {
case "Useful Links":
return customPalette[0]
@@ -80,6 +87,34 @@ private enum ClipboardCollectionVisuals {
}
}
static func defaultColorHex(forCollectionNamed name: String) -> String {
hexString(for: defaultColor(forCollectionNamed: name))
}
static func hexString(for 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)
}
private static func color(fromHex value: String?) -> NSColor? {
guard let value else { return nil }
var hex = value.clipboardTrimmed
if hex.hasPrefix("#") {
hex.removeFirst()
}
guard hex.count == 6,
let rawValue = Int(hex, radix: 16) else {
return nil
}
let red = CGFloat((rawValue >> 16) & 0xFF) / 255
let green = CGFloat((rawValue >> 8) & 0xFF) / 255
let blue = CGFloat(rawValue & 0xFF) / 255
return NSColor(deviceRed: red, green: green, blue: blue, alpha: 1)
}
private static func stablePaletteIndex(for name: String) -> Int {
var hash: UInt64 = 1_469_598_103_934_665_603
for scalar in name.lowercased().unicodeScalars {
@@ -90,6 +125,11 @@ private enum ClipboardCollectionVisuals {
}
}
private struct CollectionCreationRequest {
let name: String
let colorHex: String
}
final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
private enum Metrics {
static let actionButtonSize: CGFloat = 30
@@ -549,10 +589,10 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
addCollectionButton.layer?.borderColor = NSColor.separatorColor.withAlphaComponent(0.16).cgColor
addCollectionButton.layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.26).cgColor
addCollectionButton.contentTintColor = .secondaryLabelColor
addCollectionButton.toolTip = "Add selected clip to a new collection"
addCollectionButton.setAccessibilityLabel("Add selected clip to a new collection")
addCollectionButton.toolTip = "New collection"
addCollectionButton.setAccessibilityLabel("New collection")
addCollectionButton.target = self
addCollectionButton.action = #selector(addSelectedClipToCollection)
addCollectionButton.action = #selector(createCollectionFromToolbar)
addCollectionButton.translatesAutoresizingMaskIntoConstraints = false
addCollectionButton.widthAnchor.constraint(equalToConstant: 30).isActive = true
addCollectionButton.heightAnchor.constraint(equalToConstant: 26).isActive = true
@@ -576,7 +616,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
}
private func collectionColor(forCollectionNamed name: String) -> NSColor {
ClipboardCollectionVisuals.color(forCollectionNamed: name)
ClipboardCollectionVisuals.color(forCollectionNamed: name, overrideHex: viewModel.collectionColorHex(named: name))
}
private func applyCardDensity() {
@@ -670,7 +710,8 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
isStacked: viewModel.isItemStacked(at: index),
stackCount: viewModel.stackCount,
canShowInClipboard: viewModel.canShowVisibleItemsInClipboard,
selectedCollectionName: viewModel.selectedCollectionName
selectedCollectionName: viewModel.selectedCollectionName,
selectedCollectionColor: viewModel.selectedCollectionName.map { collectionColor(forCollectionNamed: $0) }
)
card.onSelect = { [weak self] selected in
self?.viewModel.selectItem(at: selected)
@@ -812,9 +853,8 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
}
private func updateAddCollectionButtonState() {
let hasSelectedItem = viewModel.selectedItem != nil
addCollectionButton.isEnabled = hasSelectedItem
addCollectionButton.alphaValue = hasSelectedItem ? 1.0 : 0.42
addCollectionButton.isEnabled = true
addCollectionButton.alphaValue = 1.0
}
private func scrollCardIntoView(_ card: NSView) {
@@ -856,7 +896,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
if lower.hasPrefix("captured") || lower.contains("capture running") || lower.contains("capture is running") || lower.contains("capture resumed") {
return .ready
}
if lower.hasPrefix("copied") || lower.hasPrefix("pasted") || lower.hasPrefix("updated") || lower.hasPrefix("added") || lower.hasPrefix("removed") || lower.hasPrefix("cleared") || lower.hasPrefix("ignored") || lower.hasPrefix("showing") {
if lower.hasPrefix("copied") || lower.hasPrefix("pasted") || lower.hasPrefix("updated") || lower.hasPrefix("added") || lower.hasPrefix("created") || lower.hasPrefix("removed") || lower.hasPrefix("cleared") || lower.hasPrefix("ignored") || lower.hasPrefix("showing") {
return .action
}
if lower.hasPrefix("error") || lower.contains("failed") {
@@ -968,6 +1008,13 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
)
}
if let collectionName = viewModel.selectedCollectionName {
return (
"No clips in \(collectionName)",
"Drag clips here or use Collect to add them."
)
}
if viewModel.totalItemCount == 0 {
return (
"Copy something to start your history",
@@ -1229,6 +1276,9 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
if stackChip.isSelected {
return stackChip.titleText
}
if let custom = customCollectionButtons.first(where: { $0.value.isSelected }) {
return custom.value.titleText
}
return collectionButtons.first(where: { $0.value.isSelected })?.value.titleText
}
@@ -1248,7 +1298,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
var debugCustomCollectionColorHexes: [String: String] {
Dictionary(uniqueKeysWithValues: viewModel.collectionNames.map { name in
(name, ClipboardItemCardView.debugHex(ClipboardCollectionVisuals.color(forCollectionNamed: name)))
(name, ClipboardCollectionVisuals.hexString(for: collectionColor(forCollectionNamed: name)))
})
}
@@ -1294,7 +1344,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
}
func debugPressAddCollectionButton() {
addSelectedClipToCollection()
createCollectionFromToolbar()
}
func debugShowFirstCardInClipboard() {
@@ -1373,18 +1423,25 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
searchField.stringValue = viewModel.searchText
}
@objc private func addSelectedClipToCollection() {
guard viewModel.selectedItem != nil,
let name = requestCollectionName() else {
return
}
viewModel.assignSelected(to: name)
func createCollection() {
createCollectionFromToolbar()
}
private func requestCollectionName() -> String? {
@objc private func createCollectionFromToolbar() {
guard let request = requestCollectionCreation() else { return }
viewModel.createCollection(named: request.name, colorHex: request.colorHex, selectAfterCreate: true)
}
private func requestCollectionCreation() -> CollectionCreationRequest? {
#if DEBUG
if let collectionNameProviderForTesting {
return ClipboardCollectionDefaults.normalizedName(collectionNameProviderForTesting())
guard let name = ClipboardCollectionDefaults.normalizedName(collectionNameProviderForTesting()) else {
return nil
}
return CollectionCreationRequest(
name: name,
colorHex: ClipboardCollectionVisuals.defaultColorHex(forCollectionNamed: name)
)
}
#endif
@@ -1392,18 +1449,40 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
input.placeholderString = "Collection name"
input.stringValue = ""
let colorWell = NSColorWell(frame: NSRect(x: 0, y: 0, width: 48, height: 28))
colorWell.color = ClipboardCollectionVisuals.defaultColor(forCollectionNamed: "New Collection")
let colorLabel = NSTextField(labelWithString: "Color")
colorLabel.font = .systemFont(ofSize: NSFont.smallSystemFontSize, weight: .medium)
colorLabel.textColor = .secondaryLabelColor
let colorRow = NSStackView(views: [colorLabel, colorWell])
colorRow.orientation = .horizontal
colorRow.alignment = .centerY
colorRow.spacing = 10
let stack = NSStackView(views: [input, colorRow])
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 10
stack.frame = NSRect(x: 0, y: 0, width: 260, height: 64)
let alert = NSAlert()
alert.messageText = "New Collection"
alert.informativeText = "Name this collection and add the selected clip to it."
alert.accessoryView = input
alert.addButton(withTitle: "Add")
alert.informativeText = "Name this collection and choose its color."
alert.accessoryView = stack
alert.addButton(withTitle: "Create")
alert.addButton(withTitle: "Cancel")
alert.window.initialFirstResponder = input
guard alert.runModal() == .alertFirstButtonReturn else {
guard alert.runModal() == .alertFirstButtonReturn,
let name = ClipboardCollectionDefaults.normalizedName(input.stringValue) else {
return nil
}
return ClipboardCollectionDefaults.normalizedName(input.stringValue)
return CollectionCreationRequest(
name: name,
colorHex: ClipboardCollectionVisuals.hexString(for: colorWell.color)
)
}
}
@@ -1720,7 +1799,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
isStacked: Bool = false,
stackCount: Int = 0,
canShowInClipboard: Bool = false,
selectedCollectionName: String? = nil
selectedCollectionName: String? = nil,
selectedCollectionColor: NSColor? = nil
) {
let normalizedItemCollection = ClipboardCollectionDefaults.normalizedName(item.collectionName)
let normalizedSelectedCollection = ClipboardCollectionDefaults.normalizedName(selectedCollectionName)
@@ -1737,7 +1817,9 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
self.itemSourceAppBundleID = Self.presentSourceText(item.sourceAppBundleId)
self.itemCollectionName = normalizedItemCollection
self.activeCollectionName = activeCollection
self.activeCollectionColor = activeCollection.map(ClipboardCollectionVisuals.color(forCollectionNamed:))
self.activeCollectionColor = activeCollection.map { name in
selectedCollectionColor ?? ClipboardCollectionVisuals.color(forCollectionNamed: name)
}
self.collectionNames = collectionNames.compactMap { ClipboardCollectionDefaults.normalizedName($0) }
super.init(frame: .zero)
configure(item: item, thumbnail: thumbnail)
@@ -1925,11 +2007,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
}
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)
ClipboardCollectionVisuals.hexString(for: color)
}
#endif

View File

@@ -111,11 +111,18 @@ final class ClipboardPanelViewModel {
}
}
settings.observe { [weak self] change in
guard case .captureStatus = change else { return }
self?.notifyMain {
self?.statusMessage = ""
self?.onStatusMessageChanged?("")
self?.onCaptureStatusChanged?()
switch change {
case .captureStatus:
self?.statusMessage = ""
self?.onStatusMessageChanged?("")
self?.onCaptureStatusChanged?()
case .collections:
self?.recomputeVisibleItems()
self?.onCollectionsChanged?()
default:
break
}
}
}
}
@@ -155,11 +162,20 @@ final class ClipboardPanelViewModel {
ClipboardCollectionDefaults.normalizedName(item.collectionName)
}
)
let defaultNames = ClipboardCollectionDefaults.names.filter { assignedNames.contains($0) }
let customNames = assignedNames
let configuredNames = settings.customCollectionNames
let configuredNameSet = Set(configuredNames.map { $0.lowercased() })
let allNames = assignedNames.union(configuredNames)
let defaultNames = ClipboardCollectionDefaults.names.filter { allNames.contains($0) }
var configuredCustomNames: [String] = []
for name in configuredNames where !ClipboardCollectionDefaults.names.contains(name) {
guard !configuredCustomNames.contains(where: { $0.caseInsensitiveCompare(name) == .orderedSame }) else { continue }
configuredCustomNames.append(name)
}
let assignedCustomNames = assignedNames
.filter { !ClipboardCollectionDefaults.names.contains($0) }
.filter { !configuredNameSet.contains($0.lowercased()) }
.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
return defaultNames + customNames
return defaultNames + configuredCustomNames + assignedCustomNames
}
func collectionCount(for sortMode: ClipboardSortMode) -> Int {
@@ -472,6 +488,21 @@ final class ClipboardPanelViewModel {
selectedCollectionName = normalizedName
}
func createCollection(named name: String, colorHex: String? = nil, selectAfterCreate: Bool = true) {
guard let normalizedName = settings.ensureCollection(named: name, colorHex: colorHex) else { return }
statusMessage = "Created \(normalizedName)"
if selectAfterCreate {
selectCollection(named: normalizedName)
} else {
recomputeVisibleItems()
onCollectionsChanged?()
}
}
func collectionColorHex(named name: String) -> String? {
settings.collectionColorHex(forCollectionNamed: name)
}
func clearSearch() {
searchText = ""
}
@@ -857,6 +888,9 @@ final class ClipboardPanelViewModel {
private func assign(item: ClipboardItem, to collectionName: String?) {
let normalizedName = ClipboardCollectionDefaults.normalizedName(collectionName)
if let normalizedName {
settings.ensureCollection(named: normalizedName)
}
store.setCollection(item.id, name: normalizedName)
if let normalizedName {
statusMessage = "Added to \(normalizedName)"