WIP: show stack in collection rail
This commit is contained in:
@@ -56,6 +56,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
|||||||
private let collectionScrollView = NSScrollView()
|
private let collectionScrollView = NSScrollView()
|
||||||
private let collectionStack = NSStackView()
|
private let collectionStack = NSStackView()
|
||||||
private let addCollectionButton = NSButton()
|
private let addCollectionButton = NSButton()
|
||||||
|
private let stackChip = CollectionChipView(title: "Stack", color: .systemGreen)
|
||||||
private let itemsStack = NSStackView()
|
private let itemsStack = NSStackView()
|
||||||
private let scrollView = NSScrollView()
|
private let scrollView = NSScrollView()
|
||||||
private let statusLabel = NSTextField(labelWithString: "")
|
private let statusLabel = NSTextField(labelWithString: "")
|
||||||
@@ -326,6 +327,7 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
|||||||
viewModel.onStackChanged = { [weak self] in
|
viewModel.onStackChanged = { [weak self] in
|
||||||
self?.reloadItems()
|
self?.reloadItems()
|
||||||
self?.updateSelection()
|
self?.updateSelection()
|
||||||
|
self?.configureCollectionButtons()
|
||||||
self?.updateStatus(self?.viewModel.statusMessage ?? "")
|
self?.updateStatus(self?.viewModel.statusMessage ?? "")
|
||||||
}
|
}
|
||||||
viewModel.onCaptureStatusChanged = { [weak self] in
|
viewModel.onCaptureStatusChanged = { [weak self] in
|
||||||
@@ -390,11 +392,22 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
|||||||
customCollectionButtons[collectionName] = chip
|
customCollectionButtons[collectionName] = chip
|
||||||
collectionStack.addArrangedSubview(chip)
|
collectionStack.addArrangedSubview(chip)
|
||||||
}
|
}
|
||||||
|
configureStackChip()
|
||||||
collectionStack.addArrangedSubview(addCollectionButton)
|
collectionStack.addArrangedSubview(addCollectionButton)
|
||||||
updateAddCollectionButtonState()
|
updateAddCollectionButtonState()
|
||||||
sizeCollectionDocument()
|
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() {
|
private func configureAddCollectionButton() {
|
||||||
let image = NSImage(systemSymbolName: "plus", accessibilityDescription: "New collection")
|
let image = NSImage(systemSymbolName: "plus", accessibilityDescription: "New collection")
|
||||||
image?.isTemplate = true
|
image?.isTemplate = true
|
||||||
@@ -845,6 +858,8 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
|||||||
chip.setSelected(viewModel.selectedCollectionName == name)
|
chip.setSelected(viewModel.selectedCollectionName == name)
|
||||||
chip.setCount(viewModel.collectionCount(named: name))
|
chip.setCount(viewModel.collectionCount(named: name))
|
||||||
}
|
}
|
||||||
|
stackChip.setSelected(viewModel.isStackFilterSelected)
|
||||||
|
stackChip.setCount(viewModel.stackCount)
|
||||||
sizeCollectionDocument()
|
sizeCollectionDocument()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1028,7 +1043,10 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var debugSelectedCollectionTitle: String? {
|
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] {
|
var debugCollectionCounts: [Int] {
|
||||||
@@ -1045,6 +1063,23 @@ final class ClipboardPanelView: NSVisualEffectView, NSSearchFieldDelegate {
|
|||||||
return viewModel.collectionNames.compactMap { customCollectionButtons[$0]?.count }
|
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 {
|
var debugCollectionRailVisibleWidth: CGFloat {
|
||||||
collectionScrollView.contentView.bounds.width
|
collectionScrollView.contentView.bounds.width
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ final class ClipboardPanelViewModel {
|
|||||||
var sortMode: ClipboardSortMode {
|
var sortMode: ClipboardSortMode {
|
||||||
didSet {
|
didSet {
|
||||||
guard oldValue != sortMode else { return }
|
guard oldValue != sortMode else { return }
|
||||||
|
isStackFilterSelected = false
|
||||||
selectedCollectionName = nil
|
selectedCollectionName = nil
|
||||||
settings.defaultSortMode = sortMode
|
settings.defaultSortMode = sortMode
|
||||||
recomputeVisibleItems()
|
recomputeVisibleItems()
|
||||||
@@ -28,6 +29,13 @@ final class ClipboardPanelViewModel {
|
|||||||
onCollectionsChanged?()
|
onCollectionsChanged?()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
private(set) var isStackFilterSelected = false {
|
||||||
|
didSet {
|
||||||
|
guard oldValue != isStackFilterSelected else { return }
|
||||||
|
recomputeVisibleItems()
|
||||||
|
onStackChanged?()
|
||||||
|
}
|
||||||
|
}
|
||||||
var selectedIndex: Int = 0 {
|
var selectedIndex: Int = 0 {
|
||||||
didSet {
|
didSet {
|
||||||
guard oldValue != selectedIndex else { return }
|
guard oldValue != selectedIndex else { return }
|
||||||
@@ -96,6 +104,10 @@ final class ClipboardPanelViewModel {
|
|||||||
stackItemIDs.count
|
stackItemIDs.count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var stackTitle: String {
|
||||||
|
"Stack"
|
||||||
|
}
|
||||||
|
|
||||||
var collectionNames: [String] {
|
var collectionNames: [String] {
|
||||||
let assignedNames = Set(
|
let assignedNames = Set(
|
||||||
items.compactMap { item -> String? in
|
items.compactMap { item -> String? in
|
||||||
@@ -216,12 +228,24 @@ final class ClipboardPanelViewModel {
|
|||||||
statusMessage = "Added to Stack"
|
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() {
|
func clearStack() {
|
||||||
guard !stackItemIDs.isEmpty else {
|
guard !stackItemIDs.isEmpty else {
|
||||||
statusMessage = "Stack is empty"
|
statusMessage = "Stack is empty"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
stackItemIDs.removeAll()
|
stackItemIDs.removeAll()
|
||||||
|
isStackFilterSelected = false
|
||||||
statusMessage = "Cleared Stack"
|
statusMessage = "Cleared Stack"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,6 +387,7 @@ final class ClipboardPanelViewModel {
|
|||||||
|
|
||||||
func selectCollection(named name: String) {
|
func selectCollection(named name: String) {
|
||||||
guard let normalizedName = ClipboardCollectionDefaults.normalizedName(name) else { return }
|
guard let normalizedName = ClipboardCollectionDefaults.normalizedName(name) else { return }
|
||||||
|
isStackFilterSelected = false
|
||||||
selectedCollectionName = normalizedName
|
selectedCollectionName = normalizedName
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,7 +399,14 @@ final class ClipboardPanelViewModel {
|
|||||||
pruneStackItems()
|
pruneStackItems()
|
||||||
let previousSelection = selectedItemID
|
let previousSelection = selectedItemID
|
||||||
let query = searchText.clipboardTrimmed.lowercased()
|
let query = searchText.clipboardTrimmed.lowercased()
|
||||||
|
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)
|
visibleItems = computeVisibleItems(from: items, query: query, sortMode: sortMode, collectionName: selectedCollectionName)
|
||||||
|
}
|
||||||
|
|
||||||
if let selectedID = previousSelection, let index = visibleItems.firstIndex(where: { $0.id == selectedID }) {
|
if let selectedID = previousSelection, let index = visibleItems.firstIndex(where: { $0.id == selectedID }) {
|
||||||
selectedIndex = index
|
selectedIndex = index
|
||||||
@@ -432,6 +464,18 @@ final class ClipboardPanelViewModel {
|
|||||||
if pruned != stackItemIDs {
|
if pruned != stackItemIDs {
|
||||||
stackItemIDs = pruned
|
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(
|
internal func computeVisibleItems(
|
||||||
|
|||||||
@@ -367,6 +367,40 @@ final class ClipboardPanelViewModelTests: XCTestCase {
|
|||||||
XCTAssertEqual(viewModel.statusMessage, "Cleared Stack")
|
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() {
|
func testUpdateSelectedTextRefreshesVisibleItemAndSearch() {
|
||||||
let settings = makeSettings()
|
let settings = makeSettings()
|
||||||
let cacheService = makeCacheService()
|
let cacheService = makeCacheService()
|
||||||
|
|||||||
@@ -370,6 +370,42 @@ final class ClipboardPanelViewTests: XCTestCase {
|
|||||||
XCTAssertEqual(fixture.view.debugFirstCardVisibleActionLabels, ["Paste", "Copy", "Pin", "Remove from Stack", "Edit", "Delete"])
|
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() {
|
func testCollectionMenuOffersExistingCustomCollections() {
|
||||||
let fixture = makePanelFixture()
|
let fixture = makePanelFixture()
|
||||||
var existing = makeTextItem("Existing client note", store: fixture.store)
|
var existing = makeTextItem("Existing client note", store: fixture.store)
|
||||||
|
|||||||
Reference in New Issue
Block a user