WIP: add compact shelf cards

This commit is contained in:
Akshay Kolli
2026-06-30 03:11:40 -07:00
parent 3c58ab8c38
commit 870e284fbd
5 changed files with 186 additions and 64 deletions

View File

@@ -1,6 +1,6 @@
# ClipBored # ClipBored
ClipBored is a small native macOS clipboard manager. It captures local clipboard history and opens a keyboard-first bottom panel for search, sorting, copy, paste, pinning, and deletion. It runs as a dockless menu-bar utility by default, with an optional Dock icon mode. ClipBored is a small native macOS clipboard manager. It captures local clipboard history and opens a keyboard-first responsive bottom panel for search, sorting, copy, paste, pinning, and deletion. It runs as a dockless menu-bar utility by default, with an optional Dock icon mode.
The project is intentionally dependency-light: Swift Package Manager, AppKit, Carbon hotkeys, SQLite, and system frameworks only. The project is intentionally dependency-light: Swift Package Manager, AppKit, Carbon hotkeys, SQLite, and system frameworks only.

View File

@@ -15,7 +15,6 @@ This roadmap keeps future work aligned with the project's constraints: small exe
## Product Polish ## Product Polish
- Improve keyboard focus states and VoiceOver labels. - Improve keyboard focus states and VoiceOver labels.
- Add a compact mode for narrower displays.
- Add import/export only if the storage and privacy story remains clear. - Add import/export only if the storage and privacy story remains clear.
## Performance ## Performance

View File

@@ -43,6 +43,7 @@ Use this checklist before a release or after changes to panel, pasteboard, setti
10. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry. 10. Double-click an item and confirm it attempts to paste or falls back to copy without creating a duplicate history entry.
11. Right-click a card, use Capture Rules to ignore its source app, copy from that app again, and confirm the new item is skipped. 11. 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. Drag an unassigned card onto the Client Work chip and confirm the chip count increases and the card appears when Client Work is selected. 12. 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. Resize or test on a narrow display and confirm the bottom shelf switches to compact cards that still show two recent clips cleanly.
## Copy And Paste ## Copy And Paste

View File

@@ -1,12 +1,47 @@
import AppKit import AppKit
private struct ClipboardItemCardLayout: Equatable {
let width: CGFloat
let height: CGFloat
let inset: CGFloat
let headerHeight: CGFloat
let bodyHeight: CGFloat
let footerHeight: CGFloat
let actionButtonSize: CGFloat
let primaryActionButtonSize: CGFloat
let actionRailHeight: CGFloat
static let regular = ClipboardItemCardLayout(
width: 320,
height: 244,
inset: 16,
headerHeight: 56,
bodyHeight: 152,
footerHeight: 36,
actionButtonSize: 24,
primaryActionButtonSize: 30,
actionRailHeight: 34
)
static let compact = ClipboardItemCardLayout(
width: 264,
height: 220,
inset: 13,
headerHeight: 50,
bodyHeight: 138,
footerHeight: 32,
actionButtonSize: 22,
primaryActionButtonSize: 28,
actionRailHeight: 32
)
var isCompact: Bool {
self == Self.compact
}
}
final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
private enum Metrics { private enum Metrics {
static let cardRailHeight: CGFloat = 266
static let cardWidth: CGFloat = 320
static let cardHeight: CGFloat = 244
static let cardSpacing: CGFloat = 16
static let cardStackInset: CGFloat = 10
static let actionButtonSize: CGFloat = 30 static let actionButtonSize: CGFloat = 30
static let panelTopInset: CGFloat = 12 static let panelTopInset: CGFloat = 12
static let panelSideInset: CGFloat = 22 static let panelSideInset: CGFloat = 22
@@ -14,6 +49,49 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
static let panelStatusBarHeight: CGFloat = 24 static let panelStatusBarHeight: CGFloat = 24
static let minimumBottomInset: CGFloat = 20 static let minimumBottomInset: CGFloat = 20
static let panelCornerRadius: CGFloat = 0 static let panelCornerRadius: CGFloat = 0
static let compactCardThreshold: CGFloat = 760
static let emptyStateMinimumWidth: CGFloat = 760
}
private enum CardDensity: String {
case regular
case compact
static func fitting(width: CGFloat) -> CardDensity {
width > 0 && width < Metrics.compactCardThreshold ? .compact : .regular
}
var layout: ClipboardItemCardLayout {
switch self {
case .regular: return .regular
case .compact: return .compact
}
}
var cardSpacing: CGFloat {
switch self {
case .regular: return 16
case .compact: return 12
}
}
var cardStackInset: CGFloat {
switch self {
case .regular: return 10
case .compact: return 8
}
}
var railHeight: CGFloat {
layout.height + (cardStackInset * 2) + 2
}
var emptyStateMinimumWidth: CGFloat {
switch self {
case .regular: return Metrics.emptyStateMinimumWidth
case .compact: return 420
}
}
} }
private enum Palette { private enum Palette {
@@ -66,6 +144,8 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
private var mainStack: NSStackView? private var mainStack: NSStackView?
private var bottomSafeInset = Metrics.minimumBottomInset private var bottomSafeInset = Metrics.minimumBottomInset
private var currentStatusTone: StatusTone = .ready private var currentStatusTone: StatusTone = .ready
private var cardDensity: CardDensity = .regular
private var scrollViewHeightConstraint: NSLayoutConstraint?
private var cardViews: [ClipboardItemCardView] = [] private var cardViews: [ClipboardItemCardView] = []
private var collectionButtons: [ClipboardSortMode: CollectionChipView] = [:] private var collectionButtons: [ClipboardSortMode: CollectionChipView] = [:]
private var customCollectionButtons: [String: CollectionChipView] = [:] private var customCollectionButtons: [String: CollectionChipView] = [:]
@@ -206,13 +286,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
itemsStack.orientation = .horizontal itemsStack.orientation = .horizontal
itemsStack.alignment = .top itemsStack.alignment = .top
itemsStack.spacing = Metrics.cardSpacing applyCardDensity()
itemsStack.edgeInsets = NSEdgeInsets(
top: Metrics.cardStackInset,
left: Metrics.cardStackInset,
bottom: Metrics.cardStackInset,
right: Metrics.cardStackInset
)
itemsStack.translatesAutoresizingMaskIntoConstraints = true itemsStack.translatesAutoresizingMaskIntoConstraints = true
scrollView.documentView = itemsStack scrollView.documentView = itemsStack
scrollView.hasHorizontalScroller = true scrollView.hasHorizontalScroller = true
@@ -223,7 +297,8 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
scrollView.borderType = .noBorder scrollView.borderType = .noBorder
scrollView.setContentHuggingPriority(.required, for: .vertical) scrollView.setContentHuggingPriority(.required, for: .vertical)
scrollView.setContentCompressionResistancePriority(.required, for: .vertical) scrollView.setContentCompressionResistancePriority(.required, for: .vertical)
scrollView.heightAnchor.constraint(equalToConstant: Metrics.cardRailHeight).isActive = true scrollViewHeightConstraint = scrollView.heightAnchor.constraint(equalToConstant: cardDensity.railHeight)
scrollViewHeightConstraint?.isActive = true
statusLabel.font = .systemFont(ofSize: NSFont.systemFontSize - 1) statusLabel.font = .systemFont(ofSize: NSFont.systemFontSize - 1)
statusLabel.textColor = .secondaryLabelColor statusLabel.textColor = .secondaryLabelColor
@@ -474,6 +549,29 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
} }
} }
private func applyCardDensity() {
itemsStack.spacing = cardDensity.cardSpacing
let inset = cardDensity.cardStackInset
itemsStack.edgeInsets = NSEdgeInsets(
top: inset,
left: inset,
bottom: inset,
right: inset
)
scrollViewHeightConstraint?.constant = cardDensity.railHeight
}
@discardableResult
private func updateCardDensityForCurrentWidth() -> Bool {
let targetDensity = CardDensity.fitting(width: bounds.width)
guard targetDensity != cardDensity else { return false }
cardDensity = targetDensity
applyCardDensity()
reloadItems()
return true
}
private func contentInsets() -> NSEdgeInsets { private func contentInsets() -> NSEdgeInsets {
NSEdgeInsets( NSEdgeInsets(
top: Metrics.panelTopInset, top: Metrics.panelTopInset,
@@ -531,11 +629,13 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
scrollView.documentView = itemsStack scrollView.documentView = itemsStack
} }
let collectionNames = viewModel.collectionNames let collectionNames = viewModel.collectionNames
let layout = cardDensity.layout
for (index, item) in items.enumerated() { for (index, item) in items.enumerated() {
let card = ClipboardItemCardView( let card = ClipboardItemCardView(
item: item, item: item,
thumbnail: viewModel.thumbnail(for: item), thumbnail: viewModel.thumbnail(for: item),
index: index, index: index,
layout: layout,
collectionNames: collectionNames, collectionNames: collectionNames,
isStacked: viewModel.isItemStacked(at: index), isStacked: viewModel.isItemStacked(at: index),
stackCount: viewModel.stackCount stackCount: viewModel.stackCount
@@ -689,7 +789,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
itemsStack.layoutSubtreeIfNeeded() itemsStack.layoutSubtreeIfNeeded()
let frame = card.convert(card.bounds, to: itemsStack) let frame = card.convert(card.bounds, to: itemsStack)
let paddedFrame = frame.insetBy(dx: -Metrics.cardSpacing, dy: 0) let paddedFrame = frame.insetBy(dx: -cardDensity.cardSpacing, dy: 0)
itemsStack.scrollToVisible(paddedFrame) itemsStack.scrollToVisible(paddedFrame)
scrollView.reflectScrolledClipView(scrollView.contentView) scrollView.reflectScrolledClipView(scrollView.contentView)
} }
@@ -780,8 +880,8 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
} }
private func emptyStateView() -> NSView { private func emptyStateView() -> NSView {
let width = max(760, scrollView.contentView.bounds.width) let width = max(cardDensity.emptyStateMinimumWidth, scrollView.contentView.bounds.width)
let container = NSView(frame: NSRect(x: 0, y: 0, width: width, height: Metrics.cardRailHeight)) let container = NSView(frame: NSRect(x: 0, y: 0, width: width, height: cardDensity.railHeight))
let copy = emptyStateCopy() let copy = emptyStateCopy()
let title = NSTextField(labelWithString: copy.title) let title = NSTextField(labelWithString: copy.title)
title.font = .systemFont(ofSize: 14, weight: .medium) title.font = .systemFont(ofSize: 14, weight: .medium)
@@ -811,9 +911,9 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
private func sizeItemsDocument(itemCount: Int) { private func sizeItemsDocument(itemCount: Int) {
let count = CGFloat(itemCount) let count = CGFloat(itemCount)
let contentWidth = (count * Metrics.cardWidth) let contentWidth = (count * cardDensity.layout.width)
+ max(0, count - 1) * Metrics.cardSpacing + max(0, count - 1) * cardDensity.cardSpacing
+ (Metrics.cardStackInset * 2) + (cardDensity.cardStackInset * 2)
let width = max(scrollView.contentView.bounds.width, contentWidth) let width = max(scrollView.contentView.bounds.width, contentWidth)
lastScrollContentWidth = width lastScrollContentWidth = width
itemsStack.frame = NSRect(x: 0, y: 0, width: width, height: currentListHeight()) itemsStack.frame = NSRect(x: 0, y: 0, width: width, height: currentListHeight())
@@ -822,7 +922,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
} }
private func currentListHeight() -> CGFloat { private func currentListHeight() -> CGFloat {
Metrics.cardHeight + (Metrics.cardStackInset * 2) cardDensity.layout.height + (cardDensity.cardStackInset * 2)
} }
private func emptyStateCopy() -> (title: String, detail: String) { private func emptyStateCopy() -> (title: String, detail: String) {
@@ -897,6 +997,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
override func layout() { override func layout() {
super.layout() super.layout()
_ = updateCardDensityForCurrentWidth()
let collectionViewportWidth = collectionScrollView.contentView.bounds.width let collectionViewportWidth = collectionScrollView.contentView.bounds.width
if collectionViewportWidth != lastCollectionViewportWidth { if collectionViewportWidth != lastCollectionViewportWidth {
lastCollectionViewportWidth = collectionViewportWidth lastCollectionViewportWidth = collectionViewportWidth
@@ -913,7 +1014,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
if cardViews.isEmpty { if cardViews.isEmpty {
guard let documentView = scrollView.documentView else { return } guard let documentView = scrollView.documentView else { return }
documentView.frame.size = NSSize( documentView.frame.size = NSSize(
width: max(760, scrollView.contentView.bounds.width), width: max(cardDensity.emptyStateMinimumWidth, scrollView.contentView.bounds.width),
height: currentListHeight() height: currentListHeight()
) )
return return
@@ -997,6 +1098,14 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
cardViews.map(\.debugPreviewStyle) cardViews.map(\.debugPreviewStyle)
} }
var debugCardDensity: String {
cardDensity.rawValue
}
var debugCardSizes: [NSSize] {
cardViews.map { $0.frame.size }
}
var debugCardHeaderBadgeSymbols: [String] { var debugCardHeaderBadgeSymbols: [String] {
cardViews.map(\.debugHeaderBadgeSymbol) cardViews.map(\.debugHeaderBadgeSymbol)
} }
@@ -1464,15 +1573,6 @@ private final class AspectFillImageView: NSView {
private final class ClipboardItemCardView: NSView, NSDraggingSource { private final class ClipboardItemCardView: NSView, NSDraggingSource {
private enum Metrics { private enum Metrics {
static let width: CGFloat = 320
static let height: CGFloat = 244
static let inset: CGFloat = 16
static let headerHeight: CGFloat = 56
static let bodyHeight: CGFloat = 152
static let footerHeight: CGFloat = 36
static let actionButtonSize: CGFloat = 24
static let primaryActionButtonSize: CGFloat = 30
static let actionRailHeight: CGFloat = 34
static let dragThreshold: CGFloat = 4 static let dragThreshold: CGFloat = 4
} }
private enum Palette { private enum Palette {
@@ -1507,6 +1607,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
private let index: Int private let index: Int
private let itemID: UUID private let itemID: UUID
private let layout: ClipboardItemCardLayout
private let itemKind: ClipboardItemKind private let itemKind: ClipboardItemKind
private let itemIsPinned: Bool private let itemIsPinned: Bool
private let itemIsStacked: Bool private let itemIsStacked: Bool
@@ -1531,12 +1632,14 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
item: ClipboardItem, item: ClipboardItem,
thumbnail: NSImage?, thumbnail: NSImage?,
index: Int, index: Int,
layout: ClipboardItemCardLayout = .regular,
collectionNames: [String] = [], collectionNames: [String] = [],
isStacked: Bool = false, isStacked: Bool = false,
stackCount: Int = 0 stackCount: Int = 0
) { ) {
self.index = index self.index = index
self.itemID = item.id self.itemID = item.id
self.layout = layout
self.itemKind = item.kind self.itemKind = item.kind
self.itemIsPinned = item.isPinned self.itemIsPinned = item.isPinned
self.itemIsStacked = isStacked self.itemIsStacked = isStacked
@@ -1895,7 +1998,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
actionRail.spacing = 4 actionRail.spacing = 4
actionRail.edgeInsets = NSEdgeInsets(top: 2, left: 6, bottom: 2, right: 6) actionRail.edgeInsets = NSEdgeInsets(top: 2, left: 6, bottom: 2, right: 6)
actionRail.wantsLayer = true actionRail.wantsLayer = true
actionRail.layer?.cornerRadius = Metrics.actionRailHeight / 2 actionRail.layer?.cornerRadius = layout.actionRailHeight / 2
actionRail.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.44).cgColor actionRail.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.44).cgColor
actionRail.layer?.borderWidth = 0.5 actionRail.layer?.borderWidth = 0.5
actionRail.layer?.borderColor = NSColor.white.withAlphaComponent(0.18).cgColor actionRail.layer?.borderColor = NSColor.white.withAlphaComponent(0.18).cgColor
@@ -1904,7 +2007,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
actionRail.layer?.shadowRadius = 10 actionRail.layer?.shadowRadius = 10
actionRail.layer?.shadowOffset = NSSize(width: 0, height: 4) actionRail.layer?.shadowOffset = NSSize(width: 0, height: 4)
actionRail.translatesAutoresizingMaskIntoConstraints = false actionRail.translatesAutoresizingMaskIntoConstraints = false
actionRail.heightAnchor.constraint(equalToConstant: Metrics.actionRailHeight).isActive = true actionRail.heightAnchor.constraint(equalToConstant: layout.actionRailHeight).isActive = true
actionRail.setContentHuggingPriority(.required, for: .horizontal) actionRail.setContentHuggingPriority(.required, for: .horizontal)
actionRail.setContentCompressionResistancePriority(.required, for: .horizontal) actionRail.setContentCompressionResistancePriority(.required, for: .horizontal)
@@ -1934,8 +2037,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
} }
let buttonCount = CGFloat(actionRailButtons.count) let buttonCount = CGFloat(actionRailButtons.count)
let secondaryCount = CGFloat(max(0, actionRailButtons.count - 1)) let secondaryCount = CGFloat(max(0, actionRailButtons.count - 1))
let contentWidth = Metrics.primaryActionButtonSize let contentWidth = layout.primaryActionButtonSize
+ secondaryCount * Metrics.actionButtonSize + secondaryCount * layout.actionButtonSize
+ max(0, buttonCount - 1) * actionRail.spacing + max(0, buttonCount - 1) * actionRail.spacing
+ actionRail.edgeInsets.left + actionRail.edgeInsets.left
+ actionRail.edgeInsets.right + actionRail.edgeInsets.right
@@ -1957,7 +2060,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
button.imageScaling = .scaleProportionallyDown button.imageScaling = .scaleProportionallyDown
button.isBordered = false button.isBordered = false
button.wantsLayer = true button.wantsLayer = true
let size = isPrimary ? Metrics.primaryActionButtonSize : Metrics.actionButtonSize let size = isPrimary ? layout.primaryActionButtonSize : layout.actionButtonSize
button.layer?.cornerRadius = size / 2 button.layer?.cornerRadius = size / 2
button.layer?.backgroundColor = isPrimary button.layer?.backgroundColor = isPrimary
? NSColor.controlAccentColor.cgColor ? NSColor.controlAccentColor.cgColor
@@ -2094,8 +2197,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
setAccessibilityRole(.button) setAccessibilityRole(.button)
setAccessibilityLabel(accessibilityTitle(for: item)) setAccessibilityLabel(accessibilityTitle(for: item))
setAccessibilityHelp("Selects this clipboard item. Double-click to paste.") setAccessibilityHelp("Selects this clipboard item. Double-click to paste.")
widthAnchor.constraint(equalToConstant: Metrics.width).isActive = true widthAnchor.constraint(equalToConstant: layout.width).isActive = true
heightAnchor.constraint(equalToConstant: Metrics.height).isActive = true heightAnchor.constraint(equalToConstant: layout.height).isActive = true
contentView.wantsLayer = true contentView.wantsLayer = true
contentView.layer?.cornerRadius = 8 contentView.layer?.cornerRadius = 8
@@ -2142,17 +2245,17 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
let header = NSView() let header = NSView()
header.wantsLayer = true header.wantsLayer = true
header.layer?.backgroundColor = accentColor(for: item.kind).cgColor header.layer?.backgroundColor = accentColor(for: item.kind).cgColor
header.heightAnchor.constraint(equalToConstant: Metrics.headerHeight).isActive = true header.heightAnchor.constraint(equalToConstant: layout.headerHeight).isActive = true
let kind = NSTextField(labelWithString: kindLabel(for: item.kind)) let kind = NSTextField(labelWithString: kindLabel(for: item.kind))
kind.font = .systemFont(ofSize: 16, weight: .bold) kind.font = .systemFont(ofSize: layout.isCompact ? 15 : 16, weight: .bold)
kind.textColor = .white kind.textColor = .white
kind.lineBreakMode = .byTruncatingTail kind.lineBreakMode = .byTruncatingTail
kind.maximumNumberOfLines = 1 kind.maximumNumberOfLines = 1
kind.toolTip = kind.stringValue kind.toolTip = kind.stringValue
let source = NSTextField(labelWithString: Self.relativeDateText(for: item.createdAt)) let source = NSTextField(labelWithString: Self.relativeDateText(for: item.createdAt))
source.font = .systemFont(ofSize: 11, weight: .regular) source.font = .systemFont(ofSize: layout.isCompact ? 10 : 11, weight: .regular)
source.textColor = NSColor.white.withAlphaComponent(0.72) source.textColor = NSColor.white.withAlphaComponent(0.72)
source.lineBreakMode = .byTruncatingTail source.lineBreakMode = .byTruncatingTail
source.maximumNumberOfLines = 1 source.maximumNumberOfLines = 1
@@ -2191,15 +2294,15 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
source.setContentHuggingPriority(.defaultLow, for: .horizontal) source.setContentHuggingPriority(.defaultLow, for: .horizontal)
var constraints: [NSLayoutConstraint] = [ var constraints: [NSLayoutConstraint] = [
labelStack.leadingAnchor.constraint(equalTo: header.leadingAnchor, constant: Metrics.inset), labelStack.leadingAnchor.constraint(equalTo: header.leadingAnchor, constant: layout.inset),
labelStack.centerYAnchor.constraint(equalTo: header.centerYAnchor), labelStack.centerYAnchor.constraint(equalTo: header.centerYAnchor),
labelStack.trailingAnchor.constraint(lessThanOrEqualTo: badge.leadingAnchor, constant: -12), labelStack.trailingAnchor.constraint(lessThanOrEqualTo: badge.leadingAnchor, constant: -12),
badge.trailingAnchor.constraint(equalTo: header.trailingAnchor, constant: -Metrics.inset), badge.trailingAnchor.constraint(equalTo: header.trailingAnchor, constant: -layout.inset),
badge.centerYAnchor.constraint(equalTo: header.centerYAnchor), badge.centerYAnchor.constraint(equalTo: header.centerYAnchor),
badge.widthAnchor.constraint(equalToConstant: 42), badge.widthAnchor.constraint(equalToConstant: layout.isCompact ? 36 : 42),
badge.heightAnchor.constraint(equalToConstant: 42), badge.heightAnchor.constraint(equalToConstant: layout.isCompact ? 36 : 42),
separator.leadingAnchor.constraint(equalTo: header.leadingAnchor, constant: Metrics.inset), separator.leadingAnchor.constraint(equalTo: header.leadingAnchor, constant: layout.inset),
separator.trailingAnchor.constraint(equalTo: header.trailingAnchor, constant: -Metrics.inset), separator.trailingAnchor.constraint(equalTo: header.trailingAnchor, constant: -layout.inset),
separator.bottomAnchor.constraint(equalTo: header.bottomAnchor), separator.bottomAnchor.constraint(equalTo: header.bottomAnchor),
separator.heightAnchor.constraint(equalToConstant: 1) separator.heightAnchor.constraint(equalToConstant: 1)
] ]
@@ -2246,7 +2349,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
let body = NSView() let body = NSView()
body.wantsLayer = true body.wantsLayer = true
body.layer?.backgroundColor = Palette.bodyBackground body.layer?.backgroundColor = Palette.bodyBackground
body.heightAnchor.constraint(equalToConstant: Metrics.bodyHeight).isActive = true body.heightAnchor.constraint(equalToConstant: layout.bodyHeight).isActive = true
let content = previewView(for: item, thumbnail: thumbnail) let content = previewView(for: item, thumbnail: thumbnail)
body.addSubview(content) body.addSubview(content)
@@ -2311,8 +2414,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
title.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true title.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
detail.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true detail.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: Metrics.inset), stack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: layout.inset),
stack.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -Metrics.inset), stack.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -layout.inset),
stack.topAnchor.constraint(equalTo: container.topAnchor, constant: 16), stack.topAnchor.constraint(equalTo: container.topAnchor, constant: 16),
stack.bottomAnchor.constraint(lessThanOrEqualTo: container.bottomAnchor, constant: -14) stack.bottomAnchor.constraint(lessThanOrEqualTo: container.bottomAnchor, constant: -14)
]) ])
@@ -2377,8 +2480,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
globe.widthAnchor.constraint(equalToConstant: 28), globe.widthAnchor.constraint(equalToConstant: 28),
globe.heightAnchor.constraint(equalToConstant: 28), globe.heightAnchor.constraint(equalToConstant: 28),
host.widthAnchor.constraint(lessThanOrEqualTo: hero.widthAnchor, constant: -48), host.widthAnchor.constraint(lessThanOrEqualTo: hero.widthAnchor, constant: -48),
textStack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: Metrics.inset), textStack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: layout.inset),
textStack.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -Metrics.inset), textStack.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -layout.inset),
textStack.topAnchor.constraint(equalTo: hero.bottomAnchor, constant: 11), textStack.topAnchor.constraint(equalTo: hero.bottomAnchor, constant: 11),
textStack.bottomAnchor.constraint(lessThanOrEqualTo: container.bottomAnchor, constant: -10), textStack.bottomAnchor.constraint(lessThanOrEqualTo: container.bottomAnchor, constant: -10),
title.widthAnchor.constraint(equalTo: textStack.widthAnchor), title.widthAnchor.constraint(equalTo: textStack.widthAnchor),
@@ -2427,8 +2530,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
imageView.topAnchor.constraint(equalTo: container.topAnchor), imageView.topAnchor.constraint(equalTo: container.topAnchor),
hostPill.leadingAnchor.constraint(equalTo: imageView.leadingAnchor, constant: 12), hostPill.leadingAnchor.constraint(equalTo: imageView.leadingAnchor, constant: 12),
hostPill.bottomAnchor.constraint(equalTo: imageView.bottomAnchor, constant: -10), hostPill.bottomAnchor.constraint(equalTo: imageView.bottomAnchor, constant: -10),
textStack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: Metrics.inset), textStack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: layout.inset),
textStack.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -Metrics.inset), textStack.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -layout.inset),
textStack.topAnchor.constraint(equalTo: imageView.bottomAnchor, constant: 10), textStack.topAnchor.constraint(equalTo: imageView.bottomAnchor, constant: 10),
textStack.bottomAnchor.constraint(lessThanOrEqualTo: container.bottomAnchor, constant: -8), textStack.bottomAnchor.constraint(lessThanOrEqualTo: container.bottomAnchor, constant: -8),
title.widthAnchor.constraint(equalTo: textStack.widthAnchor), title.widthAnchor.constraint(equalTo: textStack.widthAnchor),
@@ -2495,8 +2598,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
container.addSubview(row) container.addSubview(row)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
row.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: Metrics.inset), row.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: layout.inset),
row.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -Metrics.inset), row.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -layout.inset),
row.centerYAnchor.constraint(equalTo: container.centerYAnchor), row.centerYAnchor.constraint(equalTo: container.centerYAnchor),
iconBox.widthAnchor.constraint(equalToConstant: thumbnail == nil ? 72 : 96), iconBox.widthAnchor.constraint(equalToConstant: thumbnail == nil ? 72 : 96),
iconBox.heightAnchor.constraint(equalToConstant: thumbnail == nil ? 84 : 104), iconBox.heightAnchor.constraint(equalToConstant: thumbnail == nil ? 84 : 104),
@@ -2567,8 +2670,8 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
note.heightAnchor.constraint(equalToConstant: 28), note.heightAnchor.constraint(equalToConstant: 28),
waveform.centerXAnchor.constraint(equalTo: container.centerXAnchor), waveform.centerXAnchor.constraint(equalTo: container.centerXAnchor),
waveform.topAnchor.constraint(equalTo: note.bottomAnchor, constant: 8), waveform.topAnchor.constraint(equalTo: note.bottomAnchor, constant: 8),
labels.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: Metrics.inset), labels.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: layout.inset),
labels.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -Metrics.inset), labels.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -layout.inset),
labels.topAnchor.constraint(equalTo: waveform.bottomAnchor, constant: 10), labels.topAnchor.constraint(equalTo: waveform.bottomAnchor, constant: 10),
title.widthAnchor.constraint(equalTo: labels.widthAnchor), title.widthAnchor.constraint(equalTo: labels.widthAnchor),
detail.widthAnchor.constraint(equalTo: labels.widthAnchor) detail.widthAnchor.constraint(equalTo: labels.widthAnchor)
@@ -2668,7 +2771,7 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
let footer = NSView() let footer = NSView()
footer.wantsLayer = true footer.wantsLayer = true
footer.layer?.backgroundColor = Palette.footerBackground footer.layer?.backgroundColor = Palette.footerBackground
footer.heightAnchor.constraint(equalToConstant: Metrics.footerHeight).isActive = true footer.heightAnchor.constraint(equalToConstant: layout.footerHeight).isActive = true
let source = NSTextField(labelWithString: sourceText(for: item)) let source = NSTextField(labelWithString: sourceText(for: item))
source.font = .systemFont(ofSize: NSFont.smallSystemFontSize, weight: .medium) source.font = .systemFont(ofSize: NSFont.smallSystemFontSize, weight: .medium)
@@ -2707,12 +2810,12 @@ private final class ClipboardItemCardView: NSView, NSDraggingSource {
footer.addSubview(divider) footer.addSubview(divider)
footer.addSubview(stack) footer.addSubview(stack)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
divider.leadingAnchor.constraint(equalTo: footer.leadingAnchor, constant: Metrics.inset), divider.leadingAnchor.constraint(equalTo: footer.leadingAnchor, constant: layout.inset),
divider.trailingAnchor.constraint(equalTo: footer.trailingAnchor, constant: -Metrics.inset), divider.trailingAnchor.constraint(equalTo: footer.trailingAnchor, constant: -layout.inset),
divider.topAnchor.constraint(equalTo: footer.topAnchor), divider.topAnchor.constraint(equalTo: footer.topAnchor),
divider.heightAnchor.constraint(equalToConstant: 1), divider.heightAnchor.constraint(equalToConstant: 1),
stack.leadingAnchor.constraint(equalTo: footer.leadingAnchor, constant: Metrics.inset), stack.leadingAnchor.constraint(equalTo: footer.leadingAnchor, constant: layout.inset),
stack.trailingAnchor.constraint(equalTo: footer.trailingAnchor, constant: -Metrics.inset), stack.trailingAnchor.constraint(equalTo: footer.trailingAnchor, constant: -layout.inset),
stack.centerYAnchor.constraint(equalTo: footer.centerYAnchor) stack.centerYAnchor.constraint(equalTo: footer.centerYAnchor)
]) ])
return footer return footer

View File

@@ -59,6 +59,25 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["text-preview"]) XCTAssertEqual(fixture.view.debugCardPreviewStyles, ["text-preview"])
} }
func testCompactCardsFitTwoItemsOnNarrowDockShelf() {
let fixture = makePanelFixture()
fixture.window.setFrame(NSRect(x: 0, y: 0, width: 620, height: 520), display: true)
fixture.store.upsert(makeTextItem("Compact first", store: fixture.store))
fixture.store.upsert(makeTextItem("Compact second", store: fixture.store))
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugCardDensity, "compact")
XCTAssertEqual(fixture.view.debugVisibleCardCount, 2)
XCTAssertEqual(fixture.view.debugCardSizes.count, 2)
XCTAssertEqual(fixture.view.debugCardSizes.first?.width ?? 0, 264, accuracy: 0.5)
XCTAssertEqual(fixture.view.debugCardSizes.first?.height ?? 0, 220, accuracy: 0.5)
XCTAssertLessThanOrEqual(
fixture.view.debugDocumentViewFrame.width,
fixture.view.debugCardRailVisibleRect.width + 1
)
}
func testCardsShowQuickPasteNumberBadgesForFirstNineItems() { func testCardsShowQuickPasteNumberBadgesForFirstNineItems() {
let fixture = makePanelFixture() let fixture = makePanelFixture()
for index in 0..<10 { for index in 0..<10 {