Somewhat working file tray

This commit is contained in:
Hariharan Mudaliar 2025-12-08 13:29:25 +05:30
commit 30422c3d95
29 changed files with 4032 additions and 65 deletions

View file

@ -0,0 +1,29 @@
import SwiftUI
import AppKit
struct DragPreviewView: View {
let thumbnail: NSImage?
let displayName: String
var body: some View {
VStack(alignment: .center, spacing: 4) {
Image(nsImage: thumbnail ?? NSImage())
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 56, height: 56)
.clipShape(RoundedRectangle(cornerRadius: 12))
Text(displayName)
.font(.system(size: 12, weight: .medium))
.foregroundColor(.white)
.lineLimit(2)
.truncationMode(.middle)
.multilineTextAlignment(.center)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(RoundedRectangle(cornerRadius: 4).fill(Color.accentColor))
.frame(alignment: .top)
}
.frame(width: 105)
}
}

View file

@ -0,0 +1,135 @@
//
// FileShareView.swift
// boringNotch
//
// Created by Alexander on 2025-09-24.
//
import AppKit
import Defaults
import SwiftUI
import UniformTypeIdentifiers
struct FileShareView: View {
@EnvironmentObject private var vm: DynamicIslandViewModel
@StateObject private var quickShare = QuickShareService.shared
@Default(.quickShareProvider) var quickShareProvider: String
@State private var hostView: NSView?
@State private var interactionNonce: UUID = .init()
@State private var isProcessing = false
private var selectedProvider: QuickShareProvider {
quickShare.availableProviders.first(where: { $0.id == quickShareProvider }) ?? QuickShareProvider(id: "System Share Menu", imageData: nil, supportsRawText: true)
}
var body: some View {
dropArea
.background(NSViewHost(view: $hostView))
.onDrop(of: [.fileURL, .url, .utf8PlainText, .plainText, .data, .image], isTargeted: $vm.dropZoneTargeting) { providers in
interactionNonce = .init()
vm.dropEvent = true
Task { await handleDrop(providers) }
return true
}
.onTapGesture {
Task {
await handleClick()
}
}
}
private var dropArea: some View {
ZStack {
RoundedRectangle(cornerRadius: 12)
.fill(
LinearGradient(colors: [Color.black.opacity(0.35), Color.black.opacity(0.20)], startPoint: .topLeading, endPoint: .bottomTrailing)
)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(
vm.dropZoneTargeting
? Color.accentColor.opacity(0.9)
: Color.white.opacity(0.1),
style: StrokeStyle(lineWidth: 3, lineCap: .round, dash: [10])
)
)
.shadow(color: Color.black.opacity(0.6), radius: 6, x: 0, y: 2)
// Content
VStack(spacing: 5) {
ZStack {
Circle()
.fill(Color.white.opacity(
vm.dropZoneTargeting ? 0.11 : 0.09
))
.frame(width: 55, height: 55)
Image(systemName: "square.and.arrow.up")
Group {
if let imgData = selectedProvider.imageData, let nsImg = NSImage(data: imgData) {
Image(nsImage: nsImg)
.resizable()
.aspectRatio(contentMode: .fit)
} else {
Image(systemName: "square.and.arrow.up")
}
}
.frame(width: 34, height: 34)
.foregroundStyle(
vm.dropZoneTargeting ? Color.accentColor : Color.gray
)
.scaleEffect(
vm.dropZoneTargeting ? 1.06 : 1.0
)
.animation(.spring(response: 0.36, dampingFraction: 0.7), value: vm.dropZoneTargeting)
}
Text(selectedProvider.id)
.font(.system(.headline, design: .rounded))
.foregroundColor(.white.opacity(0.8))
}
.padding(18)
// Loading overlay
if isProcessing || quickShare.isPickerOpen {
RoundedRectangle(cornerRadius: 12)
.fill(.black.opacity(0.3))
.overlay(
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(0.8)
)
}
}
.contentShape(RoundedRectangle(cornerRadius: 12))
}
// MARK: - Actions
private func handleDrop(_ providers: [NSItemProvider]) async {
isProcessing = true
defer { isProcessing = false }
await quickShare.shareDroppedFiles(providers, using: selectedProvider, from: hostView)
}
private func handleClick() async {
await quickShare.showFilePicker(for: selectedProvider, from: hostView)
}
}
// MARK: - Host NSView extractor for anchoring share sheet
private struct NSViewHost: NSViewRepresentable {
@Binding var view: NSView?
func makeNSView(context: Context) -> NSView {
let v = NSView(frame: .zero)
DispatchQueue.main.async { self.view = v }
return v
}
func updateNSView(_ nsView: NSView, context: Context) {
DispatchQueue.main.async { self.view = nsView }
}
}

View file

@ -0,0 +1,371 @@
//
// ShelfItemView.swift
// boringNotch
//
// Created by Alexander on 2025-09-24.
//
import AppKit
import SwiftUI
import Defaults
import QuickLook
struct ShelfItemView: View {
let item: ShelfItem
@EnvironmentObject var vm: DynamicIslandViewModel
@ObservedObject var selection = ShelfSelectionModel.shared
@StateObject private var viewModel: ShelfItemViewModel
@EnvironmentObject private var quickLookService: QuickLookService
@State private var showStack = false
@State private var cachedPreviewImage: NSImage?
@State private var debouncedDropTarget = false
private var isSelected: Bool { viewModel.isSelected }
private var shouldHideDuringDrag: Bool { selection.isDragging && selection.isSelected(item.id) && false }
init(item: ShelfItem) {
self.item = item
_viewModel = StateObject(wrappedValue: ShelfItemViewModel(item: item))
}
var body: some View {
ZStack {
if !shouldHideDuringDrag {
VStack(alignment: .center, spacing: 2) {
iconView
textView
}
.frame(width: 105)
.padding(.vertical, 10)
.padding(.horizontal, 5)
.background(backgroundView)
.contentShape(Rectangle())
.animation(.easeInOut(duration: 0.1), value: debouncedDropTarget)
.animation(.easeInOut(duration: 0.1), value: isSelected)
DraggableClickHandler(
item: item,
viewModel: viewModel,
cachedPreviewImage: $cachedPreviewImage,
dragPreviewContent: {
DragPreviewView(thumbnail: viewModel.thumbnail ?? item.icon, displayName: item.displayName)
},
onRightClick: viewModel.handleRightClick,
onClick: { event, nsview in
viewModel.handleClick(event: event, view: nsview)
}
)
} else {
Color.clear
.frame(width: 105)
.padding(.vertical, 10)
.padding(.horizontal, 5)
}
}
.onChange(of: viewModel.isDropTargeted) { _, targeted in
vm.dragDetectorTargeting = targeted
// Debounce drop target state changes
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(50))
debouncedDropTarget = targeted
}
}
.onAppear {
Task {
await viewModel.loadThumbnail()
// Pre-render drag preview once on appear
if cachedPreviewImage == nil {
cachedPreviewImage = await renderDragPreview()
}
}
viewModel.onQuickLookRequest = { urls in
quickLookService.show(urls: urls, selectFirst: true)
}
}
.onChange(of: viewModel.thumbnail) { _, _ in
// Invalidate cached preview when thumbnail changes
Task {
cachedPreviewImage = await renderDragPreview()
}
}
.quickLookPresenter(using: quickLookService)
}
// MARK: - View Components
private var iconView: some View {
Image(nsImage: viewModel.thumbnail ?? item.icon)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 56, height: 56)
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: .black.opacity(0.15), radius: 3, x: 0, y: 2)
}
private var textView: some View {
Text(item.displayName)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(.primary)
.lineLimit(2)
.truncationMode(.middle)
.multilineTextAlignment(.center)
.frame(height: 30, alignment: .top)
}
private var backgroundView: some View {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.fill(backgroundColor)
.overlay(
RoundedRectangle(cornerRadius: 12, style: .continuous)
.strokeBorder(
strokeColor,
lineWidth: strokeWidth
)
)
}
private var backgroundColor: Color {
if debouncedDropTarget {
return Color.accentColor.opacity(0.25)
} else if isSelected {
return Color.accentColor.opacity(0.15)
} else {
return Color.clear
}
}
private var strokeColor: Color {
if debouncedDropTarget {
return Color.accentColor.opacity(0.9)
} else if isSelected {
return Color.accentColor.opacity(0.8)
} else {
return Color.clear
}
}
private var strokeWidth: CGFloat {
if debouncedDropTarget {
return 3
} else if isSelected {
return 2
} else {
return 1
}
}
// MARK: - Drag Preview Rendering
@MainActor
private func renderDragPreview() async -> NSImage {
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)
}
}
// MARK: - Draggable Click Handler with NSDraggingSource
private struct DraggableClickHandler<Content: View>: NSViewRepresentable {
let item: ShelfItem
let viewModel: ShelfItemViewModel
@Binding var cachedPreviewImage: NSImage?
@ViewBuilder let dragPreviewContent: () -> Content
let onRightClick: (NSEvent, NSView) -> Void
let onClick: (NSEvent, NSView) -> Void
func makeNSView(context: Context) -> DraggableClickView {
let view = DraggableClickView()
view.item = item
view.viewModel = viewModel
view.dragPreviewImage = cachedPreviewImage ?? renderDragPreview()
view.onRightClick = onRightClick
view.onClick = onClick
return view
}
func updateNSView(_ nsView: DraggableClickView, context: Context) {
nsView.item = item
nsView.viewModel = viewModel
// Only update preview if cached version is available
if let cached = cachedPreviewImage {
nsView.dragPreviewImage = cached
}
nsView.onRightClick = onRightClick
nsView.onClick = onClick
}
private func renderDragPreview() -> NSImage {
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
}
final class DraggableClickView: NSView, NSDraggingSource {
var item: ShelfItem!
weak var viewModel: ShelfItemViewModel?
var dragPreviewImage: NSImage?
var onRightClick: ((NSEvent, NSView) -> Void)?
var onClick: ((NSEvent, NSView) -> Void)?
private var mouseDownEvent: NSEvent?
private let dragThreshold: CGFloat = 3.0
private var draggedURLs: [URL] = []
private var draggedItems: [ShelfItem] = []
override func rightMouseDown(with event: NSEvent) {
onRightClick?(event, self)
}
override func mouseDown(with event: NSEvent) {
mouseDownEvent = event
onClick?(event, self)
}
override func mouseDragged(with event: NSEvent) {
guard let mouseDownEvent = mouseDownEvent else {
super.mouseDragged(with: event)
return
}
let dragDistance = hypot(
event.locationInWindow.x - mouseDownEvent.locationInWindow.x,
event.locationInWindow.y - mouseDownEvent.locationInWindow.y
)
if dragDistance > dragThreshold {
startDragSession(with: event)
self.mouseDownEvent = nil
} else {
super.mouseDragged(with: event)
}
}
private func startDragSession(with event: NSEvent) {
// Prepare dragging items
let selectedItems = ShelfSelectionModel.shared.selectedItems(in: ShelfStateViewModel.shared.items)
let itemsToDrag: [ShelfItem]
if selectedItems.count > 1 && selectedItems.contains(where: { $0.id == item.id }) {
itemsToDrag = selectedItems
} else {
itemsToDrag = [item]
}
// Store items being dragged for auto-remove feature
draggedItems = itemsToDrag
// Create dragging items for AppKit
var draggingItems: [NSDraggingItem] = []
for dragItem in itemsToDrag {
if let pasteboardItem = createPasteboardItem(for: dragItem) {
let draggingItem = NSDraggingItem(pasteboardWriter: pasteboardItem)
// Use the drag preview image
let image = dragPreviewImage ?? dragItem.icon
let imageFrame = NSRect(
x: 0,
y: 0,
width: image.size.width,
height: image.size.height
)
draggingItem.setDraggingFrame(imageFrame, contents: image)
draggingItems.append(draggingItem)
}
}
guard !draggingItems.isEmpty else { return }
beginDraggingSession(with: draggingItems, event: event, source: self)
}
private func createPasteboardItem(for item: ShelfItem) -> NSPasteboardItem? {
let pasteboardItem = NSPasteboardItem()
switch item.kind {
case .file:
guard let url = ShelfStateViewModel.shared.resolveAndUpdateBookmark(for: item) else {
pasteboardItem.setString(item.displayName, forType: .string)
return pasteboardItem
}
// Start accessing security-scoped resource and keep it active during drag
if url.startAccessingSecurityScopedResource() {
draggedURLs.append(url)
NSLog("🔐 Started security-scoped access for drag: \(url.path)")
}
pasteboardItem.setString(url.absoluteString, forType: .fileURL)
pasteboardItem.setString(url.path, forType: .string)
return pasteboardItem
case .text(let string):
pasteboardItem.setString(string, forType: .string)
return pasteboardItem
case .link(let url):
pasteboardItem.setString(url.absoluteString, forType: .URL)
pasteboardItem.setString(url.absoluteString, forType: .string)
return pasteboardItem
}
}
// MARK: - NSDraggingSource
func draggingSession(_ session: NSDraggingSession, sourceOperationMaskFor context: NSDraggingContext) -> NSDragOperation {
// When copyOnDrag is enabled, only allow copy operations
if Defaults[.copyOnDrag] {
return [.copy]
}
switch context {
case .outsideApplication:
return [.copy, .move]
case .withinApplication:
return [.copy, .move, .generic]
@unknown default:
return [.copy]
}
}
func draggingSession(_ session: NSDraggingSession, willBeginAt screenPoint: NSPoint) {
ShelfSelectionModel.shared.beginDrag()
}
func draggingSession(_ session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) {
ShelfSelectionModel.shared.endDrag()
// Stop accessing security-scoped resources after drag completes
for url in draggedURLs {
url.stopAccessingSecurityScopedResource()
NSLog("🔐 Stopped security-scoped access after drag: \(url.path)")
}
draggedURLs.removeAll()
// Auto-remove items from shelf if enabled and drag succeeded
if Defaults[.autoRemoveShelfItems] && !operation.isEmpty {
for item in draggedItems {
ShelfStateViewModel.shared.remove(item)
}
}
draggedItems.removeAll()
}
func ignoreModifierKeys(for session: NSDraggingSession) -> Bool {
return false
}
}
}

View file

@ -0,0 +1,115 @@
//
// ShelfItemView.swift
// boringNotch
//
// Created by Alexander on 2025-09-24.
//
import SwiftUI
import AppKit
struct ShelfView: View {
@EnvironmentObject var vm: DynamicIslandViewModel
@StateObject var tvm = ShelfStateViewModel.shared
@StateObject var selection = ShelfSelectionModel.shared
@StateObject private var quickLookService = QuickLookService()
private let spacing: CGFloat = 8
var body: some View {
HStack(spacing: 12) {
FileShareView()
.aspectRatio(1, contentMode: .fit)
.environmentObject(vm)
panel
.onDrop(of: [.fileURL, .url, .utf8PlainText, .plainText, .data], isTargeted: $vm.dragDetectorTargeting) { providers in
handleDrop(providers: providers)
}
}
// Bind Quick Look to shelf selection
.onChange(of: selection.selectedIDs) {
updateQuickLookSelection()
}
.quickLookPresenter(using: quickLookService)
}
private func handleDrop(providers: [NSItemProvider]) -> Bool {
guard !selection.isDragging else { return false }
vm.dropEvent = true
ShelfStateViewModel.shared.load(providers)
return true
}
private func updateQuickLookSelection() {
guard quickLookService.isQuickLookOpen && !selection.selectedIDs.isEmpty else { return }
let selectedItems = selection.selectedItems(in: tvm.items)
let urls: [URL] = selectedItems.compactMap { item in
if let fileURL = item.fileURL {
return fileURL
}
if case .link(let url) = item.kind {
return url
}
return nil
}
if !urls.isEmpty {
quickLookService.updateSelection(urls: urls)
}
}
var panel: some View {
RoundedRectangle(cornerRadius: 16)
.stroke(
vm.dragDetectorTargeting
? Color.accentColor.opacity(0.9)
: Color.white.opacity(0.1),
style: StrokeStyle(lineWidth: 3, lineCap: .round, dash: [10])
)
.overlay {
content
.padding()
}
.transaction { transaction in
transaction.animation = vm.animation
}
.contentShape(Rectangle())
.onTapGesture { selection.clear() }
}
var content: some View {
Group {
if tvm.isEmpty {
VStack(spacing: 10) {
Image(systemName: "tray.and.arrow.down")
.symbolVariant(.fill)
.symbolRenderingMode(.hierarchical)
.foregroundStyle(.white, .gray)
.imageScale(.large)
Text("Drop files here")
.foregroundStyle(.gray)
.font(.system(.title3, design: .rounded))
.fontWeight(.medium)
}
} else {
ScrollView(.horizontal) {
HStack(spacing: spacing) {
ForEach(tvm.items) { item in
ShelfItemView(item: item)
.environmentObject(quickLookService)
}
}
}
.padding(-spacing)
.scrollIndicators(.never)
.onDrop(of: [.fileURL, .url, .utf8PlainText, .plainText, .data], isTargeted: $vm.dragDetectorTargeting) { providers in
handleDrop(providers: providers)
}
}
}
.onAppear {
ShelfStateViewModel.shared.cleanupInvalidItems()
}
}
}