Internal tooling for Mac utility for storage management.

Swift 98.7% JSON 1.1% Markdown 0.2%
SpaceLensView.swift 248 lines (9 KB)
//
//  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<Content: View>: 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..<steps {
                let angle = (2 * .pi / CGFloat(steps)) * CGFloat(step)
                let x = centerX + cos(angle) * ringRadius
                let y = centerY + sin(angle) * ringRadius

                // Check if this position is valid
                let candidate = CGPoint(x: x, y: y)

                // Must be within container
                guard x - radius >= 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)
}

About

Internal tooling for Mac utility for storage management.

0 stars
0 forks