WIP: add empty color-coded collections
This commit is contained in:
@@ -13,11 +13,12 @@ The project is intentionally dependency-light: Swift Package Manager, AppKit, Ca
|
|||||||
- `Command + ,` opens settings
|
- `Command + ,` opens settings
|
||||||
- `Command + 1` through `Command + 9` paste the numbered visible card; add `Shift` to paste that card as plain text
|
- `Command + 1` through `Command + 9` paste the numbered visible card; add `Shift` to paste that card as plain text
|
||||||
- `Command + G` shows a filtered result back in the full clipboard history
|
- `Command + G` shows a filtered result back in the full clipboard history
|
||||||
|
- `Shift + Command + N` creates a new collection
|
||||||
- Clipboard history for text, URLs with local preview thumbnails when available, images, audio, RTF/HTML rich text, PDFs, and file references
|
- Clipboard history for text, URLs with local preview thumbnails when available, images, audio, RTF/HTML rich text, PDFs, and file references
|
||||||
- SQLite persistence with bounded history, pinned-item retention, and encrypted app-managed payloads
|
- 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
|
- 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
|
- 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, with consistent collection colors in chips and collection card headers
|
- Custom named collections, including empty color-coded collections, for organizing clips from the card Collect control, context menu, or by dragging cards onto collection chips
|
||||||
- Copy and paste actions with Accessibility permission fallback
|
- Copy and paste actions with Accessibility permission fallback
|
||||||
- Image thumbnail cache with byte and file-count pruning
|
- 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
|
- 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
|
||||||
|
|||||||
@@ -38,9 +38,9 @@ Use this checklist before a release or after changes to panel, pasteboard, setti
|
|||||||
5. Press `Esc` once with a non-empty search field and confirm search clears.
|
5. Press `Esc` once with a non-empty search field and confirm search clears.
|
||||||
6. Press `Esc` again and confirm the panel closes.
|
6. Press `Esc` again and confirm the panel closes.
|
||||||
7. Reopen the panel, change sort segments, and confirm each segment updates results.
|
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.
|
8. Press `Shift + Command + N` or the collection rail `+`, enter `Client Work`, choose a color, and confirm a Client Work chip appears with 0 clips and an empty collection view.
|
||||||
9. Select another card and confirm its Collect button offers Client Work as a reusable destination.
|
9. Return to Clipboard, select a card, use its Collect button to choose Client Work, and confirm the Client Work chip count increases.
|
||||||
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.
|
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 collection/color/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.
|
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.
|
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.
|
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.
|
||||||
|
|||||||
@@ -437,7 +437,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
}
|
}
|
||||||
case .pollProfile:
|
case .pollProfile:
|
||||||
monitor.setPaused(settings.pauseCapture)
|
monitor.setPaused(settings.pauseCapture)
|
||||||
case .status, .other:
|
case .status, .collections, .other:
|
||||||
break
|
break
|
||||||
case .captureStatus:
|
case .captureStatus:
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
final class SettingsModel {
|
final class SettingsModel {
|
||||||
enum Change {
|
enum Change: Equatable {
|
||||||
case maxHistoryItems
|
case maxHistoryItems
|
||||||
case imageCacheMaxBytes
|
case imageCacheMaxBytes
|
||||||
case openShortcut
|
case openShortcut
|
||||||
@@ -12,6 +12,7 @@ final class SettingsModel {
|
|||||||
case pauseCapture
|
case pauseCapture
|
||||||
case pollProfile
|
case pollProfile
|
||||||
case captureStatus
|
case captureStatus
|
||||||
|
case collections
|
||||||
case status
|
case status
|
||||||
case other
|
case other
|
||||||
}
|
}
|
||||||
@@ -35,6 +36,8 @@ final class SettingsModel {
|
|||||||
static let pauseCapture = "pauseCapture"
|
static let pauseCapture = "pauseCapture"
|
||||||
static let clearHistoryOnQuit = "clearHistoryOnQuit"
|
static let clearHistoryOnQuit = "clearHistoryOnQuit"
|
||||||
static let accessibilityNoticeShown = "accessibilityNoticeShown"
|
static let accessibilityNoticeShown = "accessibilityNoticeShown"
|
||||||
|
static let customCollectionNames = "customCollectionNames"
|
||||||
|
static let collectionColorHexes = "collectionColorHexes"
|
||||||
}
|
}
|
||||||
|
|
||||||
var maxHistoryItems: Int {
|
var maxHistoryItems: Int {
|
||||||
@@ -88,6 +91,8 @@ final class SettingsModel {
|
|||||||
var clearHistoryOnQuit: Bool {
|
var clearHistoryOnQuit: Bool {
|
||||||
didSet { if oldValue != clearHistoryOnQuit { storeAndNotify(.other) } }
|
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 launchAtLoginErrorMessage: String = ""
|
||||||
private(set) var accessibilityPermissionStatusMessage: String = ""
|
private(set) var accessibilityPermissionStatusMessage: String = ""
|
||||||
private(set) var captureStatusMessage: String = ""
|
private(set) var captureStatusMessage: String = ""
|
||||||
@@ -123,6 +128,8 @@ final class SettingsModel {
|
|||||||
excludeSensitive = defaults.object(forKey: Keys.excludeSensitive) as? Bool ?? false
|
excludeSensitive = defaults.object(forKey: Keys.excludeSensitive) as? Bool ?? false
|
||||||
pauseCapture = defaults.object(forKey: Keys.pauseCapture) as? Bool ?? false
|
pauseCapture = defaults.object(forKey: Keys.pauseCapture) as? Bool ?? false
|
||||||
clearHistoryOnQuit = defaults.object(forKey: Keys.clearHistoryOnQuit) 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
|
accessibilityNoticeShown = defaults.object(forKey: Keys.accessibilityNoticeShown) as? Bool ?? false
|
||||||
|
|
||||||
maxHistoryItems = max(AppConfiguration.minHistoryLength, min(AppConfiguration.maxHistoryLength, maxHistoryItems))
|
maxHistoryItems = max(AppConfiguration.minHistoryLength, min(AppConfiguration.maxHistoryLength, maxHistoryItems))
|
||||||
@@ -151,6 +158,8 @@ final class SettingsModel {
|
|||||||
defaults.set(excludeSensitive, forKey: Keys.excludeSensitive)
|
defaults.set(excludeSensitive, forKey: Keys.excludeSensitive)
|
||||||
defaults.set(pauseCapture, forKey: Keys.pauseCapture)
|
defaults.set(pauseCapture, forKey: Keys.pauseCapture)
|
||||||
defaults.set(clearHistoryOnQuit, forKey: Keys.clearHistoryOnQuit)
|
defaults.set(clearHistoryOnQuit, forKey: Keys.clearHistoryOnQuit)
|
||||||
|
defaults.set(customCollectionNames, forKey: Keys.customCollectionNames)
|
||||||
|
defaults.set(collectionColorHexes, forKey: Keys.collectionColorHexes)
|
||||||
}
|
}
|
||||||
|
|
||||||
func observe(_ observer: @escaping (Change) -> Void) {
|
func observe(_ observer: @escaping (Change) -> Void) {
|
||||||
@@ -204,6 +213,45 @@ final class SettingsModel {
|
|||||||
notify(.status)
|
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? {
|
private static func readShortcut(from value: String?) -> ShortcutBinding? {
|
||||||
guard let value else { return nil }
|
guard let value else { return nil }
|
||||||
return ShortcutBinding(encoded: value)
|
return ShortcutBinding(encoded: value)
|
||||||
@@ -218,4 +266,39 @@ final class SettingsModel {
|
|||||||
maxHistoryItems = max(AppConfiguration.minHistoryLength, min(AppConfiguration.maxHistoryLength, maxHistoryItems))
|
maxHistoryItems = max(AppConfiguration.minHistoryLength, min(AppConfiguration.maxHistoryLength, maxHistoryItems))
|
||||||
imageCacheMaxBytes = max(4 * 1024 * 1024, imageCacheMaxBytes)
|
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)"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ struct ClipboardPanelReflowPlan {
|
|||||||
enum ClipboardPanelShortcutAction: Equatable {
|
enum ClipboardPanelShortcutAction: Equatable {
|
||||||
case copy
|
case copy
|
||||||
case copyPlainText
|
case copyPlainText
|
||||||
|
case newCollection
|
||||||
case open
|
case open
|
||||||
case pastePlainText
|
case pastePlainText
|
||||||
case pasteStackNext
|
case pasteStackNext
|
||||||
@@ -398,6 +399,8 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
|||||||
viewModel.copySelected()
|
viewModel.copySelected()
|
||||||
case .copyPlainText:
|
case .copyPlainText:
|
||||||
viewModel.copySelectedPlainText()
|
viewModel.copySelectedPlainText()
|
||||||
|
case .newCollection:
|
||||||
|
panelView.createCollection()
|
||||||
case .open:
|
case .open:
|
||||||
viewModel.openSelected()
|
viewModel.openSelected()
|
||||||
case .pastePlainText:
|
case .pastePlainText:
|
||||||
@@ -493,6 +496,8 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
|
|||||||
return .toggleStack
|
return .toggleStack
|
||||||
case 8:
|
case 8:
|
||||||
return .copyPlainText
|
return .copyPlainText
|
||||||
|
case 45:
|
||||||
|
return .newCollection
|
||||||
case 9:
|
case 9:
|
||||||
return .pastePlainText
|
return .pastePlainText
|
||||||
case 36:
|
case 36:
|
||||||
|
|||||||
@@ -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 {
|
switch name {
|
||||||
case "Useful Links":
|
case "Useful Links":
|
||||||
return customPalette[0]
|
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 {
|
private static func stablePaletteIndex(for name: String) -> Int {
|
||||||
var hash: UInt64 = 1_469_598_103_934_665_603
|
var hash: UInt64 = 1_469_598_103_934_665_603
|
||||||
for scalar in name.lowercased().unicodeScalars {
|
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 {
|
final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
||||||
private enum Metrics {
|
private enum Metrics {
|
||||||
static let actionButtonSize: CGFloat = 30
|
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?.borderColor = NSColor.separatorColor.withAlphaComponent(0.16).cgColor
|
||||||
addCollectionButton.layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.26).cgColor
|
addCollectionButton.layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.26).cgColor
|
||||||
addCollectionButton.contentTintColor = .secondaryLabelColor
|
addCollectionButton.contentTintColor = .secondaryLabelColor
|
||||||
addCollectionButton.toolTip = "Add selected clip to a new collection"
|
addCollectionButton.toolTip = "New collection"
|
||||||
addCollectionButton.setAccessibilityLabel("Add selected clip to a new collection")
|
addCollectionButton.setAccessibilityLabel("New collection")
|
||||||
addCollectionButton.target = self
|
addCollectionButton.target = self
|
||||||
addCollectionButton.action = #selector(addSelectedClipToCollection)
|
addCollectionButton.action = #selector(createCollectionFromToolbar)
|
||||||
addCollectionButton.translatesAutoresizingMaskIntoConstraints = false
|
addCollectionButton.translatesAutoresizingMaskIntoConstraints = false
|
||||||
addCollectionButton.widthAnchor.constraint(equalToConstant: 30).isActive = true
|
addCollectionButton.widthAnchor.constraint(equalToConstant: 30).isActive = true
|
||||||
addCollectionButton.heightAnchor.constraint(equalToConstant: 26).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 {
|
private func collectionColor(forCollectionNamed name: String) -> NSColor {
|
||||||
ClipboardCollectionVisuals.color(forCollectionNamed: name)
|
ClipboardCollectionVisuals.color(forCollectionNamed: name, overrideHex: viewModel.collectionColorHex(named: name))
|
||||||
}
|
}
|
||||||
|
|
||||||
private func applyCardDensity() {
|
private func applyCardDensity() {
|
||||||
@@ -670,7 +710,8 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
|||||||
isStacked: viewModel.isItemStacked(at: index),
|
isStacked: viewModel.isItemStacked(at: index),
|
||||||
stackCount: viewModel.stackCount,
|
stackCount: viewModel.stackCount,
|
||||||
canShowInClipboard: viewModel.canShowVisibleItemsInClipboard,
|
canShowInClipboard: viewModel.canShowVisibleItemsInClipboard,
|
||||||
selectedCollectionName: viewModel.selectedCollectionName
|
selectedCollectionName: viewModel.selectedCollectionName,
|
||||||
|
selectedCollectionColor: viewModel.selectedCollectionName.map { collectionColor(forCollectionNamed: $0) }
|
||||||
)
|
)
|
||||||
card.onSelect = { [weak self] selected in
|
card.onSelect = { [weak self] selected in
|
||||||
self?.viewModel.selectItem(at: selected)
|
self?.viewModel.selectItem(at: selected)
|
||||||
@@ -812,9 +853,8 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func updateAddCollectionButtonState() {
|
private func updateAddCollectionButtonState() {
|
||||||
let hasSelectedItem = viewModel.selectedItem != nil
|
addCollectionButton.isEnabled = true
|
||||||
addCollectionButton.isEnabled = hasSelectedItem
|
addCollectionButton.alphaValue = 1.0
|
||||||
addCollectionButton.alphaValue = hasSelectedItem ? 1.0 : 0.42
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func scrollCardIntoView(_ card: NSView) {
|
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") {
|
if lower.hasPrefix("captured") || lower.contains("capture running") || lower.contains("capture is running") || lower.contains("capture resumed") {
|
||||||
return .ready
|
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
|
return .action
|
||||||
}
|
}
|
||||||
if lower.hasPrefix("error") || lower.contains("failed") {
|
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 {
|
if viewModel.totalItemCount == 0 {
|
||||||
return (
|
return (
|
||||||
"Copy something to start your history",
|
"Copy something to start your history",
|
||||||
@@ -1229,6 +1276,9 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
|||||||
if stackChip.isSelected {
|
if stackChip.isSelected {
|
||||||
return stackChip.titleText
|
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
|
return collectionButtons.first(where: { $0.value.isSelected })?.value.titleText
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1248,7 +1298,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
|||||||
|
|
||||||
var debugCustomCollectionColorHexes: [String: String] {
|
var debugCustomCollectionColorHexes: [String: String] {
|
||||||
Dictionary(uniqueKeysWithValues: viewModel.collectionNames.map { name in
|
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() {
|
func debugPressAddCollectionButton() {
|
||||||
addSelectedClipToCollection()
|
createCollectionFromToolbar()
|
||||||
}
|
}
|
||||||
|
|
||||||
func debugShowFirstCardInClipboard() {
|
func debugShowFirstCardInClipboard() {
|
||||||
@@ -1373,18 +1423,25 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
|||||||
searchField.stringValue = viewModel.searchText
|
searchField.stringValue = viewModel.searchText
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func addSelectedClipToCollection() {
|
func createCollection() {
|
||||||
guard viewModel.selectedItem != nil,
|
createCollectionFromToolbar()
|
||||||
let name = requestCollectionName() else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
viewModel.assignSelected(to: name)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 DEBUG
|
||||||
if let collectionNameProviderForTesting {
|
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
|
#endif
|
||||||
|
|
||||||
@@ -1392,18 +1449,40 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
|||||||
input.placeholderString = "Collection name"
|
input.placeholderString = "Collection name"
|
||||||
input.stringValue = ""
|
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()
|
let alert = NSAlert()
|
||||||
alert.messageText = "New Collection"
|
alert.messageText = "New Collection"
|
||||||
alert.informativeText = "Name this collection and add the selected clip to it."
|
alert.informativeText = "Name this collection and choose its color."
|
||||||
alert.accessoryView = input
|
alert.accessoryView = stack
|
||||||
alert.addButton(withTitle: "Add")
|
alert.addButton(withTitle: "Create")
|
||||||
alert.addButton(withTitle: "Cancel")
|
alert.addButton(withTitle: "Cancel")
|
||||||
alert.window.initialFirstResponder = input
|
alert.window.initialFirstResponder = input
|
||||||
|
|
||||||
guard alert.runModal() == .alertFirstButtonReturn else {
|
guard alert.runModal() == .alertFirstButtonReturn,
|
||||||
|
let name = ClipboardCollectionDefaults.normalizedName(input.stringValue) else {
|
||||||
return nil
|
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,
|
isStacked: Bool = false,
|
||||||
stackCount: Int = 0,
|
stackCount: Int = 0,
|
||||||
canShowInClipboard: Bool = false,
|
canShowInClipboard: Bool = false,
|
||||||
selectedCollectionName: String? = nil
|
selectedCollectionName: String? = nil,
|
||||||
|
selectedCollectionColor: NSColor? = nil
|
||||||
) {
|
) {
|
||||||
let normalizedItemCollection = ClipboardCollectionDefaults.normalizedName(item.collectionName)
|
let normalizedItemCollection = ClipboardCollectionDefaults.normalizedName(item.collectionName)
|
||||||
let normalizedSelectedCollection = ClipboardCollectionDefaults.normalizedName(selectedCollectionName)
|
let normalizedSelectedCollection = ClipboardCollectionDefaults.normalizedName(selectedCollectionName)
|
||||||
@@ -1737,7 +1817,9 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
|||||||
self.itemSourceAppBundleID = Self.presentSourceText(item.sourceAppBundleId)
|
self.itemSourceAppBundleID = Self.presentSourceText(item.sourceAppBundleId)
|
||||||
self.itemCollectionName = normalizedItemCollection
|
self.itemCollectionName = normalizedItemCollection
|
||||||
self.activeCollectionName = activeCollection
|
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) }
|
self.collectionNames = collectionNames.compactMap { ClipboardCollectionDefaults.normalizedName($0) }
|
||||||
super.init(frame: .zero)
|
super.init(frame: .zero)
|
||||||
configure(item: item, thumbnail: thumbnail)
|
configure(item: item, thumbnail: thumbnail)
|
||||||
@@ -1925,11 +2007,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func debugHex(_ color: NSColor) -> String {
|
static func debugHex(_ color: NSColor) -> String {
|
||||||
let rgb = color.usingColorSpace(.deviceRGB) ?? color
|
ClipboardCollectionVisuals.hexString(for: 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
|
#endif
|
||||||
|
|
||||||
|
|||||||
@@ -111,11 +111,18 @@ final class ClipboardPanelViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
settings.observe { [weak self] change in
|
settings.observe { [weak self] change in
|
||||||
guard case .captureStatus = change else { return }
|
|
||||||
self?.notifyMain {
|
self?.notifyMain {
|
||||||
|
switch change {
|
||||||
|
case .captureStatus:
|
||||||
self?.statusMessage = ""
|
self?.statusMessage = ""
|
||||||
self?.onStatusMessageChanged?("")
|
self?.onStatusMessageChanged?("")
|
||||||
self?.onCaptureStatusChanged?()
|
self?.onCaptureStatusChanged?()
|
||||||
|
case .collections:
|
||||||
|
self?.recomputeVisibleItems()
|
||||||
|
self?.onCollectionsChanged?()
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -155,11 +162,20 @@ final class ClipboardPanelViewModel {
|
|||||||
ClipboardCollectionDefaults.normalizedName(item.collectionName)
|
ClipboardCollectionDefaults.normalizedName(item.collectionName)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
let defaultNames = ClipboardCollectionDefaults.names.filter { assignedNames.contains($0) }
|
let configuredNames = settings.customCollectionNames
|
||||||
let customNames = assignedNames
|
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 { !ClipboardCollectionDefaults.names.contains($0) }
|
||||||
|
.filter { !configuredNameSet.contains($0.lowercased()) }
|
||||||
.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
|
.sorted { $0.localizedCaseInsensitiveCompare($1) == .orderedAscending }
|
||||||
return defaultNames + customNames
|
return defaultNames + configuredCustomNames + assignedCustomNames
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectionCount(for sortMode: ClipboardSortMode) -> Int {
|
func collectionCount(for sortMode: ClipboardSortMode) -> Int {
|
||||||
@@ -472,6 +488,21 @@ final class ClipboardPanelViewModel {
|
|||||||
selectedCollectionName = normalizedName
|
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() {
|
func clearSearch() {
|
||||||
searchText = ""
|
searchText = ""
|
||||||
}
|
}
|
||||||
@@ -857,6 +888,9 @@ final class ClipboardPanelViewModel {
|
|||||||
|
|
||||||
private func assign(item: ClipboardItem, to collectionName: String?) {
|
private func assign(item: ClipboardItem, to collectionName: String?) {
|
||||||
let normalizedName = ClipboardCollectionDefaults.normalizedName(collectionName)
|
let normalizedName = ClipboardCollectionDefaults.normalizedName(collectionName)
|
||||||
|
if let normalizedName {
|
||||||
|
settings.ensureCollection(named: normalizedName)
|
||||||
|
}
|
||||||
store.setCollection(item.id, name: normalizedName)
|
store.setCollection(item.id, name: normalizedName)
|
||||||
if let normalizedName {
|
if let normalizedName {
|
||||||
statusMessage = "Added to \(normalizedName)"
|
statusMessage = "Added to \(normalizedName)"
|
||||||
|
|||||||
@@ -182,6 +182,7 @@ final class ClipboardPanelControllerTests: XCTestCase {
|
|||||||
XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 1, modifiers: [.command, .shift]), .toggleStack)
|
XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 1, modifiers: [.command, .shift]), .toggleStack)
|
||||||
XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 8, modifiers: [.command, .shift]), .copyPlainText)
|
XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 8, modifiers: [.command, .shift]), .copyPlainText)
|
||||||
XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 9, modifiers: [.command, .shift]), .pastePlainText)
|
XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 9, modifiers: [.command, .shift]), .pastePlainText)
|
||||||
|
XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 45, modifiers: [.command, .shift]), .newCollection)
|
||||||
XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 36, modifiers: [.command, .shift]), .pasteStackNext)
|
XCTAssertEqual(ClipboardPanelController.modifiedShortcutAction(forKeyCode: 36, modifiers: [.command, .shift]), .pasteStackNext)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -239,6 +239,40 @@ final class ClipboardPanelViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(viewModel.statusMessage, "Added to Pinned Research")
|
XCTAssertEqual(viewModel.statusMessage, "Added to Pinned Research")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testCreateCollectionAddsEmptySelectableCollection() {
|
||||||
|
let suiteName = "com.clipbored.testmodel.\(UUID().uuidString)"
|
||||||
|
let defaults = UserDefaults(suiteName: suiteName)!
|
||||||
|
defer {
|
||||||
|
defaults.removePersistentDomain(forName: suiteName)
|
||||||
|
}
|
||||||
|
let settings = SettingsModel(defaults: defaults)
|
||||||
|
settings.maxHistoryItems = 10
|
||||||
|
settings.includeImageTextInSearch = false
|
||||||
|
settings.pruneDuplicates = false
|
||||||
|
let cacheService = makeCacheService()
|
||||||
|
let store = makeStore(settings: settings, cacheService: cacheService)
|
||||||
|
store.upsert(makeTextItem("outside note", createdAt: Date(timeIntervalSince1970: 100)))
|
||||||
|
store.flushPersistenceForTesting()
|
||||||
|
|
||||||
|
let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService)
|
||||||
|
waitForVisibleItems(in: viewModel, count: 1)
|
||||||
|
|
||||||
|
viewModel.createCollection(named: " Client Work ", colorHex: "#0A9EB8")
|
||||||
|
|
||||||
|
XCTAssertEqual(viewModel.collectionNames, ["Client Work"])
|
||||||
|
XCTAssertEqual(viewModel.collectionCount(named: "Client Work"), 0)
|
||||||
|
XCTAssertEqual(viewModel.selectedCollectionName, "Client Work")
|
||||||
|
XCTAssertTrue(viewModel.visibleItems.isEmpty)
|
||||||
|
XCTAssertEqual(viewModel.collectionColorHex(named: "client work"), "#0A9EB8")
|
||||||
|
XCTAssertEqual(viewModel.statusMessage, "Created Client Work")
|
||||||
|
|
||||||
|
let restoredSettings = SettingsModel(defaults: defaults)
|
||||||
|
let restoredViewModel = ClipboardPanelViewModel(store: store, settings: restoredSettings, cacheService: cacheService)
|
||||||
|
waitForVisibleItems(in: restoredViewModel, count: 1)
|
||||||
|
XCTAssertEqual(restoredViewModel.collectionNames, ["Client Work"])
|
||||||
|
XCTAssertEqual(restoredViewModel.collectionColorHex(named: "Client Work"), "#0A9EB8")
|
||||||
|
}
|
||||||
|
|
||||||
func testSearchTextRecomputesVisibleItemsImmediately() {
|
func testSearchTextRecomputesVisibleItemsImmediately() {
|
||||||
let settings = makeSettings()
|
let settings = makeSettings()
|
||||||
let cacheService = makeCacheService()
|
let cacheService = makeCacheService()
|
||||||
|
|||||||
@@ -184,16 +184,10 @@ final class ClipboardPanelViewTests: XCTestCase {
|
|||||||
XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Links")
|
XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Links")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testCollectionRailAddButtonCreatesCollectionForSelectedClip() {
|
func testCollectionRailAddButtonCreatesEmptyCollection() {
|
||||||
let fixture = makePanelFixture()
|
let fixture = makePanelFixture()
|
||||||
|
|
||||||
XCTAssertTrue(fixture.view.debugCollectionRailContainsAddButton)
|
XCTAssertTrue(fixture.view.debugCollectionRailContainsAddButton)
|
||||||
XCTAssertFalse(fixture.view.debugAddCollectionButtonIsEnabled)
|
|
||||||
|
|
||||||
fixture.store.upsert(makeTextItem("Collect this note", store: fixture.store))
|
|
||||||
drainMainQueue()
|
|
||||||
fixture.window.contentView?.layoutSubtreeIfNeeded()
|
|
||||||
|
|
||||||
XCTAssertTrue(fixture.view.debugAddCollectionButtonIsEnabled)
|
XCTAssertTrue(fixture.view.debugAddCollectionButtonIsEnabled)
|
||||||
|
|
||||||
fixture.view.debugSetCollectionNameProvider { " Research Stack " }
|
fixture.view.debugSetCollectionNameProvider { " Research Stack " }
|
||||||
@@ -201,22 +195,29 @@ final class ClipboardPanelViewTests: XCTestCase {
|
|||||||
drainMainQueue()
|
drainMainQueue()
|
||||||
fixture.window.contentView?.layoutSubtreeIfNeeded()
|
fixture.window.contentView?.layoutSubtreeIfNeeded()
|
||||||
|
|
||||||
XCTAssertEqual(fixture.viewModel.statusMessage, "Added to Research Stack")
|
XCTAssertEqual(fixture.viewModel.statusMessage, "Created Research Stack")
|
||||||
XCTAssertEqual(fixture.view.debugCustomCollectionTitles, ["Research Stack"])
|
XCTAssertEqual(fixture.view.debugCustomCollectionTitles, ["Research Stack"])
|
||||||
XCTAssertEqual(fixture.view.debugFirstCardHeaderTitle, "Text")
|
XCTAssertEqual(fixture.view.debugCustomCollectionCounts, [0])
|
||||||
XCTAssertEqual(fixture.view.debugFirstCardFooterDetailText, "Research Stack - 17 characters")
|
XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Research Stack")
|
||||||
|
XCTAssertEqual(fixture.view.debugVisibleCardCount, 0)
|
||||||
|
XCTAssertEqual(fixture.view.debugEmptyStateText?.title, "No clips in Research Stack")
|
||||||
|
XCTAssertEqual(fixture.view.debugEmptyStateText?.detail, "Drag clips here or use Collect to add them.")
|
||||||
|
}
|
||||||
|
|
||||||
fixture.viewModel.selectCollection(named: "Research Stack")
|
func testCollectionFilteredCardsUseStoredCollectionHeaderColor() {
|
||||||
|
let fixture = makePanelFixture()
|
||||||
|
fixture.viewModel.createCollection(named: "Research Stack", colorHex: "#0A9EB8")
|
||||||
|
var item = makeTextItem("Collect this note", store: fixture.store)
|
||||||
|
item.collectionName = "Research Stack"
|
||||||
|
fixture.store.upsert(item)
|
||||||
drainMainQueue()
|
drainMainQueue()
|
||||||
fixture.window.contentView?.layoutSubtreeIfNeeded()
|
fixture.window.contentView?.layoutSubtreeIfNeeded()
|
||||||
|
|
||||||
XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Collect this note"])
|
XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["Collect this note"])
|
||||||
XCTAssertEqual(fixture.view.debugFirstCardHeaderTitle, "Research Stack")
|
XCTAssertEqual(fixture.view.debugFirstCardHeaderTitle, "Research Stack")
|
||||||
XCTAssertEqual(fixture.view.debugFirstCardHeaderSubtitle, "Text - Just now")
|
XCTAssertEqual(fixture.view.debugFirstCardHeaderSubtitle, "Text - Just now")
|
||||||
XCTAssertEqual(
|
XCTAssertEqual(fixture.view.debugFirstCardHeaderColorHex, "#0A9EB8")
|
||||||
fixture.view.debugFirstCardHeaderColorHex,
|
XCTAssertEqual(fixture.view.debugCustomCollectionColorHexes["Research Stack"], "#0A9EB8")
|
||||||
fixture.view.debugCustomCollectionColorHexes["Research Stack"] ?? ""
|
|
||||||
)
|
|
||||||
XCTAssertEqual(fixture.view.debugFirstCardFooterDetailText, "17 characters")
|
XCTAssertEqual(fixture.view.debugFirstCardFooterDetailText, "17 characters")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,4 +25,28 @@ final class SettingsModelTests: XCTestCase {
|
|||||||
let restored = SettingsModel(defaults: defaults)
|
let restored = SettingsModel(defaults: defaults)
|
||||||
XCTAssertTrue(restored.showDockIcon)
|
XCTAssertTrue(restored.showDockIcon)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testCustomCollectionsPersistWithNormalizedColors() {
|
||||||
|
let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)"
|
||||||
|
let defaults = UserDefaults(suiteName: suiteName)!
|
||||||
|
defer {
|
||||||
|
defaults.removePersistentDomain(forName: suiteName)
|
||||||
|
}
|
||||||
|
let settings = SettingsModel(defaults: defaults)
|
||||||
|
var changes: [SettingsModel.Change] = []
|
||||||
|
settings.observe { changes.append($0) }
|
||||||
|
|
||||||
|
settings.ensureCollection(named: " Research Stack ", colorHex: "0a9eb8")
|
||||||
|
settings.ensureCollection(named: "research stack", colorHex: "#FF3355")
|
||||||
|
settings.ensureCollection(named: "Client Work", colorHex: "not-a-color")
|
||||||
|
|
||||||
|
XCTAssertEqual(settings.customCollectionNames, ["Research Stack", "Client Work"])
|
||||||
|
XCTAssertEqual(settings.collectionColorHex(forCollectionNamed: "research stack"), "#FF3355")
|
||||||
|
XCTAssertNil(settings.collectionColorHex(forCollectionNamed: "Client Work"))
|
||||||
|
XCTAssertEqual(changes, [.collections, .collections, .collections])
|
||||||
|
|
||||||
|
let restored = SettingsModel(defaults: defaults)
|
||||||
|
XCTAssertEqual(restored.customCollectionNames, ["Research Stack", "Client Work"])
|
||||||
|
XCTAssertEqual(restored.collectionColorHex(forCollectionNamed: "Research Stack"), "#FF3355")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user