diff --git a/docs/SMOKE_TEST.md b/docs/SMOKE_TEST.md index f34ad60..8e0b28d 100644 --- a/docs/SMOKE_TEST.md +++ b/docs/SMOKE_TEST.md @@ -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. 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. +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 diff --git a/sources/clipbored/views/ClipboardPanelView.swift b/sources/clipbored/views/ClipboardPanelView.swift index 4246f71..01027e5 100644 --- a/sources/clipbored/views/ClipboardPanelView.swift +++ b/sources/clipbored/views/ClipboardPanelView.swift @@ -141,6 +141,8 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { 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 { @@ -236,6 +238,9 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { 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] = [:] @@ -288,13 +293,6 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { layer?.shadowRadius = 20 layer?.shadowOffset = NSSize(width: 0, height: 10) - let toolbarIcon = NSImageView(image: appIconImage()) - toolbarIcon.imageScaling = .scaleProportionallyUpOrDown - toolbarIcon.toolTip = "ClipBored" - toolbarIcon.widthAnchor.constraint(equalToConstant: 22).isActive = true - toolbarIcon.heightAnchor.constraint(equalToConstant: 22).isActive = true - - searchField.placeholderString = "Search clips" searchField.setAccessibilityLabel("Search clipboard history") searchField.delegate = self searchField.target = self @@ -312,10 +310,12 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { 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(.defaultLow, for: .horizontal) - searchField.setContentHuggingPriority(.defaultLow, for: .horizontal) - searchField.widthAnchor.constraint(greaterThanOrEqualToConstant: 280).isActive = true - searchField.widthAnchor.constraint(lessThanOrEqualToConstant: 620).isActive = true + 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 @@ -351,29 +351,18 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { actionStrip.setContentCompressionResistancePriority(.required, for: .horizontal) let actionGroup = groupedToolbar(actionStrip) - let topSpacer = NSView() - topSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal) - topSpacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - let topBar = row([ - toolbarIcon, - searchField, - topSpacer, - actionGroup - ]) - topBar.distribution = .fill - topBar.setHuggingPriority(.defaultHigh, for: .vertical) actionGroup.setContentHuggingPriority(.required, for: .horizontal) actionGroup.setContentCompressionResistancePriority(.required, for: .horizontal) - let filterSpacer = NSView() - filterSpacer.setContentHuggingPriority(.defaultLow, for: .horizontal) - filterSpacer.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - let filterBar = row([ + let shelfChrome = row([ + searchField, collectionScrollView, - filterSpacer + actionGroup ]) - filterBar.spacing = 12 - filterBar.distribution = .fill + shelfChrome.spacing = 10 + shelfChrome.distribution = .fill + shelfChrome.setHuggingPriority(.defaultHigh, for: .vertical) + shelfChromeStack = shelfChrome itemsStack.orientation = .horizontal itemsStack.alignment = .top @@ -427,11 +416,12 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { statusResultCountLabel.setAccessibilityLabel("Result count") statusLabel.setAccessibilityLabel("Status") - let headerStack = NSStackView(views: [topBar, filterBar]) + let headerStack = NSStackView(views: [shelfChrome]) headerStack.orientation = .vertical headerStack.alignment = .leading - headerStack.spacing = 10 + headerStack.spacing = 0 headerStack.setContentCompressionResistancePriority(.required, for: .vertical) + self.headerStack = headerStack let statusContainer = NSView() statusContainer.wantsLayer = true @@ -464,8 +454,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { mainStack.topAnchor.constraint(equalTo: topAnchor), mainStack.bottomAnchor.constraint(equalTo: bottomAnchor), headerStack.widthAnchor.constraint(equalTo: mainStack.widthAnchor, constant: -(Metrics.panelSideInset * 2)), - topBar.widthAnchor.constraint(equalTo: headerStack.widthAnchor), - filterBar.widthAnchor.constraint(equalTo: headerStack.widthAnchor), + 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)) ]) @@ -716,15 +705,6 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { return button } - private func appIconImage() -> NSImage { - if let url = Bundle.main.url(forResource: "AppIcon", withExtension: "icns"), - let icon = NSImage(contentsOf: url) { - icon.size = NSSize(width: 22, height: 22) - return icon - } - return NSImage(systemSymbolName: "doc.on.clipboard.fill", accessibilityDescription: "ClipBored") ?? NSImage() - } - private func reloadItems() { cardViews.removeAll() lastScrollContentWidth = 0 @@ -1240,8 +1220,25 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { 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 @@ -1692,6 +1689,24 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { 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) @@ -1708,6 +1723,16 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { 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 } @@ -1739,6 +1764,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { private func updateSearchText() { viewModel.searchText = searchField.stringValue + updateSearchFieldPresentation() } private func moveSelectionFromFocusedCard(_ delta: Int) { @@ -1780,6 +1806,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { viewModel.selectItem(at: index) viewModel.showSelectedInClipboard() searchField.stringValue = viewModel.searchText + updateSearchFieldPresentation() } func createCollection() { diff --git a/tests/clipboredtests/ClipboardPanelViewTests.swift b/tests/clipboredtests/ClipboardPanelViewTests.swift index c98e397..8c1362b 100644 --- a/tests/clipboredtests/ClipboardPanelViewTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewTests.swift @@ -47,6 +47,25 @@ final class ClipboardPanelViewTests: XCTestCase { 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() { let fixture = makePanelFixture() let item = makeTextItem("Bruh it said copied text but it does not appear.", store: fixture.store)