Internal tooling for Mac utility for storage management.
Swift
98.7%
JSON
1.1%
Markdown
0.2%
//
// 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