// // SpaceLensView.swift // MUA // // Created by Mitchel Volkering on 21/12/2025. // import SwiftUI /// Main SpaceLens visualization - shows bubbles sized by file/folder size struct SpaceLensView: View { let items: [FileItem] let parentItem: FileItem? @Binding var selectedItem: FileItem? let onNavigate: (FileItem) -> Void let onNavigateUp: () -> Void @State private var containerSize: CGSize = .zero private var sortedItems: [FileItem] { items.sorted { $0.size > $1.size } } private var totalSize: Int64 { items.reduce(0) { $0 + $1.size } } var body: some View { GeometryReader { geometry in ZStack { // Background gradient RadialGradient( colors: [ Color(nsColor: .windowBackgroundColor).opacity(0.3), Color(nsColor: .windowBackgroundColor) ], center: .center, startRadius: 50, endRadius: max(geometry.size.width, geometry.size.height) ) .ignoresSafeArea() // Bubbles layout BubblePackingLayout(items: sortedItems, containerSize: geometry.size) { item, frame in BubbleView( item: item, size: min(frame.width, frame.height), isSelected: selectedItem?.id == item.id, onTap: { withAnimation(.spring(response: 0.3)) { selectedItem = item } }, onDoubleTap: { if item.isDirectory { withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) { onNavigate(item) } } } ) .position(x: frame.midX, y: frame.midY) } // Back button when in subfolder if parentItem != nil { VStack { HStack { Button(action: onNavigateUp) { HStack(spacing: 6) { Image(systemName: "chevron.left") Text("Back") } .font(.system(size: 13, weight: .medium)) .padding(.horizontal, 12) .padding(.vertical, 8) .background(.ultraThinMaterial, in: Capsule()) } .buttonStyle(.plain) .shadow(color: .black.opacity(0.1), radius: 4, y: 2) .padding() Spacer() } Spacer() } } // Empty state if items.isEmpty { VStack(spacing: 16) { Image(systemName: "folder.badge.questionmark") .font(.system(size: 48)) .foregroundStyle(.secondary) Text("No items to display") .font(.title3) .foregroundStyle(.secondary) } } } .onAppear { containerSize = geometry.size } .onChange(of: geometry.size) { _, newSize in containerSize = newSize } } } } /// Custom layout that packs bubbles based on size struct BubblePackingLayout: View { let items: [FileItem] let containerSize: CGSize let content: (FileItem, CGRect) -> Content private var bubbleFrames: [(FileItem, CGRect)] { guard !items.isEmpty, containerSize.width > 0, containerSize.height > 0 else { return [] } let totalSize = items.reduce(0) { $0 + $1.size } guard totalSize > 0 else { return [] } let containerArea = containerSize.width * containerSize.height let padding: CGFloat = 8 let minBubbleSize: CGFloat = 40 let maxBubbleSize: CGFloat = min(containerSize.width, containerSize.height) * 0.45 var frames: [(FileItem, CGRect)] = [] var placedCircles: [(center: CGPoint, radius: CGFloat)] = [] for item in items.prefix(50) { // Limit to 50 items for performance let sizeRatio = Double(item.size) / Double(totalSize) let areaForBubble = containerArea * sizeRatio * 0.7 // 70% of proportional area let rawRadius = sqrt(areaForBubble / .pi) let radius = min(max(rawRadius, minBubbleSize / 2), maxBubbleSize / 2) // Find position for this bubble let center = findBubblePosition( radius: radius, placed: placedCircles, containerSize: containerSize, padding: padding ) placedCircles.append((center: center, radius: radius)) let frame = CGRect( x: center.x - radius, y: center.y - radius, width: radius * 2, height: radius * 2 ) frames.append((item, frame)) } return frames } private func findBubblePosition( radius: CGFloat, placed: [(center: CGPoint, radius: CGFloat)], containerSize: CGSize, padding: CGFloat ) -> CGPoint { let centerX = containerSize.width / 2 let centerY = containerSize.height / 2 if placed.isEmpty { return CGPoint(x: centerX, y: centerY) } // Spiral outward from center to find valid position var bestPoint = CGPoint(x: centerX, y: centerY) var found = false for ring in 0..<30 { let ringRadius = CGFloat(ring) * (radius * 0.8) let circumference = 2 * .pi * max(ringRadius, 1) let steps = max(Int(circumference / (radius * 0.5)), 8) for step in 0..= padding, x + radius <= containerSize.width - padding, y - radius >= padding, y + radius <= containerSize.height - padding else { continue } // Must not overlap with placed bubbles var overlaps = false for placed in placed { let distance = hypot(candidate.x - placed.center.x, candidate.y - placed.center.y) if distance < radius + placed.radius + padding { overlaps = true break } } if !overlaps { bestPoint = candidate found = true break } } if found { break } } return bestPoint } var body: some View { ZStack { ForEach(bubbleFrames, id: \.0.id) { item, frame in content(item, frame) } } } } #Preview { let items = [ FileItem(url: URL(fileURLWithPath: "/Applications"), isDirectory: true, size: 15_000_000_000), FileItem(url: URL(fileURLWithPath: "/Users"), isDirectory: true, size: 80_000_000_000), FileItem(url: URL(fileURLWithPath: "/Library"), isDirectory: true, size: 25_000_000_000), FileItem(url: URL(fileURLWithPath: "/System"), isDirectory: true, size: 12_000_000_000), FileItem(url: URL(fileURLWithPath: "/movie.mp4"), isDirectory: false, size: 8_000_000_000), FileItem(url: URL(fileURLWithPath: "/backup.zip"), isDirectory: false, size: 5_000_000_000), FileItem(url: URL(fileURLWithPath: "/photos"), isDirectory: true, size: 3_000_000_000), ] SpaceLensView( items: items, parentItem: nil, selectedItem: .constant(nil), onNavigate: { _ in }, onNavigateUp: {} ) .frame(width: 800, height: 600) }