diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index ee649f5..318a54c 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -222,13 +222,13 @@ final class AppController: NSObject, NSApplicationDelegate { case kVK_RightArrow, kVK_DownArrow: store.moveSelection(by: 1); return nil case kVK_Delete: - if cmd, let sel = store.selectedItem { store.delete(sel); return nil } + if cmd { store.deleteSelected(); return nil } if !store.searchText.isEmpty { store.searchText.removeLast(); store.selectFirst(); return nil } return nil case kVK_ForwardDelete: - if let sel = store.selectedItem { store.delete(sel) } + store.deleteSelected() return nil default: break diff --git a/Sources/Pesty/Store/ClipboardStore.swift b/Sources/Pesty/Store/ClipboardStore.swift index 5db8257..7bcdeff 100644 --- a/Sources/Pesty/Store/ClipboardStore.swift +++ b/Sources/Pesty/Store/ClipboardStore.swift @@ -14,9 +14,20 @@ final class ClipboardStore { private(set) var history: [ClipItem] = [] private(set) var pinboards: [Pinboard] = [] - var source: BarSource = .history + var source: BarSource = .history { + didSet { + // A selection belongs to the visible strip, not to a clip UUID + // globally. Pinboard copies intentionally share their source + // item IDs, so carrying selection across sources is misleading. + if source != oldValue { selectFirst() } + } + } var searchText: String = "" var selectedID: UUID? + /// Every selected clip in the current strip. `selectedID` remains the + /// primary selection used by keyboard navigation and Return-to-paste. + private(set) var selectedIDs: Set = [] + private var selectionAnchorID: UUID? var historyLimit: Int { get { Settings.shared.historyLimit } @@ -91,14 +102,14 @@ final class ClipboardStore { var existing = history.remove(at: idx) existing.createdAt = item.createdAt history.insert(existing, at: 0) - if source == .history && searchText.isEmpty { selectedID = existing.id } + if source == .history && searchText.isEmpty { selectOnly(existing.id) } scheduleSave() return } history.insert(item, at: 0) trimHistory() if source == .history && searchText.isEmpty { - selectedID = item.id + selectOnly(item.id) } scheduleSave() } @@ -110,20 +121,63 @@ final class ClipboardStore { let removed = Array(history[historyLimit...]) history.removeLast(history.count - historyLimit) for item in removed { deleteImageFile(item) } + reconcileSelection() } func delete(_ item: ClipItem) { - history.removeAll { $0.id == item.id } - for i in pinboards.indices { pinboards[i].items.removeAll { $0.id == item.id } } - deleteImageFile(item) - if selectedID == item.id { selectFirst() } + delete([item]) + } + + func deleteSelected() { + let items = visibleItems.filter { selectedIDs.contains($0.id) } + if items.isEmpty, let selectedItem { + delete(selectedItem) + } else { + delete(items) + } + } + + /// Deleting from a selected card follows Finder behavior: delete the + /// entire current selection. A context menu opened on an unselected card + /// only deletes that card. + func deleteSelection(containing item: ClipItem) { + if selectedIDs.contains(item.id) { + deleteSelected() + } else { + delete(item) + } + } + + private func delete(_ items: [ClipItem]) { + let ids = Set(items.map(\.id)) + guard !ids.isEmpty else { return } + + // A Pinboard is an independently saved collection. Deleting from the + // current strip must not erase same-ID copies in other pinboards (or + // from history), even though copies retain their source clip ID. + let removed: [ClipItem] + switch source { + case .history: + removed = history.filter { ids.contains($0.id) } + history.removeAll { ids.contains($0.id) } + case .pinboard(let boardID): + guard let boardIndex = pinboards.firstIndex(where: { $0.id == boardID }) else { return } + removed = pinboards[boardIndex].items.filter { ids.contains($0.id) } + pinboards[boardIndex].items.removeAll { ids.contains($0.id) } + } + for item in removed { deleteImageFile(item) } + reconcileSelection() scheduleSave() } func clearHistory() { let old = history history.removeAll() - selectedID = nil + if source == .history { + selectOnly(nil) + } else { + reconcileSelection() + } for item in old { deleteImageFile(item) } scheduleSave() } @@ -170,16 +224,89 @@ final class ClipboardStore { scheduleSave() } - func selectFirst() { selectedID = visibleItems.first?.id } + func selectFirst() { selectOnly(visibleItems.first?.id) } + + /// Removes selections that are no longer in the current source or filter. + /// This is called after structural changes so selection count, highlights, + /// and bulk-delete always refer to the same visible cards. + private func reconcileSelection() { + let visibleIDs = Set(visibleItems.map(\.id)) + selectedIDs.formIntersection(visibleIDs) + + guard let selectedID, visibleIDs.contains(selectedID) else { + if let first = visibleItems.first?.id { + selectOnly(first) + } else { + selectOnly(nil) + } + return + } + + selectedIDs.insert(selectedID) + if let selectionAnchorID, !visibleIDs.contains(selectionAnchorID) { + self.selectionAnchorID = selectedID + } + } func moveSelection(by delta: Int) { let items = visibleItems guard !items.isEmpty else { return } guard let id = selectedID, let idx = items.firstIndex(where: { $0.id == id }) else { - selectedID = items.first?.id; return + selectOnly(items.first?.id); return } let next = max(0, min(items.count - 1, idx + delta)) - selectedID = items[next].id + selectOnly(items[next].id) + } + + /// Applies Finder-style selection to a card click: + /// - a normal click selects only that clip; + /// - Command-click toggles that clip without disturbing the other clips; + /// - Shift-click selects the contiguous range from the selection anchor. + /// Command-Shift-click adds that range to an existing selection. + func select(_ id: UUID, with modifiers: NSEvent.ModifierFlags) { + let items = visibleItems + guard let targetIndex = items.firstIndex(where: { $0.id == id }) else { return } + + let flags = modifiers.intersection(.deviceIndependentFlagsMask) + let command = flags.contains(.command) + + if flags.contains(.shift), + let anchorID = selectionAnchorID ?? selectedID, + let anchorIndex = items.firstIndex(where: { $0.id == anchorID }) { + let range = Set(items[min(anchorIndex, targetIndex)...max(anchorIndex, targetIndex)].map(\.id)) + if command { + selectedIDs.formUnion(range) + } else { + selectedIDs = range + } + selectedID = id + return + } + + if command { + if selectedIDs.contains(id) { + selectedIDs.remove(id) + if selectedID == id { + selectedID = items.first(where: { selectedIDs.contains($0.id) })?.id + } + if selectionAnchorID == id { + selectionAnchorID = selectedID + } + } else { + selectedIDs.insert(id) + selectedID = id + if selectionAnchorID == nil { selectionAnchorID = id } + } + return + } + + selectOnly(id) + } + + private func selectOnly(_ id: UUID?) { + selectedID = id + selectedIDs = id.map { [$0] } ?? [] + selectionAnchorID = id } func imageURL(for item: ClipItem) -> URL? { diff --git a/Sources/Pesty/UI/BarView.swift b/Sources/Pesty/UI/BarView.swift index c37985b..20626cd 100644 --- a/Sources/Pesty/UI/BarView.swift +++ b/Sources/Pesty/UI/BarView.swift @@ -26,6 +26,9 @@ struct BarView: View { PinboardTabs() .layoutPriority(1) Spacer(minLength: 8) + if store.selectedIDs.count > 1 { + bulkDeleteButton + } moreMenu } .padding(.horizontal, 18) @@ -86,6 +89,21 @@ struct BarView: View { .fixedSize() } + private var bulkDeleteButton: some View { + let count = store.selectedIDs.count + return Button(role: .destructive) { + store.deleteSelected() + } label: { + Label("Delete \(count)", systemImage: "trash") + .font(.system(size: 12.5, weight: .medium)) + .lineLimit(1) + } + .buttonStyle(.bordered) + .controlSize(.small) + .help("Delete \(count) selected clips (⌘⌫)") + .accessibilityLabel("Delete \(count) selected clips") + } + private var strip: some View { ScrollViewReader { proxy in ScrollView(.horizontal, showsIndicators: false) { @@ -93,7 +111,7 @@ struct BarView: View { ForEach(Array(store.visibleItems.enumerated()), id: \.element.id) { index, item in ClipCardView(item: item, index: index, - selected: item.id == store.selectedID) + selected: store.selectedIDs.contains(item.id)) .id(item.id) .transition(.asymmetric( insertion: .scale(scale: 0.92).combined(with: .opacity), diff --git a/Sources/Pesty/UI/ClipCardView.swift b/Sources/Pesty/UI/ClipCardView.swift index e4c1b73..a02db45 100644 --- a/Sources/Pesty/UI/ClipCardView.swift +++ b/Sources/Pesty/UI/ClipCardView.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI struct ClipCardView: View { @@ -29,7 +30,7 @@ struct ClipCardView: View { .contentShape(Rectangle()) .onHover { hovering = $0 } .onTapGesture(count: 2) { AppController.shared.pasteItem(item) } - .onTapGesture { store.selectedID = item.id } + .onTapGesture { store.select(item.id, with: NSEvent.modifierFlags) } .contextMenu { menu } } @@ -196,6 +197,6 @@ struct ClipCardView: View { } } Divider() - Button("Delete", role: .destructive) { store.delete(item) } + Button("Delete", role: .destructive) { store.deleteSelection(containing: item) } } }