diff --git a/boringNotch/components/Shelf/ViewModels/ShelfSelectionModel.swift b/boringNotch/components/Shelf/ViewModels/ShelfSelectionModel.swift index e6585284..f9e47435 100644 --- a/boringNotch/components/Shelf/ViewModels/ShelfSelectionModel.swift +++ b/boringNotch/components/Shelf/ViewModels/ShelfSelectionModel.swift @@ -63,6 +63,11 @@ final class ShelfSelectionModel: ObservableObject { selectedIDs = Set(rangeIDs) } + func selectAll(in items: [ShelfItem]) { + selectedIDs = Set(items.map { $0.id }) + lastAnchorID = items.first?.id + } + func clear() { selectedIDs.removeAll() lastAnchorID = nil diff --git a/boringNotch/components/Shelf/Views/ShelfItemView.swift b/boringNotch/components/Shelf/Views/ShelfItemView.swift index 739ae992..fae8c90e 100644 --- a/boringNotch/components/Shelf/Views/ShelfItemView.swift +++ b/boringNotch/components/Shelf/Views/ShelfItemView.swift @@ -141,6 +141,39 @@ struct ShelfItemView: View { return 1 } } + + // MARK: - Drag Preview Rendering + + @MainActor + private func renderDragPreview() async -> NSImage { + // Check if multiple items are selected + let selectedItems = selection.selectedItems(in: ShelfStateViewModel.shared.items) + + if selectedItems.count > 1 && selectedItems.contains(where: { $0.id == item.id }) { + // Render stacked preview for multi-item drag + return renderStackedPreview(for: selectedItems) + } else { + // Render single item preview + let content = DragPreviewView(thumbnail: viewModel.thumbnail ?? item.icon, displayName: item.displayName) + let renderer = ImageRenderer(content: content) + renderer.scale = NSScreen.main?.backingScaleFactor ?? 2.0 + return renderer.nsImage ?? (viewModel.thumbnail ?? item.icon) + } + } + + @MainActor + private func renderStackedPreview(for items: [ShelfItem]) -> NSImage { + // Collect icons from selected items (max 3) + // We use icons instead of thumbnails for simplicity and performance + let thumbnails = items.prefix(3).map { $0.icon } + + let content = StackedDragPreviewView(thumbnails: Array(thumbnails), count: items.count) + let renderer = ImageRenderer(content: content) + renderer.scale = NSScreen.main?.backingScaleFactor ?? 2.0 + return renderer.nsImage ?? (thumbnails.first ?? item.icon) + } + + } // MARK: - Draggable Click Handler with NSDraggingSource @@ -175,14 +208,32 @@ private struct DraggableClickHandler: NSViewRepresentable { } private func renderDragPreview() -> NSImage { + // Check if multiple items are selected + let selectedItems = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items) + + if selectedItems.count > 1 && selectedItems.contains(where: { $0.id == item.id }) { + // Render stacked preview for multi-item drag + // Use icons for simplicity and performance + let thumbnails = selectedItems.prefix(3).map { $0.icon } + + let stackedContent = StackedDragPreviewView(thumbnails: Array(thumbnails), count: selectedItems.count) + let stackedRenderer = ImageRenderer(content: stackedContent) + stackedRenderer.scale = NSScreen.main?.backingScaleFactor ?? 2.0 + + if let nsImage = stackedRenderer.nsImage { + return nsImage + } + } + + // Render single item preview let content = dragPreviewContent() let renderer = ImageRenderer(content: content) renderer.scale = NSScreen.main?.backingScaleFactor ?? 2.0 - + if let nsImage = renderer.nsImage { return nsImage } - + // Fallback to icon if rendering fails return viewModel.thumbnail ?? item.icon } diff --git a/boringNotch/components/Shelf/Views/ShelfView.swift b/boringNotch/components/Shelf/Views/ShelfView.swift index 6af023f3..23b9df8d 100644 --- a/boringNotch/components/Shelf/Views/ShelfView.swift +++ b/boringNotch/components/Shelf/Views/ShelfView.swift @@ -75,6 +75,13 @@ struct ShelfView: View { } .contentShape(Rectangle()) .onTapGesture { selection.clear() } + .background( + KeyboardEventHandler( + onCommandA: { + selection.selectAll(in: tvm.items) + } + ) + ) } var content: some View { @@ -113,3 +120,33 @@ struct ShelfView: View { } } } + +// MARK: - Keyboard Event Handler +private struct KeyboardEventHandler: NSViewRepresentable { + let onCommandA: () -> Void + + func makeNSView(context: Context) -> KeyEventView { + let view = KeyEventView() + view.onCommandA = onCommandA + return view + } + + func updateNSView(_ nsView: KeyEventView, context: Context) { + nsView.onCommandA = onCommandA + } + + final class KeyEventView: NSView { + var onCommandA: (() -> Void)? + + override var acceptsFirstResponder: Bool { true } + + override func performKeyEquivalent(with event: NSEvent) -> Bool { + // Check for Command+A (Select All) + if event.modifierFlags.contains(.command) && event.charactersIgnoringModifiers == "a" { + onCommandA?() + return true + } + return super.performKeyEquivalent(with: event) + } + } +} diff --git a/boringNotch/components/Shelf/Views/StackedDragPreviewView.swift b/boringNotch/components/Shelf/Views/StackedDragPreviewView.swift new file mode 100644 index 00000000..380a69bb --- /dev/null +++ b/boringNotch/components/Shelf/Views/StackedDragPreviewView.swift @@ -0,0 +1,105 @@ +// +// StackedDragPreviewView.swift +// boringNotch +// +// Created for Issue #890 - File Stacking / Group Dragging Feature +// + +import SwiftUI +import AppKit + +struct StackedDragPreviewView: View { + let thumbnails: [NSImage] + let count: Int + + private let cardWidth: CGFloat = 56 + private let cardHeight: CGFloat = 56 + private let stackOffset: CGFloat = 3 + private let maxStackLayers: Int = 3 + + var body: some View { + VStack(alignment: .center, spacing: 4) { + // Stacked cards with count badge + ZStack(alignment: .topTrailing) { + // Stack layers (up to 3) + ForEach(0.. 1 { + Text("\(count)") + .font(.system(size: 11, weight: .bold)) + .foregroundColor(.white) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background( + Capsule() + .fill(Color.accentColor) + ) + .offset(x: 8, y: -8) + } + } + .frame( + width: cardWidth + CGFloat(min(maxStackLayers - 1, 2)) * stackOffset, + height: cardHeight + CGFloat(min(maxStackLayers - 1, 2)) * stackOffset + ) + + // Label showing item count + Text(count == 1 ? "1 item" : "\(count) items") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.white) + .lineLimit(1) + .truncationMode(.tail) + .multilineTextAlignment(.center) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background(RoundedRectangle(cornerRadius: 4).fill(Color.accentColor)) + .frame(alignment: .top) + } + .frame(width: 105) + } + + private func opacityForLayer(_ layer: Int) -> Double { + switch layer { + case 0: return 1.0 + case 1: return 0.85 + case 2: return 0.7 + default: return 0.6 + } + } +} + +// Preview for development +#Preview { + VStack(spacing: 20) { + StackedDragPreviewView( + thumbnails: [ + NSImage(systemSymbolName: "doc.fill", accessibilityDescription: nil)!, + NSImage(systemSymbolName: "photo.fill", accessibilityDescription: nil)!, + NSImage(systemSymbolName: "video.fill", accessibilityDescription: nil)! + ], + count: 3 + ) + + StackedDragPreviewView( + thumbnails: [ + NSImage(systemSymbolName: "doc.fill", accessibilityDescription: nil)! + ], + count: 5 + ) + } + .padding() + .background(Color.gray.opacity(0.3)) +}