diff --git a/sources/clipbored/views/ClipboardPanelView.swift b/sources/clipbored/views/ClipboardPanelView.swift index 8f8822d..b76fdc9 100644 --- a/sources/clipbored/views/ClipboardPanelView.swift +++ b/sources/clipbored/views/ClipboardPanelView.swift @@ -56,6 +56,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { private let collectionScrollView = NSScrollView() private let collectionStack = NSStackView() private let addCollectionButton = NSButton() + private let stackChip = CollectionChipView(title: "Stack", color: .systemGreen) private let itemsStack = NSStackView() private let scrollView = NSScrollView() private let statusLabel = NSTextField(labelWithString: "") @@ -326,6 +327,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { viewModel.onStackChanged = { [weak self] in self?.reloadItems() self?.updateSelection() + self?.configureCollectionButtons() self?.updateStatus(self?.viewModel.statusMessage ?? "") } viewModel.onCaptureStatusChanged = { [weak self] in @@ -390,11 +392,22 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { customCollectionButtons[collectionName] = 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() + } + if viewModel.stackCount > 0 { + collectionStack.addArrangedSubview(stackChip) + } + } + private func configureAddCollectionButton() { let image = NSImage(systemSymbolName: "plus", accessibilityDescription: "New collection") image?.isTemplate = true @@ -845,6 +858,8 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { chip.setSelected(viewModel.selectedCollectionName == name) chip.setCount(viewModel.collectionCount(named: name)) } + stackChip.setSelected(viewModel.isStackFilterSelected) + stackChip.setCount(viewModel.stackCount) sizeCollectionDocument() } @@ -1028,7 +1043,10 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { } var debugSelectedCollectionTitle: String? { - collectionButtons.first(where: { $0.value.isSelected })?.value.titleText + if stackChip.isSelected { + return stackChip.titleText + } + return collectionButtons.first(where: { $0.value.isSelected })?.value.titleText } var debugCollectionCounts: [Int] { @@ -1045,6 +1063,23 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate { return viewModel.collectionNames.compactMap { customCollectionButtons[$0]?.count } } + var debugStackChipIsVisible: Bool { + collectionStack.arrangedSubviews.contains(stackChip) + } + + var debugStackChipCount: Int { + updateCollectionButtons() + return stackChip.count + } + + var debugStackChipIsSelected: Bool { + stackChip.isSelected + } + + func debugPressStackChip() { + stackChip.onPress() + } + var debugCollectionRailVisibleWidth: CGFloat { collectionScrollView.contentView.bounds.width } diff --git a/sources/clipbored/views/ClipboardPanelViewModel.swift b/sources/clipbored/views/ClipboardPanelViewModel.swift index 25019a3..87a5996 100644 --- a/sources/clipbored/views/ClipboardPanelViewModel.swift +++ b/sources/clipbored/views/ClipboardPanelViewModel.swift @@ -15,6 +15,7 @@ final class ClipboardPanelViewModel { var sortMode: ClipboardSortMode { didSet { guard oldValue != sortMode else { return } + isStackFilterSelected = false selectedCollectionName = nil settings.defaultSortMode = sortMode recomputeVisibleItems() @@ -28,6 +29,13 @@ final class ClipboardPanelViewModel { onCollectionsChanged?() } } + private(set) var isStackFilterSelected = false { + didSet { + guard oldValue != isStackFilterSelected else { return } + recomputeVisibleItems() + onStackChanged?() + } + } var selectedIndex: Int = 0 { didSet { guard oldValue != selectedIndex else { return } @@ -96,6 +104,10 @@ final class ClipboardPanelViewModel { stackItemIDs.count } + var stackTitle: String { + "Stack" + } + var collectionNames: [String] { let assignedNames = Set( items.compactMap { item -> String? in @@ -216,12 +228,24 @@ final class ClipboardPanelViewModel { statusMessage = "Added to Stack" } + func selectStack() { + guard !stackItemIDs.isEmpty else { return } + selectedCollectionName = nil + isStackFilterSelected = true + } + + func clearStackSelection() { + guard isStackFilterSelected else { return } + isStackFilterSelected = false + } + func clearStack() { guard !stackItemIDs.isEmpty else { statusMessage = "Stack is empty" return } stackItemIDs.removeAll() + isStackFilterSelected = false statusMessage = "Cleared Stack" } @@ -363,6 +387,7 @@ final class ClipboardPanelViewModel { func selectCollection(named name: String) { guard let normalizedName = ClipboardCollectionDefaults.normalizedName(name) else { return } + isStackFilterSelected = false selectedCollectionName = normalizedName } @@ -374,7 +399,14 @@ final class ClipboardPanelViewModel { pruneStackItems() let previousSelection = selectedItemID let query = searchText.clipboardTrimmed.lowercased() - visibleItems = computeVisibleItems(from: items, query: query, sortMode: sortMode, collectionName: selectedCollectionName) + if isStackFilterSelected { + let stackedItems = stackItemIDs.compactMap { id in + items.first { $0.id == id } + } + visibleItems = computeStackVisibleItems(from: stackedItems, query: query) + } else { + visibleItems = computeVisibleItems(from: items, query: query, sortMode: sortMode, collectionName: selectedCollectionName) + } if let selectedID = previousSelection, let index = visibleItems.firstIndex(where: { $0.id == selectedID }) { selectedIndex = index @@ -432,6 +464,18 @@ final class ClipboardPanelViewModel { if pruned != stackItemIDs { stackItemIDs = pruned } + if stackItemIDs.isEmpty && isStackFilterSelected { + isStackFilterSelected = false + } + } + + private func computeStackVisibleItems(from items: [ClipboardItem], query: String) -> [ClipboardItem] { + let tokens = searchTokens(from: query.lowercased()) + guard !tokens.isEmpty else { return items } + return items.filter { item in + let text = searchableText(for: item) + return tokens.allSatisfy { text.contains($0) } + } } internal func computeVisibleItems( diff --git a/tests/clipboredtests/ClipboardPanelViewModelTests.swift b/tests/clipboredtests/ClipboardPanelViewModelTests.swift index 44f5d73..113cef6 100644 --- a/tests/clipboredtests/ClipboardPanelViewModelTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewModelTests.swift @@ -367,6 +367,40 @@ final class ClipboardPanelViewModelTests: XCTestCase { XCTAssertEqual(viewModel.statusMessage, "Cleared Stack") } + func testSelectingStackFiltersVisibleItemsInQueueOrder() { + let settings = makeSettings() + let cacheService = makeCacheService() + let store = makeStore(settings: settings, cacheService: cacheService) + let first = makeTextItem("first queue note", createdAt: Date(timeIntervalSince1970: 100)) + let second = makeTextItem("second queue note", createdAt: Date(timeIntervalSince1970: 200)) + let outside = makeTextItem("outside note", createdAt: Date(timeIntervalSince1970: 300)) + store.upsert(first) + store.upsert(second) + store.upsert(outside) + store.flushPersistenceForTesting() + + let viewModel = ClipboardPanelViewModel(store: store, settings: settings, cacheService: cacheService) + waitForVisibleItems(in: viewModel, count: 3) + XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["outside note", "second queue note", "first queue note"]) + + viewModel.selectItem(at: 2) + viewModel.toggleSelectedStackMembership() + viewModel.selectItem(at: 1) + viewModel.toggleSelectedStackMembership() + viewModel.selectStack() + + XCTAssertTrue(viewModel.isStackFilterSelected) + XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["first queue note", "second queue note"]) + + viewModel.searchText = "second" + XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["second queue note"]) + + viewModel.clearSearch() + viewModel.sortMode = .text + XCTAssertFalse(viewModel.isStackFilterSelected) + XCTAssertEqual(viewModel.visibleItems.map(\.payload), ["outside note", "second queue note", "first queue note"]) + } + func testUpdateSelectedTextRefreshesVisibleItemAndSearch() { let settings = makeSettings() let cacheService = makeCacheService() diff --git a/tests/clipboredtests/ClipboardPanelViewTests.swift b/tests/clipboredtests/ClipboardPanelViewTests.swift index 57a274c..3fb7142 100644 --- a/tests/clipboredtests/ClipboardPanelViewTests.swift +++ b/tests/clipboredtests/ClipboardPanelViewTests.swift @@ -370,6 +370,42 @@ final class ClipboardPanelViewTests: XCTestCase { XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Pin", "Remove from Stack", "Edit", "Delete"]) } + func testStackChipAppearsFiltersAndClearsWithStack() { + let fixture = makePanelFixture() + fixture.store.upsert(makeTextItem("First stack chip item", store: fixture.store)) + fixture.store.upsert(makeTextItem("Second stack chip item", store: fixture.store)) + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + XCTAssertFalse(fixture.view.debugStackChipIsVisible) + + fixture.viewModel.selectItem(at: 1) + fixture.viewModel.toggleSelectedStackMembership() + fixture.viewModel.selectItem(at: 0) + fixture.viewModel.toggleSelectedStackMembership() + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + XCTAssertTrue(fixture.view.debugStackChipIsVisible) + XCTAssertEqual(fixture.view.debugStackChipCount, 2) + XCTAssertFalse(fixture.view.debugStackChipIsSelected) + + fixture.view.debugPressStackChip() + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + XCTAssertEqual(fixture.view.debugSelectedCollectionTitle, "Stack") + XCTAssertTrue(fixture.view.debugStackChipIsSelected) + XCTAssertEqual(fixture.viewModel.visibleItems.map(\.payload), ["First stack chip item", "Second stack chip item"]) + + fixture.viewModel.clearStack() + drainMainQueue() + fixture.window.contentView?.layoutSubtreeIfNeeded() + + XCTAssertFalse(fixture.view.debugStackChipIsVisible) + XCTAssertEqual(fixture.view.debugStackChipCount, 0) + } + func testCollectionMenuOffersExistingCustomCollections() { let fixture = makePanelFixture() var existing = makeTextItem("Existing client note", store: fixture.store)