WIP: compact shelf chrome

This commit is contained in:
Akshay Kolli
2026-07-01 14:39:36 -07:00
parent fdf9c6f326
commit d22c0c23ec
3 changed files with 89 additions and 42 deletions

View File

@@ -65,6 +65,7 @@ Use this checklist before a release or after changes to panel, pasteboard, setti
32. Confirm built-in collection chips use recognizable glyphs, while custom collection chips keep color-dot swatches. 32. Confirm built-in collection chips use recognizable glyphs, while custom collection chips keep color-dot swatches.
33. Confirm cards from known apps show app identity in the header tile, falling back to source initials when an app icon is unavailable. 33. Confirm cards from known apps show app identity in the header tile, falling back to source initials when an app icon is unavailable.
34. Confirm multi-file cards show a stacked file preview, while single-file cards keep the regular file layout. 34. Confirm multi-file cards show a stacked file preview, while single-file cards keep the regular file layout.
35. Confirm the shelf chrome uses one row with compact search, collection chips, and utility buttons; typing a search expands the search field without pushing cards out of view.
## Copy And Paste ## Copy And Paste

View File

@@ -141,6 +141,8 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
static let panelCornerRadius: CGFloat = 0 static let panelCornerRadius: CGFloat = 0
static let compactCardThreshold: CGFloat = 760 static let compactCardThreshold: CGFloat = 760
static let emptyStateMinimumWidth: CGFloat = 760 static let emptyStateMinimumWidth: CGFloat = 760
static let compactSearchWidth: CGFloat = 34
static let expandedSearchWidth: CGFloat = 300
} }
private enum CardDensity: String { private enum CardDensity: String {
@@ -236,6 +238,9 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
private var currentStatusTone: StatusTone = .ready private var currentStatusTone: StatusTone = .ready
private var cardDensity: CardDensity = .regular private var cardDensity: CardDensity = .regular
private var scrollViewHeightConstraint: NSLayoutConstraint? private var scrollViewHeightConstraint: NSLayoutConstraint?
private var searchFieldWidthConstraint: NSLayoutConstraint?
private weak var shelfChromeStack: NSStackView?
private weak var headerStack: NSStackView?
private var cardViews: [ClipboardItemCardView] = [] private var cardViews: [ClipboardItemCardView] = []
private var collectionButtons: [ClipboardSortMode: CollectionChipView] = [:] private var collectionButtons: [ClipboardSortMode: CollectionChipView] = [:]
private var customCollectionButtons: [String: CollectionChipView] = [:] private var customCollectionButtons: [String: CollectionChipView] = [:]
@@ -288,13 +293,6 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
layer?.shadowRadius = 20 layer?.shadowRadius = 20
layer?.shadowOffset = NSSize(width: 0, height: 10) layer?.shadowOffset = NSSize(width: 0, height: 10)
let toolbarIcon = NSImageView(image: appIconImage())
toolbarIcon.imageScaling = .scaleProportionallyUpOrDown
toolbarIcon.toolTip = "ClipBored"
toolbarIcon.widthAnchor.constraint(equalToConstant: 22).isActive = true
toolbarIcon.heightAnchor.constraint(equalToConstant: 22).isActive = true
searchField.placeholderString = "Search clips"
searchField.setAccessibilityLabel("Search clipboard history") searchField.setAccessibilityLabel("Search clipboard history")
searchField.delegate = self searchField.delegate = self
searchField.target = self searchField.target = self
@@ -312,10 +310,12 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
searchField.backgroundColor = NSColor.controlBackgroundColor.withAlphaComponent(0.6) searchField.backgroundColor = NSColor.controlBackgroundColor.withAlphaComponent(0.6)
searchField.focusRingType = .none 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.toolTip = "Search clipboard history. Supports app:Safari, type:image, date:2026-06-30, after:2026-06-01, and pinned:on."
searchField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) searchField.setContentCompressionResistancePriority(.required, for: .horizontal)
searchField.setContentHuggingPriority(.defaultLow, for: .horizontal) searchField.setContentHuggingPriority(.required, for: .horizontal)
searchField.widthAnchor.constraint(greaterThanOrEqualToConstant: 280).isActive = true searchFieldWidthConstraint = searchField.widthAnchor.constraint(equalToConstant: Metrics.compactSearchWidth)
searchField.widthAnchor.constraint(lessThanOrEqualToConstant: 620).isActive = true searchFieldWidthConstraint?.isActive = true
searchField.heightAnchor.constraint(equalToConstant: 30).isActive = true
updateSearchFieldPresentation()
collectionStack.orientation = .horizontal collectionStack.orientation = .horizontal
collectionStack.alignment = .centerY collectionStack.alignment = .centerY
@@ -351,29 +351,18 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
actionStrip.setContentCompressionResistancePriority(.required, for: .horizontal) actionStrip.setContentCompressionResistancePriority(.required, for: .horizontal)
let actionGroup = groupedToolbar(actionStrip) let actionGroup = groupedToolbar(actionStrip)
let topSpacer = NSView()
topSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
topSpacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
let topBar = row([
toolbarIcon,
searchField,
topSpacer,
actionGroup
])
topBar.distribution = .fill
topBar.setHuggingPriority(.defaultHigh, for: .vertical)
actionGroup.setContentHuggingPriority(.required, for: .horizontal) actionGroup.setContentHuggingPriority(.required, for: .horizontal)
actionGroup.setContentCompressionResistancePriority(.required, for: .horizontal) actionGroup.setContentCompressionResistancePriority(.required, for: .horizontal)
let filterSpacer = NSView() let shelfChrome = row([
filterSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal) searchField,
filterSpacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
let filterBar = row([
collectionScrollView, collectionScrollView,
filterSpacer actionGroup
]) ])
filterBar.spacing = 12 shelfChrome.spacing = 10
filterBar.distribution = .fill shelfChrome.distribution = .fill
shelfChrome.setHuggingPriority(.defaultHigh, for: .vertical)
shelfChromeStack = shelfChrome
itemsStack.orientation = .horizontal itemsStack.orientation = .horizontal
itemsStack.alignment = .top itemsStack.alignment = .top
@@ -427,11 +416,12 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
statusResultCountLabel.setAccessibilityLabel("Result count") statusResultCountLabel.setAccessibilityLabel("Result count")
statusLabel.setAccessibilityLabel("Status") statusLabel.setAccessibilityLabel("Status")
let headerStack = NSStackView(views: [topBar, filterBar]) let headerStack = NSStackView(views: [shelfChrome])
headerStack.orientation = .vertical headerStack.orientation = .vertical
headerStack.alignment = .leading headerStack.alignment = .leading
headerStack.spacing = 10 headerStack.spacing = 0
headerStack.setContentCompressionResistancePriority(.required, for: .vertical) headerStack.setContentCompressionResistancePriority(.required, for: .vertical)
self.headerStack = headerStack
let statusContainer = NSView() let statusContainer = NSView()
statusContainer.wantsLayer = true statusContainer.wantsLayer = true
@@ -464,8 +454,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
mainStack.topAnchor.constraint(equalTo: topAnchor), mainStack.topAnchor.constraint(equalTo: topAnchor),
mainStack.bottomAnchor.constraint(equalTo: bottomAnchor), mainStack.bottomAnchor.constraint(equalTo: bottomAnchor),
headerStack.widthAnchor.constraint(equalTo: mainStack.widthAnchor, constant: -(Metrics.panelSideInset * 2)), headerStack.widthAnchor.constraint(equalTo: mainStack.widthAnchor, constant: -(Metrics.panelSideInset * 2)),
topBar.widthAnchor.constraint(equalTo: headerStack.widthAnchor), shelfChrome.widthAnchor.constraint(equalTo: headerStack.widthAnchor),
filterBar.widthAnchor.constraint(equalTo: headerStack.widthAnchor),
scrollView.widthAnchor.constraint(equalTo: mainStack.widthAnchor, constant: -(Metrics.panelSideInset * 2)), scrollView.widthAnchor.constraint(equalTo: mainStack.widthAnchor, constant: -(Metrics.panelSideInset * 2)),
statusContainer.widthAnchor.constraint(equalTo: mainStack.widthAnchor, constant: -(Metrics.panelSideInset * 2)) statusContainer.widthAnchor.constraint(equalTo: mainStack.widthAnchor, constant: -(Metrics.panelSideInset * 2))
]) ])
@@ -716,15 +705,6 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
return button return button
} }
private func appIconImage() -> NSImage {
if let url = Bundle.main.url(forResource: "AppIcon", withExtension: "icns"),
let icon = NSImage(contentsOf: url) {
icon.size = NSSize(width: 22, height: 22)
return icon
}
return NSImage(systemSymbolName: "doc.on.clipboard.fill", accessibilityDescription: "ClipBored") ?? NSImage()
}
private func reloadItems() { private func reloadItems() {
cardViews.removeAll() cardViews.removeAll()
lastScrollContentWidth = 0 lastScrollContentWidth = 0
@@ -1240,8 +1220,25 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
collectionStack.frame = NSRect(x: 0, y: 0, width: width, height: 30) 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() { func focusSearchField() {
window?.makeFirstResponder(searchField) window?.makeFirstResponder(searchField)
updateSearchFieldPresentation()
} }
@discardableResult @discardableResult
@@ -1692,6 +1689,24 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
searchField.stringValue 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) { func debugDropFirstCard(onCollectionNamed collectionName: String) {
guard let itemID = cardViews.first?.debugItemID else { return } guard let itemID = cardViews.first?.debugItemID else { return }
customCollectionButtons[collectionName]?.debugDropItem(itemID) customCollectionButtons[collectionName]?.debugDropItem(itemID)
@@ -1708,6 +1723,16 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
updateSearchText() 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 { func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
guard control === searchField else { return false } guard control === searchField else { return false }
@@ -1739,6 +1764,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
private func updateSearchText() { private func updateSearchText() {
viewModel.searchText = searchField.stringValue viewModel.searchText = searchField.stringValue
updateSearchFieldPresentation()
} }
private func moveSelectionFromFocusedCard(_ delta: Int) { private func moveSelectionFromFocusedCard(_ delta: Int) {
@@ -1780,6 +1806,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
viewModel.selectItem(at: index) viewModel.selectItem(at: index)
viewModel.showSelectedInClipboard() viewModel.showSelectedInClipboard()
searchField.stringValue = viewModel.searchText searchField.stringValue = viewModel.searchText
updateSearchFieldPresentation()
} }
func createCollection() { func createCollection() {

View File

@@ -47,6 +47,25 @@ final class ClipboardPanelViewTests: XCTestCase {
XCTAssertTrue(view.isSearchFieldEditing) XCTAssertTrue(view.isSearchFieldEditing)
} }
func testShelfChromeKeepsSearchAndCollectionsInOneCompactRow() {
let fixture = makePanelFixture()
fixture.store.upsert(makeTextItem("Compact shelf chrome", store: fixture.store))
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugShelfChromeRowCount, 1)
XCTAssertTrue(fixture.view.debugShelfChromeContainsSearchAndCollections)
XCTAssertEqual(fixture.view.debugSearchFieldWidth, 34, accuracy: 0.5)
XCTAssertEqual(fixture.view.debugSearchFieldPlaceholderText, "")
fixture.view.debugSetSearchFieldText("type:text")
drainMainQueue()
fixture.window.contentView?.layoutSubtreeIfNeeded()
XCTAssertEqual(fixture.view.debugSearchFieldWidth, 300, accuracy: 0.5)
XCTAssertEqual(fixture.view.debugSearchFieldPlaceholderText, "Search clips")
}
func testCapturedTextItemCreatesVisibleCardDocument() { func testCapturedTextItemCreatesVisibleCardDocument() {
let fixture = makePanelFixture() let fixture = makePanelFixture()
let item = makeTextItem("Bruh it said copied text but it does not appear.", store: fixture.store) let item = makeTextItem("Bruh it said copied text but it does not appear.", store: fixture.store)