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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 53 additions & 2 deletions boringNotch/components/Shelf/Views/ShelfItemView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,39 @@ struct ShelfItemView: View {
return 1
}
}

// MARK: - Drag Preview Rendering

@MainActor
private func renderDragPreview() async -> NSImage {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate function

// 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
Expand Down Expand Up @@ -175,14 +208,32 @@ private struct DraggableClickHandler<Content: View>: 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
}
Expand Down
37 changes: 37 additions & 0 deletions boringNotch/components/Shelf/Views/ShelfView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,13 @@ struct ShelfView: View {
}
.contentShape(Rectangle())
.onTapGesture { selection.clear() }
.background(
KeyboardEventHandler(
onCommandA: {
selection.selectAll(in: tvm.items)
}
)
)
}

var content: some View {
Expand Down Expand Up @@ -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)
}
}
}
105 changes: 105 additions & 0 deletions boringNotch/components/Shelf/Views/StackedDragPreviewView.swift
Original file line number Diff line number Diff line change
@@ -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..<min(maxStackLayers, thumbnails.count), id: \.self) { index in
let reverseIndex = min(maxStackLayers, thumbnails.count) - 1 - index

Image(nsImage: thumbnails[min(reverseIndex, thumbnails.count - 1)])
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: cardWidth, height: cardHeight)
.clipShape(RoundedRectangle(cornerRadius: 12))
.opacity(opacityForLayer(reverseIndex))
.offset(
x: CGFloat(reverseIndex) * stackOffset,
y: CGFloat(reverseIndex) * stackOffset
)
}

// Count badge
if count > 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))
}
Loading