5239 lines
185 KiB
Swift
5239 lines
185 KiB
Swift
import AppKit
|
|
|
|
private struct ClipboardItemCardLayout: Equatable {
|
|
let width: CGFloat
|
|
let height: CGFloat
|
|
let inset: CGFloat
|
|
let headerHeight: CGFloat
|
|
let bodyHeight: CGFloat
|
|
let footerHeight: CGFloat
|
|
let actionButtonSize: CGFloat
|
|
let primaryActionButtonSize: CGFloat
|
|
let actionRailHeight: CGFloat
|
|
|
|
static let regular = ClipboardItemCardLayout(
|
|
width: 320,
|
|
height: 244,
|
|
inset: 16,
|
|
headerHeight: 56,
|
|
bodyHeight: 152,
|
|
footerHeight: 36,
|
|
actionButtonSize: 24,
|
|
primaryActionButtonSize: 30,
|
|
actionRailHeight: 34
|
|
)
|
|
|
|
static let compact = ClipboardItemCardLayout(
|
|
width: 264,
|
|
height: 220,
|
|
inset: 13,
|
|
headerHeight: 50,
|
|
bodyHeight: 138,
|
|
footerHeight: 32,
|
|
actionButtonSize: 22,
|
|
primaryActionButtonSize: 28,
|
|
actionRailHeight: 32
|
|
)
|
|
|
|
var isCompact: Bool {
|
|
self == Self.compact
|
|
}
|
|
}
|
|
|
|
private enum ClipboardCollectionVisuals {
|
|
private static let customPalette: [NSColor] = [
|
|
NSColor(calibratedRed: 0.98, green: 0.30, blue: 0.32, alpha: 1),
|
|
NSColor(calibratedRed: 0.96, green: 0.64, blue: 0.00, alpha: 1),
|
|
NSColor(calibratedRed: 0.04, green: 0.47, blue: 0.95, alpha: 1),
|
|
NSColor(calibratedRed: 0.18, green: 0.72, blue: 0.34, alpha: 1),
|
|
NSColor(calibratedRed: 0.55, green: 0.35, blue: 0.88, alpha: 1),
|
|
NSColor(calibratedRed: 0.93, green: 0.12, blue: 0.34, alpha: 1),
|
|
NSColor(calibratedRed: 0.10, green: 0.62, blue: 0.72, alpha: 1),
|
|
NSColor(calibratedRed: 0.74, green: 0.42, blue: 0.16, alpha: 1)
|
|
]
|
|
|
|
static func color(for mode: ClipboardSortMode) -> NSColor {
|
|
switch mode {
|
|
case .mostRecent: return .secondaryLabelColor
|
|
case .mostUsed: return NSColor(calibratedRed: 0.58, green: 0.42, blue: 0.92, alpha: 1)
|
|
case .text: return NSColor(calibratedRed: 0.96, green: 0.64, blue: 0.00, alpha: 1)
|
|
case .links: return NSColor(calibratedRed: 0.02, green: 0.47, blue: 0.98, alpha: 1)
|
|
case .images: return NSColor(calibratedRed: 1.00, green: 0.22, blue: 0.25, alpha: 1)
|
|
case .colors: return NSColor(calibratedRed: 0.00, green: 0.65, blue: 0.74, 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)
|
|
case .code: return NSColor(calibratedRed: 0.25, green: 0.38, blue: 0.78, alpha: 1)
|
|
case .videos: return NSColor(calibratedRed: 0.43, green: 0.32, blue: 0.94, alpha: 1)
|
|
}
|
|
}
|
|
|
|
static func color(forCollectionNamed name: String, overrideHex: String? = nil) -> NSColor {
|
|
if let color = color(fromHex: overrideHex) {
|
|
return color
|
|
}
|
|
return defaultColor(forCollectionNamed: name)
|
|
}
|
|
|
|
static func defaultColor(forCollectionNamed name: String) -> NSColor {
|
|
switch name {
|
|
case "Useful Links":
|
|
return customPalette[0]
|
|
case "Important Notes":
|
|
return customPalette[1]
|
|
case "Code Snippets":
|
|
return customPalette[2]
|
|
case "Read Later":
|
|
return customPalette[3]
|
|
default:
|
|
return customPalette[stablePaletteIndex(for: name)]
|
|
}
|
|
}
|
|
|
|
static func defaultColorHex(forCollectionNamed name: String) -> String {
|
|
hexString(for: defaultColor(forCollectionNamed: name))
|
|
}
|
|
|
|
static func hexString(for color: NSColor) -> String {
|
|
let rgb = color.usingColorSpace(.deviceRGB) ?? color
|
|
let red = Int((rgb.redComponent * 255).rounded())
|
|
let green = Int((rgb.greenComponent * 255).rounded())
|
|
let blue = Int((rgb.blueComponent * 255).rounded())
|
|
return String(format: "#%02X%02X%02X", red, green, blue)
|
|
}
|
|
|
|
private static func color(fromHex value: String?) -> NSColor? {
|
|
guard let value else { return nil }
|
|
var hex = value.clipboardTrimmed
|
|
if hex.hasPrefix("#") {
|
|
hex.removeFirst()
|
|
}
|
|
guard hex.count == 6,
|
|
let rawValue = Int(hex, radix: 16) else {
|
|
return nil
|
|
}
|
|
let red = CGFloat((rawValue >> 16) & 0xFF) / 255
|
|
let green = CGFloat((rawValue >> 8) & 0xFF) / 255
|
|
let blue = CGFloat(rawValue & 0xFF) / 255
|
|
return NSColor(deviceRed: red, green: green, blue: blue, alpha: 1)
|
|
}
|
|
|
|
private static func stablePaletteIndex(for name: String) -> Int {
|
|
var hash: UInt64 = 1_469_598_103_934_665_603
|
|
for scalar in name.lowercased().unicodeScalars {
|
|
hash ^= UInt64(scalar.value)
|
|
hash &*= 1_099_511_628_211
|
|
}
|
|
return Int(hash % UInt64(customPalette.count))
|
|
}
|
|
}
|
|
|
|
private struct CollectionCreationRequest {
|
|
let name: String
|
|
let colorHex: String
|
|
}
|
|
|
|
final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
|
private enum Metrics {
|
|
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
|
|
static let compactCardThreshold: CGFloat = 760
|
|
static let emptyStateMinimumWidth: CGFloat = 760
|
|
static let compactSearchWidth: CGFloat = 34
|
|
static let expandedSearchWidth: CGFloat = 300
|
|
}
|
|
|
|
private enum CardDensity: String {
|
|
case regular
|
|
case compact
|
|
|
|
static func fitting(width: CGFloat) -> CardDensity {
|
|
width > 0 && width < Metrics.compactCardThreshold ? .compact : .regular
|
|
}
|
|
|
|
var layout: ClipboardItemCardLayout {
|
|
switch self {
|
|
case .regular: return .regular
|
|
case .compact: return .compact
|
|
}
|
|
}
|
|
|
|
var cardSpacing: CGFloat {
|
|
switch self {
|
|
case .regular: return 16
|
|
case .compact: return 12
|
|
}
|
|
}
|
|
|
|
var cardStackInset: CGFloat {
|
|
switch self {
|
|
case .regular: return 10
|
|
case .compact: return 8
|
|
}
|
|
}
|
|
|
|
var railHeight: CGFloat {
|
|
layout.height + (cardStackInset * 2) + 2
|
|
}
|
|
|
|
var emptyStateMinimumWidth: CGFloat {
|
|
switch self {
|
|
case .regular: return Metrics.emptyStateMinimumWidth
|
|
case .compact: return 420
|
|
}
|
|
}
|
|
}
|
|
|
|
private enum Palette {
|
|
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
|
|
private let onPreview: () -> Void
|
|
|
|
private let searchField = NSSearchField()
|
|
private let collectionScrollView = HorizontalRailScrollView()
|
|
private let collectionStack = NSStackView()
|
|
private let addCollectionButton = NSButton()
|
|
private let stackChip = CollectionChipView(title: "Stack", color: .systemGreen, symbolName: "square.stack.3d.up.fill")
|
|
private let itemsStack = NSStackView()
|
|
private let scrollView = HorizontalRailScrollView()
|
|
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 cardDensity: CardDensity = .regular
|
|
private var scrollViewHeightConstraint: NSLayoutConstraint?
|
|
private var searchFieldWidthConstraint: NSLayoutConstraint?
|
|
private weak var shelfChromeStack: NSStackView?
|
|
private weak var headerStack: NSStackView?
|
|
private var cardViews: [ClipboardItemCardView] = []
|
|
private var collectionButtons: [ClipboardSortMode: CollectionChipView] = [:]
|
|
private var customCollectionButtons: [String: CollectionChipView] = [:]
|
|
private var collectionChipOrder: [CollectionChipView] = []
|
|
private var lastScrollContentWidth: CGFloat = 0
|
|
private var lastCollectionViewportWidth: CGFloat = 0
|
|
private var defersVisualReloads = false
|
|
private var pendingItemReload = false
|
|
private var pendingCollectionReload = false
|
|
#if DEBUG
|
|
private var collectionNameProviderForTesting: (() -> String?)?
|
|
#endif
|
|
|
|
init(
|
|
viewModel: ClipboardPanelViewModel,
|
|
onClose: @escaping () -> Void,
|
|
onSettings: @escaping () -> Void = {},
|
|
onPreview: @escaping () -> Void = {}
|
|
) {
|
|
self.viewModel = viewModel
|
|
self.onClose = onClose
|
|
self.onSettings = onSettings
|
|
self.onPreview = onPreview
|
|
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)
|
|
|
|
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(
|
|
string: "Search clips",
|
|
attributes: [
|
|
.foregroundColor: NSColor.tertiaryLabelColor
|
|
]
|
|
)
|
|
searchField.bezelStyle = .roundedBezel
|
|
searchField.backgroundColor = NSColor.controlBackgroundColor.withAlphaComponent(0.6)
|
|
searchField.focusRingType = .none
|
|
searchField.toolTip = "Search clipboard history. Supports app:Safari, type:image, date:2026-06-30, after:2026-06-01, and pinned:on."
|
|
searchField.setContentCompressionResistancePriority(.required, for: .horizontal)
|
|
searchField.setContentHuggingPriority(.required, for: .horizontal)
|
|
searchFieldWidthConstraint = searchField.widthAnchor.constraint(equalToConstant: Metrics.compactSearchWidth)
|
|
searchFieldWidthConstraint?.isActive = true
|
|
searchField.heightAnchor.constraint(equalToConstant: 30).isActive = true
|
|
updateSearchFieldPresentation()
|
|
|
|
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
|
|
configureAddCollectionButton()
|
|
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)
|
|
|
|
actionGroup.setContentHuggingPriority(.required, for: .horizontal)
|
|
actionGroup.setContentCompressionResistancePriority(.required, for: .horizontal)
|
|
|
|
let shelfChrome = row([
|
|
searchField,
|
|
collectionScrollView,
|
|
actionGroup
|
|
])
|
|
shelfChrome.spacing = 10
|
|
shelfChrome.distribution = .fill
|
|
shelfChrome.setHuggingPriority(.defaultHigh, for: .vertical)
|
|
shelfChromeStack = shelfChrome
|
|
|
|
itemsStack.orientation = .horizontal
|
|
itemsStack.alignment = .top
|
|
applyCardDensity()
|
|
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)
|
|
scrollViewHeightConstraint = scrollView.heightAnchor.constraint(equalToConstant: cardDensity.railHeight)
|
|
scrollViewHeightConstraint?.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: [shelfChrome])
|
|
headerStack.orientation = .vertical
|
|
headerStack.alignment = .leading
|
|
headerStack.spacing = 0
|
|
headerStack.setContentCompressionResistancePriority(.required, for: .vertical)
|
|
self.headerStack = headerStack
|
|
|
|
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)),
|
|
shelfChrome.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()
|
|
}
|
|
viewModel.onStackChanged = { [weak self] in
|
|
self?.reloadItems()
|
|
self?.updateSelection()
|
|
self?.configureCollectionButtons()
|
|
self?.updateStatus(self?.viewModel.statusMessage ?? "")
|
|
}
|
|
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()
|
|
collectionChipOrder.removeAll()
|
|
for view in collectionStack.arrangedSubviews {
|
|
(view as? CollectionChipView)?.clearKeyboardFocus()
|
|
collectionStack.removeArrangedSubview(view)
|
|
view.removeFromSuperview()
|
|
}
|
|
|
|
for mode in ClipboardSortMode.allCases {
|
|
let chip = CollectionChipView(title: collectionTitle(for: mode), color: collectionColor(for: mode), symbolName: collectionSymbol(for: mode))
|
|
chip.toolTip = mode.title
|
|
chip.onPress = { [weak self] in
|
|
self?.viewModel.sortMode = mode
|
|
}
|
|
configureCollectionKeyboardNavigation(for: chip)
|
|
collectionButtons[mode] = chip
|
|
collectionChipOrder.append(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)
|
|
}
|
|
chip.onDropItem = { [weak self] itemID in
|
|
self?.viewModel.assignItem(withID: itemID, to: collectionName)
|
|
}
|
|
chip.onEdit = { [weak self] in
|
|
self?.editCollection(named: collectionName)
|
|
}
|
|
chip.onDelete = { [weak self] in
|
|
self?.deleteCollection(named: collectionName)
|
|
}
|
|
configureCollectionKeyboardNavigation(for: chip)
|
|
customCollectionButtons[collectionName] = chip
|
|
collectionChipOrder.append(chip)
|
|
collectionStack.addArrangedSubview(chip)
|
|
}
|
|
configureStackChip()
|
|
collectionStack.addArrangedSubview(addCollectionButton)
|
|
updateAddCollectionButtonState()
|
|
sizeCollectionDocument()
|
|
}
|
|
|
|
private func configureStackChip() {
|
|
stackChip.toolTip = "Queued clips"
|
|
stackChip.onPress = { [weak self] in
|
|
self?.viewModel.selectStack()
|
|
}
|
|
stackChip.onAddVisibleToStack = { [weak self] in
|
|
self?.viewModel.addVisibleItemsToStack()
|
|
}
|
|
stackChip.onPasteStackNext = { [weak self] in
|
|
self?.viewModel.pasteNextStackItem()
|
|
}
|
|
stackChip.onCopyStackNext = { [weak self] in
|
|
self?.viewModel.copyNextStackItem()
|
|
}
|
|
stackChip.onPasteStackText = { [weak self] in
|
|
self?.viewModel.pasteStackAsText()
|
|
}
|
|
stackChip.onCopyStackText = { [weak self] in
|
|
self?.viewModel.copyStackAsText()
|
|
}
|
|
stackChip.onClearStack = { [weak self] in
|
|
self?.viewModel.clearStack()
|
|
}
|
|
configureCollectionKeyboardNavigation(for: stackChip)
|
|
if viewModel.stackCount > 0 {
|
|
collectionChipOrder.append(stackChip)
|
|
collectionStack.addArrangedSubview(stackChip)
|
|
}
|
|
}
|
|
|
|
private func configureCollectionKeyboardNavigation(for chip: CollectionChipView) {
|
|
chip.onStartSearch = { [weak self] text in
|
|
self?.startSearchFromShelf(text)
|
|
}
|
|
chip.onMoveFocus = { [weak self, weak chip] delta in
|
|
self?.moveCollectionFocus(from: chip, delta: delta)
|
|
}
|
|
chip.onSelectFirst = { [weak self] in
|
|
self?.selectCollectionChip(at: 0)
|
|
}
|
|
chip.onSelectLast = { [weak self] in
|
|
guard let self else { return }
|
|
self.selectCollectionChip(at: self.collectionChipOrder.count - 1)
|
|
}
|
|
}
|
|
|
|
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 = "New collection"
|
|
addCollectionButton.setAccessibilityLabel("New collection")
|
|
addCollectionButton.target = self
|
|
addCollectionButton.action = #selector(createCollectionFromToolbar)
|
|
addCollectionButton.translatesAutoresizingMaskIntoConstraints = false
|
|
addCollectionButton.widthAnchor.constraint(equalToConstant: 30).isActive = true
|
|
addCollectionButton.heightAnchor.constraint(equalToConstant: 26).isActive = true
|
|
}
|
|
|
|
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 .colors: return "Colors"
|
|
case .audio: return "Audio"
|
|
case .files: return "Files"
|
|
case .pinned: return "Pinned"
|
|
case .code: return "Code"
|
|
case .videos: return "Videos"
|
|
}
|
|
}
|
|
|
|
private func collectionSymbol(for mode: ClipboardSortMode) -> String {
|
|
switch mode {
|
|
case .mostRecent: return "doc.on.clipboard"
|
|
case .mostUsed: return "chart.bar.fill"
|
|
case .text: return "text.alignleft"
|
|
case .links: return "link"
|
|
case .images: return "photo"
|
|
case .colors: return "paintpalette"
|
|
case .audio: return "music.note"
|
|
case .files: return "doc.fill"
|
|
case .pinned: return "pin.fill"
|
|
case .code: return "chevron.left.forwardslash.chevron.right"
|
|
case .videos: return "film"
|
|
}
|
|
}
|
|
|
|
private func collectionColor(for mode: ClipboardSortMode) -> NSColor {
|
|
ClipboardCollectionVisuals.color(for: mode)
|
|
}
|
|
|
|
private func collectionColor(forCollectionNamed name: String) -> NSColor {
|
|
ClipboardCollectionVisuals.color(forCollectionNamed: name, overrideHex: viewModel.collectionColorHex(named: name))
|
|
}
|
|
|
|
private func applyCardDensity() {
|
|
itemsStack.spacing = cardDensity.cardSpacing
|
|
let inset = cardDensity.cardStackInset
|
|
itemsStack.edgeInsets = NSEdgeInsets(
|
|
top: inset,
|
|
left: inset,
|
|
bottom: inset,
|
|
right: inset
|
|
)
|
|
scrollViewHeightConstraint?.constant = cardDensity.railHeight
|
|
}
|
|
|
|
@discardableResult
|
|
private func updateCardDensityForCurrentWidth() -> Bool {
|
|
let targetDensity = CardDensity.fitting(width: bounds.width)
|
|
guard targetDensity != cardDensity else { return false }
|
|
|
|
cardDensity = targetDensity
|
|
applyCardDensity()
|
|
reloadItems()
|
|
return true
|
|
}
|
|
|
|
private func contentInsets() -> NSEdgeInsets {
|
|
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 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
|
|
let layout = cardDensity.layout
|
|
for (index, item) in items.enumerated() {
|
|
let card = ClipboardItemCardView(
|
|
item: item,
|
|
thumbnail: viewModel.thumbnail(for: item),
|
|
index: index,
|
|
layout: layout,
|
|
collectionNames: collectionNames,
|
|
isStacked: viewModel.isItemStacked(at: index),
|
|
stackCount: viewModel.stackCount,
|
|
canShowInClipboard: viewModel.canShowVisibleItemsInClipboard,
|
|
selectedCollectionName: viewModel.selectedCollectionName,
|
|
selectedCollectionColor: viewModel.selectedCollectionName.map { collectionColor(forCollectionNamed: $0) }
|
|
)
|
|
card.onSelect = { [weak self] selected in
|
|
self?.viewModel.selectItem(at: selected)
|
|
}
|
|
card.onStartSearch = { [weak self] text in
|
|
self?.startSearchFromShelf(text)
|
|
}
|
|
card.onMoveSelection = { [weak self] delta in
|
|
self?.moveSelectionFromFocusedCard(delta)
|
|
}
|
|
card.onPageSelection = { [weak self] direction in
|
|
guard let self else { return }
|
|
self.moveSelectionFromFocusedCard(direction * self.visibleCardPageStep)
|
|
}
|
|
card.onSelectFirst = { [weak self] in
|
|
self?.selectFirstCardFromFocusedCard()
|
|
}
|
|
card.onSelectLast = { [weak self] in
|
|
self?.selectLastCardFromFocusedCard()
|
|
}
|
|
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()
|
|
}
|
|
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()
|
|
}
|
|
card.onToggleStack = { [weak self] selected in
|
|
self?.viewModel.selectItem(at: selected)
|
|
self?.viewModel.toggleSelectedStackMembership()
|
|
}
|
|
card.onAddVisibleToStack = { [weak self] in
|
|
self?.viewModel.addVisibleItemsToStack()
|
|
}
|
|
card.onPasteStackNext = { [weak self] in
|
|
self?.viewModel.pasteNextStackItem()
|
|
}
|
|
card.onCopyStackNext = { [weak self] in
|
|
self?.viewModel.copyNextStackItem()
|
|
}
|
|
card.onPasteStackText = { [weak self] in
|
|
self?.viewModel.pasteStackAsText()
|
|
}
|
|
card.onCopyStackText = { [weak self] in
|
|
self?.viewModel.copyStackAsText()
|
|
}
|
|
card.onClearStack = { [weak self] in
|
|
self?.viewModel.clearStack()
|
|
}
|
|
card.onShowInClipboard = { [weak self] selected in
|
|
self?.showSelectedInClipboard(at: selected)
|
|
}
|
|
card.onRename = { [weak self] selected in
|
|
self?.renameClip(at: selected)
|
|
}
|
|
card.onEditText = { [weak self] selected in
|
|
self?.editText(at: selected)
|
|
}
|
|
card.onPreview = { [weak self] selected in
|
|
self?.viewModel.selectItem(at: selected)
|
|
self?.onPreview()
|
|
}
|
|
card.onPasteboardWriters = { [weak self] selected in
|
|
self?.viewModel.pasteboardWriters(forItemAt: selected) ?? []
|
|
}
|
|
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)
|
|
}
|
|
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()
|
|
}
|
|
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() {
|
|
var selectedCard: ClipboardItemCardView?
|
|
for (index, card) in cardViews.enumerated() {
|
|
let selected = index == viewModel.selectedIndex
|
|
card.setSelected(selected)
|
|
if selected {
|
|
selectedCard = card
|
|
}
|
|
}
|
|
|
|
if let selectedCard {
|
|
scrollCardIntoView(selectedCard)
|
|
}
|
|
updateAddCollectionButtonState()
|
|
}
|
|
|
|
private func updateAddCollectionButtonState() {
|
|
addCollectionButton.isEnabled = true
|
|
addCollectionButton.alphaValue = 1.0
|
|
}
|
|
|
|
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: -cardDensity.cardSpacing, dy: 0)
|
|
itemsStack.scrollToVisible(paddedFrame)
|
|
scrollView.reflectScrolledClipView(scrollView.contentView)
|
|
}
|
|
|
|
private func moveCollectionFocus(from chip: CollectionChipView?, delta: Int) {
|
|
guard let chip,
|
|
let currentIndex = collectionChipOrder.firstIndex(where: { $0 === chip }) else {
|
|
return
|
|
}
|
|
selectCollectionChip(at: currentIndex + delta)
|
|
}
|
|
|
|
private func selectCollectionChip(at index: Int) {
|
|
guard !collectionChipOrder.isEmpty else { return }
|
|
let targetIndex = max(0, min(collectionChipOrder.count - 1, index))
|
|
let title = collectionChipOrder[targetIndex].titleText
|
|
collectionChipOrder[targetIndex].onPress()
|
|
|
|
let focusedChip: CollectionChipView?
|
|
if let rebuiltChip = collectionChipOrder.first(where: { $0.titleText == title }) {
|
|
focusedChip = rebuiltChip
|
|
} else if collectionChipOrder.indices.contains(targetIndex) {
|
|
focusedChip = collectionChipOrder[targetIndex]
|
|
} else {
|
|
focusedChip = nil
|
|
}
|
|
guard let focusedChip else { return }
|
|
collectionChipOrder.forEach { $0.clearKeyboardFocus() }
|
|
window?.makeFirstResponder(focusedChip)
|
|
scrollCollectionChipIntoView(focusedChip)
|
|
}
|
|
|
|
private func scrollCollectionChipIntoView(_ chip: NSView) {
|
|
guard collectionScrollView.documentView === collectionStack else { return }
|
|
guard chip.window != nil else { return }
|
|
collectionScrollView.layoutSubtreeIfNeeded()
|
|
collectionStack.layoutSubtreeIfNeeded()
|
|
|
|
let frame = chip.convert(chip.bounds, to: collectionStack)
|
|
let paddedFrame = frame.insetBy(dx: -10, dy: 0)
|
|
collectionStack.scrollToVisible(paddedFrame)
|
|
collectionScrollView.reflectScrolledClipView(collectionScrollView.contentView)
|
|
}
|
|
|
|
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
|
|
}
|
|
if lower.hasPrefix("copied") || lower.hasPrefix("pasted") || lower.hasPrefix("updated") || lower.hasPrefix("renamed") || lower.hasPrefix("added") || lower.hasPrefix("created") || lower.hasPrefix("removed") || lower.hasPrefix("cleared") || lower.hasPrefix("ignored") || lower.hasPrefix("showing") {
|
|
return .action
|
|
}
|
|
if lower.hasPrefix("error") || lower.contains("failed") {
|
|
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
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
private func renameClip(at index: Int) {
|
|
viewModel.selectItem(at: index)
|
|
guard let currentTitle = viewModel.editableTitleForSelected() else { return }
|
|
|
|
let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 320, height: 24))
|
|
input.placeholderString = "Clip title"
|
|
input.stringValue = currentTitle
|
|
|
|
let alert = NSAlert()
|
|
alert.messageText = "Rename Clip"
|
|
alert.informativeText = "Give this clip a searchable title. Leave it blank to clear the title."
|
|
alert.accessoryView = input
|
|
alert.addButton(withTitle: "Save")
|
|
alert.addButton(withTitle: "Cancel")
|
|
alert.window.initialFirstResponder = input
|
|
|
|
guard alert.runModal() == .alertFirstButtonReturn else { return }
|
|
viewModel.updateSelectedTitle(to: input.stringValue)
|
|
}
|
|
|
|
private func emptyStateView() -> NSView {
|
|
let width = max(cardDensity.emptyStateMinimumWidth, scrollView.contentView.bounds.width)
|
|
let container = NSView(frame: NSRect(x: 0, y: 0, width: width, height: cardDensity.railHeight))
|
|
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 * cardDensity.layout.width)
|
|
+ max(0, count - 1) * cardDensity.cardSpacing
|
|
+ (cardDensity.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 {
|
|
cardDensity.layout.height + (cardDensity.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 let collectionName = viewModel.selectedCollectionName {
|
|
return (
|
|
"No clips in \(collectionName)",
|
|
"Drag clips here or use Collect to add them."
|
|
)
|
|
}
|
|
|
|
if viewModel.totalItemCount == 0 {
|
|
return (
|
|
"Copy something to start your history",
|
|
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 .colors:
|
|
return ("No colors yet", "Copied color swatches appear here.")
|
|
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 .code:
|
|
return ("No code snippets yet", "Copied code snippets appear here.")
|
|
case .videos:
|
|
return ("No videos yet", "Copied movie and video clips 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", "Use the Pin action on a card to keep important clips here.")
|
|
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))
|
|
}
|
|
stackChip.setSelected(viewModel.isStackFilterSelected)
|
|
stackChip.setCount(viewModel.stackCount)
|
|
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
|
|
}
|
|
|
|
var searchTextForKeyboardShortcut: String {
|
|
searchField.stringValue
|
|
}
|
|
|
|
override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
|
|
return true
|
|
}
|
|
|
|
override func layout() {
|
|
super.layout()
|
|
_ = updateCardDensityForCurrentWidth()
|
|
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(cardDensity.emptyStateMinimumWidth, 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)
|
|
}
|
|
|
|
private func updateSearchFieldPresentation() {
|
|
let hasSearchText = !searchField.stringValue.clipboardTrimmed.isEmpty
|
|
searchFieldWidthConstraint?.constant = hasSearchText
|
|
? Metrics.expandedSearchWidth
|
|
: Metrics.compactSearchWidth
|
|
searchField.placeholderAttributedString = NSAttributedString(
|
|
string: hasSearchText ? "Search clips" : "",
|
|
attributes: [
|
|
.foregroundColor: NSColor.tertiaryLabelColor
|
|
]
|
|
)
|
|
needsLayout = true
|
|
shelfChromeStack?.needsLayout = true
|
|
headerStack?.needsLayout = true
|
|
}
|
|
|
|
func focusSearchField() {
|
|
window?.makeFirstResponder(searchField)
|
|
updateSearchFieldPresentation()
|
|
}
|
|
|
|
@discardableResult
|
|
func clearSearchForKeyboardCancel() -> Bool {
|
|
guard !searchField.stringValue.clipboardTrimmed.isEmpty else { return false }
|
|
searchField.stringValue = ""
|
|
updateSearchText()
|
|
focusSearchField()
|
|
return true
|
|
}
|
|
|
|
private func startSearchFromShelf(_ text: String) {
|
|
guard !text.isEmpty else { return }
|
|
focusSearchField()
|
|
searchField.stringValue += text
|
|
updateSearchText()
|
|
}
|
|
|
|
func setBottomSafeInset(_ inset: CGFloat) {
|
|
bottomSafeInset = max(Metrics.minimumBottomInset, inset)
|
|
mainStack?.edgeInsets = contentInsets()
|
|
needsLayout = true
|
|
}
|
|
|
|
var visibleCardPageStep: Int {
|
|
let span = cardDensity.layout.width + cardDensity.cardSpacing
|
|
guard span > 0 else { return 1 }
|
|
return max(1, Int(floor(scrollView.contentView.bounds.width / span)))
|
|
}
|
|
|
|
func focusSelectedCardForKeyboardNavigation() {
|
|
focusSelectedCard()
|
|
}
|
|
|
|
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 debugCardAccessibilityValues: [String] {
|
|
cardViews.compactMap { $0.accessibilityValue() as? String }
|
|
}
|
|
|
|
var debugCardAccessibilityHelps: [String] {
|
|
cardViews.compactMap { $0.accessibilityHelp() }
|
|
}
|
|
|
|
var debugCardAcceptsFirstResponder: [Bool] {
|
|
cardViews.map(\.acceptsFirstResponder)
|
|
}
|
|
|
|
var debugKeyboardFocusedCardIndexes: [Int] {
|
|
cardViews.enumerated().compactMap { index, card in
|
|
card.debugIsKeyboardFocused ? index : nil
|
|
}
|
|
}
|
|
|
|
var debugCardBorderWidths: [CGFloat] {
|
|
cardViews.map(\.debugBorderWidth)
|
|
}
|
|
|
|
var debugCardPreviewSummaries: [String] {
|
|
cardViews.map(\.debugPreviewSummary)
|
|
}
|
|
|
|
var debugCardTextPreviewTitles: [String] {
|
|
cardViews.map(\.debugTextPreviewTitle)
|
|
}
|
|
|
|
var debugCardTextPreviewBodies: [String] {
|
|
cardViews.map(\.debugTextPreviewBody)
|
|
}
|
|
|
|
var debugCardPreviewStyles: [String] {
|
|
cardViews.map(\.debugPreviewStyle)
|
|
}
|
|
|
|
var debugCardDensity: String {
|
|
cardDensity.rawValue
|
|
}
|
|
|
|
var debugCardSizes: [NSSize] {
|
|
cardViews.map { $0.frame.size }
|
|
}
|
|
|
|
var debugCardHeaderBadgeSymbols: [String] {
|
|
cardViews.map(\.debugHeaderBadgeSymbol)
|
|
}
|
|
|
|
var debugCardHeaderBadgeTexts: [String] {
|
|
cardViews.map(\.debugHeaderBadgeText)
|
|
}
|
|
|
|
var debugFirstCardHeaderTitle: String {
|
|
cardViews.first?.debugHeaderTitle ?? ""
|
|
}
|
|
|
|
var debugFirstCardHeaderSubtitle: String {
|
|
cardViews.first?.debugHeaderSubtitle ?? ""
|
|
}
|
|
|
|
var debugFirstCardHeaderColorHex: String {
|
|
cardViews.first?.debugHeaderColorHex ?? ""
|
|
}
|
|
|
|
var debugFirstCardFooterDetailText: String {
|
|
cardViews.first?.debugFooterDetailText ?? ""
|
|
}
|
|
|
|
var debugFirstCardFooterSourceText: String {
|
|
cardViews.first?.debugFooterSourceText ?? ""
|
|
}
|
|
|
|
var debugFirstCardFooterSourceIsHidden: Bool {
|
|
cardViews.first?.debugFooterSourceIsHidden ?? true
|
|
}
|
|
|
|
var debugQuickPasteBadgeTexts: [String] {
|
|
cardViews.compactMap(\.debugQuickPasteBadgeText)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
var debugCardRailDocumentWidth: CGFloat {
|
|
scrollView.documentView?.frame.width ?? 0
|
|
}
|
|
|
|
var debugVisibleCardPageStep: Int {
|
|
visibleCardPageStep
|
|
}
|
|
|
|
var debugCardRailOverflowFadeVisibility: [Bool] {
|
|
scrollView.overflowFadeVisibility
|
|
}
|
|
|
|
func debugScrollCardRailVertically(deltaY: CGFloat) {
|
|
scrollView.scrollHorizontallyByVerticalDelta(deltaY)
|
|
}
|
|
|
|
var debugFirstCardMenuTitles: [String] {
|
|
cardViews.first?.debugMenuTitles ?? []
|
|
}
|
|
|
|
var debugFirstCardCollectionMenuTitles: [String] {
|
|
cardViews.first?.debugCollectionMenuTitles ?? []
|
|
}
|
|
|
|
var debugFirstCardCollectActionMenuTitles: [String] {
|
|
cardViews.first?.debugCollectActionMenuTitles ?? []
|
|
}
|
|
|
|
var debugFirstCardCaptureRuleMenuTitles: [String] {
|
|
cardViews.first?.debugCaptureRuleMenuTitles ?? []
|
|
}
|
|
|
|
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 debugFirstCardHeaderBadgeFrame: NSRect {
|
|
cardViews.first?.debugHeaderBadgeFrame ?? .zero
|
|
}
|
|
|
|
var debugStackCornerLabels: [String] {
|
|
cardViews.map(\.debugStackCornerLabel)
|
|
}
|
|
|
|
var debugStackCornerHiddenStates: [Bool] {
|
|
cardViews.map(\.debugStackCornerIsHidden)
|
|
}
|
|
|
|
var debugFirstCardStackCornerFrame: NSRect {
|
|
cardViews.first?.debugStackCornerFrame ?? .zero
|
|
}
|
|
|
|
func debugPressFirstCardStackCornerButton() {
|
|
cardViews.first?.debugPressStackCornerButton()
|
|
}
|
|
|
|
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 debugCollectionLeadingSymbols: [String] {
|
|
ClipboardSortMode.allCases.compactMap { collectionButtons[$0]?.debugLeadingSymbolName }
|
|
}
|
|
|
|
var debugSelectedCollectionTitle: String? {
|
|
if stackChip.isSelected {
|
|
return stackChip.titleText
|
|
}
|
|
if let custom = customCollectionButtons.first(where: { $0.value.isSelected }) {
|
|
return custom.value.titleText
|
|
}
|
|
return collectionButtons.first(where: { $0.value.isSelected })?.value.titleText
|
|
}
|
|
|
|
var debugCollectionCounts: [Int] {
|
|
updateCollectionButtons()
|
|
return ClipboardSortMode.allCases.compactMap { collectionButtons[$0]?.count }
|
|
}
|
|
|
|
var debugCollectionCountLabelHiddenStates: [Bool] {
|
|
updateCollectionButtons()
|
|
return ClipboardSortMode.allCases.compactMap { collectionButtons[$0]?.debugCountLabelIsHidden }
|
|
}
|
|
|
|
var debugCollectionChipAccessibilityLabels: [String] {
|
|
updateCollectionButtons()
|
|
return ClipboardSortMode.allCases.compactMap { collectionButtons[$0]?.accessibilityLabel() }
|
|
}
|
|
|
|
var debugCollectionChipAcceptsFirstResponder: [Bool] {
|
|
ClipboardSortMode.allCases.compactMap { collectionButtons[$0]?.acceptsFirstResponder }
|
|
}
|
|
|
|
var debugKeyboardFocusedCollectionTitles: [String] {
|
|
collectionChipOrder.compactMap { $0.debugIsKeyboardFocused ? $0.titleText : nil }
|
|
}
|
|
|
|
func debugFocusCollectionChip(_ mode: ClipboardSortMode) -> Bool {
|
|
guard let chip = collectionButtons[mode] else { return false }
|
|
return window?.makeFirstResponder(chip) ?? false
|
|
}
|
|
|
|
func debugFocusCard(at index: Int) -> Bool {
|
|
guard index >= 0, index < cardViews.count else { return false }
|
|
return window?.makeFirstResponder(cardViews[index]) ?? false
|
|
}
|
|
|
|
func debugPressFocusedResponderWithReturn() {
|
|
debugPressFocusedResponder(characters: "\r", keyCode: 36)
|
|
}
|
|
|
|
func debugPressFocusedResponderWithSpace() {
|
|
debugPressFocusedResponder(characters: " ", keyCode: 49)
|
|
}
|
|
|
|
func debugPressFocusedResponderKeyCode(_ keyCode: UInt16) {
|
|
debugPressFocusedResponder(characters: "", keyCode: keyCode)
|
|
}
|
|
|
|
func debugTypeFocusedResponder(_ characters: String, keyCode: UInt16) {
|
|
debugPressFocusedResponder(characters: characters, keyCode: keyCode)
|
|
}
|
|
|
|
func debugSetSearchFieldText(_ text: String) {
|
|
searchField.stringValue = text
|
|
updateSearchText()
|
|
}
|
|
|
|
private func debugPressFocusedResponder(characters: String, keyCode: UInt16) {
|
|
guard let window,
|
|
let event = NSEvent.keyEvent(
|
|
with: .keyDown,
|
|
location: .zero,
|
|
modifierFlags: [],
|
|
timestamp: 0,
|
|
windowNumber: window.windowNumber,
|
|
context: nil,
|
|
characters: characters,
|
|
charactersIgnoringModifiers: characters,
|
|
isARepeat: false,
|
|
keyCode: keyCode
|
|
) else {
|
|
return
|
|
}
|
|
window.firstResponder?.keyDown(with: event)
|
|
}
|
|
|
|
var debugCustomCollectionTitles: [String] {
|
|
viewModel.collectionNames
|
|
}
|
|
|
|
var debugCustomCollectionCounts: [Int] {
|
|
updateCollectionButtons()
|
|
return viewModel.collectionNames.compactMap { customCollectionButtons[$0]?.count }
|
|
}
|
|
|
|
var debugCustomCollectionCountLabelHiddenStates: [Bool] {
|
|
updateCollectionButtons()
|
|
return viewModel.collectionNames.compactMap { customCollectionButtons[$0]?.debugCountLabelIsHidden }
|
|
}
|
|
|
|
var debugCustomCollectionColorHexes: [String: String] {
|
|
Dictionary(uniqueKeysWithValues: viewModel.collectionNames.map { name in
|
|
(name, ClipboardCollectionVisuals.hexString(for: collectionColor(forCollectionNamed: name)))
|
|
})
|
|
}
|
|
|
|
var debugStackChipIsVisible: Bool {
|
|
collectionStack.arrangedSubviews.contains(stackChip)
|
|
}
|
|
|
|
var debugStackChipCount: Int {
|
|
updateCollectionButtons()
|
|
return stackChip.count
|
|
}
|
|
|
|
var debugStackChipIsSelected: Bool {
|
|
stackChip.isSelected
|
|
}
|
|
|
|
var debugStackChipMenuTitles: [String] {
|
|
stackChip.debugMenuTitles
|
|
}
|
|
|
|
func debugAddVisibleClipsToStackFromStackChip() {
|
|
stackChip.debugPerformMenuItem(titled: "Add Visible Clips to Stack")
|
|
}
|
|
|
|
func debugPressStackChip() {
|
|
stackChip.onPress()
|
|
}
|
|
|
|
var debugCollectionRailVisibleWidth: CGFloat {
|
|
collectionScrollView.contentView.bounds.width
|
|
}
|
|
|
|
var debugCollectionRailDocumentWidth: CGFloat {
|
|
collectionScrollView.documentView?.frame.width ?? 0
|
|
}
|
|
|
|
var debugCollectionRailVisibleRect: NSRect {
|
|
collectionScrollView.contentView.bounds
|
|
}
|
|
|
|
var debugCollectionRailOverflowFadeVisibility: [Bool] {
|
|
collectionScrollView.overflowFadeVisibility
|
|
}
|
|
|
|
func debugScrollCollectionRailVertically(deltaY: CGFloat) {
|
|
collectionScrollView.scrollHorizontallyByVerticalDelta(deltaY)
|
|
}
|
|
|
|
var debugEmptyStateText: (title: String, detail: String)? {
|
|
emptyStateText
|
|
}
|
|
|
|
var debugAddCollectionButtonIsEnabled: Bool {
|
|
addCollectionButton.isEnabled
|
|
}
|
|
|
|
var debugCollectionRailContainsAddButton: Bool {
|
|
collectionStack.arrangedSubviews.contains(addCollectionButton)
|
|
}
|
|
|
|
func debugSetCollectionNameProvider(_ provider: @escaping () -> String?) {
|
|
collectionNameProviderForTesting = provider
|
|
}
|
|
|
|
func debugPressAddCollectionButton() {
|
|
createCollectionFromToolbar()
|
|
}
|
|
|
|
func debugCustomCollectionMenuTitles(named collectionName: String) -> [String] {
|
|
customCollectionButtons[collectionName]?.debugMenuTitles ?? []
|
|
}
|
|
|
|
func debugEditCollection(named collectionName: String, to newName: String, colorHex: String) {
|
|
viewModel.updateCollection(named: collectionName, to: newName, colorHex: colorHex)
|
|
}
|
|
|
|
func debugDeleteCollection(named collectionName: String) {
|
|
viewModel.deleteCollection(named: collectionName)
|
|
}
|
|
|
|
func debugRenameFirstCard(to title: String) {
|
|
viewModel.selectItem(at: 0)
|
|
viewModel.updateSelectedTitle(to: title)
|
|
}
|
|
|
|
func debugShowFirstCardInClipboard() {
|
|
showSelectedInClipboard(at: 0)
|
|
}
|
|
|
|
var debugSearchFieldText: String {
|
|
searchField.stringValue
|
|
}
|
|
|
|
var debugSearchFieldWidth: CGFloat {
|
|
searchFieldWidthConstraint?.constant ?? searchField.frame.width
|
|
}
|
|
|
|
var debugSearchFieldPlaceholderText: String {
|
|
searchField.placeholderAttributedString?.string ?? searchField.placeholderString ?? ""
|
|
}
|
|
|
|
var debugShelfChromeRowCount: Int {
|
|
headerStack?.arrangedSubviews.count ?? 0
|
|
}
|
|
|
|
var debugShelfChromeContainsSearchAndCollections: Bool {
|
|
guard let arrangedSubviews = shelfChromeStack?.arrangedSubviews else { return false }
|
|
return arrangedSubviews.contains { $0 === searchField }
|
|
&& arrangedSubviews.contains { $0 === collectionScrollView }
|
|
}
|
|
|
|
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 }
|
|
}
|
|
|
|
#endif
|
|
|
|
func controlTextDidChange(_ notification: Notification) {
|
|
guard notification.object as? NSSearchField === searchField else { return }
|
|
updateSearchText()
|
|
}
|
|
|
|
func controlTextDidBeginEditing(_ notification: Notification) {
|
|
guard notification.object as? NSSearchField === searchField else { return }
|
|
updateSearchFieldPresentation()
|
|
}
|
|
|
|
func controlTextDidEndEditing(_ notification: Notification) {
|
|
guard notification.object as? NSSearchField === searchField else { return }
|
|
updateSearchFieldPresentation()
|
|
}
|
|
|
|
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 clearSearchForKeyboardCancel() {
|
|
return true
|
|
} else {
|
|
onClose()
|
|
}
|
|
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
|
|
updateSearchFieldPresentation()
|
|
}
|
|
|
|
private func moveSelectionFromFocusedCard(_ delta: Int) {
|
|
viewModel.moveSelection(delta)
|
|
focusSelectedCard()
|
|
}
|
|
|
|
private func selectFirstCardFromFocusedCard() {
|
|
viewModel.selectFirstItem()
|
|
focusSelectedCard()
|
|
}
|
|
|
|
private func selectLastCardFromFocusedCard() {
|
|
viewModel.selectLastItem()
|
|
focusSelectedCard()
|
|
}
|
|
|
|
private func focusSelectedCard() {
|
|
guard viewModel.selectedIndex >= 0,
|
|
viewModel.selectedIndex < cardViews.count else {
|
|
return
|
|
}
|
|
window?.makeFirstResponder(cardViews[viewModel.selectedIndex])
|
|
}
|
|
|
|
@objc private func closePanel() {
|
|
onClose()
|
|
}
|
|
|
|
@objc private func openSettings() {
|
|
onSettings()
|
|
}
|
|
|
|
func showSelectedInClipboard() {
|
|
showSelectedInClipboard(at: viewModel.selectedIndex)
|
|
}
|
|
|
|
private func showSelectedInClipboard(at index: Int) {
|
|
viewModel.selectItem(at: index)
|
|
viewModel.showSelectedInClipboard()
|
|
searchField.stringValue = viewModel.searchText
|
|
updateSearchFieldPresentation()
|
|
}
|
|
|
|
func createCollection() {
|
|
createCollectionFromToolbar()
|
|
}
|
|
|
|
@objc private func createCollectionFromToolbar() {
|
|
guard let request = requestCollectionCreation() else { return }
|
|
viewModel.createCollection(named: request.name, colorHex: request.colorHex, selectAfterCreate: true)
|
|
}
|
|
|
|
private func editCollection(named collectionName: String) {
|
|
guard let request = requestCollectionEdit(named: collectionName) else { return }
|
|
viewModel.updateCollection(named: collectionName, to: request.name, colorHex: request.colorHex)
|
|
}
|
|
|
|
private func deleteCollection(named collectionName: String) {
|
|
let count = viewModel.collectionCount(named: collectionName)
|
|
guard confirmDeleteCollection(named: collectionName, count: count) else { return }
|
|
viewModel.deleteCollection(named: collectionName)
|
|
}
|
|
|
|
private func requestCollectionCreation() -> CollectionCreationRequest? {
|
|
#if DEBUG
|
|
if let collectionNameProviderForTesting {
|
|
guard let name = ClipboardCollectionDefaults.normalizedName(collectionNameProviderForTesting()) else {
|
|
return nil
|
|
}
|
|
return CollectionCreationRequest(
|
|
name: name,
|
|
colorHex: ClipboardCollectionVisuals.defaultColorHex(forCollectionNamed: name)
|
|
)
|
|
}
|
|
#endif
|
|
|
|
return requestCollectionDetails(
|
|
title: "New Collection",
|
|
message: "Name this collection and choose its color.",
|
|
actionTitle: "Create",
|
|
initialName: "",
|
|
initialColor: ClipboardCollectionVisuals.defaultColor(forCollectionNamed: "New Collection")
|
|
)
|
|
}
|
|
|
|
private func requestCollectionEdit(named collectionName: String) -> CollectionCreationRequest? {
|
|
requestCollectionDetails(
|
|
title: "Edit Collection",
|
|
message: "Update this collection's name and color.",
|
|
actionTitle: "Save",
|
|
initialName: collectionName,
|
|
initialColor: collectionColor(forCollectionNamed: collectionName)
|
|
)
|
|
}
|
|
|
|
private func requestCollectionDetails(
|
|
title: String,
|
|
message: String,
|
|
actionTitle: String,
|
|
initialName: String,
|
|
initialColor: NSColor
|
|
) -> CollectionCreationRequest? {
|
|
let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24))
|
|
input.placeholderString = "Collection name"
|
|
input.stringValue = initialName
|
|
|
|
let colorWell = NSColorWell(frame: NSRect(x: 0, y: 0, width: 48, height: 28))
|
|
colorWell.color = initialColor
|
|
|
|
let colorLabel = NSTextField(labelWithString: "Color")
|
|
colorLabel.font = .systemFont(ofSize: NSFont.smallSystemFontSize, weight: .medium)
|
|
colorLabel.textColor = .secondaryLabelColor
|
|
|
|
let colorRow = NSStackView(views: [colorLabel, colorWell])
|
|
colorRow.orientation = .horizontal
|
|
colorRow.alignment = .centerY
|
|
colorRow.spacing = 10
|
|
|
|
let stack = NSStackView(views: [input, colorRow])
|
|
stack.orientation = .vertical
|
|
stack.alignment = .leading
|
|
stack.spacing = 10
|
|
stack.frame = NSRect(x: 0, y: 0, width: 260, height: 64)
|
|
|
|
let alert = NSAlert()
|
|
alert.messageText = title
|
|
alert.informativeText = message
|
|
alert.accessoryView = stack
|
|
alert.addButton(withTitle: actionTitle)
|
|
alert.addButton(withTitle: "Cancel")
|
|
alert.window.initialFirstResponder = input
|
|
|
|
guard alert.runModal() == .alertFirstButtonReturn,
|
|
let name = ClipboardCollectionDefaults.normalizedName(input.stringValue) else {
|
|
return nil
|
|
}
|
|
return CollectionCreationRequest(
|
|
name: name,
|
|
colorHex: ClipboardCollectionVisuals.hexString(for: colorWell.color)
|
|
)
|
|
}
|
|
|
|
private func confirmDeleteCollection(named collectionName: String, count: Int) -> Bool {
|
|
let alert = NSAlert()
|
|
alert.alertStyle = .warning
|
|
alert.messageText = "Delete \(collectionName)?"
|
|
let noun = count == 1 ? "clip" : "clips"
|
|
alert.informativeText = count > 0
|
|
? "This removes \(count) \(noun) in this collection from clipboard history."
|
|
: "This removes the empty collection."
|
|
alert.addButton(withTitle: "Delete")
|
|
alert.addButton(withTitle: "Cancel")
|
|
return alert.runModal() == .alertFirstButtonReturn
|
|
}
|
|
}
|
|
|
|
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,
|
|
.color
|
|
] + VideoPayload.pasteboardTypes
|
|
}
|
|
|
|
private func shelfSearchText(from event: NSEvent) -> String? {
|
|
let blockedModifiers: NSEvent.ModifierFlags = [.command, .control, .option, .function]
|
|
guard event.modifierFlags.intersection(blockedModifiers).isEmpty else { return nil }
|
|
guard let characters = event.characters, !characters.isEmpty else { return nil }
|
|
guard characters.rangeOfCharacter(from: .controlCharacters) == nil else { return nil }
|
|
return characters
|
|
}
|
|
|
|
private enum ClipboardCardDragContext {
|
|
static var itemID: UUID?
|
|
}
|
|
|
|
private final class HorizontalRailScrollView: NSScrollView {
|
|
private let leadingFade = RailEdgeFadeView(edge: .leading)
|
|
private let trailingFade = RailEdgeFadeView(edge: .trailing)
|
|
private let fadeWidth: CGFloat = 26
|
|
|
|
override init(frame frameRect: NSRect) {
|
|
super.init(frame: frameRect)
|
|
configureOverflowFades()
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
super.init(coder: coder)
|
|
configureOverflowFades()
|
|
}
|
|
|
|
override func scrollWheel(with event: NSEvent) {
|
|
let horizontalDelta = event.scrollingDeltaX
|
|
let verticalDelta = event.scrollingDeltaY
|
|
if abs(verticalDelta) > abs(horizontalDelta),
|
|
abs(verticalDelta) > 0,
|
|
canScrollHorizontally {
|
|
scrollHorizontally(by: -verticalDelta)
|
|
return
|
|
}
|
|
|
|
super.scrollWheel(with: event)
|
|
updateOverflowFades()
|
|
}
|
|
|
|
override func layout() {
|
|
super.layout()
|
|
updateOverflowFades()
|
|
}
|
|
|
|
override func reflectScrolledClipView(_ clipView: NSClipView) {
|
|
super.reflectScrolledClipView(clipView)
|
|
updateOverflowFades()
|
|
}
|
|
|
|
func scrollHorizontallyByVerticalDelta(_ deltaY: CGFloat) {
|
|
scrollHorizontally(by: -deltaY)
|
|
}
|
|
|
|
var overflowFadeVisibility: [Bool] {
|
|
updateOverflowFades()
|
|
return [!leadingFade.isHidden, !trailingFade.isHidden]
|
|
}
|
|
|
|
private func configureOverflowFades() {
|
|
leadingFade.translatesAutoresizingMaskIntoConstraints = false
|
|
trailingFade.translatesAutoresizingMaskIntoConstraints = false
|
|
leadingFade.isHidden = true
|
|
trailingFade.isHidden = true
|
|
addSubview(leadingFade)
|
|
addSubview(trailingFade)
|
|
NSLayoutConstraint.activate([
|
|
leadingFade.leadingAnchor.constraint(equalTo: leadingAnchor),
|
|
leadingFade.topAnchor.constraint(equalTo: topAnchor),
|
|
leadingFade.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
leadingFade.widthAnchor.constraint(equalToConstant: fadeWidth),
|
|
trailingFade.trailingAnchor.constraint(equalTo: trailingAnchor),
|
|
trailingFade.topAnchor.constraint(equalTo: topAnchor),
|
|
trailingFade.bottomAnchor.constraint(equalTo: bottomAnchor),
|
|
trailingFade.widthAnchor.constraint(equalToConstant: fadeWidth)
|
|
])
|
|
}
|
|
|
|
private var canScrollHorizontally: Bool {
|
|
maxHorizontalOffset > 0
|
|
}
|
|
|
|
private var maxHorizontalOffset: CGFloat {
|
|
guard let documentView else { return 0 }
|
|
return max(0, documentView.frame.width - contentView.bounds.width)
|
|
}
|
|
|
|
private func scrollHorizontally(by deltaX: CGFloat) {
|
|
let maxOffset = maxHorizontalOffset
|
|
guard maxOffset > 0 else { return }
|
|
let origin = contentView.bounds.origin
|
|
let targetX = min(max(origin.x + deltaX, 0), maxOffset)
|
|
guard targetX != origin.x else { return }
|
|
|
|
contentView.scroll(to: NSPoint(x: targetX, y: origin.y))
|
|
reflectScrolledClipView(contentView)
|
|
}
|
|
|
|
private func updateOverflowFades() {
|
|
let maxOffset = maxHorizontalOffset
|
|
guard maxOffset > 0 else {
|
|
leadingFade.isHidden = true
|
|
trailingFade.isHidden = true
|
|
return
|
|
}
|
|
|
|
let currentX = contentView.bounds.minX
|
|
leadingFade.isHidden = currentX <= 0.5
|
|
trailingFade.isHidden = currentX >= maxOffset - 0.5
|
|
}
|
|
}
|
|
|
|
private final class RailEdgeFadeView: NSView {
|
|
enum Edge {
|
|
case leading
|
|
case trailing
|
|
}
|
|
|
|
private let edge: Edge
|
|
|
|
init(edge: Edge) {
|
|
self.edge = edge
|
|
super.init(frame: .zero)
|
|
wantsLayer = true
|
|
setAccessibilityElement(false)
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
override func hitTest(_ point: NSPoint) -> NSView? {
|
|
nil
|
|
}
|
|
|
|
override func draw(_ dirtyRect: NSRect) {
|
|
let color = NSColor.windowBackgroundColor.withAlphaComponent(0.88)
|
|
let clear = color.withAlphaComponent(0)
|
|
let gradient = NSGradient(colors: edge == .leading ? [color, clear] : [clear, color])
|
|
gradient?.draw(in: bounds, angle: 0)
|
|
}
|
|
}
|
|
|
|
private final class CollectionChipView: NSView {
|
|
let titleText: String
|
|
private let color: NSColor
|
|
private let symbolName: String?
|
|
private let dot = NSView()
|
|
private let symbolView = NSImageView()
|
|
private let label: NSTextField
|
|
private let countLabel = NSTextField(labelWithString: "0")
|
|
private(set) var isSelected = false
|
|
private(set) var count = 0
|
|
private var isKeyboardFocused = false
|
|
private var isDropTargeted = false
|
|
var onPress: () -> Void = {}
|
|
var onStartSearch: (String) -> Void = { _ in }
|
|
var onMoveFocus: (Int) -> Void = { _ in }
|
|
var onSelectFirst: () -> Void = {}
|
|
var onSelectLast: () -> Void = {}
|
|
var onDropItem: ((UUID) -> Void)?
|
|
var onAddVisibleToStack: (() -> Void)?
|
|
var onPasteStackNext: (() -> Void)?
|
|
var onCopyStackNext: (() -> Void)?
|
|
var onPasteStackText: (() -> Void)?
|
|
var onCopyStackText: (() -> Void)?
|
|
var onClearStack: (() -> Void)?
|
|
var onEdit: (() -> Void)?
|
|
var onDelete: (() -> Void)?
|
|
|
|
init(title: String, color: NSColor, symbolName: String? = nil) {
|
|
self.titleText = title
|
|
self.color = color
|
|
self.symbolName = symbolName
|
|
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
|
|
focusRingType = .default
|
|
layer?.cornerRadius = 13
|
|
layer?.borderWidth = 0.6
|
|
layer?.borderColor = NSColor.clear.cgColor
|
|
setAccessibilityElement(true)
|
|
setAccessibilityRole(.button)
|
|
setAccessibilityHelp("Press Return or Space to show \(titleText). Use Left and Right to move between collections.")
|
|
heightAnchor.constraint(equalToConstant: 26).isActive = true
|
|
registerForDraggedTypes(ClipboardItemDragPasteboard.acceptedTypes)
|
|
|
|
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
|
|
|
|
let leadingIndicator: NSView
|
|
if let symbolName {
|
|
let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: titleText)
|
|
image?.isTemplate = true
|
|
symbolView.image = image
|
|
symbolView.imageScaling = .scaleProportionallyUpOrDown
|
|
symbolView.contentTintColor = color.withAlphaComponent(0.86)
|
|
symbolView.widthAnchor.constraint(equalToConstant: 13).isActive = true
|
|
symbolView.heightAnchor.constraint(equalToConstant: 13).isActive = true
|
|
leadingIndicator = symbolView
|
|
} else {
|
|
leadingIndicator = dot
|
|
}
|
|
|
|
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: [leadingIndicator, 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)
|
|
])
|
|
setSelected(false)
|
|
}
|
|
|
|
func setSelected(_ selected: Bool) {
|
|
isSelected = selected
|
|
label.textColor = selected ? .labelColor : .secondaryLabelColor
|
|
countLabel.textColor = selected ? .labelColor : .tertiaryLabelColor
|
|
countLabel.layer?.backgroundColor = (
|
|
selected
|
|
? color.withAlphaComponent(0.16)
|
|
: NSColor.labelColor.withAlphaComponent(0.07)
|
|
).cgColor
|
|
symbolView.contentTintColor = color.withAlphaComponent(selected ? 0.98 : 0.78)
|
|
updateCountLabelVisibility()
|
|
updateAccessibility()
|
|
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 {
|
|
layer?.backgroundColor = color.withAlphaComponent(0.13).cgColor
|
|
layer?.borderColor = color.withAlphaComponent(isKeyboardFocused ? 0.72 : 0.38).cgColor
|
|
} else if isKeyboardFocused {
|
|
layer?.backgroundColor = color.withAlphaComponent(0.09).cgColor
|
|
layer?.borderColor = color.withAlphaComponent(0.44).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)"
|
|
updateCountLabelVisibility()
|
|
updateAccessibility()
|
|
}
|
|
|
|
private func updateCountLabelVisibility() {
|
|
countLabel.isHidden = count == 0
|
|
}
|
|
|
|
private func updateAccessibility() {
|
|
let noun = count == 1 ? "clip" : "clips"
|
|
let selectedText = isSelected ? "selected, " : ""
|
|
setAccessibilityLabel("\(titleText), \(selectedText)\(count) \(noun)")
|
|
setAccessibilityValue("\(count)")
|
|
setAccessibilityHelp("Press Return or Space to show \(titleText). Use Left and Right to move between collections.")
|
|
toolTip = "\(titleText), \(selectedText)\(count) \(noun)"
|
|
}
|
|
|
|
override var acceptsFirstResponder: Bool {
|
|
true
|
|
}
|
|
|
|
override func becomeFirstResponder() -> Bool {
|
|
isKeyboardFocused = true
|
|
updateChrome()
|
|
return true
|
|
}
|
|
|
|
override func resignFirstResponder() -> Bool {
|
|
isKeyboardFocused = false
|
|
updateChrome()
|
|
return true
|
|
}
|
|
|
|
func clearKeyboardFocus() {
|
|
guard isKeyboardFocused else { return }
|
|
isKeyboardFocused = false
|
|
updateChrome()
|
|
}
|
|
|
|
override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
|
|
true
|
|
}
|
|
|
|
override func mouseDown(with event: NSEvent) {
|
|
onPress()
|
|
}
|
|
|
|
override func keyDown(with event: NSEvent) {
|
|
switch event.keyCode {
|
|
case 36, 49:
|
|
onPress()
|
|
case 115:
|
|
onSelectFirst()
|
|
case 119:
|
|
onSelectLast()
|
|
case 123:
|
|
onMoveFocus(-1)
|
|
case 124:
|
|
onMoveFocus(1)
|
|
default:
|
|
if let text = shelfSearchText(from: event) {
|
|
onStartSearch(text)
|
|
} else {
|
|
super.keyDown(with: event)
|
|
}
|
|
}
|
|
}
|
|
|
|
override func accessibilityPerformPress() -> Bool {
|
|
onPress()
|
|
return true
|
|
}
|
|
|
|
override func menu(for event: NSEvent) -> NSMenu? {
|
|
guard hasContextMenuActions else { return nil }
|
|
return contextMenu()
|
|
}
|
|
|
|
private var hasContextMenuActions: Bool {
|
|
onAddVisibleToStack != nil
|
|
|| onPasteStackNext != nil
|
|
|| onCopyStackNext != nil
|
|
|| onPasteStackText != nil
|
|
|| onCopyStackText != nil
|
|
|| onClearStack != nil
|
|
|| onEdit != nil
|
|
|| onDelete != nil
|
|
}
|
|
|
|
private func contextMenu() -> NSMenu {
|
|
let menu = NSMenu(title: titleText)
|
|
menu.autoenablesItems = false
|
|
if onAddVisibleToStack != nil {
|
|
let item = NSMenuItem(title: "Add Visible Clips to Stack", action: #selector(addVisibleToStackFromMenu), keyEquivalent: "")
|
|
item.target = self
|
|
menu.addItem(item)
|
|
}
|
|
if onPasteStackNext != nil {
|
|
let item = NSMenuItem(title: "Paste Stack Next", action: #selector(pasteStackNextFromMenu), keyEquivalent: "")
|
|
item.target = self
|
|
menu.addItem(item)
|
|
}
|
|
if onCopyStackNext != nil {
|
|
let item = NSMenuItem(title: "Copy Stack Next", action: #selector(copyStackNextFromMenu), keyEquivalent: "")
|
|
item.target = self
|
|
menu.addItem(item)
|
|
}
|
|
if onPasteStackText != nil {
|
|
let item = NSMenuItem(title: "Paste Stack as Text", action: #selector(pasteStackTextFromMenu), keyEquivalent: "")
|
|
item.target = self
|
|
menu.addItem(item)
|
|
}
|
|
if onCopyStackText != nil {
|
|
let item = NSMenuItem(title: "Copy Stack as Text", action: #selector(copyStackTextFromMenu), keyEquivalent: "")
|
|
item.target = self
|
|
menu.addItem(item)
|
|
}
|
|
if onClearStack != nil {
|
|
let item = NSMenuItem(title: "Clear Stack", action: #selector(clearStackFromMenu), keyEquivalent: "")
|
|
item.target = self
|
|
menu.addItem(item)
|
|
}
|
|
if (onEdit != nil || onDelete != nil) && !menu.items.isEmpty {
|
|
menu.addItem(NSMenuItem.separator())
|
|
}
|
|
if onEdit != nil {
|
|
let item = NSMenuItem(title: "Edit Collection...", action: #selector(editFromMenu), keyEquivalent: "")
|
|
item.target = self
|
|
menu.addItem(item)
|
|
}
|
|
if onDelete != nil {
|
|
if !menu.items.isEmpty {
|
|
menu.addItem(NSMenuItem.separator())
|
|
}
|
|
let item = NSMenuItem(title: "Delete Collection", action: #selector(deleteFromMenu), keyEquivalent: "")
|
|
item.target = self
|
|
menu.addItem(item)
|
|
}
|
|
return menu
|
|
}
|
|
|
|
@objc private func addVisibleToStackFromMenu() {
|
|
onAddVisibleToStack?()
|
|
}
|
|
|
|
@objc private func pasteStackNextFromMenu() {
|
|
onPasteStackNext?()
|
|
}
|
|
|
|
@objc private func copyStackNextFromMenu() {
|
|
onCopyStackNext?()
|
|
}
|
|
|
|
@objc private func pasteStackTextFromMenu() {
|
|
onPasteStackText?()
|
|
}
|
|
|
|
@objc private func copyStackTextFromMenu() {
|
|
onCopyStackText?()
|
|
}
|
|
|
|
@objc private func clearStackFromMenu() {
|
|
onClearStack?()
|
|
}
|
|
|
|
@objc private func editFromMenu() {
|
|
onEdit?()
|
|
}
|
|
|
|
@objc private func deleteFromMenu() {
|
|
onDelete?()
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
var debugIsKeyboardFocused: Bool {
|
|
isKeyboardFocused
|
|
}
|
|
|
|
var debugCountLabelIsHidden: Bool {
|
|
countLabel.isHidden
|
|
}
|
|
|
|
var debugLeadingSymbolName: String {
|
|
symbolName ?? ""
|
|
}
|
|
|
|
func debugDropItem(_ itemID: UUID) {
|
|
onDropItem?(itemID)
|
|
}
|
|
|
|
var debugMenuTitles: [String] {
|
|
contextMenu().items.map { $0.isSeparatorItem ? "-" : $0.title }
|
|
}
|
|
|
|
func debugPerformMenuItem(titled title: String) {
|
|
guard let item = contextMenu().items.first(where: { $0.title == title }) else { return }
|
|
_ = item.target?.perform(item.action, with: item)
|
|
}
|
|
#endif
|
|
}
|
|
|
|
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
|
|
)
|
|
}
|
|
}
|
|
|
|
private final class ClipboardItemCardView: NSView, NSDraggingSource {
|
|
private enum Metrics {
|
|
static let dragThreshold: CGFloat = 4
|
|
static let actionRailBadgeGap: CGFloat = 8
|
|
static let actionRailLeadingMargin: CGFloat = 10
|
|
}
|
|
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
|
|
}
|
|
|
|
private struct ActionRailButtonSpec {
|
|
let systemName: String
|
|
let toolTip: String
|
|
let action: Selector
|
|
let isPrimary: Bool
|
|
let overflowPriority: Int?
|
|
|
|
init(
|
|
_ systemName: String,
|
|
toolTip: String,
|
|
action: Selector,
|
|
isPrimary: Bool = false,
|
|
overflowPriority: Int? = nil
|
|
) {
|
|
self.systemName = systemName
|
|
self.toolTip = toolTip
|
|
self.action = action
|
|
self.isPrimary = isPrimary
|
|
self.overflowPriority = overflowPriority
|
|
}
|
|
}
|
|
|
|
var onSelect: (Int) -> Void = { _ in }
|
|
var onMoveSelection: (Int) -> Void = { _ in }
|
|
var onPageSelection: (Int) -> Void = { _ in }
|
|
var onSelectFirst: () -> Void = {}
|
|
var onSelectLast: () -> Void = {}
|
|
var onPaste: (Int) -> Void = { _ in }
|
|
var onCopy: (Int) -> Void = { _ in }
|
|
var onPastePlainText: (Int) -> Void = { _ in }
|
|
var onCopyPlainText: (Int) -> Void = { _ in }
|
|
var onToggleStack: (Int) -> Void = { _ in }
|
|
var onAddVisibleToStack: () -> Void = {}
|
|
var onPasteStackNext: () -> Void = {}
|
|
var onCopyStackNext: () -> Void = {}
|
|
var onPasteStackText: () -> Void = {}
|
|
var onCopyStackText: () -> Void = {}
|
|
var onClearStack: () -> Void = {}
|
|
var onShowInClipboard: (Int) -> Void = { _ in }
|
|
var onRename: (Int) -> Void = { _ in }
|
|
var onEditText: (Int) -> Void = { _ in }
|
|
var onPreview: (Int) -> Void = { _ in }
|
|
var onPasteboardWriters: (Int) -> [NSPasteboardWriting] = { _ in [] }
|
|
var onOpen: (Int) -> Void = { _ in }
|
|
var onReveal: (Int) -> Void = { _ in }
|
|
var onTogglePin: (Int) -> Void = { _ in }
|
|
var onAssignCollection: (Int, String?) -> Void = { _, _ in }
|
|
var onIgnoreSourceApp: (Int) -> Void = { _ in }
|
|
var onIgnoreKind: (Int) -> Void = { _ in }
|
|
var onDelete: (Int) -> Void = { _ in }
|
|
var onStartSearch: (String) -> Void = { _ in }
|
|
|
|
private let index: Int
|
|
private let itemID: UUID
|
|
private let layout: ClipboardItemCardLayout
|
|
private let itemKind: ClipboardItemKind
|
|
private let itemIsPinned: Bool
|
|
private let itemIsStacked: Bool
|
|
private let stackCount: Int
|
|
private let canShowInClipboard: Bool
|
|
private let itemSourceAppName: String?
|
|
private let itemSourceAppBundleID: String?
|
|
private let itemCollectionName: String?
|
|
private let activeCollectionName: String?
|
|
private let activeCollectionColor: NSColor?
|
|
private let collectionNames: [String]
|
|
private let contentView = NSView()
|
|
private let footerSourceLabel = NSTextField(labelWithString: "")
|
|
private let footerDetailLabel = NSTextField(labelWithString: "")
|
|
private let actionRail = NSStackView()
|
|
private let stackCornerButton = NSButton()
|
|
private var actionRailButtons: [NSButton] = []
|
|
private weak var headerBadgeView: NSView?
|
|
private weak var headerPinView: NSView?
|
|
private weak var quickPasteBadgeLabel: NSTextField?
|
|
private var isSelected = false
|
|
private var isHovered = false
|
|
private var isKeyboardFocused = false
|
|
private var mouseDownLocation: NSPoint?
|
|
private var trackingAreaRef: NSTrackingArea?
|
|
|
|
init(
|
|
item: ClipboardItem,
|
|
thumbnail: NSImage?,
|
|
index: Int,
|
|
layout: ClipboardItemCardLayout = .regular,
|
|
collectionNames: [String] = [],
|
|
isStacked: Bool = false,
|
|
stackCount: Int = 0,
|
|
canShowInClipboard: Bool = false,
|
|
selectedCollectionName: String? = nil,
|
|
selectedCollectionColor: NSColor? = nil
|
|
) {
|
|
let normalizedItemCollection = ClipboardCollectionDefaults.normalizedName(item.collectionName)
|
|
let normalizedSelectedCollection = ClipboardCollectionDefaults.normalizedName(selectedCollectionName)
|
|
let activeCollection = normalizedSelectedCollection == normalizedItemCollection ? normalizedSelectedCollection : nil
|
|
self.index = index
|
|
self.itemID = item.id
|
|
self.layout = layout
|
|
self.itemKind = item.kind
|
|
self.itemIsPinned = item.isPinned
|
|
self.itemIsStacked = isStacked
|
|
self.stackCount = stackCount
|
|
self.canShowInClipboard = canShowInClipboard
|
|
self.itemSourceAppName = Self.presentSourceText(item.sourceApp)
|
|
self.itemSourceAppBundleID = Self.presentSourceText(item.sourceAppBundleId)
|
|
self.itemCollectionName = normalizedItemCollection
|
|
self.activeCollectionName = activeCollection
|
|
self.activeCollectionColor = activeCollection.map { name in
|
|
selectedCollectionColor ?? ClipboardCollectionVisuals.color(forCollectionNamed: name)
|
|
}
|
|
self.collectionNames = collectionNames.compactMap { ClipboardCollectionDefaults.normalizedName($0) }
|
|
super.init(frame: .zero)
|
|
configure(item: item, thumbnail: thumbnail)
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
func setSelected(_ selected: Bool) {
|
|
isSelected = selected
|
|
contentView.layer?.borderWidth = isKeyboardFocused ? 2 : 1
|
|
if selected {
|
|
contentView.layer?.backgroundColor = Palette.selectedSurface
|
|
contentView.layer?.borderColor = isKeyboardFocused
|
|
? NSColor.controlAccentColor.withAlphaComponent(0.86).cgColor
|
|
: Palette.selectedBorder
|
|
} else if isKeyboardFocused {
|
|
contentView.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor
|
|
contentView.layer?.borderColor = NSColor.controlAccentColor.withAlphaComponent(0.58).cgColor
|
|
} 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
|
|
}
|
|
let emphasized = selected || isKeyboardFocused
|
|
layer?.shadowOpacity = emphasized ? 0.16 : (isHovered ? 0.12 : 0.08)
|
|
layer?.shadowRadius = emphasized ? 16 : 12
|
|
layer?.shadowOffset = NSSize(width: 0, height: emphasized ? 6 : 4)
|
|
layer?.transform = emphasized ? CATransform3DMakeTranslation(0, -4, 0) : CATransform3DIdentity
|
|
setAccessibilityValue(selected ? "Selected" : "Not selected")
|
|
updateActionRailVisibility()
|
|
}
|
|
|
|
override var acceptsFirstResponder: Bool {
|
|
true
|
|
}
|
|
|
|
override func becomeFirstResponder() -> Bool {
|
|
isKeyboardFocused = true
|
|
onSelect(index)
|
|
setSelected(isSelected)
|
|
return true
|
|
}
|
|
|
|
override func resignFirstResponder() -> Bool {
|
|
isKeyboardFocused = false
|
|
setSelected(isSelected)
|
|
return true
|
|
}
|
|
|
|
override func keyDown(with event: NSEvent) {
|
|
switch event.keyCode {
|
|
case 36, 76:
|
|
onPaste(index)
|
|
case 49:
|
|
if canPreview {
|
|
onPreview(index)
|
|
} else {
|
|
onPaste(index)
|
|
}
|
|
case 115:
|
|
onSelectFirst()
|
|
case 116:
|
|
onPageSelection(-1)
|
|
case 119:
|
|
onSelectLast()
|
|
case 121:
|
|
onPageSelection(1)
|
|
case 123:
|
|
onMoveSelection(-1)
|
|
case 124:
|
|
onMoveSelection(1)
|
|
default:
|
|
if let text = shelfSearchText(from: event) {
|
|
onStartSearch(text)
|
|
} else {
|
|
super.keyDown(with: event)
|
|
}
|
|
}
|
|
}
|
|
|
|
override func accessibilityPerformPress() -> Bool {
|
|
onPaste(index)
|
|
return true
|
|
}
|
|
|
|
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 {
|
|
mouseDownLocation = nil
|
|
onPaste(index)
|
|
} else {
|
|
mouseDownLocation = convert(event.locationInWindow, from: nil)
|
|
onSelect(index)
|
|
}
|
|
}
|
|
|
|
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)
|
|
let dragWriters = writers.isEmpty ? [internalDragPasteboardItem()] : writers
|
|
onSelect(index)
|
|
ClipboardCardDragContext.itemID = itemID
|
|
|
|
let preview = dragPreviewImage()
|
|
let dragItems = dragWriters.enumerated().map { offset, writer in
|
|
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
|
|
}
|
|
}
|
|
|
|
private func internalDragPasteboardItem() -> NSPasteboardItem {
|
|
let pasteboardItem = NSPasteboardItem()
|
|
pasteboardItem.setString(itemID.uuidString, forType: ClipboardItemDragPasteboard.itemIDType)
|
|
return pasteboardItem
|
|
}
|
|
|
|
func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation {
|
|
.copy
|
|
}
|
|
|
|
func ignoreModifierKeys(for session: NSDraggingSession) -> Bool {
|
|
true
|
|
}
|
|
|
|
func draggingSession(_ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) {
|
|
if ClipboardCardDragContext.itemID == itemID {
|
|
ClipboardCardDragContext.itemID = nil
|
|
}
|
|
}
|
|
|
|
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 = ""
|
|
private(set) var debugHeaderBadgeText = ""
|
|
private(set) var debugHeaderTitle = ""
|
|
private(set) var debugHeaderSubtitle = ""
|
|
private(set) var debugHeaderColorHex = ""
|
|
private(set) var debugTextPreviewTitle = ""
|
|
private(set) var debugTextPreviewBody = ""
|
|
|
|
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 }
|
|
}
|
|
|
|
var debugCollectActionMenuTitles: [String] {
|
|
collectionAssignmentMenu().items.map { $0.isSeparatorItem ? "-" : $0.title }
|
|
}
|
|
|
|
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 }
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
var debugHeaderBadgeFrame: NSRect {
|
|
guard let headerBadgeView else { return .zero }
|
|
return headerBadgeView.convert(headerBadgeView.bounds, to: self)
|
|
}
|
|
|
|
var debugStackCornerLabel: String {
|
|
stackCornerButton.toolTip ?? ""
|
|
}
|
|
|
|
var debugStackCornerIsHidden: Bool {
|
|
stackCornerButton.isHidden
|
|
}
|
|
|
|
var debugStackCornerFrame: NSRect {
|
|
stackCornerButton.convert(stackCornerButton.bounds, to: self)
|
|
}
|
|
|
|
func debugPressStackCornerButton() {
|
|
stackCornerButton.performClick(nil)
|
|
}
|
|
|
|
var debugQuickPasteBadgeText: String? {
|
|
quickPasteBadgeLabel?.stringValue
|
|
}
|
|
|
|
var debugIsKeyboardFocused: Bool {
|
|
isKeyboardFocused
|
|
}
|
|
|
|
var debugBorderWidth: CGFloat {
|
|
contentView.layer?.borderWidth ?? 0
|
|
}
|
|
|
|
var debugFooterDetailText: String {
|
|
footerDetailLabel.stringValue
|
|
}
|
|
|
|
var debugFooterSourceText: String {
|
|
footerSourceLabel.stringValue
|
|
}
|
|
|
|
var debugFooterSourceIsHidden: Bool {
|
|
footerSourceLabel.isHidden
|
|
}
|
|
|
|
var debugItemID: UUID {
|
|
itemID
|
|
}
|
|
|
|
static func debugHex(_ color: NSColor) -> String {
|
|
ClipboardCollectionVisuals.hexString(for: color)
|
|
}
|
|
#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)
|
|
if canPlainText {
|
|
addMenuItem("Paste Plain Text", action: #selector(pastePlainTextFromMenu), to: menu)
|
|
addMenuItem("Copy Plain Text", action: #selector(copyPlainTextFromMenu), to: menu)
|
|
}
|
|
if canShowInClipboard {
|
|
addMenuItem("Show in Clipboard", action: #selector(showInClipboardFromMenu), to: menu)
|
|
}
|
|
addMenuItem("Rename...", action: #selector(renameFromMenu), to: menu)
|
|
addMenuItem(itemIsStacked ? "Remove from Stack" : "Add to Stack", action: #selector(toggleStackFromMenu), to: menu)
|
|
addMenuItem("Add Visible Clips to Stack", action: #selector(addVisibleToStackFromMenu), to: menu)
|
|
if stackCount > 0 {
|
|
addMenuItem("Paste Stack Next", action: #selector(pasteStackNextFromMenu), to: menu)
|
|
addMenuItem("Copy Stack Next", action: #selector(copyStackNextFromMenu), to: menu)
|
|
addMenuItem("Paste Stack as Text", action: #selector(pasteStackTextFromMenu), to: menu)
|
|
addMenuItem("Copy Stack as Text", action: #selector(copyStackTextFromMenu), to: menu)
|
|
addMenuItem("Clear Stack", action: #selector(clearStackFromMenu), to: menu)
|
|
}
|
|
if canEditText {
|
|
addMenuItem("Edit", action: #selector(editTextFromMenu), to: menu)
|
|
}
|
|
if canPreview {
|
|
addMenuItem("Quick Look", action: #selector(previewFromMenu), to: menu)
|
|
}
|
|
addMenuItem(itemIsPinned ? "Unpin" : "Pin", action: #selector(togglePinFromMenu), to: menu)
|
|
addCollectionMenu(to: menu)
|
|
addCaptureRulesMenu(to: menu)
|
|
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 = collectionAssignmentMenu()
|
|
menu.addItem(parent)
|
|
menu.setSubmenu(submenu, for: parent)
|
|
}
|
|
|
|
private func collectionAssignmentMenu() -> NSMenu {
|
|
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)
|
|
}
|
|
|
|
return submenu
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
private static func sourceMonogram(from value: String?) -> String? {
|
|
guard let text = presentSourceText(value) else { return nil }
|
|
let words = text
|
|
.components(separatedBy: CharacterSet.alphanumerics.inverted)
|
|
.map(\.clipboardTrimmed)
|
|
.filter { !$0.isEmpty }
|
|
let initials = words.prefix(2).compactMap(\.first).map { String($0).uppercased() }.joined()
|
|
return initials.isEmpty ? nil : initials
|
|
}
|
|
|
|
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, .video:
|
|
return true
|
|
case .text, .richText, .unknown, .color, .code:
|
|
return false
|
|
}
|
|
}
|
|
|
|
private var canPreview: Bool {
|
|
switch itemKind {
|
|
case .text, .url, .image, .richText, .file, .pdf, .audio, .unknown, .color, .code, .video:
|
|
return true
|
|
}
|
|
}
|
|
|
|
private var canEditText: Bool {
|
|
itemKind == .text || itemKind == .code
|
|
}
|
|
|
|
private var canPlainText: Bool {
|
|
switch itemKind {
|
|
case .url, .image, .richText, .file, .pdf, .audio, .color, .video:
|
|
return true
|
|
case .text, .unknown, .code:
|
|
return false
|
|
}
|
|
}
|
|
|
|
private var canReveal: Bool {
|
|
switch itemKind {
|
|
case .file, .image, .pdf, .audio, .video:
|
|
return true
|
|
case .text, .richText, .url, .unknown, .color, .code:
|
|
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 = layout.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: layout.actionRailHeight).isActive = true
|
|
actionRail.setContentHuggingPriority(.required, for: .horizontal)
|
|
actionRail.setContentCompressionResistancePriority(.required, for: .horizontal)
|
|
|
|
let specs = fittedActionRailButtonSpecs(from: preferredActionRailButtonSpecs())
|
|
actionRailButtons = specs.map { spec in
|
|
cardActionButton(
|
|
spec.systemName,
|
|
toolTip: spec.toolTip,
|
|
action: spec.action,
|
|
isPrimary: spec.isPrimary
|
|
)
|
|
}
|
|
|
|
for button in actionRailButtons {
|
|
actionRail.addArrangedSubview(button)
|
|
}
|
|
let contentWidth = actionRailWidth(for: specs)
|
|
actionRail.widthAnchor.constraint(equalToConstant: contentWidth).isActive = true
|
|
updateActionRailVisibility()
|
|
}
|
|
|
|
private func preferredActionRailButtonSpecs() -> [ActionRailButtonSpec] {
|
|
let pinTitle = itemIsPinned ? "Unpin" : "Pin"
|
|
var specs: [ActionRailButtonSpec] = [
|
|
ActionRailButtonSpec("return", toolTip: "Paste", action: #selector(pasteFromMenu), isPrimary: true),
|
|
ActionRailButtonSpec("doc.on.doc", toolTip: "Copy", action: #selector(copyFromMenu))
|
|
]
|
|
|
|
if canPlainText {
|
|
specs.append(ActionRailButtonSpec("textformat", toolTip: "Paste Plain Text", action: #selector(pastePlainTextFromMenu)))
|
|
specs.append(ActionRailButtonSpec("doc.plaintext", toolTip: "Copy Plain Text", action: #selector(copyPlainTextFromMenu), overflowPriority: 20))
|
|
}
|
|
|
|
specs.append(ActionRailButtonSpec(itemIsPinned ? "pin.slash" : "pin", toolTip: pinTitle, action: #selector(togglePinFromMenu), overflowPriority: 30))
|
|
specs.append(ActionRailButtonSpec("plus", toolTip: "Collect", action: #selector(showCollectionMenuFromAction(_:))))
|
|
|
|
if canEditText {
|
|
specs.append(ActionRailButtonSpec("pencil", toolTip: "Edit", action: #selector(editTextFromMenu), overflowPriority: 50))
|
|
}
|
|
if canPreview {
|
|
specs.append(ActionRailButtonSpec("eye", toolTip: "Preview", action: #selector(previewFromMenu), overflowPriority: 60))
|
|
}
|
|
if canOpen {
|
|
specs.append(ActionRailButtonSpec("arrow.up.right.square", toolTip: "Open", action: #selector(openFromMenu), overflowPriority: 50))
|
|
}
|
|
if canReveal {
|
|
specs.append(ActionRailButtonSpec("magnifyingglass", toolTip: "Reveal", action: #selector(revealFromMenu), overflowPriority: 40))
|
|
}
|
|
specs.append(ActionRailButtonSpec("trash", toolTip: "Delete", action: #selector(deleteFromMenu), overflowPriority: 10))
|
|
return specs
|
|
}
|
|
|
|
private func fittedActionRailButtonSpecs(from specs: [ActionRailButtonSpec]) -> [ActionRailButtonSpec] {
|
|
let maximumWidth = maximumVisibleActionRailWidth
|
|
guard actionRailWidth(for: specs) > maximumWidth else { return specs }
|
|
|
|
let moreSpec = ActionRailButtonSpec("ellipsis.circle", toolTip: "More", action: #selector(showMoreActionsFromActionRail(_:)))
|
|
let overflowCandidates = specs.enumerated().compactMap { index, spec -> (index: Int, priority: Int)? in
|
|
guard let priority = spec.overflowPriority else { return nil }
|
|
return (index, priority)
|
|
}.sorted { lhs, rhs in
|
|
lhs.priority == rhs.priority ? lhs.index > rhs.index : lhs.priority < rhs.priority
|
|
}
|
|
|
|
var hiddenIndexes = Set<Int>()
|
|
for candidate in overflowCandidates {
|
|
hiddenIndexes.insert(candidate.index)
|
|
let visibleSpecs = specs.enumerated().compactMap { index, spec in
|
|
hiddenIndexes.contains(index) ? nil : spec
|
|
} + [moreSpec]
|
|
if actionRailWidth(for: visibleSpecs) <= maximumWidth {
|
|
return visibleSpecs
|
|
}
|
|
}
|
|
|
|
return specs.enumerated().compactMap { index, spec in
|
|
hiddenIndexes.contains(index) ? nil : spec
|
|
} + [moreSpec]
|
|
}
|
|
|
|
private func actionRailWidth(for specs: [ActionRailButtonSpec]) -> CGFloat {
|
|
guard !specs.isEmpty else { return 0 }
|
|
let buttonWidth = specs.reduce(CGFloat(0)) { width, spec in
|
|
width + (spec.isPrimary ? layout.primaryActionButtonSize : layout.actionButtonSize)
|
|
}
|
|
return buttonWidth
|
|
+ CGFloat(max(0, specs.count - 1)) * actionRail.spacing
|
|
+ actionRail.edgeInsets.left
|
|
+ actionRail.edgeInsets.right
|
|
}
|
|
|
|
private var maximumVisibleActionRailWidth: CGFloat {
|
|
layout.width
|
|
- Metrics.actionRailLeadingMargin
|
|
- Metrics.actionRailBadgeGap
|
|
- headerBadgeSize
|
|
}
|
|
|
|
private var headerBadgeSize: CGFloat {
|
|
layout.headerHeight
|
|
}
|
|
|
|
private var headerBadgeCornerRadius: CGFloat {
|
|
layout.isCompact ? 13 : 15
|
|
}
|
|
|
|
private var headerBadgeIconInset: CGFloat {
|
|
layout.isCompact ? 9 : 10
|
|
}
|
|
|
|
private var stackCornerButtonSize: CGFloat {
|
|
layout.isCompact ? 28 : 30
|
|
}
|
|
|
|
private var actionRailHeaderTopInset: CGFloat {
|
|
max(8, (layout.headerHeight - layout.actionRailHeight) / 2)
|
|
}
|
|
|
|
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 ? layout.primaryActionButtonSize : layout.actionButtonSize
|
|
button.layer?.cornerRadius = size / 2
|
|
if isPrimary {
|
|
button.layer?.backgroundColor = NSColor.controlAccentColor.cgColor
|
|
button.contentTintColor = .white
|
|
} else if toolTip == "Collect" {
|
|
button.layer?.backgroundColor = NSColor.systemGreen.withAlphaComponent(0.88).cgColor
|
|
button.contentTintColor = .white
|
|
} else {
|
|
button.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.08).cgColor
|
|
button.contentTintColor = 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 configureStackCornerButton() {
|
|
let toolTip = itemIsStacked ? "Remove from Stack" : "Add to Stack"
|
|
let image = NSImage(systemSymbolName: itemIsStacked ? "checkmark" : "plus", accessibilityDescription: toolTip)
|
|
image?.isTemplate = true
|
|
stackCornerButton.image = image
|
|
stackCornerButton.imagePosition = .imageOnly
|
|
stackCornerButton.imageScaling = .scaleProportionallyDown
|
|
stackCornerButton.isBordered = false
|
|
stackCornerButton.wantsLayer = true
|
|
stackCornerButton.layer?.cornerRadius = stackCornerButtonSize / 2
|
|
stackCornerButton.layer?.backgroundColor = NSColor.systemGreen.withAlphaComponent(0.94).cgColor
|
|
stackCornerButton.layer?.borderWidth = 1
|
|
stackCornerButton.layer?.borderColor = NSColor.white.withAlphaComponent(0.82).cgColor
|
|
stackCornerButton.layer?.shadowColor = NSColor.black.cgColor
|
|
stackCornerButton.layer?.shadowOpacity = 0.22
|
|
stackCornerButton.layer?.shadowRadius = 7
|
|
stackCornerButton.layer?.shadowOffset = NSSize(width: 0, height: 3)
|
|
stackCornerButton.contentTintColor = .white
|
|
stackCornerButton.toolTip = toolTip
|
|
stackCornerButton.setAccessibilityLabel(toolTip)
|
|
stackCornerButton.target = self
|
|
stackCornerButton.action = #selector(toggleStackFromCornerButton)
|
|
stackCornerButton.translatesAutoresizingMaskIntoConstraints = false
|
|
stackCornerButton.setContentHuggingPriority(.required, for: .horizontal)
|
|
stackCornerButton.setContentCompressionResistancePriority(.required, for: .horizontal)
|
|
}
|
|
|
|
private func updateActionRailVisibility() {
|
|
actionRail.isHidden = !isSelected
|
|
headerBadgeView?.isHidden = false
|
|
headerPinView?.isHidden = isSelected
|
|
footerDetailLabel.isHidden = false
|
|
stackCornerButton.isHidden = !(itemIsStacked || isSelected || isHovered || isKeyboardFocused)
|
|
stackCornerButton.alphaValue = itemIsStacked ? 1.0 : 0.94
|
|
for button in actionRailButtons {
|
|
button.alphaValue = 1.0
|
|
}
|
|
}
|
|
|
|
@objc private func pasteFromMenu() {
|
|
onPaste(index)
|
|
}
|
|
|
|
@objc private func copyFromMenu() {
|
|
onCopy(index)
|
|
}
|
|
|
|
@objc private func pastePlainTextFromMenu() {
|
|
onPastePlainText(index)
|
|
}
|
|
|
|
@objc private func copyPlainTextFromMenu() {
|
|
onCopyPlainText(index)
|
|
}
|
|
|
|
@objc private func showMoreActionsFromActionRail(_ sender: NSButton) {
|
|
contextMenu().popUp(
|
|
positioning: nil,
|
|
at: NSPoint(x: sender.bounds.minX, y: sender.bounds.minY - 4),
|
|
in: sender
|
|
)
|
|
}
|
|
|
|
@objc private func toggleStackFromMenu() {
|
|
onToggleStack(index)
|
|
}
|
|
|
|
@objc private func addVisibleToStackFromMenu() {
|
|
onAddVisibleToStack()
|
|
}
|
|
|
|
@objc private func toggleStackFromCornerButton() {
|
|
onSelect(index)
|
|
onToggleStack(index)
|
|
}
|
|
|
|
@objc private func pasteStackNextFromMenu() {
|
|
onPasteStackNext()
|
|
}
|
|
|
|
@objc private func copyStackNextFromMenu() {
|
|
onCopyStackNext()
|
|
}
|
|
|
|
@objc private func pasteStackTextFromMenu() {
|
|
onPasteStackText()
|
|
}
|
|
|
|
@objc private func copyStackTextFromMenu() {
|
|
onCopyStackText()
|
|
}
|
|
|
|
@objc private func clearStackFromMenu() {
|
|
onClearStack()
|
|
}
|
|
|
|
@objc private func showInClipboardFromMenu() {
|
|
onShowInClipboard(index)
|
|
}
|
|
|
|
@objc private func renameFromMenu() {
|
|
onRename(index)
|
|
}
|
|
|
|
@objc private func editTextFromMenu() {
|
|
onEditText(index)
|
|
}
|
|
|
|
@objc private func previewFromMenu() {
|
|
onPreview(index)
|
|
}
|
|
|
|
@objc private func openFromMenu() {
|
|
onOpen(index)
|
|
}
|
|
|
|
@objc private func revealFromMenu() {
|
|
onReveal(index)
|
|
}
|
|
|
|
@objc private func togglePinFromMenu() {
|
|
onTogglePin(index)
|
|
}
|
|
|
|
@objc private func showCollectionMenuFromAction(_ sender: NSButton) {
|
|
let menu = collectionAssignmentMenu()
|
|
menu.popUp(positioning: nil, at: NSPoint(x: 0, y: sender.bounds.maxY + 4), in: sender)
|
|
}
|
|
|
|
@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)
|
|
}
|
|
|
|
@objc private func ignoreSourceAppFromMenu() {
|
|
onIgnoreSourceApp(index)
|
|
}
|
|
|
|
@objc private func ignoreKindFromMenu() {
|
|
onIgnoreKind(index)
|
|
}
|
|
|
|
@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)
|
|
debugHeaderTitle = headerTitle(for: item)
|
|
debugHeaderSubtitle = headerSubtitle(for: item)
|
|
debugHeaderColorHex = Self.debugHex(headerColor(for: item))
|
|
#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(accessibilityHelpText())
|
|
widthAnchor.constraint(equalToConstant: layout.width).isActive = true
|
|
heightAnchor.constraint(equalToConstant: layout.height).isActive = true
|
|
focusRingType = .default
|
|
|
|
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)
|
|
])
|
|
configureStackCornerButton()
|
|
contentView.addSubview(stackCornerButton)
|
|
contentView.addSubview(actionRail)
|
|
let actionRailTrailingConstraint: NSLayoutConstraint
|
|
if let headerBadgeView {
|
|
actionRailTrailingConstraint = actionRail.trailingAnchor.constraint(
|
|
equalTo: headerBadgeView.leadingAnchor,
|
|
constant: -Metrics.actionRailBadgeGap
|
|
)
|
|
} else {
|
|
actionRailTrailingConstraint = actionRail.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12)
|
|
}
|
|
NSLayoutConstraint.activate([
|
|
stackCornerButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -12),
|
|
stackCornerButton.centerYAnchor.constraint(equalTo: footer.topAnchor),
|
|
stackCornerButton.widthAnchor.constraint(equalToConstant: stackCornerButtonSize),
|
|
stackCornerButton.heightAnchor.constraint(equalToConstant: stackCornerButtonSize),
|
|
actionRailTrailingConstraint,
|
|
actionRail.topAnchor.constraint(equalTo: contentView.topAnchor, constant: actionRailHeaderTopInset)
|
|
])
|
|
setSelected(false)
|
|
}
|
|
|
|
private func headerView(for item: ClipboardItem) -> NSView {
|
|
let header = NSView()
|
|
header.wantsLayer = true
|
|
header.layer?.backgroundColor = headerColor(for: item).cgColor
|
|
header.heightAnchor.constraint(equalToConstant: layout.headerHeight).isActive = true
|
|
|
|
let kind = NSTextField(labelWithString: headerTitle(for: item))
|
|
kind.font = .systemFont(ofSize: layout.isCompact ? 15 : 16, weight: .bold)
|
|
kind.textColor = .white
|
|
kind.lineBreakMode = .byTruncatingTail
|
|
kind.maximumNumberOfLines = 1
|
|
kind.toolTip = kind.stringValue
|
|
|
|
let source = NSTextField(labelWithString: headerSubtitle(for: item))
|
|
source.font = .systemFont(ofSize: layout.isCompact ? 10 : 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
|
|
|
|
var labelViews: [NSView] = []
|
|
if let quickPasteBadge = quickPasteBadge() {
|
|
labelViews.append(quickPasteBadge)
|
|
}
|
|
labelViews.append(titleAndSource)
|
|
let labelStack = NSStackView(views: labelViews)
|
|
labelStack.orientation = .horizontal
|
|
labelStack.alignment = .centerY
|
|
labelStack.distribution = .fill
|
|
labelStack.spacing = labelViews.count > 1 ? 9 : 1
|
|
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: layout.inset),
|
|
labelStack.centerYAnchor.constraint(equalTo: header.centerYAnchor),
|
|
labelStack.trailingAnchor.constraint(lessThanOrEqualTo: badge.leadingAnchor, constant: -12),
|
|
badge.trailingAnchor.constraint(equalTo: header.trailingAnchor),
|
|
badge.topAnchor.constraint(equalTo: header.topAnchor),
|
|
badge.widthAnchor.constraint(equalToConstant: headerBadgeSize),
|
|
badge.heightAnchor.constraint(equalToConstant: headerBadgeSize),
|
|
separator.leadingAnchor.constraint(equalTo: header.leadingAnchor, constant: layout.inset),
|
|
separator.trailingAnchor.constraint(equalTo: header.trailingAnchor, constant: -layout.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
|
|
}
|
|
|
|
private func headerTitle(for item: ClipboardItem) -> String {
|
|
activeCollectionName ?? kindLabel(for: item.kind)
|
|
}
|
|
|
|
private func headerSubtitle(for item: ClipboardItem) -> String {
|
|
let relativeDate = Self.relativeDateText(for: item.createdAt)
|
|
guard activeCollectionName != nil else { return relativeDate }
|
|
return "\(kindLabel(for: item.kind)) - \(relativeDate)"
|
|
}
|
|
|
|
private func headerColor(for item: ClipboardItem) -> NSColor {
|
|
activeCollectionColor ?? accentColor(for: item.kind)
|
|
}
|
|
|
|
private func quickPasteBadge() -> NSTextField? {
|
|
guard index < 9 else { return nil }
|
|
let label = NSTextField(labelWithString: "\(index + 1)")
|
|
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
|
|
}
|
|
|
|
private func bodyView(for item: ClipboardItem, thumbnail: NSImage?) -> NSView {
|
|
let body = NSView()
|
|
body.wantsLayer = true
|
|
body.layer?.backgroundColor = Palette.bodyBackground
|
|
body.heightAnchor.constraint(equalToConstant: layout.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:
|
|
if let thumbnail {
|
|
return mediaPreviewView(for: item, thumbnail: thumbnail)
|
|
}
|
|
if FilePayload.paths(from: item.payload).count > 1 {
|
|
return multiFilePreviewView(for: item)
|
|
}
|
|
return filePreviewView(for: item, thumbnail: thumbnail)
|
|
case .pdf:
|
|
if let thumbnail {
|
|
return mediaPreviewView(for: item, thumbnail: thumbnail)
|
|
}
|
|
return filePreviewView(for: item, thumbnail: thumbnail)
|
|
case .audio:
|
|
return audioPreviewView(for: item)
|
|
case .video:
|
|
if let thumbnail {
|
|
return videoMediaPreviewView(for: item, thumbnail: thumbnail)
|
|
}
|
|
return videoPreviewView(for: item)
|
|
case .color:
|
|
return colorPreviewView(for: item)
|
|
case .code:
|
|
return codePreviewView(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)
|
|
#if DEBUG
|
|
debugTextPreviewTitle = titleString
|
|
debugTextPreviewBody = bodyString ?? ""
|
|
#endif
|
|
let title = NSTextField(wrappingLabelWithString: titleString)
|
|
title.font = bodyString == nil
|
|
? .systemFont(ofSize: item.kind == .richText ? 15 : 14, weight: .regular)
|
|
: .systemFont(ofSize: 13, weight: .semibold)
|
|
title.textColor = .labelColor
|
|
title.maximumNumberOfLines = bodyString == nil ? 5 : 1
|
|
title.lineBreakMode = .byTruncatingTail
|
|
title.toolTip = title.stringValue
|
|
|
|
var textViews: [NSView] = [title]
|
|
if let bodyString {
|
|
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
|
|
textViews.append(detail)
|
|
}
|
|
|
|
let stack = NSStackView(views: textViews)
|
|
stack.orientation = .vertical
|
|
stack.alignment = .leading
|
|
stack.spacing = bodyString == nil ? 0 : 10
|
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
|
container.addSubview(stack)
|
|
for view in textViews {
|
|
view.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
|
|
}
|
|
NSLayoutConstraint.activate([
|
|
stack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: layout.inset),
|
|
stack.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -layout.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 hostText = webHostText(from: item.payload) ?? "Link"
|
|
let siteColor = linkVisualColor(for: hostText)
|
|
let hero = NSView()
|
|
hero.wantsLayer = true
|
|
hero.layer?.backgroundColor = siteColor.withAlphaComponent(0.16).cgColor
|
|
hero.translatesAutoresizingMaskIntoConstraints = false
|
|
hero.heightAnchor.constraint(equalToConstant: 82).isActive = true
|
|
|
|
let tile = NSView()
|
|
tile.wantsLayer = true
|
|
tile.layer?.cornerRadius = 18
|
|
tile.layer?.backgroundColor = siteColor.cgColor
|
|
tile.layer?.shadowColor = NSColor.black.cgColor
|
|
tile.layer?.shadowOpacity = 0.14
|
|
tile.layer?.shadowRadius = 9
|
|
tile.layer?.shadowOffset = NSSize(width: 0, height: 4)
|
|
tile.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
let monogram = NSTextField(labelWithString: linkMonogram(from: hostText))
|
|
monogram.font = .systemFont(ofSize: 20, weight: .heavy)
|
|
monogram.textColor = .white
|
|
monogram.alignment = .center
|
|
monogram.lineBreakMode = .byClipping
|
|
monogram.maximumNumberOfLines = 1
|
|
monogram.translatesAutoresizingMaskIntoConstraints = false
|
|
tile.addSubview(monogram)
|
|
|
|
let host = NSTextField(labelWithString: hostText)
|
|
host.font = .systemFont(ofSize: 12, weight: .semibold)
|
|
host.textColor = siteColor
|
|
host.alignment = .center
|
|
host.lineBreakMode = .byTruncatingTail
|
|
host.maximumNumberOfLines = 1
|
|
host.toolTip = host.stringValue
|
|
|
|
let heroStack = NSStackView(views: [tile, host])
|
|
heroStack.orientation = .vertical
|
|
heroStack.alignment = .centerX
|
|
heroStack.spacing = 6
|
|
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),
|
|
tile.widthAnchor.constraint(equalToConstant: 44),
|
|
tile.heightAnchor.constraint(equalToConstant: 44),
|
|
monogram.leadingAnchor.constraint(equalTo: tile.leadingAnchor, constant: 6),
|
|
monogram.trailingAnchor.constraint(equalTo: tile.trailingAnchor, constant: -6),
|
|
monogram.centerYAnchor.constraint(equalTo: tile.centerYAnchor),
|
|
host.widthAnchor.constraint(lessThanOrEqualTo: hero.widthAnchor, constant: -48),
|
|
textStack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: layout.inset),
|
|
textStack.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -layout.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: layout.inset),
|
|
textStack.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -layout.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: layout.inset),
|
|
row.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -layout.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 multiFilePreviewView(for item: ClipboardItem) -> NSView {
|
|
let container = NSView()
|
|
container.wantsLayer = true
|
|
container.layer?.backgroundColor = accentColor(for: item.kind).withAlphaComponent(0.08).cgColor
|
|
container.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
let urls = FilePayload.urls(from: item.payload)
|
|
let displayURLs = Array(urls.prefix(3))
|
|
let tileViews = displayURLs.enumerated().map { offset, url in
|
|
miniFileTile(for: url, index: offset, hiddenCount: max(0, urls.count - displayURLs.count))
|
|
}
|
|
let tileStack = NSStackView(views: tileViews)
|
|
tileStack.orientation = .horizontal
|
|
tileStack.alignment = .centerY
|
|
tileStack.spacing = layout.isCompact ? 6 : 8
|
|
tileStack.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
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 location = NSTextField(labelWithString: previewText(for: item))
|
|
location.font = .systemFont(ofSize: 12)
|
|
location.textColor = .secondaryLabelColor
|
|
location.maximumNumberOfLines = 1
|
|
location.lineBreakMode = .byTruncatingMiddle
|
|
location.toolTip = location.stringValue
|
|
|
|
let labels = NSStackView(views: [title, location])
|
|
labels.orientation = .vertical
|
|
labels.alignment = .leading
|
|
labels.spacing = 3
|
|
labels.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
container.addSubview(tileStack)
|
|
container.addSubview(labels)
|
|
NSLayoutConstraint.activate([
|
|
tileStack.centerXAnchor.constraint(equalTo: container.centerXAnchor),
|
|
tileStack.topAnchor.constraint(equalTo: container.topAnchor, constant: layout.isCompact ? 14 : 16),
|
|
labels.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: layout.inset),
|
|
labels.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -layout.inset),
|
|
labels.topAnchor.constraint(equalTo: tileStack.bottomAnchor, constant: layout.isCompact ? 10 : 12),
|
|
labels.bottomAnchor.constraint(lessThanOrEqualTo: container.bottomAnchor, constant: -10),
|
|
title.widthAnchor.constraint(equalTo: labels.widthAnchor),
|
|
location.widthAnchor.constraint(equalTo: labels.widthAnchor)
|
|
])
|
|
return container
|
|
}
|
|
|
|
private func miniFileTile(for url: URL, index: Int, hiddenCount: Int) -> NSView {
|
|
let tile = NSView()
|
|
tile.wantsLayer = true
|
|
tile.layer?.cornerRadius = 10
|
|
tile.layer?.backgroundColor = NSColor.windowBackgroundColor.withAlphaComponent(0.84).cgColor
|
|
tile.layer?.borderWidth = 0.8
|
|
tile.layer?.borderColor = NSColor.separatorColor.withAlphaComponent(0.16).cgColor
|
|
tile.layer?.shadowColor = NSColor.black.cgColor
|
|
tile.layer?.shadowOpacity = 0.08
|
|
tile.layer?.shadowRadius = 5
|
|
tile.layer?.shadowOffset = NSSize(width: 0, height: 2)
|
|
tile.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
let iconName = url.hasDirectoryPath ? "folder.fill" : "doc.fill"
|
|
let icon = headerIcon(iconName, color: accentColor(for: .file))
|
|
icon.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
let extensionText = fileKindText(from: url.path, fallback: url.hasDirectoryPath ? "DIR" : "FILE")
|
|
let extensionPill = capsuleLabel(extensionText, color: accentColor(for: .file))
|
|
extensionPill.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
tile.addSubview(icon)
|
|
tile.addSubview(extensionPill)
|
|
|
|
var constraints = [
|
|
tile.widthAnchor.constraint(equalToConstant: layout.isCompact ? 48 : 54),
|
|
tile.heightAnchor.constraint(equalToConstant: layout.isCompact ? 58 : 64),
|
|
icon.centerXAnchor.constraint(equalTo: tile.centerXAnchor),
|
|
icon.centerYAnchor.constraint(equalTo: tile.centerYAnchor, constant: -7),
|
|
icon.widthAnchor.constraint(equalToConstant: layout.isCompact ? 23 : 26),
|
|
icon.heightAnchor.constraint(equalToConstant: layout.isCompact ? 26 : 29),
|
|
extensionPill.centerXAnchor.constraint(equalTo: tile.centerXAnchor),
|
|
extensionPill.bottomAnchor.constraint(equalTo: tile.bottomAnchor, constant: -7),
|
|
extensionPill.widthAnchor.constraint(lessThanOrEqualTo: tile.widthAnchor, constant: -8)
|
|
]
|
|
|
|
if hiddenCount > 0, index == 2 {
|
|
let morePill = capsuleLabel("+\(hiddenCount)", color: NSColor.black.withAlphaComponent(0.52))
|
|
morePill.translatesAutoresizingMaskIntoConstraints = false
|
|
tile.addSubview(morePill)
|
|
constraints += [
|
|
morePill.trailingAnchor.constraint(equalTo: tile.trailingAnchor, constant: -5),
|
|
morePill.topAnchor.constraint(equalTo: tile.topAnchor, constant: 5),
|
|
morePill.widthAnchor.constraint(lessThanOrEqualTo: tile.widthAnchor, constant: -10)
|
|
]
|
|
}
|
|
|
|
NSLayoutConstraint.activate(constraints)
|
|
return tile
|
|
}
|
|
|
|
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: layout.inset),
|
|
labels.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -layout.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 videoPreviewView(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 frame = NSView()
|
|
frame.wantsLayer = true
|
|
frame.layer?.cornerRadius = 14
|
|
frame.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.78).cgColor
|
|
frame.layer?.borderWidth = 1
|
|
frame.layer?.borderColor = NSColor.white.withAlphaComponent(0.18).cgColor
|
|
frame.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
let film = headerIcon("film", color: .white)
|
|
film.translatesAutoresizingMaskIntoConstraints = false
|
|
let play = headerIcon("play.fill", color: .white)
|
|
play.translatesAutoresizingMaskIntoConstraints = false
|
|
frame.addSubview(film)
|
|
frame.addSubview(play)
|
|
|
|
let extensionPill = capsuleLabel(VideoPayload.kindText(from: item.payload), color: accentColor(for: item.kind))
|
|
extensionPill.translatesAutoresizingMaskIntoConstraints = false
|
|
frame.addSubview(extensionPill)
|
|
|
|
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 = .leading
|
|
labels.spacing = 3
|
|
labels.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
container.addSubview(frame)
|
|
container.addSubview(labels)
|
|
NSLayoutConstraint.activate([
|
|
frame.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: layout.inset),
|
|
frame.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -layout.inset),
|
|
frame.topAnchor.constraint(equalTo: container.topAnchor, constant: layout.isCompact ? 14 : 16),
|
|
frame.heightAnchor.constraint(equalToConstant: layout.isCompact ? 82 : 92),
|
|
film.centerXAnchor.constraint(equalTo: frame.centerXAnchor),
|
|
film.centerYAnchor.constraint(equalTo: frame.centerYAnchor),
|
|
film.widthAnchor.constraint(equalToConstant: 38),
|
|
film.heightAnchor.constraint(equalToConstant: 38),
|
|
play.centerXAnchor.constraint(equalTo: frame.centerXAnchor, constant: 1),
|
|
play.centerYAnchor.constraint(equalTo: frame.centerYAnchor),
|
|
play.widthAnchor.constraint(equalToConstant: 16),
|
|
play.heightAnchor.constraint(equalToConstant: 16),
|
|
extensionPill.trailingAnchor.constraint(equalTo: frame.trailingAnchor, constant: -10),
|
|
extensionPill.bottomAnchor.constraint(equalTo: frame.bottomAnchor, constant: -10),
|
|
labels.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: layout.inset),
|
|
labels.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -layout.inset),
|
|
labels.topAnchor.constraint(equalTo: frame.bottomAnchor, constant: 10),
|
|
labels.bottomAnchor.constraint(lessThanOrEqualTo: container.bottomAnchor, constant: -8),
|
|
title.widthAnchor.constraint(equalTo: labels.widthAnchor),
|
|
detail.widthAnchor.constraint(equalTo: labels.widthAnchor)
|
|
])
|
|
return container
|
|
}
|
|
|
|
private func colorPreviewView(for item: ClipboardItem) -> NSView {
|
|
let swatchColor = ColorPayload.color(from: item.payload) ?? accentColor(for: item.kind)
|
|
let textColor = ColorPayload.contrastingTextColor(for: swatchColor)
|
|
|
|
let container = NSView()
|
|
container.wantsLayer = true
|
|
container.layer?.backgroundColor = swatchColor.cgColor
|
|
container.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
let hex = NSTextField(labelWithString: ColorPayload.displayHex(from: item.payload))
|
|
hex.font = .monospacedDigitSystemFont(ofSize: layout.isCompact ? 19 : 22, weight: .bold)
|
|
hex.textColor = textColor
|
|
hex.alignment = .center
|
|
hex.maximumNumberOfLines = 1
|
|
hex.lineBreakMode = .byTruncatingTail
|
|
hex.toolTip = hex.stringValue
|
|
|
|
let components = NSTextField(labelWithString: ColorPayload.componentSummary(from: item.payload))
|
|
components.font = .monospacedDigitSystemFont(ofSize: 11, weight: .semibold)
|
|
components.textColor = textColor.withAlphaComponent(0.72)
|
|
components.alignment = .center
|
|
components.maximumNumberOfLines = 1
|
|
components.lineBreakMode = .byTruncatingTail
|
|
components.toolTip = components.stringValue
|
|
|
|
let labels = NSStackView(views: [hex, components])
|
|
labels.orientation = .vertical
|
|
labels.alignment = .centerX
|
|
labels.spacing = 6
|
|
labels.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
let outline = NSView()
|
|
outline.wantsLayer = true
|
|
outline.layer?.cornerRadius = 16
|
|
outline.layer?.borderWidth = 1
|
|
outline.layer?.borderColor = textColor.withAlphaComponent(0.24).cgColor
|
|
outline.layer?.backgroundColor = NSColor.white.withAlphaComponent(textColor == .white ? 0.10 : 0.26).cgColor
|
|
outline.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
container.addSubview(outline)
|
|
outline.addSubview(labels)
|
|
NSLayoutConstraint.activate([
|
|
outline.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: layout.inset),
|
|
outline.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -layout.inset),
|
|
outline.topAnchor.constraint(equalTo: container.topAnchor, constant: layout.isCompact ? 24 : 28),
|
|
outline.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: layout.isCompact ? -24 : -28),
|
|
labels.leadingAnchor.constraint(equalTo: outline.leadingAnchor, constant: 12),
|
|
labels.trailingAnchor.constraint(equalTo: outline.trailingAnchor, constant: -12),
|
|
labels.centerYAnchor.constraint(equalTo: outline.centerYAnchor),
|
|
hex.widthAnchor.constraint(equalTo: labels.widthAnchor),
|
|
components.widthAnchor.constraint(equalTo: labels.widthAnchor)
|
|
])
|
|
return container
|
|
}
|
|
|
|
private func codePreviewView(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 editor = NSView()
|
|
editor.wantsLayer = true
|
|
editor.layer?.cornerRadius = 10
|
|
editor.layer?.backgroundColor = NSColor.textBackgroundColor.withAlphaComponent(0.88).cgColor
|
|
editor.layer?.borderWidth = 0.8
|
|
editor.layer?.borderColor = NSColor.separatorColor.withAlphaComponent(0.22).cgColor
|
|
editor.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
let language = capsuleLabel(CodeSnippetPayload.languageLabel(from: item.payload), color: accentColor(for: item.kind))
|
|
language.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
let lines = CodeSnippetPayload.previewLines(from: item.payload)
|
|
let lineRows = lines.isEmpty ? ["Code snippet"] : lines
|
|
let rowViews = lineRows.enumerated().map { offset, line in
|
|
codeLineRow(number: offset + 1, text: line)
|
|
}
|
|
let rows = NSStackView(views: rowViews)
|
|
rows.orientation = .vertical
|
|
rows.alignment = .leading
|
|
rows.spacing = 3
|
|
rows.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
editor.addSubview(rows)
|
|
container.addSubview(editor)
|
|
container.addSubview(language)
|
|
for row in rowViews {
|
|
row.widthAnchor.constraint(equalTo: rows.widthAnchor).isActive = true
|
|
}
|
|
|
|
NSLayoutConstraint.activate([
|
|
editor.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: layout.inset),
|
|
editor.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -layout.inset),
|
|
editor.topAnchor.constraint(equalTo: container.topAnchor, constant: layout.isCompact ? 13 : 16),
|
|
editor.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: layout.isCompact ? -13 : -16),
|
|
rows.leadingAnchor.constraint(equalTo: editor.leadingAnchor, constant: 12),
|
|
rows.trailingAnchor.constraint(equalTo: editor.trailingAnchor, constant: -12),
|
|
rows.topAnchor.constraint(equalTo: editor.topAnchor, constant: 13),
|
|
rows.bottomAnchor.constraint(lessThanOrEqualTo: editor.bottomAnchor, constant: -12),
|
|
language.trailingAnchor.constraint(equalTo: editor.trailingAnchor, constant: -10),
|
|
language.bottomAnchor.constraint(equalTo: editor.bottomAnchor, constant: -9)
|
|
])
|
|
return container
|
|
}
|
|
|
|
private func codeLineRow(number: Int, text: String) -> NSView {
|
|
let numberLabel = NSTextField(labelWithString: "\(number)")
|
|
numberLabel.font = .monospacedDigitSystemFont(ofSize: 11, weight: .regular)
|
|
numberLabel.textColor = .tertiaryLabelColor
|
|
numberLabel.alignment = .right
|
|
numberLabel.maximumNumberOfLines = 1
|
|
numberLabel.translatesAutoresizingMaskIntoConstraints = false
|
|
numberLabel.widthAnchor.constraint(equalToConstant: 20).isActive = true
|
|
|
|
let codeLabel = NSTextField(labelWithString: text)
|
|
codeLabel.font = .monospacedSystemFont(ofSize: layout.isCompact ? 11 : 12, weight: .regular)
|
|
codeLabel.textColor = .labelColor
|
|
codeLabel.maximumNumberOfLines = 1
|
|
codeLabel.lineBreakMode = .byTruncatingTail
|
|
codeLabel.toolTip = text
|
|
codeLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
|
|
|
let row = NSStackView(views: [numberLabel, codeLabel])
|
|
row.orientation = .horizontal
|
|
row.alignment = .firstBaseline
|
|
row.spacing = 8
|
|
row.translatesAutoresizingMaskIntoConstraints = false
|
|
codeLabel.widthAnchor.constraint(equalTo: row.widthAnchor, constant: -28).isActive = true
|
|
return row
|
|
}
|
|
|
|
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 videoMediaPreviewView(for item: ClipboardItem, thumbnail: NSImage) -> NSView {
|
|
let container = NSView()
|
|
container.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
let imageView = AspectFillImageView(image: thumbnail)
|
|
imageView.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
let playBadge = NSView()
|
|
playBadge.wantsLayer = true
|
|
playBadge.layer?.cornerRadius = 22
|
|
playBadge.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.48).cgColor
|
|
playBadge.layer?.borderWidth = 0.8
|
|
playBadge.layer?.borderColor = NSColor.white.withAlphaComponent(0.30).cgColor
|
|
playBadge.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
let play = headerIcon("play.fill", color: .white)
|
|
play.translatesAutoresizingMaskIntoConstraints = false
|
|
playBadge.addSubview(play)
|
|
|
|
let extensionPill = capsuleLabel(VideoPayload.kindText(from: item.payload), color: NSColor.black.withAlphaComponent(0.60))
|
|
extensionPill.translatesAutoresizingMaskIntoConstraints = false
|
|
|
|
container.addSubview(imageView)
|
|
container.addSubview(playBadge)
|
|
container.addSubview(extensionPill)
|
|
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),
|
|
playBadge.centerXAnchor.constraint(equalTo: container.centerXAnchor),
|
|
playBadge.centerYAnchor.constraint(equalTo: container.centerYAnchor),
|
|
playBadge.widthAnchor.constraint(equalToConstant: 44),
|
|
playBadge.heightAnchor.constraint(equalToConstant: 44),
|
|
play.centerXAnchor.constraint(equalTo: playBadge.centerXAnchor, constant: 1),
|
|
play.centerYAnchor.constraint(equalTo: playBadge.centerYAnchor),
|
|
play.widthAnchor.constraint(equalToConstant: 16),
|
|
play.heightAnchor.constraint(equalToConstant: 16),
|
|
extensionPill.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -12),
|
|
extensionPill.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 nil
|
|
}
|
|
|
|
let prefix = normalizedTitle + " "
|
|
if preview.hasPrefix(prefix) {
|
|
let remainder = String(preview.dropFirst(prefix.count)).clipboardTrimmed
|
|
if !remainder.isEmpty {
|
|
return remainder
|
|
}
|
|
return nil
|
|
}
|
|
|
|
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-site-preview" : "link-media-preview"
|
|
case .file:
|
|
if thumbnail != nil {
|
|
return "file-media-preview"
|
|
}
|
|
return FilePayload.paths(from: item.payload).count > 1 ? "multi-file-preview" : "file-preview"
|
|
case .pdf:
|
|
return thumbnail == nil ? "file-preview" : "file-media-preview"
|
|
case .audio:
|
|
return "audio-preview"
|
|
case .video:
|
|
return thumbnail == nil ? "video-preview" : "video-media-preview"
|
|
case .color:
|
|
return "color-preview"
|
|
case .code:
|
|
return "code-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: layout.footerHeight).isActive = true
|
|
|
|
let sourceText = footerSourceText(for: item)
|
|
footerSourceLabel.stringValue = sourceText ?? ""
|
|
footerSourceLabel.font = .systemFont(ofSize: NSFont.smallSystemFontSize, weight: .medium)
|
|
footerSourceLabel.textColor = .secondaryLabelColor
|
|
footerSourceLabel.lineBreakMode = .byTruncatingTail
|
|
footerSourceLabel.maximumNumberOfLines = 1
|
|
footerSourceLabel.toolTip = footerSourceLabel.stringValue
|
|
footerSourceLabel.isHidden = sourceText == nil
|
|
|
|
let detailText = detailMetricText(for: item)
|
|
if activeCollectionName == nil,
|
|
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([footerSourceLabel, footerDetailLabel])
|
|
stack.distribution = .fill
|
|
stack.alignment = .centerY
|
|
stack.translatesAutoresizingMaskIntoConstraints = false
|
|
footerSourceLabel.setContentHuggingPriority(.defaultLow, for: .horizontal)
|
|
footerSourceLabel.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: layout.inset),
|
|
divider.trailingAnchor.constraint(equalTo: footer.trailingAnchor, constant: -layout.inset),
|
|
divider.topAnchor.constraint(equalTo: footer.topAnchor),
|
|
divider.heightAnchor.constraint(equalToConstant: 1),
|
|
stack.leadingAnchor.constraint(equalTo: footer.leadingAnchor, constant: layout.inset),
|
|
stack.trailingAnchor.constraint(equalTo: footer.trailingAnchor, constant: -layout.inset),
|
|
stack.centerYAnchor.constraint(equalTo: footer.centerYAnchor)
|
|
])
|
|
return footer
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
private func iconBadge(for item: ClipboardItem) -> NSView {
|
|
let badge = NSView()
|
|
badge.wantsLayer = true
|
|
badge.layer?.cornerRadius = headerBadgeCornerRadius
|
|
badge.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.95).cgColor
|
|
badge.layer?.borderWidth = 1
|
|
badge.layer?.borderColor = NSColor.white.withAlphaComponent(0.52).cgColor
|
|
badge.translatesAutoresizingMaskIntoConstraints = false
|
|
if let bundleId = item.sourceAppBundleId,
|
|
let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleId) {
|
|
#if DEBUG
|
|
debugHeaderBadgeText = ""
|
|
#endif
|
|
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: headerBadgeIconInset),
|
|
icon.trailingAnchor.constraint(equalTo: badge.trailingAnchor, constant: -headerBadgeIconInset),
|
|
icon.topAnchor.constraint(equalTo: badge.topAnchor, constant: headerBadgeIconInset),
|
|
icon.bottomAnchor.constraint(equalTo: badge.bottomAnchor, constant: -headerBadgeIconInset)
|
|
])
|
|
} else if let monogram = Self.sourceMonogram(from: itemSourceAppName) {
|
|
#if DEBUG
|
|
debugHeaderBadgeText = monogram
|
|
#endif
|
|
let label = NSTextField(labelWithString: monogram)
|
|
label.font = .systemFont(ofSize: layout.isCompact ? 15 : 17, weight: .heavy)
|
|
label.textColor = accentColor(for: item.kind)
|
|
label.alignment = .center
|
|
label.lineBreakMode = .byClipping
|
|
label.maximumNumberOfLines = 1
|
|
label.setAccessibilityLabel(itemSourceAppName ?? monogram)
|
|
label.translatesAutoresizingMaskIntoConstraints = false
|
|
badge.addSubview(label)
|
|
NSLayoutConstraint.activate([
|
|
label.leadingAnchor.constraint(equalTo: badge.leadingAnchor, constant: headerBadgeIconInset),
|
|
label.trailingAnchor.constraint(equalTo: badge.trailingAnchor, constant: -headerBadgeIconInset),
|
|
label.centerYAnchor.constraint(equalTo: badge.centerYAnchor)
|
|
])
|
|
} else {
|
|
#if DEBUG
|
|
debugHeaderBadgeText = ""
|
|
#endif
|
|
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)
|
|
let symbolInset = headerBadgeIconInset + 2
|
|
NSLayoutConstraint.activate([
|
|
icon.leadingAnchor.constraint(equalTo: badge.leadingAnchor, constant: symbolInset),
|
|
icon.trailingAnchor.constraint(equalTo: badge.trailingAnchor, constant: -symbolInset),
|
|
icon.topAnchor.constraint(equalTo: badge.topAnchor, constant: symbolInset),
|
|
icon.bottomAnchor.constraint(equalTo: badge.bottomAnchor, constant: -symbolInset)
|
|
])
|
|
}
|
|
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 {
|
|
if let customTitle = item.customTitle?.clipboardTrimmed, !customTitle.isEmpty {
|
|
return customTitle
|
|
}
|
|
|
|
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 .video:
|
|
return videoTitle(for: item)
|
|
case .image:
|
|
return imageTitle(for: item)
|
|
case .color:
|
|
return ColorPayload.displayHex(from: item.payload)
|
|
case .code:
|
|
return CodeSnippetPayload.title(from: item.payload)
|
|
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 .video:
|
|
return "Video clip"
|
|
case .color:
|
|
return ColorPayload.componentSummary(from: item.payload)
|
|
case .code:
|
|
return CodeSnippetPayload.previewText(from: item.payload)
|
|
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 .video:
|
|
return VideoPayload.kindText(from: item.payload)
|
|
case .color:
|
|
return "Color"
|
|
case .code:
|
|
return CodeSnippetPayload.languageLabel(from: item.payload)
|
|
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 footerSourceText(for item: ClipboardItem) -> String? {
|
|
let source = item.sourceApp?.clipboardTrimmed
|
|
let usage = usageText(for: item.useCount)
|
|
if let source, !source.isEmpty, let usage {
|
|
return "\(source) - \(usage)"
|
|
}
|
|
if let source, !source.isEmpty {
|
|
return source
|
|
}
|
|
return usage
|
|
}
|
|
|
|
private func usageText(for useCount: Int) -> String? {
|
|
guard useCount > 0 else { return nil }
|
|
return useCount == 1 ? "Used once" : "Used \(useCount) times"
|
|
}
|
|
|
|
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
|
|
}
|
|
if let pageTitle = webPageTitleText(from: item.payload) {
|
|
return pageTitle
|
|
}
|
|
if let hostTitle = webHostTitleText(from: item.payload) {
|
|
return hostTitle
|
|
}
|
|
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 videoTitle(for item: ClipboardItem) -> String {
|
|
let display = firstUsefulLine(item.displayText)
|
|
if !display.isEmpty, !looksInternal(display), display.lowercased() != "video" {
|
|
return display
|
|
}
|
|
return "Video"
|
|
}
|
|
|
|
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 webPageTitleText(from value: String) -> String? {
|
|
guard let components = webComponents(from: value) else { return nil }
|
|
let segments = components.path
|
|
.split(separator: "/")
|
|
.map { String($0).removingPercentEncoding ?? String($0) }
|
|
.map { $0.clipboardTrimmed }
|
|
.filter { !$0.isEmpty }
|
|
|
|
for segment in segments.reversed() where !isLikelyVersionPathSegment(segment) {
|
|
if let title = humanReadableWebTitle(from: segment) {
|
|
return title
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func webHostTitleText(from value: String) -> String? {
|
|
guard let host = webHostText(from: value) else { return nil }
|
|
let labels = host
|
|
.split(separator: ".")
|
|
.map(String.init)
|
|
.filter { !$0.isEmpty }
|
|
guard let label = labels.first else { return nil }
|
|
return humanReadableWebTitle(from: label)
|
|
}
|
|
|
|
private func humanReadableWebTitle(from value: String) -> String? {
|
|
var slug = value.clipboardTrimmed
|
|
guard !slug.isEmpty else { return nil }
|
|
if let dotIndex = slug.lastIndex(of: ".") {
|
|
let extensionText = slug[slug.index(after: dotIndex)...]
|
|
let base = slug[..<dotIndex]
|
|
if !base.isEmpty, (1...6).contains(extensionText.count), extensionText.allSatisfy(\.isLetter) {
|
|
slug = String(base)
|
|
}
|
|
}
|
|
|
|
var words: [String] = []
|
|
var current = ""
|
|
for scalar in slug.unicodeScalars {
|
|
if CharacterSet.alphanumerics.contains(scalar) {
|
|
current.unicodeScalars.append(scalar)
|
|
} else if !current.isEmpty {
|
|
words.append(current)
|
|
current = ""
|
|
}
|
|
}
|
|
if !current.isEmpty {
|
|
words.append(current)
|
|
}
|
|
words = words.filter { !isLikelyVersionPathSegment($0) }
|
|
guard !words.isEmpty else { return nil }
|
|
|
|
let title = words
|
|
.map(formattedWebTitleWord)
|
|
.joined(separator: " ")
|
|
.clipboardTrimmed
|
|
guard !title.isEmpty else { return nil }
|
|
return String(title.prefix(70))
|
|
}
|
|
|
|
private func formattedWebTitleWord(_ word: String) -> String {
|
|
let lower = word.lowercased()
|
|
switch lower {
|
|
case "api": return "API"
|
|
case "appkit": return "AppKit"
|
|
case "ios": return "iOS"
|
|
case "macos": return "macOS"
|
|
case "nscolor": return "NSColor"
|
|
case "pdf": return "PDF"
|
|
case "ui": return "UI"
|
|
case "url": return "URL"
|
|
case "wwdc": return "WWDC"
|
|
case "xcode": return "Xcode"
|
|
default:
|
|
if word.count <= 5, word.allSatisfy(\.isUppercase) {
|
|
return word
|
|
}
|
|
return lower.prefix(1).uppercased() + lower.dropFirst()
|
|
}
|
|
}
|
|
|
|
private func isLikelyVersionPathSegment(_ value: String) -> Bool {
|
|
var text = value.clipboardTrimmed.lowercased()
|
|
if text.hasPrefix("v"), text.count > 1 {
|
|
text.removeFirst()
|
|
}
|
|
guard !text.isEmpty else { return false }
|
|
return text.allSatisfy { character in
|
|
character.isNumber || character == "." || character == "-" || character == "_"
|
|
}
|
|
}
|
|
|
|
private func linkMonogram(from host: String) -> String {
|
|
let words = host
|
|
.split { character in
|
|
character == "." || character == "-" || character == "_"
|
|
}
|
|
.map(String.init)
|
|
.filter { !$0.isEmpty && $0.lowercased() != "www" }
|
|
let letters = words
|
|
.prefix(2)
|
|
.compactMap { $0.first }
|
|
.map { String($0).uppercased() }
|
|
.joined()
|
|
if !letters.isEmpty {
|
|
return letters
|
|
}
|
|
return String(host.prefix(1)).uppercased()
|
|
}
|
|
|
|
private func linkVisualColor(for host: String) -> NSColor {
|
|
let palette = [
|
|
NSColor(calibratedRed: 0.02, green: 0.47, blue: 0.98, alpha: 1),
|
|
NSColor(calibratedRed: 0.10, green: 0.62, blue: 0.72, alpha: 1),
|
|
NSColor(calibratedRed: 0.18, green: 0.72, blue: 0.34, alpha: 1),
|
|
NSColor(calibratedRed: 0.55, green: 0.35, blue: 0.88, alpha: 1),
|
|
NSColor(calibratedRed: 0.93, green: 0.12, blue: 0.34, alpha: 1),
|
|
NSColor(calibratedRed: 0.96, green: 0.64, blue: 0.00, alpha: 1)
|
|
]
|
|
var hash: UInt64 = 1_469_598_103_934_665_603
|
|
for scalar in host.lowercased().unicodeScalars {
|
|
hash ^= UInt64(scalar.value)
|
|
hash &*= 1_099_511_628_211
|
|
}
|
|
return palette[Int(hash % UInt64(palette.count))]
|
|
}
|
|
|
|
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 .video:
|
|
return NSColor(calibratedRed: 0.43, green: 0.32, blue: 0.94, alpha: 1)
|
|
case .color:
|
|
return NSColor(calibratedRed: 0.00, green: 0.65, blue: 0.74, alpha: 1)
|
|
case .code:
|
|
return NSColor(calibratedRed: 0.25, green: 0.38, blue: 0.78, 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 .video: return "film"
|
|
case .color: return "paintpalette"
|
|
case .code: return "chevron.left.forwardslash.chevron.right"
|
|
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 accessibilityHelpText() -> String {
|
|
"Press Return to paste. Press Space for Quick Look."
|
|
}
|
|
|
|
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"
|
|
case .video: return "Video"
|
|
case .color: return "Color"
|
|
case .code: return "Code"
|
|
}
|
|
}
|
|
|
|
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 {
|
|
let minutes = seconds / 60
|
|
return "\(minutes) \(minutes == 1 ? "minute" : "minutes") ago"
|
|
}
|
|
if seconds < 86400 {
|
|
let hours = seconds / 3600
|
|
return "\(hours) \(hours == 1 ? "hour" : "hours") ago"
|
|
}
|
|
if seconds < 604800 {
|
|
let days = seconds / 86400
|
|
return "\(days) \(days == 1 ? "day" : "days") ago"
|
|
}
|
|
let weeks = seconds / 604800
|
|
return "\(weeks) \(weeks == 1 ? "week" : "weeks") ago"
|
|
}
|
|
}
|