WIP: compact shelf chrome
This commit is contained in:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user