Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Sources/Pesty/AppController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
149 changes: 138 additions & 11 deletions Sources/Pesty/Store/ClipboardStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<UUID> = []
private var selectionAnchorID: UUID?

var historyLimit: Int {
get { Settings.shared.historyLimit }
Expand Down Expand Up @@ -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()
}
Expand All @@ -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()
}
Expand Down Expand Up @@ -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? {
Expand Down
20 changes: 19 additions & 1 deletion Sources/Pesty/UI/BarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ struct BarView: View {
PinboardTabs()
.layoutPriority(1)
Spacer(minLength: 8)
if store.selectedIDs.count > 1 {
bulkDeleteButton
}
moreMenu
}
.padding(.horizontal, 18)
Expand Down Expand Up @@ -86,14 +89,29 @@ 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) {
LazyHStack(spacing: Theme.cardSpacing) {
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),
Expand Down
5 changes: 3 additions & 2 deletions Sources/Pesty/UI/ClipCardView.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AppKit
import SwiftUI

struct ClipCardView: View {
Expand Down Expand Up @@ -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 }
}

Expand Down Expand Up @@ -196,6 +197,6 @@ struct ClipCardView: View {
}
}
Divider()
Button("Delete", role: .destructive) { store.delete(item) }
Button("Delete", role: .destructive) { store.deleteSelection(containing: item) }
}
}