diff --git a/Sources/Pesty/AppController.swift b/Sources/Pesty/AppController.swift index f01605d..072c0e4 100644 --- a/Sources/Pesty/AppController.swift +++ b/Sources/Pesty/AppController.swift @@ -277,6 +277,38 @@ final class AppController: NSObject, NSApplicationDelegate { hideBar() } + func deleteEffectiveSelection() { + let selection = store.effectiveSelectionIDs + let targets = store.visibleItems.filter { selection.contains($0.id) } + guard !targets.isEmpty else { return } + if targets.count == 1 { + store.delete(targets[0]) + return + } + suppressAutoHide = true + defer { suppressAutoHide = false } + let alert = NSAlert() + alert.messageText = "Delete \(targets.count) Clips?" + #if MAS + alert.informativeText = "There is no undo. When iCloud sync is on, these clips are also removed from your other devices." + #else + alert.informativeText = "There is no undo." + #endif + let confirm = alert.addButton(withTitle: "Delete \(targets.count) Clips") + confirm.hasDestructiveAction = true + alert.addButton(withTitle: "Cancel") + guard alert.runModal() == .alertFirstButtonReturn else { return } + store.delete(items: targets) + } + + func deleteSelection(containing item: ClipItem) { + if store.multiSelectedIDs.contains(item.id) { + deleteEffectiveSelection() + } else { + store.delete(item) + } + } + func showSettings() { NSApp.activate(ignoringOtherApps: true) if let win = settingsWindow { @@ -355,7 +387,9 @@ final class AppController: NSObject, NSApplicationDelegate { switch code { case kVK_Escape: - if !store.searchText.isEmpty { + if !store.multiSelectedIDs.isEmpty { + store.clearMultiSelection() + } else if !store.searchText.isEmpty { store.searchText = ""; store.selectFirst() searchClearedAt = Date() } else { hideBar() } @@ -367,7 +401,7 @@ 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 { deleteEffectiveSelection(); return nil } if !store.searchText.isEmpty { store.searchText.removeLast(); store.selectFirst() if store.searchText.isEmpty { searchClearedAt = Date() } @@ -381,13 +415,12 @@ final class AppController: NSObject, NSApplicationDelegate { // "delete a clip". There is no undo, and deletions replicate to // other devices when sync is on. if !event.isARepeat, - Date().timeIntervalSince(searchClearedAt) > Self.deleteAfterSearchClearCooldown, - let sel = store.selectedItem { - store.delete(sel) + Date().timeIntervalSince(searchClearedAt) > Self.deleteAfterSearchClearCooldown { + deleteEffectiveSelection() } return nil case kVK_ForwardDelete: - if let sel = store.selectedItem { store.delete(sel) } + deleteEffectiveSelection() return nil default: break diff --git a/Sources/Pesty/Store/ClipboardStore.swift b/Sources/Pesty/Store/ClipboardStore.swift index b999741..264becb 100644 --- a/Sources/Pesty/Store/ClipboardStore.swift +++ b/Sources/Pesty/Store/ClipboardStore.swift @@ -18,9 +18,15 @@ final class ClipboardStore { private(set) var history: [ClipItem] = [] private(set) var pinboards: [Pinboard] = [] - var source: BarSource = .history - var searchText: String = "" + var source: BarSource = .history { + didSet { if source != oldValue { clearMultiSelection() } } + } + var searchText: String = "" { + didSet { if searchText != oldValue { clearMultiSelection() } } + } var selectedID: UUID? + private(set) var multiSelectedIDs: Set = [] + private var selectionAnchorID: UUID? /// Bumped every time the bar is about to present. The hosting view is /// built once and cached, so the strip keeps its scroll offset across @@ -101,14 +107,18 @@ 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 && multiSelectedIDs.isEmpty { + selectedID = existing.id + selectionAnchorID = existing.id + } scheduleSave() return } history.insert(item, at: 0) trimHistory() - if source == .history && searchText.isEmpty { + if source == .history && searchText.isEmpty && multiSelectedIDs.isEmpty { selectedID = item.id + selectionAnchorID = item.id } scheduleSave() } @@ -162,6 +172,7 @@ final class ClipboardStore { for item in removed { deleteImageFile(item) } markRetentionPruned(removed) if let sel = selectedID, removed.contains(where: { $0.id == sel }) { selectFirst() } + reconcileMultiSelection() } private func markRetentionPruned(_ items: [ClipItem]) { @@ -179,29 +190,38 @@ final class ClipboardStore { /// Deleting exactly what was removed also closes an image-file leak: the /// old cross-container removal deleted entries under two file names but /// cleaned up only one of them. - func delete(_ item: ClipItem) { + func delete(_ item: ClipItem) { delete(items: [item]) } + + func delete(items: [ClipItem]) { + let ids = Set(items.map(\.id)) + guard !ids.isEmpty else { return } // Captured before removal so repeated deletes walk down the list // instead of snapping back to the newest clip every time. - let deletedIndex = visibleItems.firstIndex(where: { $0.id == item.id }) + let deletedIndex = visibleItems.firstIndex(where: { ids.contains($0.id) }) + let selectionDeleted = selectedID.map { ids.contains($0) } ?? false let removed: [ClipItem] switch source { case .history: - removed = history.filter { $0.id == item.id } - history.removeAll { $0.id == item.id } + 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 { $0.id == item.id } - pinboards[boardIndex].items.removeAll { $0.id == item.id } + removed = pinboards[boardIndex].items.filter { ids.contains($0.id) } + pinboards[boardIndex].items.removeAll { ids.contains($0.id) } } for entry in removed { deleteImageFile(entry) } - if selectedID == item.id { - let items = visibleItems - if let index = deletedIndex, !items.isEmpty { - selectedID = items[min(index, items.count - 1)].id + multiSelectedIDs.subtract(ids) + if multiSelectedIDs.count <= 1 { multiSelectedIDs = [] } + if selectionDeleted { + let remaining = visibleItems + if let index = deletedIndex, !remaining.isEmpty { + selectedID = remaining[min(index, remaining.count - 1)].id } else { selectFirst() } + selectionAnchorID = selectedID } + reconcileMultiSelection() scheduleSave() } @@ -210,6 +230,7 @@ final class ClipboardStore { history.removeAll() selectedID = nil for item in old { deleteImageFile(item) } + reconcileMultiSelection() scheduleSave() } @@ -233,6 +254,7 @@ final class ClipboardStore { let removedItems = pinboards[i].items pinboards.remove(at: i) for item in removedItems { deleteImageFile(item) } + reconcileMultiSelection() scheduleSave() } @@ -269,13 +291,83 @@ final class ClipboardStore { func selectFirst() { selectedID = visibleItems.first?.id } + var effectiveSelectionIDs: Set { + if !multiSelectedIDs.isEmpty { return multiSelectedIDs } + return selectedID.map { [$0] } ?? [] + } + + func isSelected(_ id: UUID) -> Bool { + if multiSelectedIDs.isEmpty { return selectedID == id } + return multiSelectedIDs.contains(id) + } + + func select(_ id: UUID) { + selectedID = id + selectionAnchorID = id + multiSelectedIDs = [] + } + + func toggleSelection(_ id: UUID) { + guard visibleItems.contains(where: { $0.id == id }) else { return } + if multiSelectedIDs.isEmpty, let sel = selectedID, sel != id, + visibleItems.contains(where: { $0.id == sel }) { + multiSelectedIDs = [sel] + } + if multiSelectedIDs.contains(id) { + multiSelectedIDs.remove(id) + if selectedID == id { selectedID = multiSelectedIDs.first ?? visibleItems.first?.id } + if selectionAnchorID == id { selectionAnchorID = selectedID } + if multiSelectedIDs.count <= 1 { multiSelectedIDs = [] } + } else { + multiSelectedIDs.insert(id) + selectedID = id + if selectionAnchorID == nil { selectionAnchorID = id } + if multiSelectedIDs.count == 1 { multiSelectedIDs = [] } + } + } + + func extendSelection(to id: UUID) { + let items = visibleItems + guard let targetIndex = items.firstIndex(where: { $0.id == id }) else { return } + guard let anchor = selectionAnchorID ?? selectedID, + let anchorIndex = items.firstIndex(where: { $0.id == anchor }) else { + select(id) + return + } + let range = items[min(anchorIndex, targetIndex)...max(anchorIndex, targetIndex)] + multiSelectedIDs = Set(range.map(\.id)) + selectedID = id + selectionAnchorID = anchor + if multiSelectedIDs.count <= 1 { multiSelectedIDs = [] } + } + + func clearMultiSelection() { + multiSelectedIDs = [] + selectionAnchorID = selectedID + } + + private func reconcileMultiSelection() { + guard !multiSelectedIDs.isEmpty else { return } + let visible = Set(visibleItems.map(\.id)) + multiSelectedIDs.formIntersection(visible) + if multiSelectedIDs.count <= 1 { multiSelectedIDs = [] } + if let anchor = selectionAnchorID, !visible.contains(anchor) { + selectionAnchorID = selectedID + } + if let sel = selectedID, !visible.contains(sel) { + selectedID = multiSelectedIDs.first ?? visibleItems.first?.id + } + } + func prepareForBarPresentation() { applyRetentionPolicy() + clearMultiSelection() barPresentationToken &+= 1 selectFirst() } func moveSelection(by delta: Int) { + clearMultiSelection() let items = visibleItems guard !items.isEmpty else { return } guard let id = selectedID, let idx = items.firstIndex(where: { $0.id == id }) else { @@ -283,6 +375,7 @@ final class ClipboardStore { } let next = max(0, min(items.count - 1, idx + delta)) selectedID = items[next].id + selectionAnchorID = selectedID } func imageURL(for item: ClipItem) -> URL? { @@ -382,6 +475,7 @@ final class ClipboardStore { } trimHistory() if selectedID == nil { selectFirst() } + reconcileMultiSelection() scheduleSave() } @@ -402,6 +496,7 @@ final class ClipboardStore { deleteImageFile(item) } if let sel = selectedID, set.contains(sel) { selectFirst() } + reconcileMultiSelection() scheduleSave() } diff --git a/Sources/Pesty/UI/BarView.swift b/Sources/Pesty/UI/BarView.swift index 54bf40d..87dcd77 100644 --- a/Sources/Pesty/UI/BarView.swift +++ b/Sources/Pesty/UI/BarView.swift @@ -42,6 +42,9 @@ struct BarView: View { PinboardTabs() .layoutPriority(1) Spacer(minLength: 8) + if store.multiSelectedIDs.count > 1 { + bulkDeleteButton + } moreMenu } .padding(.horizontal, 18) @@ -86,6 +89,21 @@ struct BarView: View { .animation(.easeOut(duration: 0.15), value: store.searchText.isEmpty) } + private var bulkDeleteButton: some View { + let count = store.multiSelectedIDs.count + return Button(role: .destructive) { + AppController.shared.deleteEffectiveSelection() + } 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 moreMenu: some View { Menu { Button("Settings…") { AppController.shared.showSettings() } @@ -117,7 +135,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.isSelected(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 3bf9b05..7e40e5a 100644 --- a/Sources/Pesty/UI/ClipCardView.swift +++ b/Sources/Pesty/UI/ClipCardView.swift @@ -40,7 +40,9 @@ 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) } + .highPriorityGesture(TapGesture().modifiers(.shift).onEnded { store.extendSelection(to: item.id) }) + .highPriorityGesture(TapGesture().modifiers(.command).onEnded { store.toggleSelection(item.id) }) .onDrag { ClipDragProvider.make(for: item) } .contextMenu { menu } } @@ -213,6 +215,13 @@ struct ClipCardView: View { } } Divider() - Button("Delete", role: .destructive) { store.delete(item) } + Button(deleteMenuTitle, role: .destructive) { + AppController.shared.deleteSelection(containing: item) + } + } + + private var deleteMenuTitle: String { + let count = store.multiSelectedIDs.contains(item.id) ? store.multiSelectedIDs.count : 1 + return count > 1 ? "Delete \(count) Clips" : "Delete" } }