2026-06-30 01:12:19 -07:00
import AppKit
final class ClipboardPanelView : NSVisualEffectView , NSSearchFieldDelegate {
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 panelTopInset : CGFloat = 12
static let panelSideInset : CGFloat = 22
static let actionBarHorizontalPadding : CGFloat = 10
static let panelStatusBarHeight : CGFloat = 24
static let minimumBottomInset : CGFloat = 20
static let panelCornerRadius : CGFloat = 0
}
private enum Palette {
static let panelBorder = NSColor . separatorColor . withAlphaComponent ( 0.18 ) . cgColor
static let panelSurface = NSColor . windowBackgroundColor . withAlphaComponent ( 0.56 ) . cgColor
static let panelShadow = NSColor . black . withAlphaComponent ( 0.18 ) . cgColor
static let panelStatusSurface = NSColor . clear . cgColor
static let statusDivider = NSColor . clear . cgColor
}
private enum StatusTone : String {
case ready
case action
case warning
case error
case neutral
var color : NSColor {
switch self {
case . ready :
return NSColor . systemGreen . withAlphaComponent ( 0.85 )
case . action :
return NSColor . controlAccentColor . withAlphaComponent ( 0.9 )
case . warning :
return NSColor . systemOrange . withAlphaComponent ( 0.9 )
case . error :
return NSColor . systemRed . withAlphaComponent ( 0.9 )
case . neutral :
return NSColor . secondaryLabelColor . withAlphaComponent ( 0.65 )
}
}
}
private let viewModel : ClipboardPanelViewModel
private let onClose : ( ) -> Void
private let onSettings : ( ) -> Void
2026-06-30 01:48:35 -07:00
private let onPreview : ( ) -> Void
2026-06-30 01:12:19 -07:00
private let searchField = NSSearchField ( )
private let collectionScrollView = NSScrollView ( )
private let collectionStack = NSStackView ( )
2026-06-30 01:34:05 -07:00
private let addCollectionButton = NSButton ( )
2026-06-30 02:19:03 -07:00
private let stackChip = CollectionChipView ( title : " Stack " , color : . systemGreen )
2026-06-30 01:12:19 -07:00
private let itemsStack = NSStackView ( )
private let scrollView = NSScrollView ( )
private let statusLabel = NSTextField ( labelWithString : " " )
private let statusResultCountLabel = NSTextField ( labelWithString : " " )
private let statusIndicator = NSView ( )
private var emptyStateText : ( title : String , detail : String ) ?
private var mainStack : NSStackView ?
private var bottomSafeInset = Metrics . minimumBottomInset
private var currentStatusTone : StatusTone = . ready
private var cardViews : [ ClipboardItemCardView ] = [ ]
private var collectionButtons : [ ClipboardSortMode : CollectionChipView ] = [ : ]
private var customCollectionButtons : [ String : CollectionChipView ] = [ : ]
private var lastScrollContentWidth : CGFloat = 0
private var lastCollectionViewportWidth : CGFloat = 0
private var defersVisualReloads = false
private var pendingItemReload = false
private var pendingCollectionReload = false
2026-06-30 01:34:05 -07:00
#if DEBUG
private var collectionNameProviderForTesting : ( ( ) -> String ? ) ?
#endif
2026-06-30 01:12:19 -07:00
2026-06-30 01:48:35 -07:00
init (
viewModel : ClipboardPanelViewModel ,
onClose : @ escaping ( ) -> Void ,
onSettings : @ escaping ( ) -> Void = { } ,
onPreview : @ escaping ( ) -> Void = { }
) {
2026-06-30 01:12:19 -07:00
self . viewModel = viewModel
self . onClose = onClose
self . onSettings = onSettings
2026-06-30 01:48:35 -07:00
self . onPreview = onPreview
2026-06-30 01:12:19 -07:00
super . init ( frame : . zero )
configureView ( )
bindViewModel ( )
reloadItems ( )
updateSelection ( )
updateStatus ( viewModel . statusMessage )
updateResultCount ( )
updateCollectionButtons ( )
}
required init ? ( coder : NSCoder ) {
fatalError ( " init(coder:) has not been implemented " )
}
private func configureView ( ) {
material = . underWindowBackground
blendingMode = . behindWindow
state = . active
wantsLayer = true
layer ? . cornerRadius = Metrics . panelCornerRadius
layer ? . masksToBounds = false
layer ? . backgroundColor = Palette . panelSurface
layer ? . borderWidth = 0.6
layer ? . borderColor = Palette . panelBorder
layer ? . shadowColor = Palette . panelShadow
layer ? . shadowOpacity = 0.18
layer ? . shadowRadius = 20
layer ? . shadowOffset = NSSize ( width : 0 , height : 10 )
let toolbarIcon = NSImageView ( image : appIconImage ( ) )
toolbarIcon . imageScaling = . scaleProportionallyUpOrDown
toolbarIcon . toolTip = " ClipBored "
toolbarIcon . widthAnchor . constraint ( equalToConstant : 22 ) . isActive = true
toolbarIcon . heightAnchor . constraint ( equalToConstant : 22 ) . isActive = true
2026-06-30 02:32:08 -07:00
searchField . placeholderString = " Search clips "
2026-06-30 01:12:19 -07:00
searchField . setAccessibilityLabel ( " Search clipboard history " )
searchField . delegate = self
searchField . target = self
searchField . action = #selector ( searchFieldChanged )
searchField . sendsSearchStringImmediately = true
searchField . sendsWholeSearchString = false
searchField . isBezeled = true
searchField . placeholderAttributedString = NSAttributedString (
2026-06-30 02:32:08 -07:00
string : " Search clips " ,
2026-06-30 01:12:19 -07:00
attributes : [
. foregroundColor : NSColor . tertiaryLabelColor
]
)
searchField . bezelStyle = . roundedBezel
searchField . backgroundColor = NSColor . controlBackgroundColor . withAlphaComponent ( 0.6 )
searchField . focusRingType = . none
2026-06-30 02:32:08 -07:00
searchField . toolTip = " Search clipboard history. Supports app:Safari, type:image, date:2026-06-30, after:2026-06-01, and pinned:on. "
2026-06-30 01:12:19 -07:00
searchField . setContentCompressionResistancePriority ( . defaultLow , for : . horizontal )
searchField . setContentHuggingPriority ( . defaultLow , for : . horizontal )
searchField . widthAnchor . constraint ( greaterThanOrEqualToConstant : 280 ) . isActive = true
searchField . widthAnchor . constraint ( lessThanOrEqualToConstant : 620 ) . isActive = true
collectionStack . orientation = . horizontal
collectionStack . alignment = . centerY
collectionStack . distribution = . fill
collectionStack . spacing = 10
collectionStack . translatesAutoresizingMaskIntoConstraints = true
collectionStack . setContentCompressionResistancePriority ( . defaultLow , for : . horizontal )
collectionStack . setContentHuggingPriority ( . defaultLow , for : . horizontal )
collectionStack . setAccessibilityLabel ( " Clipboard collections " )
collectionScrollView . documentView = collectionStack
collectionScrollView . hasHorizontalScroller = true
collectionScrollView . hasVerticalScroller = false
collectionScrollView . autohidesScrollers = true
collectionScrollView . scrollerStyle = . overlay
collectionScrollView . drawsBackground = false
collectionScrollView . borderType = . noBorder
collectionScrollView . setAccessibilityLabel ( " Clipboard collections " )
collectionScrollView . setContentCompressionResistancePriority ( . defaultLow , for : . horizontal )
collectionScrollView . setContentHuggingPriority ( . defaultLow , for : . horizontal )
collectionScrollView . heightAnchor . constraint ( equalToConstant : 30 ) . isActive = true
2026-06-30 01:34:05 -07:00
configureAddCollectionButton ( )
2026-06-30 01:12:19 -07:00
configureCollectionButtons ( )
let settingsButton = iconButton ( " gearshape " , toolTip : " Settings " , action : #selector ( openSettings ) )
let closeButton = iconButton ( " xmark.circle " , toolTip : " Close " , action : #selector ( closePanel ) )
let actionStrip = row ( [
settingsButton ,
closeButton
] )
actionStrip . spacing = 8
actionStrip . setContentCompressionResistancePriority ( . required , for : . horizontal )
let actionGroup = groupedToolbar ( actionStrip )
let topSpacer = NSView ( )
topSpacer . setContentHuggingPriority ( . defaultLow , for : . horizontal )
topSpacer . setContentCompressionResistancePriority ( . defaultLow , for : . horizontal )
let topBar = row ( [
toolbarIcon ,
searchField ,
topSpacer ,
actionGroup
] )
topBar . distribution = . fill
topBar . setHuggingPriority ( . defaultHigh , for : . vertical )
actionGroup . setContentHuggingPriority ( . required , for : . horizontal )
actionGroup . setContentCompressionResistancePriority ( . required , for : . horizontal )
let filterSpacer = NSView ( )
filterSpacer . setContentHuggingPriority ( . defaultLow , for : . horizontal )
filterSpacer . setContentCompressionResistancePriority ( . defaultLow , for : . horizontal )
let filterBar = row ( [
collectionScrollView ,
filterSpacer
] )
filterBar . spacing = 12
filterBar . distribution = . fill
itemsStack . orientation = . horizontal
itemsStack . alignment = . top
itemsStack . spacing = Metrics . cardSpacing
itemsStack . edgeInsets = NSEdgeInsets (
top : Metrics . cardStackInset ,
left : Metrics . cardStackInset ,
bottom : Metrics . cardStackInset ,
right : Metrics . cardStackInset
)
itemsStack . translatesAutoresizingMaskIntoConstraints = true
scrollView . documentView = itemsStack
scrollView . hasHorizontalScroller = true
scrollView . hasVerticalScroller = false
scrollView . autohidesScrollers = true
scrollView . scrollerStyle = . overlay
scrollView . drawsBackground = false
scrollView . borderType = . noBorder
scrollView . setContentHuggingPriority ( . required , for : . vertical )
scrollView . setContentCompressionResistancePriority ( . required , for : . vertical )
scrollView . heightAnchor . constraint ( equalToConstant : Metrics . cardRailHeight ) . isActive = true
statusLabel . font = . systemFont ( ofSize : NSFont . systemFontSize - 1 )
statusLabel . textColor = . secondaryLabelColor
statusLabel . lineBreakMode = . byTruncatingTail
statusLabel . maximumNumberOfLines = 1
statusLabel . setContentCompressionResistancePriority ( . required , for : . vertical )
statusLabel . toolTip = statusLabel . stringValue
statusLabel . setContentCompressionResistancePriority ( . defaultLow , for : . horizontal )
statusLabel . setContentHuggingPriority ( . defaultLow , for : . horizontal )
statusIndicator . wantsLayer = true
statusIndicator . layer ? . cornerRadius = 4
statusIndicator . layer ? . backgroundColor = NSColor . systemGreen . withAlphaComponent ( 0.85 ) . cgColor
statusIndicator . translatesAutoresizingMaskIntoConstraints = false
statusIndicator . widthAnchor . constraint ( equalToConstant : 8 ) . isActive = true
statusIndicator . heightAnchor . constraint ( equalToConstant : 8 ) . isActive = true
statusIndicator . setAccessibilityElement ( false )
let statusRow = row ( [ statusIndicator , statusLabel , statusResultCountLabel ] )
statusRow . distribution = . fill
statusRow . alignment = . centerY
statusRow . spacing = 8
statusRow . setContentCompressionResistancePriority ( . required , for : . vertical )
statusResultCountLabel . alignment = . right
statusResultCountLabel . textColor = . secondaryLabelColor
statusResultCountLabel . lineBreakMode = . byTruncatingTail
statusResultCountLabel . maximumNumberOfLines = 1
statusResultCountLabel . setContentCompressionResistancePriority ( . required , for : . horizontal )
statusResultCountLabel . setContentHuggingPriority ( . required , for : . horizontal )
statusResultCountLabel . setContentCompressionResistancePriority ( . required , for : . vertical )
statusResultCountLabel . setContentHuggingPriority ( . defaultLow , for : . vertical )
statusResultCountLabel . font = . systemFont ( ofSize : NSFont . systemFontSize - 1 , weight : . medium )
statusResultCountLabel . setAccessibilityLabel ( " Result count " )
statusLabel . setAccessibilityLabel ( " Status " )
let headerStack = NSStackView ( views : [ topBar , filterBar ] )
headerStack . orientation = . vertical
headerStack . alignment = . leading
headerStack . spacing = 10
headerStack . setContentCompressionResistancePriority ( . required , for : . vertical )
let statusContainer = NSView ( )
statusContainer . wantsLayer = true
statusContainer . layer ? . backgroundColor = Palette . panelStatusSurface
statusContainer . layer ? . cornerRadius = 8
statusContainer . layer ? . borderWidth = 0
statusContainer . layer ? . borderColor = Palette . statusDivider
statusContainer . translatesAutoresizingMaskIntoConstraints = false
statusContainer . addSubview ( statusRow )
NSLayoutConstraint . activate ( [
statusRow . leadingAnchor . constraint ( equalTo : statusContainer . leadingAnchor , constant : 8 ) ,
statusRow . trailingAnchor . constraint ( equalTo : statusContainer . trailingAnchor , constant : - 8 ) ,
statusRow . topAnchor . constraint ( equalTo : statusContainer . topAnchor , constant : 4 ) ,
statusRow . bottomAnchor . constraint ( equalTo : statusContainer . bottomAnchor , constant : - 4 ) ,
statusContainer . heightAnchor . constraint ( greaterThanOrEqualToConstant : Metrics . panelStatusBarHeight )
] )
let mainStack = NSStackView ( views : [ headerStack , scrollView , statusContainer ] )
mainStack . orientation = . vertical
mainStack . alignment = . leading
mainStack . spacing = 10
mainStack . edgeInsets = contentInsets ( )
mainStack . translatesAutoresizingMaskIntoConstraints = false
addSubview ( mainStack )
self . mainStack = mainStack
NSLayoutConstraint . activate ( [
mainStack . leadingAnchor . constraint ( equalTo : leadingAnchor ) ,
mainStack . trailingAnchor . constraint ( equalTo : trailingAnchor ) ,
mainStack . topAnchor . constraint ( equalTo : topAnchor ) ,
mainStack . bottomAnchor . constraint ( equalTo : bottomAnchor ) ,
headerStack . widthAnchor . constraint ( equalTo : mainStack . widthAnchor , constant : - ( Metrics . panelSideInset * 2 ) ) ,
topBar . widthAnchor . constraint ( equalTo : headerStack . widthAnchor ) ,
filterBar . widthAnchor . constraint ( equalTo : headerStack . widthAnchor ) ,
scrollView . widthAnchor . constraint ( equalTo : mainStack . widthAnchor , constant : - ( Metrics . panelSideInset * 2 ) ) ,
statusContainer . widthAnchor . constraint ( equalTo : mainStack . widthAnchor , constant : - ( Metrics . panelSideInset * 2 ) )
] )
updateCollectionButtons ( )
updateResultCount ( )
}
private func bindViewModel ( ) {
viewModel . onVisibleItemsChanged = { [ weak self ] _ in
self ? . handleVisibleItemsChanged ( )
}
viewModel . onSelectedIndexChanged = { [ weak self ] _ in
self ? . updateSelection ( )
}
viewModel . onStatusMessageChanged = { [ weak self ] message in
self ? . updateStatus ( message )
}
viewModel . onSortModeChanged = { [ weak self ] _ in
self ? . updateCollectionButtons ( )
}
viewModel . onCollectionsChanged = { [ weak self ] in
self ? . handleCollectionsChanged ( )
}
2026-06-30 02:11:50 -07:00
viewModel . onStackChanged = { [ weak self ] in
self ? . reloadItems ( )
self ? . updateSelection ( )
2026-06-30 02:19:03 -07:00
self ? . configureCollectionButtons ( )
2026-06-30 02:11:50 -07:00
self ? . updateStatus ( self ? . viewModel . statusMessage ? ? " " )
}
2026-06-30 01:12:19 -07:00
viewModel . onCaptureStatusChanged = { [ weak self ] in
self ? . updateStatus ( self ? . viewModel . statusMessage ? ? " " )
}
}
private func row ( _ views : [ NSView ] ) -> NSStackView {
let stack = NSStackView ( views : views )
stack . orientation = . horizontal
stack . alignment = . centerY
stack . spacing = 8
return stack
}
private func groupedToolbar ( _ content : NSView ) -> NSView {
let container = NSVisualEffectView ( )
container . material = . sidebar
container . blendingMode = . withinWindow
container . wantsLayer = true
container . translatesAutoresizingMaskIntoConstraints = false
container . layer ? . cornerRadius = 0
container . layer ? . borderWidth = 0
container . layer ? . borderColor = NSColor . clear . cgColor
container . layer ? . backgroundColor = NSColor . clear . cgColor
content . translatesAutoresizingMaskIntoConstraints = false
content . setContentCompressionResistancePriority ( . required , for : . horizontal )
container . addSubview ( content )
NSLayoutConstraint . activate ( [
content . leadingAnchor . constraint ( equalTo : container . leadingAnchor ) ,
content . trailingAnchor . constraint ( equalTo : container . trailingAnchor ) ,
content . topAnchor . constraint ( equalTo : container . topAnchor ) ,
content . bottomAnchor . constraint ( equalTo : container . bottomAnchor )
] )
return container
}
private func configureCollectionButtons ( ) {
collectionButtons . removeAll ( )
customCollectionButtons . removeAll ( )
for view in collectionStack . arrangedSubviews {
collectionStack . removeArrangedSubview ( view )
view . removeFromSuperview ( )
}
for mode in ClipboardSortMode . allCases {
let chip = CollectionChipView ( title : collectionTitle ( for : mode ) , color : collectionColor ( for : mode ) )
chip . toolTip = mode . title
chip . onPress = { [ weak self ] in
self ? . viewModel . sortMode = mode
}
collectionButtons [ mode ] = chip
collectionStack . addArrangedSubview ( chip )
}
for collectionName in viewModel . collectionNames {
let chip = CollectionChipView ( title : collectionName , color : collectionColor ( forCollectionNamed : collectionName ) )
chip . toolTip = collectionName
chip . onPress = { [ weak self ] in
self ? . viewModel . selectCollection ( named : collectionName )
}
2026-06-30 03:02:29 -07:00
chip . onDropItem = { [ weak self ] itemID in
self ? . viewModel . assignItem ( withID : itemID , to : collectionName )
}
2026-06-30 01:12:19 -07:00
customCollectionButtons [ collectionName ] = chip
collectionStack . addArrangedSubview ( chip )
}
2026-06-30 02:19:03 -07:00
configureStackChip ( )
2026-06-30 01:34:05 -07:00
collectionStack . addArrangedSubview ( addCollectionButton )
updateAddCollectionButtonState ( )
2026-06-30 01:12:19 -07:00
sizeCollectionDocument ( )
}
2026-06-30 02:19:03 -07:00
private func configureStackChip ( ) {
stackChip . toolTip = " Queued clips "
stackChip . onPress = { [ weak self ] in
self ? . viewModel . selectStack ( )
}
if viewModel . stackCount > 0 {
collectionStack . addArrangedSubview ( stackChip )
}
}
2026-06-30 01:34:05 -07:00
private func configureAddCollectionButton ( ) {
let image = NSImage ( systemSymbolName : " plus " , accessibilityDescription : " New collection " )
image ? . isTemplate = true
addCollectionButton . image = image
addCollectionButton . imagePosition = . imageOnly
addCollectionButton . imageScaling = . scaleProportionallyDown
addCollectionButton . isBordered = false
addCollectionButton . wantsLayer = true
addCollectionButton . layer ? . cornerRadius = 13
addCollectionButton . layer ? . borderWidth = 0.6
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 . target = self
addCollectionButton . action = #selector ( addSelectedClipToCollection )
addCollectionButton . translatesAutoresizingMaskIntoConstraints = false
addCollectionButton . widthAnchor . constraint ( equalToConstant : 30 ) . isActive = true
addCollectionButton . heightAnchor . constraint ( equalToConstant : 26 ) . isActive = true
}
2026-06-30 01:12:19 -07:00
private func collectionTitle ( for mode : ClipboardSortMode ) -> String {
switch mode {
case . mostRecent : return " Clipboard "
case . mostUsed : return " Frequent "
case . text : return " Text "
case . links : return " Links "
case . images : return " Images "
case . audio : return " Audio "
case . files : return " Files "
case . pinned : return " Pinned "
}
}
private func collectionColor ( for mode : ClipboardSortMode ) -> NSColor {
switch mode {
case . mostRecent : return . secondaryLabelColor
case . mostUsed : return NSColor ( calibratedRed : 0.58 , green : 0.42 , blue : 0.92 , alpha : 1 )
case . text : return NSColor ( calibratedRed : 0.96 , green : 0.64 , blue : 0.00 , alpha : 1 )
case . links : return NSColor ( calibratedRed : 0.02 , green : 0.47 , blue : 0.98 , alpha : 1 )
case . images : return NSColor ( calibratedRed : 1.00 , green : 0.22 , blue : 0.25 , alpha : 1 )
case . audio : return NSColor ( calibratedRed : 0.93 , green : 0.12 , blue : 0.34 , alpha : 1 )
case . files : return NSColor ( calibratedRed : 0.11 , green : 0.68 , blue : 0.36 , alpha : 1 )
case . pinned : return NSColor ( calibratedRed : 0.94 , green : 0.12 , blue : 0.48 , alpha : 1 )
}
}
private func collectionColor ( forCollectionNamed name : String ) -> NSColor {
switch name {
case " Useful Links " :
return NSColor ( calibratedRed : 0.98 , green : 0.30 , blue : 0.32 , alpha : 1 )
case " Important Notes " :
return NSColor ( calibratedRed : 0.96 , green : 0.64 , blue : 0.00 , alpha : 1 )
case " Code Snippets " :
return NSColor ( calibratedRed : 0.04 , green : 0.47 , blue : 0.95 , alpha : 1 )
case " Read Later " :
return NSColor ( calibratedRed : 0.18 , green : 0.72 , blue : 0.34 , alpha : 1 )
default :
return NSColor ( calibratedRed : 0.52 , green : 0.42 , blue : 0.86 , alpha : 1 )
}
}
private func contentInsets ( ) -> NSEdgeInsets {
NSEdgeInsets (
top : Metrics . panelTopInset ,
left : Metrics . panelSideInset ,
bottom : bottomSafeInset ,
right : Metrics . panelSideInset
)
}
private func iconButton ( _ systemName : String , toolTip : String , action : Selector ) -> NSButton {
let button = NSButton ( title : " " , target : self , action : action )
let image = NSImage ( systemSymbolName : systemName , accessibilityDescription : toolTip )
image ? . isTemplate = true
button . image = image
button . imagePosition = . imageOnly
button . imageScaling = . scaleProportionallyDown
button . bezelStyle = . smallSquare
button . isBordered = false
button . wantsLayer = true
button . layer ? . backgroundColor = NSColor . controlBackgroundColor . withAlphaComponent ( 0.14 ) . cgColor
button . layer ? . cornerRadius = 7
button . toolTip = toolTip
button . contentTintColor = . secondaryLabelColor
button . setAccessibilityLabel ( toolTip )
button . translatesAutoresizingMaskIntoConstraints = false
button . widthAnchor . constraint ( equalToConstant : Metrics . actionButtonSize ) . isActive = true
button . heightAnchor . constraint ( equalToConstant : Metrics . actionButtonSize ) . isActive = true
return button
}
private func appIconImage ( ) -> NSImage {
if let url = Bundle . main . url ( forResource : " AppIcon " , withExtension : " icns " ) ,
let icon = NSImage ( contentsOf : url ) {
icon . size = NSSize ( width : 22 , height : 22 )
return icon
}
return NSImage ( systemSymbolName : " doc.on.clipboard.fill " , accessibilityDescription : " ClipBored " ) ? ? NSImage ( )
}
private func reloadItems ( ) {
cardViews . removeAll ( )
lastScrollContentWidth = 0
for view in itemsStack . arrangedSubviews {
itemsStack . removeArrangedSubview ( view )
view . removeFromSuperview ( )
}
let items = viewModel . visibleItems
if items . isEmpty {
emptyStateText = emptyStateCopy ( )
scrollView . documentView = emptyStateView ( )
} else {
emptyStateText = nil
if scrollView . documentView !== itemsStack {
scrollView . documentView = itemsStack
}
let collectionNames = viewModel . collectionNames
for ( index , item ) in items . enumerated ( ) {
let card = ClipboardItemCardView (
item : item ,
thumbnail : viewModel . thumbnail ( for : item ) ,
index : index ,
2026-06-30 02:11:50 -07:00
collectionNames : collectionNames ,
isStacked : viewModel . isItemStacked ( at : index ) ,
stackCount : viewModel . stackCount
2026-06-30 01:12:19 -07:00
)
card . onSelect = { [ weak self ] selected in
self ? . viewModel . selectItem ( at : selected )
}
card . onPaste = { [ weak self ] selected in
self ? . viewModel . selectItem ( at : selected )
self ? . viewModel . pasteSelected ( )
}
card . onCopy = { [ weak self ] selected in
self ? . viewModel . selectItem ( at : selected )
self ? . viewModel . copySelected ( )
}
2026-06-30 02:04:12 -07:00
card . onPastePlainText = { [ weak self ] selected in
self ? . viewModel . selectItem ( at : selected )
self ? . viewModel . pasteSelectedPlainText ( )
}
card . onCopyPlainText = { [ weak self ] selected in
self ? . viewModel . selectItem ( at : selected )
self ? . viewModel . copySelectedPlainText ( )
}
2026-06-30 02:11:50 -07:00
card . onToggleStack = { [ weak self ] selected in
self ? . viewModel . selectItem ( at : selected )
self ? . viewModel . toggleSelectedStackMembership ( )
}
card . onPasteStackNext = { [ weak self ] in
self ? . viewModel . pasteNextStackItem ( )
}
card . onCopyStackNext = { [ weak self ] in
self ? . viewModel . copyNextStackItem ( )
}
card . onClearStack = { [ weak self ] in
self ? . viewModel . clearStack ( )
}
2026-06-30 01:56:40 -07:00
card . onEditText = { [ weak self ] selected in
self ? . editText ( at : selected )
}
2026-06-30 01:48:35 -07:00
card . onPreview = { [ weak self ] selected in
self ? . viewModel . selectItem ( at : selected )
self ? . onPreview ( )
}
2026-06-30 01:24:01 -07:00
card . onPasteboardWriters = { [ weak self ] selected in
self ? . viewModel . pasteboardWriters ( forItemAt : selected ) ? ? [ ]
}
2026-06-30 01:12:19 -07:00
card . onOpen = { [ weak self ] selected in
self ? . viewModel . selectItem ( at : selected )
self ? . viewModel . openSelected ( )
}
card . onReveal = { [ weak self ] selected in
self ? . viewModel . selectItem ( at : selected )
self ? . viewModel . revealSelected ( )
}
card . onTogglePin = { [ weak self ] selected in
self ? . viewModel . selectItem ( at : selected )
self ? . viewModel . togglePinSelected ( )
}
card . onAssignCollection = { [ weak self ] selected , collectionName in
self ? . viewModel . selectItem ( at : selected )
self ? . viewModel . assignSelected ( to : collectionName )
}
2026-06-30 02:49:35 -07:00
card . onIgnoreSourceApp = { [ weak self ] selected in
self ? . viewModel . selectItem ( at : selected )
self ? . viewModel . ignoreSelectedSourceApp ( )
}
card . onIgnoreKind = { [ weak self ] selected in
self ? . viewModel . selectItem ( at : selected )
self ? . viewModel . ignoreSelectedKind ( )
}
2026-06-30 01:12:19 -07:00
card . onDelete = { [ weak self ] selected in
self ? . viewModel . selectItem ( at : selected )
self ? . viewModel . deleteSelected ( )
}
cardViews . append ( card )
itemsStack . addArrangedSubview ( card )
}
sizeItemsDocument ( itemCount : items . count )
}
updateSelection ( )
updateStatus ( viewModel . statusMessage )
updateResultCount ( )
}
private func handleVisibleItemsChanged ( ) {
if defersVisualReloads {
pendingItemReload = true
updateStatus ( viewModel . statusMessage )
updateResultCount ( )
return
}
reloadItems ( )
updateCollectionButtons ( )
}
private func handleCollectionsChanged ( ) {
if defersVisualReloads {
pendingCollectionReload = true
return
}
configureCollectionButtons ( )
updateCollectionButtons ( )
}
private func flushDeferredVisualReloads ( ) {
let shouldReloadItems = pendingItemReload
let shouldReloadCollections = pendingCollectionReload
pendingItemReload = false
pendingCollectionReload = false
if shouldReloadCollections {
configureCollectionButtons ( )
}
if shouldReloadItems {
reloadItems ( )
}
if shouldReloadItems || shouldReloadCollections {
updateCollectionButtons ( )
}
}
private func updateSelection ( ) {
2026-06-30 01:29:36 -07:00
var selectedCard : ClipboardItemCardView ?
2026-06-30 01:12:19 -07:00
for ( index , card ) in cardViews . enumerated ( ) {
2026-06-30 01:29:36 -07:00
let selected = index = = viewModel . selectedIndex
card . setSelected ( selected )
if selected {
selectedCard = card
}
}
if let selectedCard {
scrollCardIntoView ( selectedCard )
2026-06-30 01:12:19 -07:00
}
2026-06-30 01:34:05 -07:00
updateAddCollectionButtonState ( )
}
private func updateAddCollectionButtonState ( ) {
let hasSelectedItem = viewModel . selectedItem != nil
addCollectionButton . isEnabled = hasSelectedItem
addCollectionButton . alphaValue = hasSelectedItem ? 1.0 : 0.42
2026-06-30 01:12:19 -07:00
}
2026-06-30 01:29:36 -07:00
private func scrollCardIntoView ( _ card : NSView ) {
guard scrollView . documentView = = = itemsStack else { return }
guard card . window != nil else { return }
scrollView . layoutSubtreeIfNeeded ( )
itemsStack . layoutSubtreeIfNeeded ( )
let frame = card . convert ( card . bounds , to : itemsStack )
let paddedFrame = frame . insetBy ( dx : - Metrics . cardSpacing , dy : 0 )
itemsStack . scrollToVisible ( paddedFrame )
scrollView . reflectScrolledClipView ( scrollView . contentView )
}
2026-06-30 01:12:19 -07:00
private func updateStatus ( _ message : String ) {
let text : String
if ! message . isEmpty {
text = message
} else if ! viewModel . captureStatusMessage . isEmpty {
text = viewModel . captureStatusMessage
} else if viewModel . visibleItems . isEmpty {
text = " Capture is running. Accessibility permission is only needed for automatic paste. "
} else {
text = " Capture running "
}
statusLabel . stringValue = text
statusLabel . toolTip = statusLabel . stringValue
updateStatusIndicator ( for : text )
}
private func updateStatusIndicator ( for text : String ) {
currentStatusTone = statusTone ( for : text )
statusIndicator . layer ? . backgroundColor = currentStatusTone . color . cgColor
statusIndicator . toolTip = text
}
private func statusTone ( for text : String ) -> StatusTone {
let lower = text . lowercased ( )
if lower . hasPrefix ( " captured " ) || lower . contains ( " capture running " ) || lower . contains ( " capture is running " ) || lower . contains ( " capture resumed " ) {
return . ready
}
2026-06-30 02:49:35 -07:00
if lower . hasPrefix ( " copied " ) || lower . hasPrefix ( " pasted " ) || lower . hasPrefix ( " updated " ) || lower . hasPrefix ( " added " ) || lower . hasPrefix ( " removed " ) || lower . hasPrefix ( " cleared " ) || lower . hasPrefix ( " ignored " ) {
2026-06-30 01:12:19 -07:00
return . action
}
if lower . hasPrefix ( " error " ) || lower . contains ( " failed " ) {
return . error
}
if lower . hasPrefix ( " skipped " ) || lower . contains ( " ignored " ) || lower . contains ( " paused " ) {
return . warning
}
if lower . contains ( " could not " ) || lower . contains ( " not granted " ) {
return . error
}
return . neutral
}
private func updateResultCount ( ) {
let count = viewModel . visibleItems . count
let noun = count = = 1 ? " clip " : " clips "
let text : String
if ! viewModel . searchText . clipboardTrimmed . isEmpty {
text = " \( count ) \( noun ) matching "
} else {
text = " \( count ) \( noun ) "
}
statusResultCountLabel . stringValue = text
statusResultCountLabel . toolTip = text
}
2026-06-30 01:56:40 -07:00
private func editText ( at index : Int ) {
viewModel . selectItem ( at : index )
guard let currentText = viewModel . editableTextForSelected ( ) else { return }
let textView = NSTextView ( frame : NSRect ( x : 0 , y : 0 , width : 460 , height : 180 ) )
textView . string = currentText
textView . font = . systemFont ( ofSize : 13 )
textView . isRichText = false
textView . allowsUndo = true
textView . textContainerInset = NSSize ( width : 10 , height : 10 )
textView . usesAdaptiveColorMappingForDarkAppearance = true
let scrollView = NSScrollView ( frame : textView . frame )
scrollView . hasVerticalScroller = true
scrollView . hasHorizontalScroller = false
scrollView . autohidesScrollers = true
scrollView . borderType = . bezelBorder
scrollView . documentView = textView
let alert = NSAlert ( )
alert . messageText = " Edit Text "
alert . accessoryView = scrollView
alert . addButton ( withTitle : " Save " )
alert . addButton ( withTitle : " Cancel " )
alert . window . initialFirstResponder = textView
guard alert . runModal ( ) = = . alertFirstButtonReturn else { return }
viewModel . updateSelectedText ( to : textView . string )
}
2026-06-30 01:12:19 -07:00
private func emptyStateView ( ) -> NSView {
let width = max ( 760 , scrollView . contentView . bounds . width )
let container = NSView ( frame : NSRect ( x : 0 , y : 0 , width : width , height : Metrics . cardRailHeight ) )
let copy = emptyStateCopy ( )
let title = NSTextField ( labelWithString : copy . title )
title . font = . systemFont ( ofSize : 14 , weight : . medium )
title . textColor = . labelColor
title . alignment = . center
let detail = NSTextField ( wrappingLabelWithString : copy . detail )
detail . font = . systemFont ( ofSize : NSFont . smallSystemFontSize )
detail . textColor = . secondaryLabelColor
detail . alignment = . center
detail . maximumNumberOfLines = 3
let stack = NSStackView ( views : [ title , detail ] )
stack . orientation = . vertical
stack . alignment = . centerX
stack . spacing = 6
stack . translatesAutoresizingMaskIntoConstraints = false
container . addSubview ( stack )
NSLayoutConstraint . activate ( [
stack . centerXAnchor . constraint ( equalTo : container . centerXAnchor ) ,
stack . centerYAnchor . constraint ( equalTo : container . centerYAnchor ) ,
stack . widthAnchor . constraint ( lessThanOrEqualTo : container . widthAnchor , constant : - 80 )
] )
return container
}
private func sizeItemsDocument ( itemCount : Int ) {
let count = CGFloat ( itemCount )
let contentWidth = ( count * Metrics . cardWidth )
+ max ( 0 , count - 1 ) * Metrics . cardSpacing
+ ( Metrics . cardStackInset * 2 )
let width = max ( scrollView . contentView . bounds . width , contentWidth )
lastScrollContentWidth = width
itemsStack . frame = NSRect ( x : 0 , y : 0 , width : width , height : currentListHeight ( ) )
itemsStack . needsLayout = true
itemsStack . layoutSubtreeIfNeeded ( )
}
private func currentListHeight ( ) -> CGFloat {
Metrics . cardHeight + ( Metrics . cardStackInset * 2 )
}
private func emptyStateCopy ( ) -> ( title : String , detail : String ) {
if ! viewModel . searchText . clipboardTrimmed . isEmpty {
return (
" No matching clips " ,
" Try a broader search or switch filters. "
)
}
if viewModel . totalItemCount = = 0 {
return (
" Copy something to start your history " ,
viewModel . captureStatusMessage . isEmpty
? " ClipBored records clipboard changes locally. Accessibility is only needed for automatic paste. "
: viewModel . captureStatusMessage
)
}
switch viewModel . sortMode {
case . images :
return ( " No images yet " , " Image clips are saved when the clipboard contains image data. " )
case . links :
return ( " No links yet " , " Links are detected from copied URLs. " )
case . text :
return ( " No text clips yet " , " Copied text and rich text appear here. " )
case . files :
return ( " No files yet " , " Copied files and PDFs appear here. " )
case . audio :
return ( " No audio yet " , " Copied sound clips appear here. " )
case . pinned :
return ( " No pinned clips " , " New copies appear under Most Recent. Select an item and press P to pin it. " )
case . mostRecent , . mostUsed :
return ( " No clips in this view " , " Switch filters or copy something new. " )
}
}
private func updateCollectionButtons ( ) {
for ( mode , chip ) in collectionButtons {
chip . setSelected ( viewModel . selectedCollectionName = = nil && mode = = viewModel . sortMode )
chip . setCount ( viewModel . collectionCount ( for : mode ) )
}
for ( name , chip ) in customCollectionButtons {
chip . setSelected ( viewModel . selectedCollectionName = = name )
chip . setCount ( viewModel . collectionCount ( named : name ) )
}
2026-06-30 02:19:03 -07:00
stackChip . setSelected ( viewModel . isStackFilterSelected )
stackChip . setCount ( viewModel . stackCount )
2026-06-30 01:12:19 -07:00
sizeCollectionDocument ( )
}
var isSearchFieldEditing : Bool {
guard let firstResponder = window ? . firstResponder else { return false }
if firstResponder = = = searchField {
return true
}
if let firstResponderView = firstResponder as ? NSView , firstResponderView . isDescendant ( of : searchField ) {
return true
}
if let editor = searchField . currentEditor ( ) , firstResponder = = = editor {
return true
}
return false
}
override func acceptsFirstMouse ( for event : NSEvent ? ) -> Bool {
return true
}
override func layout ( ) {
super . layout ( )
let collectionViewportWidth = collectionScrollView . contentView . bounds . width
if collectionViewportWidth != lastCollectionViewportWidth {
lastCollectionViewportWidth = collectionViewportWidth
sizeCollectionDocument ( )
}
guard ! scrollView . frame . equalTo ( . zero ) else { return }
let contentWidth = scrollView . contentView . bounds . width
if contentWidth = = lastScrollContentWidth {
return
}
lastScrollContentWidth = contentWidth
if cardViews . isEmpty {
guard let documentView = scrollView . documentView else { return }
documentView . frame . size = NSSize (
width : max ( 760 , scrollView . contentView . bounds . width ) ,
height : currentListHeight ( )
)
return
}
sizeItemsDocument ( itemCount : cardViews . count )
}
private func sizeCollectionDocument ( ) {
collectionStack . layoutSubtreeIfNeeded ( )
let contentWidth = ceil ( collectionStack . fittingSize . width )
let viewportWidth = collectionScrollView . contentView . bounds . width
let width = max ( contentWidth , viewportWidth )
collectionStack . frame = NSRect ( x : 0 , y : 0 , width : width , height : 30 )
}
func focusSearchField ( ) {
window ? . makeFirstResponder ( searchField )
}
func setBottomSafeInset ( _ inset : CGFloat ) {
bottomSafeInset = max ( Metrics . minimumBottomInset , inset )
mainStack ? . edgeInsets = contentInsets ( )
needsLayout = true
}
func prepareForShow ( ) {
if ! searchField . stringValue . isEmpty {
searchField . stringValue = " "
updateSearchText ( )
}
focusSearchField ( )
}
func beginOpeningTransition ( ) {
defersVisualReloads = true
pendingItemReload = false
pendingCollectionReload = false
}
func finishOpeningTransition ( ) {
guard defersVisualReloads else { return }
defersVisualReloads = false
flushDeferredVisualReloads ( )
}
#if DEBUG
var debugVisibleCardCount : Int {
cardViews . count
}
var debugIsDeferringVisualReloads : Bool {
defersVisualReloads
}
var debugDocumentViewFrame : NSRect {
scrollView . documentView ? . frame ? ? . zero
}
var debugDocumentViewIsCardStack : Bool {
scrollView . documentView = = = itemsStack
}
var debugContentInsets : NSEdgeInsets {
mainStack ? . edgeInsets ? ? NSEdgeInsets ( )
}
var debugPanelCornerRadius : CGFloat {
layer ? . cornerRadius ? ? 0
}
var debugCardAccessibilityLabels : [ String ] {
cardViews . compactMap { $0 . accessibilityLabel ( ) }
}
var debugCardPreviewSummaries : [ String ] {
cardViews . map ( \ . debugPreviewSummary )
}
var debugCardPreviewStyles : [ String ] {
cardViews . map ( \ . debugPreviewStyle )
}
var debugCardHeaderBadgeSymbols : [ String ] {
cardViews . map ( \ . debugHeaderBadgeSymbol )
}
2026-06-30 02:38:48 -07:00
var debugQuickPasteBadgeTexts : [ String ] {
cardViews . compactMap ( \ . debugQuickPasteBadgeText )
}
2026-06-30 01:29:36 -07:00
var debugSelectedCardFrameInDocument : NSRect {
guard viewModel . selectedIndex >= 0 , viewModel . selectedIndex < cardViews . count else {
return . zero
}
let card = cardViews [ viewModel . selectedIndex ]
return card . convert ( card . bounds , to : itemsStack )
}
var debugCardRailVisibleRect : NSRect {
scrollView . contentView . bounds
}
2026-06-30 01:12:19 -07:00
var debugFirstCardMenuTitles : [ String ] {
cardViews . first ? . debugMenuTitles ? ? [ ]
}
var debugFirstCardCollectionMenuTitles : [ String ] {
cardViews . first ? . debugCollectionMenuTitles ? ? [ ]
}
2026-06-30 02:49:35 -07:00
var debugFirstCardCaptureRuleMenuTitles : [ String ] {
cardViews . first ? . debugCaptureRuleMenuTitles ? ? [ ]
}
2026-06-30 01:12:19 -07:00
var debugFirstCardVisibleActionLabels : [ String ] {
cardViews . first ? . debugVisibleActionLabels ? ? [ ]
}
var debugFirstCardVisibleActionRailWidth : CGFloat {
cardViews . first ? . debugVisibleActionRailWidth ? ? 0
}
var debugFirstCardFooterDetailIsHidden : Bool {
cardViews . first ? . debugFooterDetailIsHidden ? ? true
}
var debugFirstCardHeaderBadgeIsHidden : Bool {
cardViews . first ? . debugHeaderBadgeIsHidden ? ? false
}
var debugResultCountText : String {
statusResultCountLabel . stringValue
}
var debugStatusText : String {
statusLabel . stringValue
}
var debugStatusTone : String {
currentStatusTone . rawValue
}
var debugCollectionTitles : [ String ] {
ClipboardSortMode . allCases . compactMap { collectionButtons [ $0 ] ? . titleText }
}
var debugSelectedCollectionTitle : String ? {
2026-06-30 02:19:03 -07:00
if stackChip . isSelected {
return stackChip . titleText
}
return collectionButtons . first ( where : { $0 . value . isSelected } ) ? . value . titleText
2026-06-30 01:12:19 -07:00
}
var debugCollectionCounts : [ Int ] {
updateCollectionButtons ( )
return ClipboardSortMode . allCases . compactMap { collectionButtons [ $0 ] ? . count }
}
var debugCustomCollectionTitles : [ String ] {
viewModel . collectionNames
}
var debugCustomCollectionCounts : [ Int ] {
updateCollectionButtons ( )
return viewModel . collectionNames . compactMap { customCollectionButtons [ $0 ] ? . count }
}
2026-06-30 02:19:03 -07:00
var debugStackChipIsVisible : Bool {
collectionStack . arrangedSubviews . contains ( stackChip )
}
var debugStackChipCount : Int {
updateCollectionButtons ( )
return stackChip . count
}
var debugStackChipIsSelected : Bool {
stackChip . isSelected
}
func debugPressStackChip ( ) {
stackChip . onPress ( )
}
2026-06-30 01:12:19 -07:00
var debugCollectionRailVisibleWidth : CGFloat {
collectionScrollView . contentView . bounds . width
}
var debugCollectionRailDocumentWidth : CGFloat {
collectionScrollView . documentView ? . frame . width ? ? 0
}
var debugEmptyStateText : ( title : String , detail : String ) ? {
emptyStateText
}
2026-06-30 01:34:05 -07:00
var debugAddCollectionButtonIsEnabled : Bool {
addCollectionButton . isEnabled
}
var debugCollectionRailContainsAddButton : Bool {
collectionStack . arrangedSubviews . contains ( addCollectionButton )
}
func debugSetCollectionNameProvider ( _ provider : @ escaping ( ) -> String ? ) {
collectionNameProviderForTesting = provider
}
func debugPressAddCollectionButton ( ) {
addSelectedClipToCollection ( )
}
2026-06-30 03:02:29 -07:00
func debugDropFirstCard ( onCollectionNamed collectionName : String ) {
guard let itemID = cardViews . first ? . debugItemID else { return }
customCollectionButtons [ collectionName ] ? . debugDropItem ( itemID )
}
var debugCustomCollectionDropTargets : [ String ] {
viewModel . collectionNames . filter { customCollectionButtons [ $0 ] ? . debugAcceptsItemDrops = = true }
}
2026-06-30 01:12:19 -07:00
#endif
func controlTextDidChange ( _ notification : Notification ) {
guard notification . object as ? NSSearchField = = = searchField else { return }
updateSearchText ( )
}
func control ( _ control : NSControl , textView : NSTextView , doCommandBy commandSelector : Selector ) -> Bool {
guard control = = = searchField else { return false }
switch commandSelector {
case #selector ( NSResponder . insertNewline ( _ : ) ) :
viewModel . pasteSelected ( )
return true
case #selector ( NSResponder . cancelOperation ( _ : ) ) :
if searchField . stringValue . clipboardTrimmed . isEmpty {
onClose ( )
} else {
searchField . stringValue = " "
updateSearchText ( )
}
return true
case #selector ( NSResponder . moveUp ( _ : ) ) :
viewModel . moveSelection ( - 1 )
return true
case #selector ( NSResponder . moveDown ( _ : ) ) :
viewModel . moveSelection ( 1 )
return true
default :
return false
}
}
@objc private func searchFieldChanged ( ) {
updateSearchText ( )
}
private func updateSearchText ( ) {
viewModel . searchText = searchField . stringValue
}
@objc private func closePanel ( ) {
onClose ( )
}
@objc private func openSettings ( ) {
onSettings ( )
}
2026-06-30 01:34:05 -07:00
@objc private func addSelectedClipToCollection ( ) {
guard viewModel . selectedItem != nil ,
let name = requestCollectionName ( ) else {
return
}
viewModel . assignSelected ( to : name )
}
private func requestCollectionName ( ) -> String ? {
#if DEBUG
if let collectionNameProviderForTesting {
return ClipboardCollectionDefaults . normalizedName ( collectionNameProviderForTesting ( ) )
}
#endif
let input = NSTextField ( frame : NSRect ( x : 0 , y : 0 , width : 260 , height : 24 ) )
input . placeholderString = " Collection name "
input . stringValue = " "
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 . addButton ( withTitle : " Cancel " )
alert . window . initialFirstResponder = input
guard alert . runModal ( ) = = . alertFirstButtonReturn else {
return nil
}
return ClipboardCollectionDefaults . normalizedName ( input . stringValue )
}
2026-06-30 01:12:19 -07:00
}
2026-06-30 03:02:29 -07:00
private enum ClipboardItemDragPasteboard {
static let itemIDType = NSPasteboard . PasteboardType ( " com.clipbored.clipboard-item-id " )
static let acceptedTypes : [ NSPasteboard . PasteboardType ] = [
itemIDType ,
. string ,
. URL ,
. fileURL ,
. tiff ,
. png ,
. pdf ,
. sound ,
. rtf
]
}
private enum ClipboardCardDragContext {
static var itemID : UUID ?
}
2026-06-30 01:12:19 -07:00
private final class CollectionChipView : NSView {
let titleText : String
private let color : NSColor
private let dot = NSView ( )
private let label : NSTextField
private let countLabel = NSTextField ( labelWithString : " 0 " )
private ( set ) var isSelected = false
private ( set ) var count = 0
2026-06-30 03:02:29 -07:00
private var isDropTargeted = false
2026-06-30 01:12:19 -07:00
var onPress : ( ) -> Void = { }
2026-06-30 03:02:29 -07:00
var onDropItem : ( ( UUID ) -> Void ) ?
2026-06-30 01:12:19 -07:00
init ( title : String , color : NSColor ) {
self . titleText = title
self . color = color
self . label = NSTextField ( labelWithString : title )
super . init ( frame : . zero )
configure ( )
}
required init ? ( coder : NSCoder ) {
fatalError ( " init(coder:) has not been implemented " )
}
private func configure ( ) {
wantsLayer = true
layer ? . cornerRadius = 13
layer ? . borderWidth = 0.6
layer ? . borderColor = NSColor . clear . cgColor
setAccessibilityElement ( true )
setAccessibilityRole ( . button )
setAccessibilityLabel ( titleText )
heightAnchor . constraint ( equalToConstant : 26 ) . isActive = true
2026-06-30 03:02:29 -07:00
registerForDraggedTypes ( ClipboardItemDragPasteboard . acceptedTypes )
2026-06-30 01:12:19 -07:00
dot . wantsLayer = true
dot . layer ? . cornerRadius = 4
dot . layer ? . backgroundColor = color . cgColor
dot . widthAnchor . constraint ( equalToConstant : 8 ) . isActive = true
dot . heightAnchor . constraint ( equalToConstant : 8 ) . isActive = true
label . font = . systemFont ( ofSize : NSFont . smallSystemFontSize , weight : . medium )
label . textColor = . secondaryLabelColor
label . lineBreakMode = . byTruncatingTail
label . maximumNumberOfLines = 1
label . setContentCompressionResistancePriority ( . defaultLow , for : . horizontal )
label . setContentHuggingPriority ( . defaultHigh , for : . horizontal )
label . toolTip = label . stringValue
countLabel . font = . monospacedDigitSystemFont ( ofSize : 10 , weight : . semibold )
countLabel . textColor = . secondaryLabelColor
countLabel . alignment = . center
countLabel . lineBreakMode = . byTruncatingTail
countLabel . wantsLayer = true
countLabel . layer ? . cornerRadius = 8
countLabel . layer ? . backgroundColor = NSColor . labelColor . withAlphaComponent ( 0.07 ) . cgColor
countLabel . widthAnchor . constraint ( greaterThanOrEqualToConstant : 18 ) . isActive = true
countLabel . heightAnchor . constraint ( equalToConstant : 16 ) . isActive = true
countLabel . setContentCompressionResistancePriority ( . required , for : . horizontal )
let stack = NSStackView ( views : [ dot , label , countLabel ] )
stack . orientation = . horizontal
stack . alignment = . centerY
stack . spacing = 6
stack . translatesAutoresizingMaskIntoConstraints = false
addSubview ( stack )
NSLayoutConstraint . activate ( [
stack . leadingAnchor . constraint ( equalTo : leadingAnchor , constant : 10 ) ,
stack . trailingAnchor . constraint ( equalTo : trailingAnchor , constant : - 10 ) ,
stack . centerYAnchor . constraint ( equalTo : centerYAnchor ) ,
widthAnchor . constraint ( greaterThanOrEqualToConstant : 70 ) ,
widthAnchor . constraint ( lessThanOrEqualToConstant : 164 )
] )
setAccessibilityLabel ( " \( titleText ) , count: \( count ) " )
setSelected ( false )
}
func setSelected ( _ selected : Bool ) {
isSelected = selected
label . textColor = selected ? . labelColor : . secondaryLabelColor
countLabel . textColor = selected ? . labelColor : . tertiaryLabelColor
countLabel . layer ? . backgroundColor = (
selected
? NSColor . controlAccentColor . withAlphaComponent ( 0.16 )
: NSColor . labelColor . withAlphaComponent ( 0.07 )
) . cgColor
2026-06-30 03:02:29 -07:00
updateChrome ( )
}
private func setDropTargeted ( _ targeted : Bool ) {
guard isDropTargeted != targeted else { return }
isDropTargeted = targeted
updateChrome ( )
}
private func updateChrome ( ) {
if isDropTargeted {
layer ? . backgroundColor = color . withAlphaComponent ( 0.18 ) . cgColor
layer ? . borderColor = color . withAlphaComponent ( 0.68 ) . cgColor
} else if isSelected {
2026-06-30 01:12:19 -07:00
layer ? . backgroundColor = NSColor . windowBackgroundColor . withAlphaComponent ( 0.58 ) . cgColor
layer ? . borderColor = NSColor . controlAccentColor . withAlphaComponent ( 0.34 ) . cgColor
} else {
layer ? . backgroundColor = NSColor . clear . cgColor
layer ? . borderColor = NSColor . clear . cgColor
}
}
func setCount ( _ count : Int ) {
self . count = count
countLabel . stringValue = count > 999 ? " 999+ " : " \( count ) "
setAccessibilityLabel ( " \( titleText ) , \( count ) \( count = = 1 ? " clip " : " clips " ) " )
setAccessibilityValue ( " \( count ) " )
toolTip = " \( titleText ) , \( count ) \( count = = 1 ? " clip " : " clips " ) "
}
override func acceptsFirstMouse ( for event : NSEvent ? ) -> Bool {
true
}
override func mouseDown ( with event : NSEvent ) {
onPress ( )
}
2026-06-30 03:02:29 -07:00
override func draggingEntered ( _ sender : NSDraggingInfo ) -> NSDragOperation {
guard onDropItem != nil , draggedItemID ( from : sender ) != nil else { return [ ] }
setDropTargeted ( true )
return . copy
}
override func draggingUpdated ( _ sender : NSDraggingInfo ) -> NSDragOperation {
guard onDropItem != nil , draggedItemID ( from : sender ) != nil else {
setDropTargeted ( false )
return [ ]
}
setDropTargeted ( true )
return . copy
}
override func draggingExited ( _ sender : NSDraggingInfo ? ) {
setDropTargeted ( false )
}
override func prepareForDragOperation ( _ sender : NSDraggingInfo ) -> Bool {
onDropItem != nil && draggedItemID ( from : sender ) != nil
}
override func performDragOperation ( _ sender : NSDraggingInfo ) -> Bool {
guard let itemID = draggedItemID ( from : sender ) , let onDropItem else { return false }
onDropItem ( itemID )
setDropTargeted ( false )
return true
}
override func concludeDragOperation ( _ sender : NSDraggingInfo ? ) {
setDropTargeted ( false )
}
private func draggedItemID ( from sender : NSDraggingInfo ) -> UUID ? {
if let itemID = ClipboardCardDragContext . itemID {
return itemID
}
return sender . draggingPasteboard
. string ( forType : ClipboardItemDragPasteboard . itemIDType )
. flatMap ( UUID . init ( uuidString : ) )
}
#if DEBUG
var debugAcceptsItemDrops : Bool {
onDropItem != nil
}
func debugDropItem ( _ itemID : UUID ) {
onDropItem ? ( itemID )
}
#endif
2026-06-30 01:12:19 -07:00
}
private final class AspectFillImageView : NSView {
private let image : NSImage
init ( image : NSImage ) {
self . image = image
super . init ( frame : . zero )
wantsLayer = true
layer ? . backgroundColor = NSColor . black . withAlphaComponent ( 0.04 ) . cgColor
}
required init ? ( coder : NSCoder ) {
fatalError ( " init(coder:) has not been implemented " )
}
override var isFlipped : Bool {
true
}
override func draw ( _ dirtyRect : NSRect ) {
guard bounds . width > 0 , bounds . height > 0 , image . size . width > 0 , image . size . height > 0 else {
return
}
NSBezierPath ( rect : bounds ) . addClip ( )
NSGraphicsContext . current ? . imageInterpolation = . high
let scale = max ( bounds . width / image . size . width , bounds . height / image . size . height )
let drawSize = NSSize ( width : image . size . width * scale , height : image . size . height * scale )
let drawRect = NSRect (
x : bounds . midX - ( drawSize . width / 2 ) ,
y : bounds . midY - ( drawSize . height / 2 ) ,
width : drawSize . width ,
height : drawSize . height
)
image . draw (
in : drawRect ,
from : NSRect ( origin : . zero , size : image . size ) ,
operation : . sourceOver ,
fraction : 1 ,
respectFlipped : true ,
hints : nil
)
}
}
2026-06-30 01:24:01 -07:00
private final class ClipboardItemCardView : NSView , NSDraggingSource {
2026-06-30 01:12:19 -07:00
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
2026-06-30 01:24:01 -07:00
static let dragThreshold : CGFloat = 4
2026-06-30 01:12:19 -07:00
}
private enum Palette {
static let border = NSColor . separatorColor . withAlphaComponent ( 0.20 ) . cgColor
static let selectedBorder = NSColor . controlAccentColor . withAlphaComponent ( 0.62 ) . cgColor
static let cardSurface = NSColor . windowBackgroundColor . cgColor
static let selectedSurface = NSColor . windowBackgroundColor . cgColor
static let bodyBackground = NSColor . windowBackgroundColor . cgColor
static let footerBackground = NSColor . windowBackgroundColor . withAlphaComponent ( 0.96 ) . cgColor
static let divider = NSColor . separatorColor . withAlphaComponent ( 0.14 ) . cgColor
}
var onSelect : ( Int ) -> Void = { _ in }
var onPaste : ( Int ) -> Void = { _ in }
var onCopy : ( Int ) -> Void = { _ in }
2026-06-30 02:04:12 -07:00
var onPastePlainText : ( Int ) -> Void = { _ in }
var onCopyPlainText : ( Int ) -> Void = { _ in }
2026-06-30 02:11:50 -07:00
var onToggleStack : ( Int ) -> Void = { _ in }
var onPasteStackNext : ( ) -> Void = { }
var onCopyStackNext : ( ) -> Void = { }
var onClearStack : ( ) -> Void = { }
2026-06-30 01:56:40 -07:00
var onEditText : ( Int ) -> Void = { _ in }
2026-06-30 01:48:35 -07:00
var onPreview : ( Int ) -> Void = { _ in }
2026-06-30 01:24:01 -07:00
var onPasteboardWriters : ( Int ) -> [ NSPasteboardWriting ] = { _ in [ ] }
2026-06-30 01:12:19 -07:00
var onOpen : ( Int ) -> Void = { _ in }
var onReveal : ( Int ) -> Void = { _ in }
var onTogglePin : ( Int ) -> Void = { _ in }
var onAssignCollection : ( Int , String ? ) -> Void = { _ , _ in }
2026-06-30 02:49:35 -07:00
var onIgnoreSourceApp : ( Int ) -> Void = { _ in }
var onIgnoreKind : ( Int ) -> Void = { _ in }
2026-06-30 01:12:19 -07:00
var onDelete : ( Int ) -> Void = { _ in }
private let index : Int
2026-06-30 03:02:29 -07:00
private let itemID : UUID
2026-06-30 01:12:19 -07:00
private let itemKind : ClipboardItemKind
private let itemIsPinned : Bool
2026-06-30 02:11:50 -07:00
private let itemIsStacked : Bool
private let stackCount : Int
2026-06-30 02:49:35 -07:00
private let itemSourceAppName : String ?
private let itemSourceAppBundleID : String ?
2026-06-30 01:12:19 -07:00
private let itemCollectionName : String ?
private let collectionNames : [ String ]
private let contentView = NSView ( )
private let footerDetailLabel = NSTextField ( labelWithString : " " )
private let actionRail = NSStackView ( )
private var actionRailButtons : [ NSButton ] = [ ]
private weak var headerBadgeView : NSView ?
private weak var headerPinView : NSView ?
2026-06-30 02:38:48 -07:00
private weak var quickPasteBadgeLabel : NSTextField ?
2026-06-30 01:12:19 -07:00
private var isSelected = false
private var isHovered = false
2026-06-30 01:24:01 -07:00
private var mouseDownLocation : NSPoint ?
2026-06-30 01:12:19 -07:00
private var trackingAreaRef : NSTrackingArea ?
2026-06-30 02:11:50 -07:00
init (
item : ClipboardItem ,
thumbnail : NSImage ? ,
index : Int ,
collectionNames : [ String ] = [ ] ,
isStacked : Bool = false ,
stackCount : Int = 0
) {
2026-06-30 01:12:19 -07:00
self . index = index
2026-06-30 03:02:29 -07:00
self . itemID = item . id
2026-06-30 01:12:19 -07:00
self . itemKind = item . kind
self . itemIsPinned = item . isPinned
2026-06-30 02:11:50 -07:00
self . itemIsStacked = isStacked
self . stackCount = stackCount
2026-06-30 02:49:35 -07:00
self . itemSourceAppName = Self . presentSourceText ( item . sourceApp )
self . itemSourceAppBundleID = Self . presentSourceText ( item . sourceAppBundleId )
2026-06-30 01:12:19 -07:00
self . itemCollectionName = ClipboardCollectionDefaults . normalizedName ( item . collectionName )
self . collectionNames = collectionNames . compactMap { ClipboardCollectionDefaults . normalizedName ( $0 ) }
super . init ( frame : . zero )
configure ( item : item , thumbnail : thumbnail )
}
required init ? ( coder : NSCoder ) {
fatalError ( " init(coder:) has not been implemented " )
}
func setSelected ( _ selected : Bool ) {
isSelected = selected
contentView . layer ? . borderWidth = 1
contentView . layer ? . borderColor = selected ? Palette . selectedBorder : Palette . border
if selected {
contentView . layer ? . backgroundColor = Palette . selectedSurface
contentView . layer ? . borderColor = Palette . selectedBorder
} else if isHovered {
contentView . layer ? . backgroundColor = NSColor . windowBackgroundColor . cgColor
contentView . layer ? . borderColor = NSColor . controlAccentColor . withAlphaComponent ( 0.28 ) . cgColor
} else {
contentView . layer ? . backgroundColor = Palette . cardSurface
contentView . layer ? . borderColor = Palette . border
}
layer ? . shadowOpacity = selected ? 0.16 : ( isHovered ? 0.12 : 0.08 )
layer ? . shadowRadius = selected ? 16 : 12
layer ? . shadowOffset = NSSize ( width : 0 , height : selected ? 6 : 4 )
layer ? . transform = selected ? CATransform3DMakeTranslation ( 0 , - 4 , 0 ) : CATransform3DIdentity
updateActionRailVisibility ( )
}
override func updateTrackingAreas ( ) {
super . updateTrackingAreas ( )
if let trackingAreaRef {
removeTrackingArea ( trackingAreaRef )
}
let tracking = NSTrackingArea (
rect : bounds ,
options : [ . mouseEnteredAndExited , . activeAlways , . inVisibleRect ] ,
owner : self ,
userInfo : nil
)
addTrackingArea ( tracking )
trackingAreaRef = tracking
}
override func mouseEntered ( with event : NSEvent ) {
onSelect ( index )
isHovered = true
setSelected ( isSelected )
}
override func mouseExited ( with event : NSEvent ) {
isHovered = false
setSelected ( isSelected )
}
override func mouseDown ( with event : NSEvent ) {
if event . clickCount = = 2 {
2026-06-30 01:24:01 -07:00
mouseDownLocation = nil
2026-06-30 01:12:19 -07:00
onPaste ( index )
} else {
2026-06-30 01:24:01 -07:00
mouseDownLocation = convert ( event . locationInWindow , from : nil )
2026-06-30 01:12:19 -07:00
onSelect ( index )
}
}
2026-06-30 01:24:01 -07:00
override func mouseDragged ( with event : NSEvent ) {
guard let start = mouseDownLocation else { return }
let current = convert ( event . locationInWindow , from : nil )
guard hypot ( current . x - start . x , current . y - start . y ) >= Metrics . dragThreshold else {
return
}
mouseDownLocation = nil
let writers = onPasteboardWriters ( index )
2026-06-30 03:02:29 -07:00
let dragWriters = writers . isEmpty ? [ internalDragPasteboardItem ( ) ] : writers
2026-06-30 01:24:01 -07:00
onSelect ( index )
2026-06-30 03:02:29 -07:00
ClipboardCardDragContext . itemID = itemID
2026-06-30 01:24:01 -07:00
let preview = dragPreviewImage ( )
2026-06-30 03:02:29 -07:00
let dragItems = dragWriters . enumerated ( ) . map { offset , writer in
2026-06-30 01:24:01 -07:00
let draggingItem = NSDraggingItem ( pasteboardWriter : writer )
let offsetAmount = CGFloat ( offset ) * 4
let frame = NSRect (
x : bounds . minX + offsetAmount ,
y : bounds . minY - offsetAmount ,
width : bounds . width ,
height : bounds . height
)
draggingItem . setDraggingFrame ( frame , contents : preview )
return draggingItem
}
let session = beginDraggingSession ( with : dragItems , event : event , source : self )
session . animatesToStartingPositionsOnCancelOrFail = true
if dragItems . count > 1 {
session . draggingFormation = . pile
}
}
2026-06-30 03:02:29 -07:00
private func internalDragPasteboardItem ( ) -> NSPasteboardItem {
let pasteboardItem = NSPasteboardItem ( )
pasteboardItem . setString ( itemID . uuidString , forType : ClipboardItemDragPasteboard . itemIDType )
return pasteboardItem
}
2026-06-30 01:24:01 -07:00
func draggingSession ( _ session : NSDraggingSession , sourceOperationMaskFor context : NSDraggingContext ) -> NSDragOperation {
. copy
}
func ignoreModifierKeys ( for session : NSDraggingSession ) -> Bool {
true
}
2026-06-30 03:02:29 -07:00
func draggingSession ( _ session : NSDraggingSession , endedAt screenPoint : NSPoint , operation : NSDragOperation ) {
if ClipboardCardDragContext . itemID = = itemID {
ClipboardCardDragContext . itemID = nil
}
}
2026-06-30 01:12:19 -07:00
override func menu ( for event : NSEvent ) -> NSMenu ? {
onSelect ( index )
return contextMenu ( )
}
#if DEBUG
private ( set ) var debugPreviewSummary = " "
private ( set ) var debugPreviewStyle = " "
private ( set ) var debugHeaderBadgeSymbol = " "
var debugMenuTitles : [ String ] {
contextMenu ( ) . items . map { $0 . isSeparatorItem ? " - " : $0 . title }
}
var debugCollectionMenuTitles : [ String ] {
guard let collectionMenu = contextMenu ( ) . items . first ( where : { $0 . title = = " Add to Collection " } ) ? . submenu else {
return [ ]
}
return collectionMenu . items . map { $0 . isSeparatorItem ? " - " : $0 . title }
}
2026-06-30 02:49:35 -07:00
var debugCaptureRuleMenuTitles : [ String ] {
guard let rulesMenu = contextMenu ( ) . items . first ( where : { $0 . title = = " Capture Rules " } ) ? . submenu else {
return [ ]
}
return rulesMenu . items . map { $0 . isSeparatorItem ? " - " : $0 . title }
}
2026-06-30 01:12:19 -07:00
var debugVisibleActionLabels : [ String ] {
actionRail . isHidden ? [ ] : actionRailButtons . map { $0 . toolTip ? ? " " }
}
var debugVisibleActionRailWidth : CGFloat {
guard ! actionRail . isHidden else { return 0 }
return actionRail . constraints . first { constraint in
constraint . firstAttribute = = . width && constraint . secondItem = = nil
} ? . constant ? ? actionRail . fittingSize . width
}
var debugFooterDetailIsHidden : Bool {
footerDetailLabel . isHidden
}
var debugHeaderBadgeIsHidden : Bool {
headerBadgeView ? . isHidden ? ? false
}
2026-06-30 02:38:48 -07:00
var debugQuickPasteBadgeText : String ? {
quickPasteBadgeLabel ? . stringValue
}
2026-06-30 03:02:29 -07:00
var debugItemID : UUID {
itemID
}
2026-06-30 01:12:19 -07:00
#endif
private func contextMenu ( ) -> NSMenu {
let menu = NSMenu ( )
menu . autoenablesItems = false
addMenuItem ( " Paste " , action : #selector ( pasteFromMenu ) , to : menu )
addMenuItem ( " Copy " , action : #selector ( copyFromMenu ) , to : menu )
2026-06-30 02:04:12 -07:00
if canPlainText {
addMenuItem ( " Paste Plain Text " , action : #selector ( pastePlainTextFromMenu ) , to : menu )
addMenuItem ( " Copy Plain Text " , action : #selector ( copyPlainTextFromMenu ) , to : menu )
}
2026-06-30 02:11:50 -07:00
addMenuItem ( itemIsStacked ? " Remove from Stack " : " Add to Stack " , action : #selector ( toggleStackFromMenu ) , to : menu )
if stackCount > 0 {
addMenuItem ( " Paste Stack Next " , action : #selector ( pasteStackNextFromMenu ) , to : menu )
addMenuItem ( " Copy Stack Next " , action : #selector ( copyStackNextFromMenu ) , to : menu )
addMenuItem ( " Clear Stack " , action : #selector ( clearStackFromMenu ) , to : menu )
}
2026-06-30 01:56:40 -07:00
if canEditText {
addMenuItem ( " Edit " , action : #selector ( editTextFromMenu ) , to : menu )
}
2026-06-30 01:48:35 -07:00
if canPreview {
addMenuItem ( " Quick Look " , action : #selector ( previewFromMenu ) , to : menu )
}
2026-06-30 01:12:19 -07:00
addMenuItem ( itemIsPinned ? " Unpin " : " Pin " , action : #selector ( togglePinFromMenu ) , to : menu )
addCollectionMenu ( to : menu )
2026-06-30 02:49:35 -07:00
addCaptureRulesMenu ( to : menu )
2026-06-30 01:12:19 -07:00
menu . addItem ( NSMenuItem . separator ( ) )
let open = addMenuItem ( " Open " , action : #selector ( openFromMenu ) , to : menu )
open . isEnabled = canOpen
let reveal = addMenuItem ( " Reveal in Finder " , action : #selector ( revealFromMenu ) , to : menu )
reveal . isEnabled = canReveal
menu . addItem ( NSMenuItem . separator ( ) )
addMenuItem ( " Delete " , action : #selector ( deleteFromMenu ) , to : menu )
return menu
}
private func addCollectionMenu ( to menu : NSMenu ) {
let parent = NSMenuItem ( title : " Add to Collection " , action : nil , keyEquivalent : " " )
let submenu = NSMenu ( title : " Add to Collection " )
submenu . autoenablesItems = false
for name in availableCollectionNames ( ) {
let item = NSMenuItem ( title : name , action : #selector ( assignToCollectionFromMenu ( _ : ) ) , keyEquivalent : " " )
item . target = self
item . representedObject = name
if itemCollectionName = = name {
item . state = . on
}
submenu . addItem ( item )
}
submenu . addItem ( NSMenuItem . separator ( ) )
let newCollection = NSMenuItem ( title : " New Collection... " , action : #selector ( createCollectionFromMenu ) , keyEquivalent : " " )
newCollection . target = self
submenu . addItem ( newCollection )
if itemCollectionName ? . clipboardTrimmed . isEmpty = = false {
submenu . addItem ( NSMenuItem . separator ( ) )
let remove = NSMenuItem ( title : " Remove from Collection " , action : #selector ( removeFromCollectionFromMenu ) , keyEquivalent : " " )
remove . target = self
submenu . addItem ( remove )
}
menu . addItem ( parent )
menu . setSubmenu ( submenu , for : parent )
}
2026-06-30 02:49:35 -07:00
private func addCaptureRulesMenu ( to menu : NSMenu ) {
let parent = NSMenuItem ( title : " Capture Rules " , action : nil , keyEquivalent : " " )
let submenu = NSMenu ( title : " Capture Rules " )
submenu . autoenablesItems = false
let ignoreSource = NSMenuItem (
title : ignoreSourceTitle ( ) ,
action : #selector ( ignoreSourceAppFromMenu ) ,
keyEquivalent : " "
)
ignoreSource . target = self
ignoreSource . isEnabled = itemSourceAppName != nil || itemSourceAppBundleID != nil
submenu . addItem ( ignoreSource )
let ignoreKind = NSMenuItem (
title : " Ignore \( kindLabel ( for : itemKind ) ) Items " ,
action : #selector ( ignoreKindFromMenu ) ,
keyEquivalent : " "
)
ignoreKind . target = self
ignoreKind . isEnabled = true
submenu . addItem ( ignoreKind )
menu . addItem ( parent )
menu . setSubmenu ( submenu , for : parent )
}
private func ignoreSourceTitle ( ) -> String {
if let itemSourceAppName {
return " Ignore \( itemSourceAppName ) "
}
if let itemSourceAppBundleID {
return " Ignore \( itemSourceAppBundleID ) "
}
return " Ignore Source App "
}
private static func presentSourceText ( _ value : String ? ) -> String ? {
guard let text = value ? . clipboardTrimmed , ! text . isEmpty else { return nil }
return text
}
2026-06-30 01:12:19 -07:00
private func availableCollectionNames ( ) -> [ String ] {
var names : [ String ] = [ ]
var seen = Set < String > ( )
for candidate in ClipboardCollectionDefaults . names + collectionNames {
guard let name = ClipboardCollectionDefaults . normalizedName ( candidate ) else { continue }
let key = name . lowercased ( )
guard ! seen . contains ( key ) else { continue }
seen . insert ( key )
names . append ( name )
}
return names
}
@ discardableResult
private func addMenuItem ( _ title : String , action : Selector , to menu : NSMenu ) -> NSMenuItem {
let item = NSMenuItem ( title : title , action : action , keyEquivalent : " " )
item . target = self
item . isEnabled = true
menu . addItem ( item )
return item
}
private var canOpen : Bool {
switch itemKind {
case . url , . file , . image , . pdf , . audio :
return true
case . text , . richText , . unknown :
return false
}
}
2026-06-30 01:48:35 -07:00
private var canPreview : Bool {
switch itemKind {
case . url , . image , . richText , . file , . pdf , . audio :
return true
case . text , . unknown :
return false
}
}
2026-06-30 01:56:40 -07:00
private var canEditText : Bool {
itemKind = = . text
}
2026-06-30 02:04:12 -07:00
private var canPlainText : Bool {
switch itemKind {
case . url , . image , . richText , . file , . pdf , . audio :
return true
case . text , . unknown :
return false
}
}
2026-06-30 01:12:19 -07:00
private var canReveal : Bool {
switch itemKind {
case . file , . image , . pdf , . audio :
return true
case . text , . richText , . url , . unknown :
return false
}
}
private func configureActionRail ( ) {
actionRail . orientation = . horizontal
actionRail . alignment = . centerY
actionRail . spacing = 4
actionRail . edgeInsets = NSEdgeInsets ( top : 2 , left : 6 , bottom : 2 , right : 6 )
actionRail . wantsLayer = true
actionRail . layer ? . cornerRadius = Metrics . actionRailHeight / 2
actionRail . layer ? . backgroundColor = NSColor . black . withAlphaComponent ( 0.44 ) . cgColor
actionRail . layer ? . borderWidth = 0.5
actionRail . layer ? . borderColor = NSColor . white . withAlphaComponent ( 0.18 ) . cgColor
actionRail . layer ? . shadowColor = NSColor . black . cgColor
actionRail . layer ? . shadowOpacity = 0.18
actionRail . layer ? . shadowRadius = 10
actionRail . layer ? . shadowOffset = NSSize ( width : 0 , height : 4 )
actionRail . translatesAutoresizingMaskIntoConstraints = false
actionRail . heightAnchor . constraint ( equalToConstant : Metrics . actionRailHeight ) . isActive = true
actionRail . setContentHuggingPriority ( . required , for : . horizontal )
actionRail . setContentCompressionResistancePriority ( . required , for : . horizontal )
let pinTitle = itemIsPinned ? " Unpin " : " Pin "
actionRailButtons = [
cardActionButton ( " return " , toolTip : " Paste " , action : #selector ( pasteFromMenu ) , isPrimary : true ) ,
cardActionButton ( " doc.on.doc " , toolTip : " Copy " , action : #selector ( copyFromMenu ) ) ,
cardActionButton ( itemIsPinned ? " pin.slash " : " pin " , toolTip : pinTitle , action : #selector ( togglePinFromMenu ) )
]
2026-06-30 02:11:50 -07:00
actionRailButtons . append ( cardActionButton ( " square.stack.3d.up " , toolTip : itemIsStacked ? " Remove from Stack " : " Add to Stack " , action : #selector ( toggleStackFromMenu ) ) )
2026-06-30 01:56:40 -07:00
if canEditText {
actionRailButtons . append ( cardActionButton ( " pencil " , toolTip : " Edit " , action : #selector ( editTextFromMenu ) ) )
}
2026-06-30 01:48:35 -07:00
if canPreview {
actionRailButtons . append ( cardActionButton ( " eye " , toolTip : " Preview " , action : #selector ( previewFromMenu ) ) )
}
2026-06-30 01:12:19 -07:00
if canOpen {
actionRailButtons . append ( cardActionButton ( " arrow.up.right.square " , toolTip : " Open " , action : #selector ( openFromMenu ) ) )
}
if canReveal {
actionRailButtons . append ( cardActionButton ( " magnifyingglass " , toolTip : " Reveal " , action : #selector ( revealFromMenu ) ) )
}
actionRailButtons . append ( cardActionButton ( " trash " , toolTip : " Delete " , action : #selector ( deleteFromMenu ) ) )
for button in actionRailButtons {
actionRail . addArrangedSubview ( button )
}
let buttonCount = CGFloat ( actionRailButtons . count )
let secondaryCount = CGFloat ( max ( 0 , actionRailButtons . count - 1 ) )
let contentWidth = Metrics . primaryActionButtonSize
+ secondaryCount * Metrics . actionButtonSize
+ max ( 0 , buttonCount - 1 ) * actionRail . spacing
+ actionRail . edgeInsets . left
+ actionRail . edgeInsets . right
actionRail . widthAnchor . constraint ( equalToConstant : contentWidth ) . isActive = true
updateActionRailVisibility ( )
}
private func cardActionButton (
_ systemName : String ,
toolTip : String ,
action : Selector ,
isPrimary : Bool = false
) -> NSButton {
let button = NSButton ( title : " " , target : self , action : action )
let image = NSImage ( systemSymbolName : systemName , accessibilityDescription : toolTip )
image ? . isTemplate = true
button . image = image
button . imagePosition = . imageOnly
button . imageScaling = . scaleProportionallyDown
button . isBordered = false
button . wantsLayer = true
let size = isPrimary ? Metrics . primaryActionButtonSize : Metrics . actionButtonSize
button . layer ? . cornerRadius = size / 2
button . layer ? . backgroundColor = isPrimary
? NSColor . controlAccentColor . cgColor
: NSColor . white . withAlphaComponent ( 0.08 ) . cgColor
button . contentTintColor = isPrimary
? . white
: ( toolTip = = " Delete " ? NSColor . white . withAlphaComponent ( 0.48 ) : NSColor . white . withAlphaComponent ( 0.78 ) )
button . toolTip = toolTip
button . setAccessibilityLabel ( toolTip )
button . translatesAutoresizingMaskIntoConstraints = false
button . widthAnchor . constraint ( equalToConstant : size ) . isActive = true
button . heightAnchor . constraint ( equalToConstant : size ) . isActive = true
return button
}
private func updateActionRailVisibility ( ) {
actionRail . isHidden = ! isSelected
headerBadgeView ? . isHidden = isSelected
headerPinView ? . isHidden = isSelected
footerDetailLabel . isHidden = false
for button in actionRailButtons {
button . alphaValue = 1.0
}
}
@objc private func pasteFromMenu ( ) {
onPaste ( index )
}
@objc private func copyFromMenu ( ) {
onCopy ( index )
}
2026-06-30 02:04:12 -07:00
@objc private func pastePlainTextFromMenu ( ) {
onPastePlainText ( index )
}
@objc private func copyPlainTextFromMenu ( ) {
onCopyPlainText ( index )
}
2026-06-30 02:11:50 -07:00
@objc private func toggleStackFromMenu ( ) {
onToggleStack ( index )
}
@objc private func pasteStackNextFromMenu ( ) {
onPasteStackNext ( )
}
@objc private func copyStackNextFromMenu ( ) {
onCopyStackNext ( )
}
@objc private func clearStackFromMenu ( ) {
onClearStack ( )
}
2026-06-30 01:56:40 -07:00
@objc private func editTextFromMenu ( ) {
onEditText ( index )
}
2026-06-30 01:48:35 -07:00
@objc private func previewFromMenu ( ) {
onPreview ( index )
}
2026-06-30 01:12:19 -07:00
@objc private func openFromMenu ( ) {
onOpen ( index )
}
@objc private func revealFromMenu ( ) {
onReveal ( index )
}
@objc private func togglePinFromMenu ( ) {
onTogglePin ( index )
}
@objc private func assignToCollectionFromMenu ( _ sender : NSMenuItem ) {
guard let name = sender . representedObject as ? String else { return }
onAssignCollection ( index , name )
}
@objc private func createCollectionFromMenu ( ) {
let input = NSTextField ( frame : NSRect ( x : 0 , y : 0 , width : 240 , height : 24 ) )
input . placeholderString = " Collection name "
input . stringValue = " "
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 . addButton ( withTitle : " Cancel " )
alert . window . initialFirstResponder = input
guard alert . runModal ( ) = = . alertFirstButtonReturn ,
let name = ClipboardCollectionDefaults . normalizedName ( input . stringValue ) else {
return
}
onAssignCollection ( index , name )
}
@objc private func removeFromCollectionFromMenu ( ) {
onAssignCollection ( index , nil )
}
2026-06-30 02:49:35 -07:00
@objc private func ignoreSourceAppFromMenu ( ) {
onIgnoreSourceApp ( index )
}
@objc private func ignoreKindFromMenu ( ) {
onIgnoreKind ( index )
}
2026-06-30 01:12:19 -07:00
@objc private func deleteFromMenu ( ) {
onDelete ( index )
}
private func configure ( item : ClipboardItem , thumbnail : NSImage ? ) {
#if DEBUG
debugPreviewSummary = " \( titleText ( for : item ) ) | \( previewText ( for : item ) ) | \( detailMetricText ( for : item ) ) "
debugPreviewStyle = previewStyle ( for : item , thumbnail : thumbnail )
debugHeaderBadgeSymbol = headerBadgeSymbol ( for : item . kind )
#endif
wantsLayer = true
layer ? . cornerRadius = 8
layer ? . masksToBounds = false
layer ? . shadowColor = NSColor . black . cgColor
layer ? . shadowOpacity = 0.08
layer ? . shadowRadius = 12
layer ? . shadowOffset = NSSize ( width : 0 , height : 3 )
setAccessibilityElement ( true )
setAccessibilityRole ( . button )
setAccessibilityLabel ( accessibilityTitle ( for : item ) )
setAccessibilityHelp ( " Selects this clipboard item. Double-click to paste. " )
widthAnchor . constraint ( equalToConstant : Metrics . width ) . isActive = true
heightAnchor . constraint ( equalToConstant : Metrics . height ) . isActive = true
contentView . wantsLayer = true
contentView . layer ? . cornerRadius = 8
contentView . layer ? . masksToBounds = true
contentView . layer ? . borderWidth = 1
contentView . layer ? . borderColor = Palette . border
contentView . layer ? . backgroundColor = Palette . cardSurface
contentView . translatesAutoresizingMaskIntoConstraints = false
addSubview ( contentView )
let header = headerView ( for : item )
let body = bodyView ( for : item , thumbnail : thumbnail )
let footer = footerView ( for : item )
let stack = NSStackView ( views : [ header , body , footer ] )
stack . orientation = . vertical
stack . alignment = . leading
stack . spacing = 0
stack . translatesAutoresizingMaskIntoConstraints = false
contentView . addSubview ( stack )
NSLayoutConstraint . activate ( [
contentView . leadingAnchor . constraint ( equalTo : leadingAnchor ) ,
contentView . trailingAnchor . constraint ( equalTo : trailingAnchor ) ,
contentView . topAnchor . constraint ( equalTo : topAnchor ) ,
contentView . bottomAnchor . constraint ( equalTo : bottomAnchor ) ,
stack . leadingAnchor . constraint ( equalTo : contentView . leadingAnchor ) ,
stack . trailingAnchor . constraint ( equalTo : contentView . trailingAnchor ) ,
stack . topAnchor . constraint ( equalTo : contentView . topAnchor ) ,
stack . bottomAnchor . constraint ( equalTo : contentView . bottomAnchor ) ,
header . widthAnchor . constraint ( equalTo : stack . widthAnchor ) ,
body . widthAnchor . constraint ( equalTo : stack . widthAnchor ) ,
footer . widthAnchor . constraint ( equalTo : stack . widthAnchor )
] )
contentView . addSubview ( actionRail )
NSLayoutConstraint . activate ( [
actionRail . trailingAnchor . constraint ( equalTo : contentView . trailingAnchor , constant : - 12 ) ,
actionRail . topAnchor . constraint ( equalTo : contentView . topAnchor , constant : 11 )
] )
setSelected ( false )
}
private func headerView ( for item : ClipboardItem ) -> NSView {
let header = NSView ( )
header . wantsLayer = true
header . layer ? . backgroundColor = accentColor ( for : item . kind ) . cgColor
header . heightAnchor . constraint ( equalToConstant : Metrics . headerHeight ) . isActive = true
let kind = NSTextField ( labelWithString : kindLabel ( for : item . kind ) )
kind . font = . systemFont ( ofSize : 16 , weight : . bold )
kind . textColor = . white
kind . lineBreakMode = . byTruncatingTail
kind . maximumNumberOfLines = 1
kind . toolTip = kind . stringValue
2026-06-30 01:24:01 -07:00
let source = NSTextField ( labelWithString : Self . relativeDateText ( for : item . createdAt ) )
2026-06-30 01:12:19 -07:00
source . font = . systemFont ( ofSize : 11 , weight : . regular )
source . textColor = NSColor . white . withAlphaComponent ( 0.72 )
source . lineBreakMode = . byTruncatingTail
source . maximumNumberOfLines = 1
source . toolTip = source . stringValue
let titleAndSource = NSStackView ( views : [ kind , source ] )
titleAndSource . orientation = . vertical
titleAndSource . alignment = . leading
titleAndSource . spacing = 2
titleAndSource . translatesAutoresizingMaskIntoConstraints = false
2026-06-30 02:38:48 -07:00
var labelViews : [ NSView ] = [ ]
if let quickPasteBadge = quickPasteBadge ( ) {
labelViews . append ( quickPasteBadge )
}
labelViews . append ( titleAndSource )
let labelStack = NSStackView ( views : labelViews )
2026-06-30 01:12:19 -07:00
labelStack . orientation = . horizontal
labelStack . alignment = . centerY
labelStack . distribution = . fill
2026-06-30 02:38:48 -07:00
labelStack . spacing = labelViews . count > 1 ? 9 : 1
2026-06-30 01:12:19 -07:00
labelStack . translatesAutoresizingMaskIntoConstraints = false
let badge = iconBadge ( for : item )
headerBadgeView = badge
let separator = NSView ( )
separator . wantsLayer = true
separator . layer ? . backgroundColor = NSColor . white . withAlphaComponent ( 0.18 ) . cgColor
separator . translatesAutoresizingMaskIntoConstraints = false
header . addSubview ( labelStack )
header . addSubview ( badge )
header . addSubview ( separator )
kind . setContentCompressionResistancePriority ( . defaultHigh , for : . horizontal )
kind . setContentHuggingPriority ( . defaultHigh , for : . horizontal )
source . setContentCompressionResistancePriority ( . defaultLow , for : . horizontal )
source . setContentHuggingPriority ( . defaultLow , for : . horizontal )
var constraints : [ NSLayoutConstraint ] = [
labelStack . leadingAnchor . constraint ( equalTo : header . leadingAnchor , constant : Metrics . inset ) ,
labelStack . centerYAnchor . constraint ( equalTo : header . centerYAnchor ) ,
labelStack . trailingAnchor . constraint ( lessThanOrEqualTo : badge . leadingAnchor , constant : - 12 ) ,
badge . trailingAnchor . constraint ( equalTo : header . trailingAnchor , constant : - Metrics . inset ) ,
badge . centerYAnchor . constraint ( equalTo : header . centerYAnchor ) ,
badge . widthAnchor . constraint ( equalToConstant : 42 ) ,
badge . heightAnchor . constraint ( equalToConstant : 42 ) ,
separator . leadingAnchor . constraint ( equalTo : header . leadingAnchor , constant : Metrics . inset ) ,
separator . trailingAnchor . constraint ( equalTo : header . trailingAnchor , constant : - Metrics . inset ) ,
separator . bottomAnchor . constraint ( equalTo : header . bottomAnchor ) ,
separator . heightAnchor . constraint ( equalToConstant : 1 )
]
if item . isPinned {
let pin = headerIcon ( " pin.fill " , color : NSColor . white . withAlphaComponent ( 0.88 ) )
headerPinView = pin
pin . translatesAutoresizingMaskIntoConstraints = false
header . addSubview ( pin )
constraints += [
pin . trailingAnchor . constraint ( equalTo : badge . leadingAnchor , constant : - 8 ) ,
pin . centerYAnchor . constraint ( equalTo : header . centerYAnchor ) ,
pin . widthAnchor . constraint ( equalToConstant : 14 ) ,
pin . heightAnchor . constraint ( equalToConstant : 14 )
]
}
NSLayoutConstraint . activate ( constraints )
return header
}
2026-06-30 02:38:48 -07:00
private func quickPasteBadge ( ) -> NSTextField ? {
guard index < 9 else { return nil }
let label = NSTextField ( labelWithString : " \( index + 1 ) " )
label . font = . monospacedDigitSystemFont ( ofSize : 11 , weight : . bold )
label . textColor = NSColor . white . withAlphaComponent ( 0.92 )
label . alignment = . center
label . lineBreakMode = . byClipping
label . wantsLayer = true
label . layer ? . cornerRadius = 9
label . layer ? . backgroundColor = NSColor . white . withAlphaComponent ( 0.18 ) . cgColor
label . layer ? . borderWidth = 0.5
label . layer ? . borderColor = NSColor . white . withAlphaComponent ( 0.24 ) . cgColor
label . toolTip = " Press Command- \( index + 1 ) to paste "
label . setAccessibilityLabel ( " Quick paste \( index + 1 ) " )
label . translatesAutoresizingMaskIntoConstraints = false
label . widthAnchor . constraint ( equalToConstant : 19 ) . isActive = true
label . heightAnchor . constraint ( equalToConstant : 19 ) . isActive = true
quickPasteBadgeLabel = label
return label
}
2026-06-30 01:12:19 -07:00
private func bodyView ( for item : ClipboardItem , thumbnail : NSImage ? ) -> NSView {
let body = NSView ( )
body . wantsLayer = true
body . layer ? . backgroundColor = Palette . bodyBackground
body . heightAnchor . constraint ( equalToConstant : Metrics . bodyHeight ) . isActive = true
let content = previewView ( for : item , thumbnail : thumbnail )
body . addSubview ( content )
NSLayoutConstraint . activate ( [
content . leadingAnchor . constraint ( equalTo : body . leadingAnchor ) ,
content . trailingAnchor . constraint ( equalTo : body . trailingAnchor ) ,
content . topAnchor . constraint ( equalTo : body . topAnchor ) ,
content . bottomAnchor . constraint ( equalTo : body . bottomAnchor )
] )
return body
}
private func previewView ( for item : ClipboardItem , thumbnail : NSImage ? ) -> NSView {
if item . kind = = . image , let thumbnail {
return mediaPreviewView ( for : item , thumbnail : thumbnail )
}
switch item . kind {
case . url :
if let thumbnail {
return linkMediaPreviewView ( for : item , thumbnail : thumbnail )
}
return linkPreviewView ( for : item )
case . file , . pdf :
if let thumbnail {
return mediaPreviewView ( for : item , thumbnail : thumbnail )
}
return filePreviewView ( for : item , thumbnail : thumbnail )
case . audio :
return audioPreviewView ( for : item )
case . text , . richText , . image , . unknown :
return textPreviewView ( for : item )
}
}
private func textPreviewView ( for item : ClipboardItem ) -> NSView {
let container = NSView ( )
container . translatesAutoresizingMaskIntoConstraints = false
let titleString = titleText ( for : item )
let bodyString = previewBodyText ( for : item , title : titleString )
let title = NSTextField ( wrappingLabelWithString : titleString )
title . font = . systemFont ( ofSize : 13 , weight : . semibold )
title . textColor = . labelColor
title . maximumNumberOfLines = 1
title . lineBreakMode = . byTruncatingTail
title . toolTip = title . stringValue
let detail = NSTextField ( wrappingLabelWithString : bodyString )
detail . font = . systemFont ( ofSize : item . kind = = . richText ? 15 : 14 )
detail . textColor = . secondaryLabelColor
detail . maximumNumberOfLines = 5
detail . lineBreakMode = . byTruncatingTail
detail . toolTip = detail . stringValue
let stack = NSStackView ( views : [ title , detail ] )
stack . orientation = . vertical
stack . alignment = . leading
stack . spacing = 10
stack . translatesAutoresizingMaskIntoConstraints = false
container . addSubview ( stack )
title . widthAnchor . constraint ( equalTo : stack . widthAnchor ) . isActive = true
detail . widthAnchor . constraint ( equalTo : stack . widthAnchor ) . isActive = true
NSLayoutConstraint . activate ( [
stack . leadingAnchor . constraint ( equalTo : container . leadingAnchor , constant : Metrics . inset ) ,
stack . trailingAnchor . constraint ( equalTo : container . trailingAnchor , constant : - Metrics . inset ) ,
stack . topAnchor . constraint ( equalTo : container . topAnchor , constant : 16 ) ,
stack . bottomAnchor . constraint ( lessThanOrEqualTo : container . bottomAnchor , constant : - 14 )
] )
return container
}
private func linkPreviewView ( for item : ClipboardItem ) -> NSView {
let container = NSView ( )
container . translatesAutoresizingMaskIntoConstraints = false
let hero = NSView ( )
hero . wantsLayer = true
hero . layer ? . backgroundColor = accentColor ( for : item . kind ) . withAlphaComponent ( 0.12 ) . cgColor
hero . translatesAutoresizingMaskIntoConstraints = false
hero . heightAnchor . constraint ( equalToConstant : 82 ) . isActive = true
let globe = headerIcon ( " globe " , color : accentColor ( for : item . kind ) )
globe . translatesAutoresizingMaskIntoConstraints = false
let host = NSTextField ( labelWithString : webHostText ( from : item . payload ) ? ? " Link " )
host . font = . systemFont ( ofSize : 12 , weight : . semibold )
host . textColor = accentColor ( for : item . kind )
host . alignment = . center
host . lineBreakMode = . byTruncatingTail
host . maximumNumberOfLines = 1
host . toolTip = host . stringValue
let heroStack = NSStackView ( views : [ globe , host ] )
heroStack . orientation = . vertical
heroStack . alignment = . centerX
heroStack . spacing = 7
heroStack . translatesAutoresizingMaskIntoConstraints = false
hero . addSubview ( heroStack )
let title = NSTextField ( wrappingLabelWithString : titleText ( for : item ) )
title . font = . systemFont ( ofSize : 14 , weight : . semibold )
title . textColor = . labelColor
title . maximumNumberOfLines = 2
title . lineBreakMode = . byTruncatingTail
title . toolTip = title . stringValue
let address = NSTextField ( labelWithString : previewText ( for : item ) )
address . font = . systemFont ( ofSize : 12 )
address . textColor = . secondaryLabelColor
address . maximumNumberOfLines = 1
address . lineBreakMode = . byTruncatingMiddle
address . toolTip = address . stringValue
let textStack = NSStackView ( views : [ title , address ] )
textStack . orientation = . vertical
textStack . alignment = . leading
textStack . spacing = 3
textStack . translatesAutoresizingMaskIntoConstraints = false
container . addSubview ( hero )
container . addSubview ( textStack )
NSLayoutConstraint . activate ( [
hero . leadingAnchor . constraint ( equalTo : container . leadingAnchor ) ,
hero . trailingAnchor . constraint ( equalTo : container . trailingAnchor ) ,
hero . topAnchor . constraint ( equalTo : container . topAnchor ) ,
heroStack . centerXAnchor . constraint ( equalTo : hero . centerXAnchor ) ,
heroStack . centerYAnchor . constraint ( equalTo : hero . centerYAnchor ) ,
globe . widthAnchor . constraint ( equalToConstant : 28 ) ,
globe . heightAnchor . constraint ( equalToConstant : 28 ) ,
host . widthAnchor . constraint ( lessThanOrEqualTo : hero . widthAnchor , constant : - 48 ) ,
textStack . leadingAnchor . constraint ( equalTo : container . leadingAnchor , constant : Metrics . inset ) ,
textStack . trailingAnchor . constraint ( equalTo : container . trailingAnchor , constant : - Metrics . inset ) ,
textStack . topAnchor . constraint ( equalTo : hero . bottomAnchor , constant : 11 ) ,
textStack . bottomAnchor . constraint ( lessThanOrEqualTo : container . bottomAnchor , constant : - 10 ) ,
title . widthAnchor . constraint ( equalTo : textStack . widthAnchor ) ,
address . widthAnchor . constraint ( equalTo : textStack . widthAnchor )
] )
return container
}
private func linkMediaPreviewView ( for item : ClipboardItem , thumbnail : NSImage ) -> NSView {
let container = NSView ( )
container . translatesAutoresizingMaskIntoConstraints = false
let imageView = AspectFillImageView ( image : thumbnail )
imageView . translatesAutoresizingMaskIntoConstraints = false
imageView . heightAnchor . constraint ( equalToConstant : 90 ) . isActive = true
let hostPill = capsuleLabel ( webHostText ( from : item . payload ) ? ? " Link " , color : NSColor . black . withAlphaComponent ( 0.56 ) )
hostPill . translatesAutoresizingMaskIntoConstraints = false
let title = NSTextField ( wrappingLabelWithString : titleText ( for : item ) )
title . font = . systemFont ( ofSize : 14 , weight : . semibold )
title . textColor = . labelColor
title . maximumNumberOfLines = 1
title . lineBreakMode = . byTruncatingTail
title . toolTip = title . stringValue
let address = NSTextField ( labelWithString : previewText ( for : item ) )
address . font = . systemFont ( ofSize : 12 )
address . textColor = . secondaryLabelColor
address . maximumNumberOfLines = 1
address . lineBreakMode = . byTruncatingMiddle
address . toolTip = address . stringValue
let textStack = NSStackView ( views : [ title , address ] )
textStack . orientation = . vertical
textStack . alignment = . leading
textStack . spacing = 3
textStack . translatesAutoresizingMaskIntoConstraints = false
container . addSubview ( imageView )
container . addSubview ( hostPill )
container . addSubview ( textStack )
NSLayoutConstraint . activate ( [
imageView . leadingAnchor . constraint ( equalTo : container . leadingAnchor ) ,
imageView . trailingAnchor . constraint ( equalTo : container . trailingAnchor ) ,
imageView . topAnchor . constraint ( equalTo : container . topAnchor ) ,
hostPill . leadingAnchor . constraint ( equalTo : imageView . leadingAnchor , constant : 12 ) ,
hostPill . bottomAnchor . constraint ( equalTo : imageView . bottomAnchor , constant : - 10 ) ,
textStack . leadingAnchor . constraint ( equalTo : container . leadingAnchor , constant : Metrics . inset ) ,
textStack . trailingAnchor . constraint ( equalTo : container . trailingAnchor , constant : - Metrics . inset ) ,
textStack . topAnchor . constraint ( equalTo : imageView . bottomAnchor , constant : 10 ) ,
textStack . bottomAnchor . constraint ( lessThanOrEqualTo : container . bottomAnchor , constant : - 8 ) ,
title . widthAnchor . constraint ( equalTo : textStack . widthAnchor ) ,
address . widthAnchor . constraint ( equalTo : textStack . widthAnchor )
] )
return container
}
private func filePreviewView ( for item : ClipboardItem , thumbnail : NSImage ? ) -> NSView {
let container = NSView ( )
container . translatesAutoresizingMaskIntoConstraints = false
let iconBox = NSView ( )
iconBox . wantsLayer = true
iconBox . layer ? . cornerRadius = 12
iconBox . layer ? . backgroundColor = accentColor ( for : item . kind ) . withAlphaComponent ( 0.14 ) . cgColor
iconBox . translatesAutoresizingMaskIntoConstraints = false
let extensionPill = capsuleLabel ( detailMetricText ( for : item ) , color : accentColor ( for : item . kind ) )
extensionPill . translatesAutoresizingMaskIntoConstraints = false
let preview : NSView
if let thumbnail {
let imageView = NSImageView ( image : thumbnail )
imageView . imageScaling = . scaleProportionallyUpOrDown
imageView . wantsLayer = true
imageView . layer ? . cornerRadius = 8
imageView . layer ? . masksToBounds = true
imageView . translatesAutoresizingMaskIntoConstraints = false
preview = imageView
} else {
let iconName = item . kind = = . pdf ? " doc.richtext.fill " : " doc.fill "
let icon = headerIcon ( iconName , color : accentColor ( for : item . kind ) )
icon . translatesAutoresizingMaskIntoConstraints = false
preview = icon
}
iconBox . addSubview ( preview )
iconBox . addSubview ( extensionPill )
let title = NSTextField ( wrappingLabelWithString : titleText ( for : item ) )
title . font = . systemFont ( ofSize : 14 , weight : . semibold )
title . textColor = . labelColor
title . maximumNumberOfLines = 2
title . lineBreakMode = . byTruncatingTail
title . toolTip = title . stringValue
let location = NSTextField ( wrappingLabelWithString : previewText ( for : item ) )
location . font = . systemFont ( ofSize : 12 )
location . textColor = . secondaryLabelColor
location . maximumNumberOfLines = 2
location . lineBreakMode = . byTruncatingMiddle
location . toolTip = location . stringValue
let textStack = NSStackView ( views : [ title , location ] )
textStack . orientation = . vertical
textStack . alignment = . leading
textStack . spacing = 5
textStack . translatesAutoresizingMaskIntoConstraints = false
let row = NSStackView ( views : [ iconBox , textStack ] )
row . orientation = . horizontal
row . alignment = . centerY
row . spacing = 14
row . translatesAutoresizingMaskIntoConstraints = false
container . addSubview ( row )
NSLayoutConstraint . activate ( [
row . leadingAnchor . constraint ( equalTo : container . leadingAnchor , constant : Metrics . inset ) ,
row . trailingAnchor . constraint ( equalTo : container . trailingAnchor , constant : - Metrics . inset ) ,
row . centerYAnchor . constraint ( equalTo : container . centerYAnchor ) ,
iconBox . widthAnchor . constraint ( equalToConstant : thumbnail = = nil ? 72 : 96 ) ,
iconBox . heightAnchor . constraint ( equalToConstant : thumbnail = = nil ? 84 : 104 ) ,
preview . centerXAnchor . constraint ( equalTo : iconBox . centerXAnchor ) ,
preview . centerYAnchor . constraint ( equalTo : iconBox . centerYAnchor , constant : thumbnail = = nil ? - 6 : - 8 ) ,
preview . widthAnchor . constraint ( lessThanOrEqualToConstant : thumbnail = = nil ? 32 : 80 ) ,
preview . heightAnchor . constraint ( lessThanOrEqualToConstant : thumbnail = = nil ? 36 : 72 ) ,
extensionPill . centerXAnchor . constraint ( equalTo : iconBox . centerXAnchor ) ,
extensionPill . bottomAnchor . constraint ( equalTo : iconBox . bottomAnchor , constant : - 10 ) ,
title . widthAnchor . constraint ( equalTo : textStack . widthAnchor ) ,
location . widthAnchor . constraint ( equalTo : textStack . widthAnchor )
] )
return container
}
private func audioPreviewView ( for item : ClipboardItem ) -> NSView {
let container = NSView ( )
container . wantsLayer = true
container . layer ? . backgroundColor = accentColor ( for : item . kind ) . withAlphaComponent ( 0.10 ) . cgColor
container . translatesAutoresizingMaskIntoConstraints = false
let note = headerIcon ( " music.note " , color : accentColor ( for : item . kind ) )
note . translatesAutoresizingMaskIntoConstraints = false
let waveform = NSStackView ( )
waveform . orientation = . horizontal
waveform . alignment = . centerY
waveform . spacing = 5
waveform . translatesAutoresizingMaskIntoConstraints = false
for height in [ 12 , 22 , 16 , 30 , 22 , 26 , 14 ] as [ CGFloat ] {
let bar = NSView ( )
bar . wantsLayer = true
bar . layer ? . cornerRadius = 2.5
bar . layer ? . backgroundColor = accentColor ( for : item . kind ) . withAlphaComponent ( 0.55 ) . cgColor
bar . translatesAutoresizingMaskIntoConstraints = false
bar . widthAnchor . constraint ( equalToConstant : 5 ) . isActive = true
bar . heightAnchor . constraint ( equalToConstant : height ) . isActive = true
waveform . addArrangedSubview ( bar )
}
let title = NSTextField ( labelWithString : titleText ( for : item ) )
title . font = . systemFont ( ofSize : 14 , weight : . semibold )
title . textColor = . labelColor
title . maximumNumberOfLines = 1
title . lineBreakMode = . byTruncatingTail
title . toolTip = title . stringValue
let detail = NSTextField ( labelWithString : previewText ( for : item ) )
detail . font = . systemFont ( ofSize : 12 )
detail . textColor = . secondaryLabelColor
detail . maximumNumberOfLines = 1
detail . lineBreakMode = . byTruncatingTail
detail . toolTip = detail . stringValue
let labels = NSStackView ( views : [ title , detail ] )
labels . orientation = . vertical
labels . alignment = . centerX
labels . spacing = 3
labels . translatesAutoresizingMaskIntoConstraints = false
container . addSubview ( note )
container . addSubview ( waveform )
container . addSubview ( labels )
NSLayoutConstraint . activate ( [
note . centerXAnchor . constraint ( equalTo : container . centerXAnchor ) ,
note . topAnchor . constraint ( equalTo : container . topAnchor , constant : 18 ) ,
note . widthAnchor . constraint ( equalToConstant : 28 ) ,
note . heightAnchor . constraint ( equalToConstant : 28 ) ,
waveform . centerXAnchor . constraint ( equalTo : container . centerXAnchor ) ,
waveform . topAnchor . constraint ( equalTo : note . bottomAnchor , constant : 8 ) ,
labels . leadingAnchor . constraint ( equalTo : container . leadingAnchor , constant : Metrics . inset ) ,
labels . trailingAnchor . constraint ( equalTo : container . trailingAnchor , constant : - Metrics . inset ) ,
labels . topAnchor . constraint ( equalTo : waveform . bottomAnchor , constant : 10 ) ,
title . widthAnchor . constraint ( equalTo : labels . widthAnchor ) ,
detail . widthAnchor . constraint ( equalTo : labels . widthAnchor )
] )
return container
}
private func mediaPreviewView ( for item : ClipboardItem , thumbnail : NSImage ) -> NSView {
let container = NSView ( )
container . translatesAutoresizingMaskIntoConstraints = false
let imageView = AspectFillImageView ( image : thumbnail )
imageView . translatesAutoresizingMaskIntoConstraints = false
let overlay = capsuleLabel ( mediaMetricText ( for : thumbnail ) , color : NSColor . black . withAlphaComponent ( 0.60 ) )
overlay . translatesAutoresizingMaskIntoConstraints = false
container . addSubview ( imageView )
container . addSubview ( overlay )
NSLayoutConstraint . activate ( [
imageView . leadingAnchor . constraint ( equalTo : container . leadingAnchor ) ,
imageView . trailingAnchor . constraint ( equalTo : container . trailingAnchor ) ,
imageView . topAnchor . constraint ( equalTo : container . topAnchor ) ,
imageView . bottomAnchor . constraint ( equalTo : container . bottomAnchor ) ,
overlay . trailingAnchor . constraint ( equalTo : container . trailingAnchor , constant : - 12 ) ,
overlay . bottomAnchor . constraint ( equalTo : container . bottomAnchor , constant : - 10 )
] )
return container
}
private func capsuleLabel ( _ text : String , color : NSColor ) -> NSTextField {
let label = NSTextField ( labelWithString : text )
label . translatesAutoresizingMaskIntoConstraints = false
label . font = . monospacedDigitSystemFont ( ofSize : 10 , weight : . semibold )
label . textColor = . white
label . alignment = . center
label . lineBreakMode = . byTruncatingTail
label . maximumNumberOfLines = 1
label . wantsLayer = true
label . layer ? . cornerRadius = 8
label . layer ? . backgroundColor = color . cgColor
label . toolTip = text
label . setContentCompressionResistancePriority ( . required , for : . horizontal )
label . widthAnchor . constraint ( greaterThanOrEqualToConstant : 34 ) . isActive = true
label . heightAnchor . constraint ( equalToConstant : 18 ) . isActive = true
return label
}
private func previewBodyText ( for item : ClipboardItem , title : String ) -> String {
let preview = previewText ( for : item )
let normalizedTitle = normalized ( title )
if preview = = normalizedTitle {
return preview
}
let prefix = normalizedTitle + " "
if preview . hasPrefix ( prefix ) {
let remainder = String ( preview . dropFirst ( prefix . count ) ) . clipboardTrimmed
if ! remainder . isEmpty {
return remainder
}
}
return preview
}
private func mediaMetricText ( for image : NSImage ) -> String {
let width = max ( 1 , Int ( image . size . width . rounded ( ) ) )
let height = max ( 1 , Int ( image . size . height . rounded ( ) ) )
return " \( width ) x \( height ) "
}
private func previewStyle ( for item : ClipboardItem , thumbnail : NSImage ? ) -> String {
if item . kind = = . image , thumbnail != nil {
return " media-preview "
}
switch item . kind {
case . url :
return thumbnail = = nil ? " link-preview " : " link-media-preview "
case . file , . pdf :
return thumbnail = = nil ? " file-preview " : " file-media-preview "
case . audio :
return " audio-preview "
case . richText :
return " rich-text-preview "
case . text :
return " text-preview "
case . image :
return " text-fallback-preview "
case . unknown :
return " unknown-preview "
}
}
private func footerView ( for item : ClipboardItem ) -> NSView {
let footer = NSView ( )
footer . wantsLayer = true
footer . layer ? . backgroundColor = Palette . footerBackground
footer . heightAnchor . constraint ( equalToConstant : Metrics . footerHeight ) . isActive = true
let source = NSTextField ( labelWithString : sourceText ( for : item ) )
source . font = . systemFont ( ofSize : NSFont . smallSystemFontSize , weight : . medium )
source . textColor = . secondaryLabelColor
source . lineBreakMode = . byTruncatingTail
source . maximumNumberOfLines = 1
source . toolTip = source . stringValue
let detailText = detailMetricText ( for : item )
if let collectionName = item . collectionName ? . clipboardTrimmed , ! collectionName . isEmpty {
footerDetailLabel . stringValue = " \( collectionName ) - \( detailText ) "
} else {
footerDetailLabel . stringValue = detailText
}
footerDetailLabel . font = . systemFont ( ofSize : NSFont . smallSystemFontSize )
footerDetailLabel . textColor = . tertiaryLabelColor
footerDetailLabel . alignment = . right
footerDetailLabel . lineBreakMode = . byTruncatingTail
footerDetailLabel . maximumNumberOfLines = 1
footerDetailLabel . toolTip = footerDetailLabel . stringValue
configureActionRail ( )
let divider = NSView ( )
divider . wantsLayer = true
divider . layer ? . backgroundColor = Palette . divider
divider . translatesAutoresizingMaskIntoConstraints = false
let stack = row ( [ source , footerDetailLabel ] )
stack . distribution = . fill
stack . alignment = . centerY
stack . translatesAutoresizingMaskIntoConstraints = false
source . setContentHuggingPriority ( . defaultLow , for : . horizontal )
source . setContentCompressionResistancePriority ( . defaultLow , for : . horizontal )
footerDetailLabel . setContentHuggingPriority ( . required , for : . horizontal )
footerDetailLabel . setContentCompressionResistancePriority ( . required , for : . horizontal )
footer . addSubview ( divider )
footer . addSubview ( stack )
NSLayoutConstraint . activate ( [
divider . leadingAnchor . constraint ( equalTo : footer . leadingAnchor , constant : Metrics . inset ) ,
divider . trailingAnchor . constraint ( equalTo : footer . trailingAnchor , constant : - Metrics . inset ) ,
divider . topAnchor . constraint ( equalTo : footer . topAnchor ) ,
divider . heightAnchor . constraint ( equalToConstant : 1 ) ,
stack . leadingAnchor . constraint ( equalTo : footer . leadingAnchor , constant : Metrics . inset ) ,
stack . trailingAnchor . constraint ( equalTo : footer . trailingAnchor , constant : - Metrics . inset ) ,
stack . centerYAnchor . constraint ( equalTo : footer . centerYAnchor )
] )
return footer
}
2026-06-30 01:24:01 -07:00
private func dragPreviewImage ( ) -> NSImage {
guard let representation = bitmapImageRepForCachingDisplay ( in : bounds ) else {
return NSImage ( size : bounds . size )
}
representation . size = bounds . size
cacheDisplay ( in : bounds , to : representation )
let image = NSImage ( size : bounds . size )
image . addRepresentation ( representation )
return image
}
2026-06-30 01:12:19 -07:00
private func iconBadge ( for item : ClipboardItem ) -> NSView {
let badge = NSView ( )
badge . wantsLayer = true
badge . layer ? . cornerRadius = 8
badge . layer ? . backgroundColor = NSColor . white . withAlphaComponent ( 0.92 ) . cgColor
badge . layer ? . borderWidth = 1
badge . layer ? . borderColor = Palette . divider
badge . translatesAutoresizingMaskIntoConstraints = false
if let bundleId = item . sourceAppBundleId ,
let appURL = NSWorkspace . shared . urlForApplication ( withBundleIdentifier : bundleId ) {
let icon = NSImageView ( image : NSWorkspace . shared . icon ( forFile : appURL . path ) )
icon . imageScaling = . scaleProportionallyUpOrDown
icon . translatesAutoresizingMaskIntoConstraints = false
badge . addSubview ( icon )
NSLayoutConstraint . activate ( [
icon . leadingAnchor . constraint ( equalTo : badge . leadingAnchor , constant : 8 ) ,
icon . trailingAnchor . constraint ( equalTo : badge . trailingAnchor , constant : - 8 ) ,
icon . topAnchor . constraint ( equalTo : badge . topAnchor , constant : 8 ) ,
icon . bottomAnchor . constraint ( equalTo : badge . bottomAnchor , constant : - 8 )
] )
} else {
let image = NSImage ( systemSymbolName : headerBadgeSymbol ( for : item . kind ) , accessibilityDescription : kindLabel ( for : item . kind ) )
? ? NSImage ( systemSymbolName : " doc.on.clipboard " , accessibilityDescription : kindLabel ( for : item . kind ) )
? ? NSImage ( )
image . isTemplate = true
let icon = NSImageView ( image : image )
icon . imageScaling = . scaleProportionallyUpOrDown
icon . contentTintColor = accentColor ( for : item . kind )
icon . translatesAutoresizingMaskIntoConstraints = false
badge . addSubview ( icon )
NSLayoutConstraint . activate ( [
icon . leadingAnchor . constraint ( equalTo : badge . leadingAnchor , constant : 9 ) ,
icon . trailingAnchor . constraint ( equalTo : badge . trailingAnchor , constant : - 9 ) ,
icon . topAnchor . constraint ( equalTo : badge . topAnchor , constant : 9 ) ,
icon . bottomAnchor . constraint ( equalTo : badge . bottomAnchor , constant : - 9 )
] )
}
return badge
}
private func separatorLine ( ) -> NSView {
let divider = NSView ( )
divider . wantsLayer = true
divider . translatesAutoresizingMaskIntoConstraints = false
divider . layer ? . backgroundColor = NSColor . separatorColor . withAlphaComponent ( 0.6 ) . cgColor
return divider
}
private func headerIcon ( _ name : String , color : NSColor ) -> NSView {
let view = NSImageView ( image : NSImage ( systemSymbolName : name , accessibilityDescription : nil ) ? ? NSImage ( ) )
view . imageScaling = . scaleProportionallyUpOrDown
view . contentTintColor = color
return view
}
private func titleText ( for item : ClipboardItem ) -> String {
switch item . kind {
case . url :
return linkTitle ( for : item )
case . file :
return fileTitle ( for : item , fallback : " File " )
case . pdf :
return fileTitle ( for : item , fallback : " PDF document " )
case . audio :
return audioTitle ( for : item )
case . image :
return imageTitle ( for : item )
default :
break
}
let candidate = firstUsefulLine ( item . displayText ) . isEmpty ? firstUsefulLine ( item . payload ) : firstUsefulLine ( item . displayText )
if candidate . isEmpty || looksInternal ( candidate ) {
return " Copied \( kindLabel ( for : item . kind ) . lowercased ( ) ) "
}
return candidate
}
private func previewText ( for item : ClipboardItem ) -> String {
switch item . kind {
case . url :
if let address = webAddressText ( from : item . payload ) {
return address
}
let text = normalized ( item . payload )
return text . isEmpty ? " No preview available " : text
case . file :
return fileLocationText ( from : item . payload , fallback : " Local file " )
case . pdf :
if let ocrText = item . ocrText ? . clipboardTrimmed , ! ocrText . isEmpty {
return normalized ( ocrText )
}
return fileLocationText ( from : item . payload , fallback : " PDF document " )
case . audio :
return " Sound clip "
case . richText :
let text = normalized ( item . displayText )
return text . isEmpty ? " No preview available " : text
case . image :
if let ocrText = item . ocrText ? . clipboardTrimmed , ! ocrText . isEmpty {
return normalized ( ocrText )
}
return " Image clip "
default :
let text = item . payload . clipboardTrimmed . isEmpty ? item . displayText : item . payload
let normalizedText = normalized ( text )
return normalizedText . isEmpty ? " No preview available " : normalizedText
}
}
private func detailMetricText ( for item : ClipboardItem ) -> String {
switch item . kind {
case . text :
let count = item . payload . count
return " \( count ) \( count = = 1 ? " character " : " characters " ) "
case . richText :
let count = item . displayText . count
return " \( count ) \( count = = 1 ? " character " : " characters " ) "
case . url :
return webHostText ( from : item . payload ) ? ? " Link "
case . file :
return fileKindText ( from : item . payload , fallback : " File " )
case . pdf :
return " PDF "
case . audio :
return " Audio "
case . image :
if item . ocrText ? . clipboardTrimmed . isEmpty = = false {
return " OCR text "
}
let path = item . imagePath ? . clipboardTrimmed . isEmpty = = false ? item . imagePath ! : item . payload
return fileKindText ( from : path , fallback : " Image " )
case . unknown :
return metadataText ( for : item )
}
}
private func sourceText ( for item : ClipboardItem ) -> String {
item . sourceApp ? . clipboardTrimmed . isEmpty = = false ? item . sourceApp ! : " Unknown "
}
private func linkTitle ( for item : ClipboardItem ) -> String {
let display = firstUsefulLine ( item . displayText )
let payload = firstUsefulLine ( item . payload )
if ! display . isEmpty ,
display != payload ,
! looksInternal ( display ) ,
! looksGenericLink ( display ) ,
! looksLikeWebAddress ( display ) {
return display
}
return webHostText ( from : item . payload ) ? ? " Link "
}
private func fileTitle ( for item : ClipboardItem , fallback : String ) -> String {
let paths = FilePayload . paths ( from : item . payload )
if item . kind = = . file , paths . count > 1 {
return " \( paths . count ) files "
}
if let name = fileName ( from : item . payload ) , ! name . isEmpty , ! looksInternal ( name ) {
return name
}
let display = firstUsefulLine ( item . displayText )
if ! display . isEmpty , ! looksInternal ( display ) , ! looksGenericFileTitle ( display ) {
return display
}
return fallback
}
private func imageTitle ( for item : ClipboardItem ) -> String {
let display = firstUsefulLine ( item . displayText )
if ! display . isEmpty , ! looksInternal ( display ) , display . lowercased ( ) != " image " {
return display
}
let ocr = firstUsefulLine ( item . ocrText ? ? " " )
if ! ocr . isEmpty , ! looksInternal ( ocr ) {
return ocr
}
return " Image "
}
private func audioTitle ( for item : ClipboardItem ) -> String {
let display = firstUsefulLine ( item . displayText )
if ! display . isEmpty , ! looksInternal ( display ) , display . lowercased ( ) != " audio " {
return display
}
return " Audio "
}
private func webComponents ( from value : String ) -> URLComponents ? {
let trimmed = value . clipboardTrimmed
guard ! trimmed . isEmpty else { return nil }
if let components = URLComponents ( string : trimmed ) , components . host ? . isEmpty = = false {
return components
}
if ! trimmed . contains ( " :// " ) ,
let components = URLComponents ( string : " https:// \( trimmed ) " ) ,
components . host ? . isEmpty = = false {
return components
}
return nil
}
private func webHostText ( from value : String ) -> String ? {
guard let host = webComponents ( from : value ) ? . host ? . clipboardTrimmed , ! host . isEmpty else { return nil }
return host . lowercased ( ) . hasPrefix ( " www. " ) ? String ( host . dropFirst ( 4 ) ) : host
}
private func webAddressText ( from value : String ) -> String ? {
guard let components = webComponents ( from : value ) ,
let host = webHostText ( from : value ) else {
return nil
}
var address = host
let path = components . path . clipboardTrimmed
if ! path . isEmpty , path != " / " {
address += path
}
return address
}
private func fileURL ( from value : String ) -> URL ? {
FilePayload . urls ( from : value ) . first
}
private func fileName ( from value : String ) -> String ? {
guard let url = fileURL ( from : value ) else { return nil }
let name = url . lastPathComponent . removingPercentEncoding ? ? url . lastPathComponent
return name . clipboardTrimmed . isEmpty ? nil : name
}
private func fileLocationText ( from value : String , fallback : String ) -> String {
let urls = FilePayload . urls ( from : value )
guard let url = urls . first else { return fallback }
if urls . count > 1 {
let parents = Set ( urls . map { $0 . deletingLastPathComponent ( ) . path } . filter { ! $0 . isEmpty } )
if parents . count = = 1 , let parent = parents . first {
return shortenedPath ( parent )
}
return " Multiple locations "
}
let parentPath = url . deletingLastPathComponent ( ) . path
if parentPath . isEmpty {
return fallback
}
return shortenedPath ( parentPath )
}
private func fileKindText ( from value : String , fallback : String ) -> String {
let paths = FilePayload . paths ( from : value )
if paths . count > 1 {
return " \( paths . count ) files "
}
guard let fileExtension = fileURL ( from : value ) ? . pathExtension . clipboardTrimmed ,
! fileExtension . isEmpty else {
return fallback
}
return fileExtension . uppercased ( )
}
private func shortenedPath ( _ path : String ) -> String {
let home = FileManager . default . homeDirectoryForCurrentUser . path
if path = = home {
return " ~ "
}
if path . hasPrefix ( home + " / " ) {
return " ~ " + String ( path . dropFirst ( home . count ) )
}
return path
}
private func accentColor ( for kind : ClipboardItemKind ) -> NSColor {
switch kind {
case . url :
return NSColor ( calibratedRed : 0.02 , green : 0.47 , blue : 0.98 , alpha : 1 )
case . text :
return NSColor ( calibratedRed : 0.96 , green : 0.64 , blue : 0.00 , alpha : 1 )
case . image :
return NSColor ( calibratedRed : 1.00 , green : 0.22 , blue : 0.25 , alpha : 1 )
case . richText :
return NSColor ( calibratedRed : 0.94 , green : 0.12 , blue : 0.48 , alpha : 1 )
case . file :
return NSColor ( calibratedRed : 0.11 , green : 0.68 , blue : 0.36 , alpha : 1 )
case . pdf :
return NSColor ( calibratedRed : 0.55 , green : 0.35 , blue : 0.88 , alpha : 1 )
case . audio :
return NSColor ( calibratedRed : 0.93 , green : 0.12 , blue : 0.34 , alpha : 1 )
case . unknown :
return . systemGray
}
}
private func headerBadgeSymbol ( for kind : ClipboardItemKind ) -> String {
switch kind {
case . url : return " link "
case . text : return " text.alignleft "
case . image : return " photo "
case . richText : return " doc.richtext "
case . file : return " doc "
case . pdf : return " doc.text.fill "
case . audio : return " music.note "
case . unknown : return " questionmark "
}
}
private func metadataText ( for item : ClipboardItem ) -> String {
let time = Self . relativeDateText ( for : item . createdAt )
guard item . useCount > 0 else { return time }
return " \( time ) - Used \( item . useCount ) "
}
private func firstUsefulLine ( _ value : String ) -> String {
for line in value . components ( separatedBy : . newlines ) {
let text = normalized ( line )
if ! text . isEmpty {
return text
}
}
return " "
}
private func normalized ( _ value : String ) -> String {
value . split { $0 . isWhitespace } . joined ( separator : " " )
}
private func looksInternal ( _ value : String ) -> Bool {
let lower = value . lowercased ( )
return lower . hasPrefix ( " clipbored-flow-test- " ) || lower . hasPrefix ( " internal copy " )
}
private func looksGenericLink ( _ value : String ) -> Bool {
let lower = value . lowercased ( )
return lower = = " link " || lower = = " url "
}
private func looksGenericFileTitle ( _ value : String ) -> Bool {
let lower = value . lowercased ( )
return lower = = " file " || lower = = " pdf " || lower = = " image "
}
private func looksLikeWebAddress ( _ value : String ) -> Bool {
let lower = value . lowercased ( )
return lower . contains ( " :// " ) || lower . hasPrefix ( " www. " )
}
private func accessibilityTitle ( for item : ClipboardItem ) -> String {
let summary = titleText ( for : item )
return " \( kindLabel ( for : item . kind ) ) : \( summary ) "
}
private func row ( _ views : [ NSView ] ) -> NSStackView {
let stack = NSStackView ( views : views )
stack . orientation = . horizontal
stack . alignment = . top
stack . spacing = 6
return stack
}
private func kindLabel ( for kind : ClipboardItemKind ) -> String {
switch kind {
case . image : return " Image "
case . url : return " Link "
case . text : return " Text "
case . richText : return " Rich Text "
case . file : return " File "
case . unknown : return " Unknown "
case . pdf : return " PDF "
case . audio : return " Audio "
}
}
private static func relativeDateText ( for date : Date ) -> String {
let seconds = max ( 0 , Int ( Date ( ) . timeIntervalSince ( date ) ) )
if seconds < 60 { return " Just now " }
if seconds < 3600 { return " \( seconds / 60 ) m ago " }
if seconds < 86400 { return " \( seconds / 3600 ) h ago " }
if seconds < 604800 { return " \( seconds / 86400 ) d ago " }
return " \( seconds / 604800 ) w ago "
}
}