diff --git a/Sources/SpaceMatters/Views/ContentView.swift b/Sources/SpaceMatters/Views/ContentView.swift index 0168d0c..54bc461 100644 --- a/Sources/SpaceMatters/Views/ContentView.swift +++ b/Sources/SpaceMatters/Views/ContentView.swift @@ -216,18 +216,49 @@ private struct FDABanner: View { private struct TreemapPane: View { @Bindable var controller: ScanController @Environment(\.theme) private var theme + /// Persisted map projection. Both modes draw the same scan through the same + /// controller (tree, zoom, selection, search) — switching never re-scans. + @AppStorage("mapMode") private var mapMode = MapMode.treemap var body: some View { VStack(spacing: 0) { - Breadcrumb(controller: controller) - .padding(.horizontal, 12) - .padding(.vertical, 7) - .background(theme.panelBackground) + HStack(spacing: 10) { + Breadcrumb(controller: controller) + MapModePicker(mode: $mapMode) + } + .padding(.horizontal, 12) + .padding(.vertical, 7) + .background(theme.panelBackground) Divider().overlay(theme.separator) - TreemapView(controller: controller) + switch mapMode { + case .treemap: TreemapView(controller: controller) + case .sunburst: SunburstView(controller: controller) + } + } + // The treemap has its own darker canvas; the sunburst floats on the + // app's panel colour (same as the neighbouring panes). + .background(mapMode == .treemap ? theme.treemapBackground : theme.panelBackground) + } +} + +/// Treemap ⇄ sunburst switch — two projections of the same scan. +private struct MapModePicker: View { + @Binding var mode: MapMode + + var body: some View { + Picker("", selection: $mode) { + Image(systemName: "rectangle.split.3x3") + .accessibilityLabel("Treemap") + .tag(MapMode.treemap) + Image(systemName: "chart.pie") + .accessibilityLabel("Sunburst") + .tag(MapMode.sunburst) } - .background(theme.treemapBackground) + .pickerStyle(.segmented) + .fixedSize() + .accessibilityLabel("Map style") + .help("Map style: treemap or sunburst — same scan, no re-scan when switching") } } diff --git a/Sources/SpaceMatters/Views/MapChrome.swift b/Sources/SpaceMatters/Views/MapChrome.swift new file mode 100644 index 0000000..876a62b --- /dev/null +++ b/Sources/SpaceMatters/Views/MapChrome.swift @@ -0,0 +1,159 @@ +import SwiftUI +import AppKit + +// Chrome shared by the two GPU map views — treemap (SPEC-10) and sunburst +// (SPEC-13): hover pill, Metal-unavailable state, context-menu building and +// the spoken summary. Both views observe the *same* `ScanController` and walk +// the same `FSNode` tree — the two modes are two projections of one scan, so +// switching between them never re-scans and keeps zoom/selection/search. + +/// The map pane's projection. Both modes render the same scan through the same +/// controller — switching is instant, no re-scan, navigation state carries over. +enum MapMode: String, CaseIterable { + case treemap + case sunburst +} + +/// The hover pill's payload — computed in the NSViews, rendered by SwiftUI. +struct HoverInfo: Equatable { + let title: String + let isDirectory: Bool + let sizeText: String +} + +/// Isolates the hover state so a mouse move only re-evaluates the pill +/// overlay — not the host view's whole body (which would re-diff the +/// representable's inputs and recompute the a11y summary per event). +@MainActor @Observable +final class HoverModel { + var info: HoverInfo? +} + +/// The only view that observes `HoverModel.info` — mouse moves invalidate it alone. +struct HoverPill: View { + let model: HoverModel + var body: some View { + if let hover = model.info { + HoverLabel(title: hover.title, isDirectory: hover.isDirectory, sizeText: hover.sizeText) + .padding(8).allowsHitTesting(false) + } + } +} + +private struct HoverLabel: View { + let title: String + let isDirectory: Bool + let sizeText: String + @Environment(\.theme) private var theme + + var body: some View { + HStack(spacing: 6) { + Image(systemName: isDirectory ? "folder.fill" : "doc.fill") + .foregroundStyle(theme.accent) + Text(title) + .foregroundStyle(theme.textPrimary) + .lineLimit(1) + .truncationMode(.head) + Text(sizeText) + .foregroundStyle(theme.textSecondary) + } + .font(.system(size: 11, weight: .medium)) + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background( + RoundedRectangle(cornerRadius: 7) + .fill(theme.panelBackground.opacity(0.95)) + .overlay(RoundedRectangle(cornerRadius: 7).strokeBorder(theme.separator)) + ) + } +} + +/// Shown when a Metal renderer can't initialise — no GPU device (a VM without +/// paravirtualisation) or a broken runtime shader compile. Every Mac that runs +/// macOS 15 has a Metal GPU, so this is an error state, not a supported mode. +struct MapUnavailableView: View { + @Environment(\.theme) private var theme + + var body: some View { + VStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle") + .font(.system(size: 26)) + .foregroundStyle(theme.textSecondary) + Text("GPU rendering unavailable") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(theme.textPrimary) + Text("SpaceMatters draws its maps with Metal, which this machine doesn't provide.") + .font(.system(size: 11)) + .foregroundStyle(theme.textSecondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 320) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityElement(children: .combine) + } +} + +/// A spoken summary of a (drawing-opaque) map: the zoomed folder plus its +/// largest children by share — so VoiceOver conveys the shape of either view. +@MainActor +func mapAccessibilitySummary(_ controller: ScanController) -> String { + guard let zoom = controller.zoomRoot else { return "" } + let head = "Showing \(zoom.name), \(Format.bytes(zoom.sizeOnDisk))." + let total = max(zoom.sizeOnDisk, 1) + let kids = controller.sortedChildren(zoom).prefix(5).map { + "\($0.name) \(Int((Double($0.sizeOnDisk) / Double(total) * 100).rounded())) percent" + } + return kids.isEmpty ? head : head + " Largest: " + kids.joined(separator: ", ") +} + +/// Context-menu building shared by both maps — same items, same wording, +/// whatever the projection. +@MainActor +enum MapContextMenu { + /// Items for a file tile/arc (`node` is the owning directory). + static func addFileItems(_ menu: NSMenu, fileName: String, node: FSNode, controller: ScanController) { + guard let base = controller.path(for: node) else { return } + let path = (base == "/" ? "/" : base + "/") + fileName + menu.addItem(ClosureMenuItem(title: "Open", symbol: "arrow.up.forward.app") { controller.openItem(path) }) + menu.addItem(ClosureMenuItem(title: "Reveal in Finder", symbol: "folder") { controller.revealInFinder(path) }) + menu.addItem(ClosureMenuItem(title: "Copy Path", symbol: "doc.on.doc") { controller.copyPath(path) }) + } + + /// Items for a directory (or its own-files block) tile/arc. + static func addDirectoryItems(_ menu: NSMenu, node: FSNode, controller: ScanController) { + if node.isDirectory { + menu.addItem(ClosureMenuItem(title: "Zoom In", symbol: "plus.magnifyingglass") { controller.zoom(into: node) }) + } + if controller.canZoomOut { + menu.addItem(ClosureMenuItem(title: "Zoom Out", symbol: "minus.magnifyingglass") { controller.zoomOut() }) + } + guard let path = controller.path(for: node) else { return } + menu.addItem(.separator()) + if controller.isHostScan { + menu.addItem(ClosureMenuItem(title: "Reveal in Finder", symbol: "folder") { controller.revealInFinder(path) }) + menu.addItem(ClosureMenuItem(title: "Copy Path", symbol: "doc.on.doc") { controller.copyPath(path) }) + menu.addItem(.separator()) + let trash = ClosureMenuItem(title: "Move to Trash", symbol: "trash") { + Task { _ = await controller.remove(directory: node, permanently: false) } + } + trash.isEnabled = !controller.isScanning + menu.addItem(trash) + } else { + menu.addItem(ClosureMenuItem(title: "Copy Path (in VM)", symbol: "doc.on.doc") { controller.copyPath(path) }) + } + } +} + +/// An `NSMenuItem` that runs a closure when chosen. +final class ClosureMenuItem: NSMenuItem { + private let handler: () -> Void + init(title: String, symbol: String? = nil, handler: @escaping () -> Void) { + self.handler = handler + super.init(title: title, action: #selector(fire), keyEquivalent: "") + target = self + if let symbol { image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil) } + } + @available(*, unavailable) + required init(coder: NSCoder) { fatalError("init(coder:) unavailable") } + @objc private func fire() { handler() } +} diff --git a/Sources/SpaceMatters/Views/SunburstMetalRenderer.swift b/Sources/SpaceMatters/Views/SunburstMetalRenderer.swift new file mode 100644 index 0000000..e29afb1 --- /dev/null +++ b/Sources/SpaceMatters/Views/SunburstMetalRenderer.swift @@ -0,0 +1,285 @@ +import Metal +import QuartzCore +import simd + +// GPU renderer for the sunburst arcs (SPEC-13). Same engine philosophy as +// `TreemapMetalRenderer` (SPEC-09/10): one instanced draw call, a camera +// matrix, a second per-instance buffer + `morph` uniform so every structural +// change (scan tick, re-root, LOD split) is an animated interpolation. +// +// The geometry differs: an arc is drawn as its world-space *bounding quad* and +// the annular sector is carved per-pixel in the fragment shader (polar signed +// distance) — which is also what anti-aliases the curved edges, so no MSAA. +// Blending is on (the rim needs coverage alpha) and there is no depth buffer: +// arcs are 2D by nature and the draw list arrives in painter's order. +// +// Only the arc *fill* is the GPU's job; layout, colour, hit-testing and the +// selection/hover overlay live in `SunburstNSView`. + +/// One arc, as uploaded to the GPU. Layout matches the MSL `ArcInstance` (48 B). +struct ArcInstance { + /// Bounding quad in rebased world coordinates (x, y, w, h). During a morph + /// this covers the union of the previous and current sectors, so the quad + /// never clips the interpolated shape. + var bbox: SIMD4 + /// The sector: (a0, a1) radians in the world's clockwise-from-noon sweep, + /// (r0, r1) radii in world units. + var arc: SIMD4 + /// Straight sRGB rgb; `w` folds the highlight/search dimming (0…1). + var color: SIMD4 +} + +/// Uniforms shared by both shader stages. Matches the MSL `Uniforms` (96 bytes). +private struct Uniforms { + var viewProj: simd_float4x4 + var borderColor: SIMD4 + /// x = morph progress t (0 → previous instances, 1 → current). + /// y = screen points per world unit (isotropic camera). + /// zw = disc centre, rebased world coordinates. + var params: SIMD4 +} + +final class SunburstMetalRenderer { + let device: MTLDevice + private let queue: MTLCommandQueue + private let pipeline: MTLRenderPipelineState + + // Triple-buffered instance storage — same in-flight discipline as the + // treemap renderer: the CPU never rewrites a buffer the GPU is reading, + // slots rotate only on upload, camera-only frames rebind untouched. + private static let maxInflight = 3 + private let inflight = DispatchSemaphore(value: maxInflight) + private var instanceBuffers: [MTLBuffer?] + private var prevBuffers: [MTLBuffer?] + private var slot = 0 + private var boundInstances: MTLBuffer? + private var boundPrev: MTLBuffer? + private var instanceCount = 0 + + init?() { + guard let device = MTLCreateSystemDefaultDevice(), + let queue = device.makeCommandQueue() else { return nil } + + // SwiftPM (no Xcode Metal build step) → compile the shader source at launch. + guard let library = try? device.makeLibrary(source: Self.shaderSource, options: nil), + let vfn = library.makeFunction(name: "sunburstVertex"), + let ffn = library.makeFunction(name: "sunburstFragment") else { return nil } + + let pd = MTLRenderPipelineDescriptor() + pd.vertexFunction = vfn + pd.fragmentFunction = ffn + pd.colorAttachments[0].pixelFormat = .bgra8Unorm // non-sRGB: colours arrive sRGB-encoded + // Coverage alpha from the polar SDF is what anti-aliases the curved + // edges — classic source-over blending onto the cleared background. + pd.colorAttachments[0].isBlendingEnabled = true + pd.colorAttachments[0].rgbBlendOperation = .add + pd.colorAttachments[0].alphaBlendOperation = .add + pd.colorAttachments[0].sourceRGBBlendFactor = .sourceAlpha + pd.colorAttachments[0].destinationRGBBlendFactor = .oneMinusSourceAlpha + pd.colorAttachments[0].sourceAlphaBlendFactor = .one + pd.colorAttachments[0].destinationAlphaBlendFactor = .oneMinusSourceAlpha + guard let pipeline = try? device.makeRenderPipelineState(descriptor: pd) else { return nil } + + self.device = device + self.queue = queue + self.pipeline = pipeline + self.instanceBuffers = Array(repeating: nil, count: Self.maxInflight) + self.prevBuffers = Array(repeating: nil, count: Self.maxInflight) + } + + /// Upload a new arc set (rotating the buffer slot). `previous` must be + /// index-aligned with `instances` (the morph pairing); pass `nil` when there + /// is nothing to morph from — draws then run with t = 1. + func upload(instances: [ArcInstance], previous: [ArcInstance]?) { + slot = (slot + 1) % Self.maxInflight + instanceCount = instances.count + boundInstances = instances.isEmpty ? nil : copy(instances, into: &instanceBuffers[slot]) + if let previous, previous.count == instances.count, !previous.isEmpty { + boundPrev = copy(previous, into: &prevBuffers[slot]) + } else { + boundPrev = nil + } + } + + private func copy(_ data: [ArcInstance], into store: inout MTLBuffer?) -> MTLBuffer? { + let needed = data.count * MemoryLayout.stride + if store == nil || store!.length < needed { + // Grow with headroom so a slowly-growing arc count doesn't reallocate every frame. + store = device.makeBuffer(length: max(needed, 4096), options: .storageModeShared) + } + guard let buffer = store else { return nil } + data.withUnsafeBytes { raw in + buffer.contents().copyMemory(from: raw.baseAddress!, byteCount: raw.count) + } + return buffer + } + + /// Draw the last uploaded instances into `layer`. A camera-only frame calls + /// this alone — no buffer writes, just a new matrix (and morph progress). + /// Returns `false` when no frame was presented (zero-sized layer, or the + /// drawable pool was dry) so the caller can schedule a retry — a dropped + /// frame here would otherwise leave the map blank until the next data tick. + @discardableResult + func draw(into layer: CAMetalLayer, + camera: Camera, + pointsPerUnit: CGFloat, + center: SIMD2, + morph: Float = 1, + clearColor: SIMD4, + borderColor: SIMD4) -> Bool { + let size = layer.drawableSize + guard size.width > 0, size.height > 0 else { return false } + guard let drawable = layer.nextDrawable() else { return false } + + inflight.wait() + + var uniforms = Uniforms(viewProj: camera.viewProjection, + borderColor: borderColor, + params: SIMD4(boundPrev == nil ? 1 : morph, + Float(pointsPerUnit), center.x, center.y)) + + let rpd = MTLRenderPassDescriptor() + rpd.colorAttachments[0].texture = drawable.texture + rpd.colorAttachments[0].storeAction = .store + rpd.colorAttachments[0].loadAction = .clear + rpd.colorAttachments[0].clearColor = MTLClearColor( + red: Double(clearColor.x), green: Double(clearColor.y), blue: Double(clearColor.z), alpha: 1) + + guard let cmd = queue.makeCommandBuffer(), + let enc = cmd.makeRenderCommandEncoder(descriptor: rpd) else { + inflight.signal() + return false + } + if let buffer = boundInstances, instanceCount > 0 { + enc.setRenderPipelineState(pipeline) + enc.setVertexBuffer(buffer, offset: 0, index: 0) + enc.setVertexBytes(&uniforms, length: MemoryLayout.stride, index: 1) + enc.setVertexBuffer(boundPrev ?? buffer, offset: 0, index: 2) + enc.setFragmentBytes(&uniforms, length: MemoryLayout.stride, index: 1) + // 4-vertex triangle strip (the bounding quad), one instance per arc. + enc.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: 4, + instanceCount: instanceCount) + } + enc.endEncoding() + cmd.addCompletedHandler { [inflight] _ in inflight.signal() } + + // `presentsWithTransaction` (set on the layer for smooth live-resize): + // present synchronously, inside the current CATransaction — see + // `TreemapMetalRenderer.draw`. + if layer.presentsWithTransaction { + cmd.commit() + cmd.waitUntilScheduled() + drawable.present() + } else { + cmd.present(drawable) + cmd.commit() + } + return true + } + + // MARK: - Shader (MSL, compiled at launch) + + private static let shaderSource = """ + #include + using namespace metal; + + constant float TWO_PI = 6.28318530718; + + struct ArcInstance { + float4 bbox; // x, y, w, h (rebased world; prev∪current during a morph) + float4 arc; // a0, a1, r0, r1 + float4 color; // sRGB rgb, w = dim + }; + struct Uniforms { + float4x4 viewProj; + float4 borderColor; + float4 params; // x = morph t, y = points per world unit, zw = centre + }; + struct VOut { + float4 pos [[position]]; + float2 world; + float4 arc; // lerped (a0, a1, r0, r1) + float3 color; + float dim; + }; + + // The bounding quad from the vertex id (triangle strip 0..3). The quad + // comes from the *current* instance (the CPU stores the union box there + // during a morph); the sector params and colour interpolate. + vertex VOut sunburstVertex(uint vid [[vertex_id]], + uint iid [[instance_id]], + const device ArcInstance* inst [[buffer(0)]], + constant Uniforms& u [[buffer(1)]], + const device ArcInstance* prev [[buffer(2)]]) { + float fu = float(vid & 1); + float fv = float((vid >> 1) & 1); + float t = u.params.x; + ArcInstance a = prev[iid]; + ArcInstance b = inst[iid]; + float4 arc = mix(a.arc, b.arc, t); + float4 colDim = mix(a.color, b.color, t); + float2 world = float2(b.bbox.x + fu * b.bbox.z, b.bbox.y + fv * b.bbox.w); + VOut o; + // World is the XZ ground plane (the treemap camera convention). + o.pos = u.viewProj * float4(world.x, 0.0, world.y, 1.0); + o.world = world; + o.arc = arc; + o.color = colDim.rgb; + o.dim = colDim.w; + return o; + } + + // Carve the annular sector out of the quad: signed distances to the radial + // and angular edges, in screen points. The min distance drives coverage + // alpha (anti-aliasing), the border tint, and everything outside discards. + fragment float4 sunburstFragment(VOut in [[stage_in]], + constant Uniforms& u [[buffer(1)]]) { + float s = u.params.y; + float2 d = in.world - u.params.zw; + float r = length(d); + float a0 = in.arc.x, a1 = in.arc.y, r0 = in.arc.z, r1 = in.arc.w; + float span = a1 - a0; + + // Distance to the ring edges, points (negative outside). + float dr = min(r - r0, r1 - r) * s; + + // Distance to the angular edges, points — arc length at this radius. + // A (near-)full circle has no angular edges. + float da = 1e6; + if (span < TWO_PI - 1e-3) { + float theta = atan2(d.y, d.x); + float t = fmod(theta - a0, TWO_PI); + if (t < 0.0) { t += TWO_PI; } + da = min(t, span - t) * max(r, 1e-4) * s; + } + + float edge = min(dr, da); + float alpha = clamp(edge + 0.5, 0.0, 1.0); // ~1pt anti-aliased rim + if (alpha <= 0.001) { discard_fragment(); } + + float3 col = in.color; + + // Cushion: light inner edge → dark outer edge (the treemap ramp, made + // radial) — gives the rings their relief. Skip hairline rings. + float thickness = (r1 - r0) * s; + if (thickness > 6.0) { + float v = clamp((r - r0) / max(r1 - r0, 1e-6), 0.0, 1.0); + float a; float3 sheen; + if (v < 0.45) { a = mix(0.16, 0.0, v / 0.45); sheen = float3(1.0); } + else { a = mix(0.0, 0.20, (v - 0.45) / 0.55); sheen = float3(0.0); } + col = mix(col, sheen, a); + } + + // Dim non-matching arcs (highlight/search), matching the treemap's 72%. + if (in.dim > 0.0) { col = mix(col, float3(0.0), 0.72 * in.dim); } + + // Border: a ~0.6pt anti-aliased edge in the theme's border colour. + if (thickness > 3.0) { + float e = 1.0 - smoothstep(0.1, 1.1, edge); + col = mix(col, u.borderColor.rgb, u.borderColor.a * e); + } + + return float4(col, alpha); + } + """ +} diff --git a/Sources/SpaceMatters/Views/SunburstView.swift b/Sources/SpaceMatters/Views/SunburstView.swift new file mode 100644 index 0000000..9505801 --- /dev/null +++ b/Sources/SpaceMatters/Views/SunburstView.swift @@ -0,0 +1,1163 @@ +import SwiftUI +import AppKit +import Metal +import QuartzCore + +/// Sunburst view (SPEC-13) — the polar projection of the same scan the treemap +/// draws. It reads the *same* `ScanController` observables (tree, version, +/// zoom root, selection, highlight, search), so switching between the two map +/// modes never re-scans and keeps the navigation state; only the geometry +/// changes: depth becomes concentric rings, size becomes angular extent, and +/// the current zoom root becomes the hole in the middle (its total inside). +/// +/// Interaction parity with the treemap: hover pill, click to select/reveal, +/// double-click to dive (re-roots the wheel, morph-animated), double-click the +/// hole or the background to pull back, scroll to pan, pinch/wheel to zoom +/// toward the cursor, right-click menu, search/type-highlight dimming and the +/// selection spotlight. +struct SunburstView: View { + @Bindable var controller: ScanController + @Environment(\.theme) private var theme + @State private var hoverModel = HoverModel() + + /// The one GPU renderer, built once per process and shared across view + /// recreations (the shader is compiled at launch). `nil` = Metal unusable: + /// explicit failure state, same policy as the treemap. + private static let sharedRenderer = SunburstMetalRenderer() + + var body: some View { + if let renderer = Self.sharedRenderer { + SunburstRepresentable( + renderer: renderer, + controller: controller, + theme: theme, + version: controller.version, + zoomRoot: controller.zoomRoot, + zoomRequestID: controller.zoomRequestID, + selection: controller.selection, + selectedExt: controller.selectedExt, + searchMatchIDs: controller.searchMatchIDs, + highlightVersion: controller.highlightVersion, + isDark: theme.isDark, + onHover: { [hoverModel] in hoverModel.info = $0 } + ) + .overlay(alignment: .bottomLeading) { + HoverPill(model: hoverModel) + } + .accessibilityElement(children: .ignore) + .accessibilityLabel("Sunburst") + .accessibilityValue(mapAccessibilitySummary(controller)) + } else { + MapUnavailableView() + } + } +} + +/// Bridges the AppKit `SunburstNSView` into SwiftUI — same contract as the +/// treemap's representable: `updateNSView` pushes the observable inputs, the +/// NSView decides what that implies. +private struct SunburstRepresentable: NSViewRepresentable { + let renderer: SunburstMetalRenderer + let controller: ScanController + let theme: Theme + let version: UInt64 + let zoomRoot: FSNode? + let zoomRequestID: Int + let selection: FSNode? + let selectedExt: ExtKey? + let searchMatchIDs: Set + let highlightVersion: Int + let isDark: Bool + let onHover: (HoverInfo?) -> Void + + func makeNSView(context: Context) -> SunburstNSView { + let view = SunburstNSView(renderer: renderer) + view.onHover = onHover + view.apply(controller: controller, theme: theme, version: version, zoomRoot: zoomRoot, + zoomRequestID: zoomRequestID, selection: selection, + selectedExt: selectedExt, searchMatchIDs: searchMatchIDs, + highlightVersion: highlightVersion) + return view + } + + func updateNSView(_ view: SunburstNSView, context: Context) { + view.onHover = onHover + view.apply(controller: controller, theme: theme, version: version, zoomRoot: zoomRoot, + zoomRequestID: zoomRequestID, selection: selection, + selectedExt: selectedExt, searchMatchIDs: searchMatchIDs, + highlightVersion: highlightVersion) + } +} + +/// The drawn sunburst: arcs live in the persistent `SunburstWorld` and are +/// rendered by the GPU through a camera; hover/selection outlines and the +/// centre label live in a CG overlay that follows the camera. Camera moves are +/// matrix-only frames; the arc set rebuilds only when the camera leaves the +/// built margin, crosses an LOD band, or the data/root changes — and +/// structural changes morph (angles and radii interpolate) instead of jumping. +/// +/// One deliberate difference from the treemap: the camera is **isotropic** +/// (circles must stay circles), so the viewport letterboxes the square world +/// instead of stretching with the window, and there is no aspect re-bake. And +/// `zoomRoot` is not derived from the camera — the wheel re-roots explicitly +/// (double-click, breadcrumb, outline), the camera is free inspection on top. +final class SunburstNSView: NSView, CALayerDelegate { + var onHover: ((HoverInfo?) -> Void)? + + // Inputs (mirrored from SwiftUI via `apply`). + private var controller: ScanController? + private var theme = Theme(isDark: true) + private var version: UInt64 = .max + private var zoomRoot: FSNode? + private var zoomRequestID: Int = .min + private var selection: FSNode? + private var selectedExt: ExtKey? + private var searchMatchIDs: Set = [] + private var highlightVersion: Int = .min + private var isDark = true + + // The world and the camera. + private let world = SunburstWorld() + private var camera = WorldCamera(rect: .zero) + /// Camera glued to the letterboxed fit: a resize re-fits instead of + /// preserving scale. + private var fitMode = true + private var scanRoot: FSNode? + /// The wheel's layout root (= the controller's zoom root). + private var displayRoot: FSNode? + + // Draw list (world terms) & GPU instances (camera-rebased floats). + private var arcs: [SunburstWorld.Arc] = [] + private var arcBoundsScratch: [CGRect] = [] + private var instances: [ArcInstance] = [] + private var rebaseOrigin = CGPoint.zero + private var builtRect = CGRect.zero + private var builtScale: CGFloat = 1 + /// Morph pairing state, keyed by arc identity, in absolute polar terms. + private struct ArcState { + var a0: Double + var a1: Double + var r0: CGFloat + var r1: CGFloat + var color: SIMD4 + } + private var lastTargets: [SunburstWorld.ArcKey: ArcState] = [:] + private var lastPrevs: [SunburstWorld.ArcKey: ArcState] = [:] + private var hovered: SunburstWorld.Arc? + private var hoveredHole = false + + // Morph clock (structural transitions) + camera animation (navigation fits). + private var animLink: CADisplayLink? + private var morphStart: CFTimeInterval = -1 + private var morphT: Float = 1 + private static let morphDuration: CFTimeInterval = 0.32 + private var camFrom = CGRect.zero + private var camTo = CGRect.zero + private var camStart: CFTimeInterval = -1 + private var camAnimating = false + private static let camDuration: CFTimeInterval = 0.5 + + /// One-shot follow-up when a file listing was still loading during a build. + private var fileRetryScheduled = false + private var fileAttempts: [ObjectIdentifier: Int] = [:] + /// One-shot follow-up when a present dropped (dry drawable pool). + private var presentRetryScheduled = false + + // Layers. + private let metalLayer: CAMetalLayer + private let renderer: SunburstMetalRenderer + private let overlayLayer = CALayer() + + // MARK: Caches (persist across rebuilds — same scheme as the treemap) + + private static let brightnessBuckets = 256 + private let paletteCount = Theme.paletteHues.count + private var hueIndexCache: [ObjectIdentifier: Int] = [:] + private var colorLUT: [SIMD4?] = [] + private var colorKey: ColorKey? + private var sizeScratch: [Int64] = [] + + // Theme-derived colours for the overlay + Metal uniforms. + private var backgroundCG = CGColor(gray: 0, alpha: 1) + private var accentShadowCG = CGColor(gray: 0.3, alpha: 0.9) + private var holeCG = CGColor(gray: 0.05, alpha: 1) + private var separatorCG = CGColor(gray: 1, alpha: 0.1) + private var textPrimaryCG = CGColor(gray: 1, alpha: 1) + private var textSecondaryCG = CGColor(gray: 0.7, alpha: 1) + private var backgroundComps = SIMD4(0, 0, 0, 1) + private var borderComps = SIMD4(0, 0, 0, 0.45) + private static let spotlightDimCG = CGColor(gray: 0, alpha: 0.5) + private static let blackBorderCG = CGColor(gray: 0, alpha: 0.85) + private static let whiteCG = CGColor(gray: 1, alpha: 1) + private static let hoverCG = CGColor(gray: 1, alpha: 0.95) + + private struct ColorKey: Equatable { let isDark: Bool; let version: UInt64 } + + // MARK: Setup + + init(renderer: SunburstMetalRenderer) { + self.renderer = renderer + let ml = CAMetalLayer() + ml.device = renderer.device + ml.pixelFormat = .bgra8Unorm // non-sRGB: arc colours are already sRGB-encoded + ml.framebufferOnly = true + ml.isOpaque = true + ml.presentsWithTransaction = false // turned on only while resizing + ml.colorspace = CGColorSpace(name: CGColorSpace.sRGB) + self.metalLayer = ml + super.init(frame: .zero) + wantsLayer = true + // Backing-layer setup identical to the treemap: AppKit resizes the + // CAMetalLayer in lockstep with the view; the overlay sits above it. + layerContentsRedrawPolicy = .never + layer?.masksToBounds = true + + overlayLayer.delegate = self + overlayLayer.needsDisplayOnBoundsChange = true + overlayLayer.contentsScale = 2 + overlayLayer.frame = bounds + layer?.addSublayer(overlayLayer) + + rebuildThemeColors() + } + + override func makeBackingLayer() -> CALayer { + metalLayer + } + + @available(*, unavailable) + required init?(coder: NSCoder) { fatalError("init(coder:) unavailable") } + + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } + + override func viewDidChangeBackingProperties() { + super.viewDidChangeBackingProperties() + guard !inLiveResize else { return } + setScale(window?.backingScaleFactor ?? 2) + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + guard let window else { cancelAnimations(); return } + // (Re)attached — only now does the layer take part in compositing: + // size the drawable for this screen and present. Presenting while + // detached is what drained the drawable pool on a mode switch and + // left the wheel blank (nextDrawable → nil, ~1 s timeout each). + setScale(window.backingScaleFactor) + } + + private func setScale(_ scale: CGFloat) { + metalLayer.contentsScale = scale + updateDrawableSize() + presentArcs() + overlayLayer.contentsScale = scale + overlayLayer.setNeedsDisplay() + } + + private func updateDrawableSize() { + let scale = metalLayer.contentsScale + metalLayer.drawableSize = CGSize(width: max(bounds.width * scale, 1), + height: max(bounds.height * scale, 1)) + } + + override func viewWillStartLiveResize() { + super.viewWillStartLiveResize() + metalLayer.presentsWithTransaction = true + } + + override func viewDidEndLiveResize() { + super.viewDidEndLiveResize() + metalLayer.presentsWithTransaction = false + // No aspect re-bake here — the disc is aspect-free; the camera + // letterboxed all along. + presentArcs() + overlayLayer.setNeedsDisplay() + } + + // MARK: SwiftUI → view + + func apply(controller: ScanController, theme: Theme, version: UInt64, zoomRoot: FSNode?, + zoomRequestID: Int, selection: FSNode?, selectedExt: ExtKey?, + searchMatchIDs: Set, highlightVersion: Int) { + self.controller = controller + + let themeChanged = isDark != theme.isDark + self.theme = theme + self.isDark = theme.isDark + if themeChanged { rebuildThemeColors() } + + // The world's cache root is the *scan* root; the wheel is laid out from + // the zoom root (the hole). + var root = zoomRoot + while let parent = root?.parent { root = parent } + let rootChanged = root !== scanRoot + + let dataChanged = version != self.version || themeChanged + let navRequested = zoomRequestID != self.zoomRequestID && self.zoomRequestID != .min + let firstApply = self.zoomRequestID == .min + let rootMoved = zoomRoot !== displayRoot + let highlightChanged = selectedExt != self.selectedExt + || highlightVersion != self.highlightVersion || searchMatchIDs != self.searchMatchIDs + let selectionChanged = selection !== self.selection + + self.version = version + self.zoomRoot = zoomRoot + self.zoomRequestID = zoomRequestID + self.selectedExt = selectedExt + self.searchMatchIDs = searchMatchIDs + self.highlightVersion = highlightVersion + self.selection = selection + self.scanRoot = root + self.displayRoot = zoomRoot + + guard let root, zoomRoot != nil else { + arcs = []; instances = [] + lastTargets = [:]; lastPrevs = [:] + renderer.upload(instances: [], previous: nil) + presentArcs() + overlayLayer.setNeedsDisplay() + return + } + world.sync(root: root, version: version) + + if rootChanged || firstApply { + // A new scan (or first appearance): fresh camera at fit. + cancelAnimations() + fitMode = true + camera.rect = fitRect(bounds.size) + rebuildArcs(morph: false) + presentArcs() + overlayLayer.setNeedsDisplay() + return + } + + if dataChanged || rootMoved { + // Data tick → same policy as the treemap: morph in calm life, + // teleport during a live scan. A re-root (dive/pull-back) is the + // signature animation: shared arcs sweep to their new spans. + rebuildArcs(morph: !themeChanged && !controller.isScanning) + presentArcs() + overlayLayer.setNeedsDisplay() + } + + if navRequested { + // Explicit navigation: fly the camera back to the letterboxed fit — + // the wheel itself re-centred via the rebuild above. + let fit = fitRect(bounds.size) + if camera.rect != fit { + animateCamera(to: fit) + } + fitMode = true + } + + if !dataChanged && !rootMoved { + if highlightChanged { + repackInstances() // dimming changed; geometry unchanged + presentArcs() + } + if selectionChanged { overlayLayer.setNeedsDisplay() } + } + } + + private func rebuildThemeColors() { + // The wheel floats on the app's panel colour (the treemap keeps its + // dedicated darker canvas — its tiles cover it entirely, the disc + // doesn't); the hole digs below it, on the window colour. + backgroundCG = NSColor(theme.panelBackground).cgColor + accentShadowCG = NSColor(theme.accent).withAlphaComponent(0.9).cgColor + holeCG = NSColor(theme.windowBackground).cgColor + separatorCG = NSColor(theme.separator).cgColor + textPrimaryCG = NSColor(theme.textPrimary).cgColor + textSecondaryCG = NSColor(theme.textSecondary).cgColor + layer?.backgroundColor = backgroundCG + backgroundComps = srgbComps(theme.panelBackground) + borderComps = srgbComps(theme.treemapBorder) + } + + private func srgbComps(_ color: Color) -> SIMD4 { + let ns = NSColor(color).usingColorSpace(.sRGB) ?? NSColor(color) + return SIMD4(Float(ns.redComponent), Float(ns.greenComponent), + Float(ns.blueComponent), Float(ns.alphaComponent)) + } + + // MARK: Camera (isotropic — circles stay circles) + + /// Points per world unit. One number: the viewport always has the view's + /// aspect, so the two axes scale identically. + private var cameraScale: CGFloat { + guard camera.rect.width > 0 else { return 1 } + return bounds.width / camera.rect.width + } + + /// The smallest viewport with the view's aspect that contains the whole + /// (square) world, centred — the sunburst's "fit" framing. Letterboxing in + /// world coordinates is what keeps the mapping isotropic at any window shape. + private func fitRect(_ viewSize: CGSize) -> CGRect { + let wb = world.worldBounds + guard viewSize.width > 1, viewSize.height > 1 else { return wb } + let aspect = viewSize.width / viewSize.height + var w = wb.width, h = wb.height + if aspect >= 1 { w = h * aspect } else { h = w / aspect } + return CGRect(x: wb.midX - w / 2, y: wb.midY - h / 2, width: w, height: h) + } + + /// Keep the viewport inside the fit framing (when smaller) and snap to fit + /// (when it would exceed it). Also bounds the zoom-in so Double math stays sane. + private func clampedViewport(_ rect: CGRect) -> CGRect { + var r = rect + let fit = fitRect(bounds.size) + let minW = fit.width / 1_000_000 + if r.width < minW { + let f = minW / r.width + r = CGRect(x: r.midX - r.width * f / 2, y: r.midY - r.height * f / 2, + width: r.width * f, height: r.height * f) + } + if r.width >= fit.width || r.height >= fit.height { + return fit + } + if r.minX < fit.minX { r.origin.x = fit.minX } + if r.minY < fit.minY { r.origin.y = fit.minY } + if r.maxX > fit.maxX { r.origin.x = fit.maxX - r.width } + if r.maxY > fit.maxY { r.origin.y = fit.maxY - r.height } + return r + } + + // MARK: Resize (AppKit-driven — a camera move, never a re-layout) + + override func setFrameSize(_ newSize: NSSize) { + let oldSize = frame.size + let changed = newSize != oldSize + super.setFrameSize(newSize) + guard changed else { return } + overlayLayer.frame = bounds + updateDrawableSize() + metalLayer.presentsWithTransaction = true + if fitMode { + camera.rect = fitRect(newSize) + } else if oldSize.width > 1, oldSize.height > 1 { + // Free camera: keep the scale, reveal more/less world. Scaling both + // axes by their view ratio preserves the aspect equality (isotropy). + let cx = camera.rect.midX, cy = camera.rect.midY + let w = camera.rect.width * newSize.width / oldSize.width + let h = camera.rect.height * newSize.height / oldSize.height + camera.rect = clampedViewport(CGRect(x: cx - w / 2, y: cy - h / 2, width: w, height: h)) + } + maybeRebuild() + presentArcs() + overlayLayer.setNeedsDisplay() + if !inLiveResize { metalLayer.presentsWithTransaction = false } + } + + // MARK: World → GPU (the only non-camera work) + + /// Rebuild the LOD arc set for the current camera and pack it into GPU + /// instances. `morph` pairs each arc with its previous polar state and + /// animates the transition (scan ticks, refreshes, re-roots, LOD splits). + private func rebuildArcs(morph: Bool) { + guard let controller, let displayRoot, bounds.width > 1, bounds.height > 1, + camera.rect.width > 0, camera.rect.height > 0 else { + arcs = []; instances = [] + lastTargets = [:]; lastPrevs = [:] + renderer.upload(instances: [], previous: nil) + return + } + hovered = nil + hoveredHole = false + let scale = cameraScale + let result = world.build(root: displayRoot, visible: camera.rect, scale: scale, + needsSpans: false, files: { [weak self] node in + self?.fileArcs(for: node, controller: controller) + }) + arcs = result.arcs + builtRect = camera.rect.insetBy(dx: -camera.rect.width * SunburstWorld.buildMargin, + dy: -camera.rect.height * SunburstWorld.buildMargin) + builtScale = scale + if result.pendingFiles { scheduleFileRetry() } + + // Morph pairing: an arc that existed in the previous build sweeps from + // the state it was *actually displaying* (mid-morph aware) and + // cross-fades its colour. An arc that didn't exist appears in place. + let doMorph = morph && !lastTargets.isEmpty && !inLiveResize + packInstances() + var previous: [ArcInstance]? = nil + if doMorph { + let t = morphT + var prev = instances + for i in arcs.indices { + let key = SunburstWorld.ArcKey(arcs[i]) + guard let target = lastTargets[key] else { continue } + var from = target + if t < 1, let p = lastPrevs[key] { + let ct = CGFloat(t), dt = Double(t) + from.a0 = p.a0 + (target.a0 - p.a0) * dt + from.a1 = p.a1 + (target.a1 - p.a1) * dt + from.r0 = p.r0 + (target.r0 - p.r0) * ct + from.r1 = p.r1 + (target.r1 - p.r1) * ct + from.color = p.color + (target.color - p.color) * t + } + prev[i].arc = SIMD4(Float(from.a0), Float(from.a1), + Float(from.r0), Float(from.r1)) + prev[i].color = from.color + // The quad must cover the whole sweep: union of both sectors' + // boxes, stored on both buffers (the vertex reads the current). + let fromBox = SunburstWorld.arcBounds(a0: from.a0, a1: from.a1, + r0: from.r0, r1: from.r1) + let union = arcBoundsScratch[i].union(fromBox) + let bbox = SIMD4(Float(union.minX - rebaseOrigin.x), + Float(union.minY - rebaseOrigin.y), + Float(union.width), Float(union.height)) + instances[i].bbox = bbox + prev[i].bbox = bbox + } + previous = prev + } + recordMorphState(previous: previous) + + renderer.upload(instances: instances, previous: previous) + if previous != nil { startMorph() } else { morphT = 1 } + } + + /// Snapshot the pairing dictionaries for the *next* rebuild. + private func recordMorphState(previous: [ArcInstance]?) { + var targets: [SunburstWorld.ArcKey: ArcState] = [:] + targets.reserveCapacity(arcs.count) + var prevs: [SunburstWorld.ArcKey: ArcState] = [:] + if previous != nil { prevs.reserveCapacity(arcs.count) } + for i in arcs.indices { + let key = SunburstWorld.ArcKey(arcs[i]) + targets[key] = ArcState(a0: arcs[i].a0, a1: arcs[i].a1, + r0: arcs[i].r0, r1: arcs[i].r1, + color: instances[i].color) + if let previous { + let p = previous[i] + prevs[key] = ArcState(a0: Double(p.arc.x), a1: Double(p.arc.y), + r0: CGFloat(p.arc.z), r1: CGFloat(p.arc.w), + color: p.color) + } + } + lastTargets = targets + lastPrevs = prevs + } + + /// Rebuild the camera-dependent parts only when the camera leaves the built + /// margin or its scale drifts past the LOD band. Pure camera frames skip this. + private func maybeRebuild() { + guard displayRoot != nil else { return } + guard builtRect.width > 0 else { + rebuildArcs(morph: false) + return + } + let drift = cameraScale / builtScale + let outgrown = !builtRect.contains(camera.rect) + if outgrown || drift > 1.3 || drift < 0.77 { + // Morph on zoom-driven rebuilds (LOD ring splits/merges); pan-driven + // edge fills appear instantly. + rebuildArcs(morph: drift > 1.3 || drift < 0.77) + } + } + + /// Pack `arcs` into GPU instances: bounding quad rebased on the built rect + /// (floating origin), absolute polar params, packed sRGB colour, dim folded in. + private func packInstances() { + let n = arcs.count + rebaseOrigin = builtRect.origin + let key = ColorKey(isDark: isDark, version: version) + if colorKey != key { + hueIndexCache.removeAll(keepingCapacity: true) + colorLUT = Array(repeating: nil, count: paletteCount * Self.brightnessBuckets) + colorKey = key + } + if sizeScratch.count < n { sizeScratch = [Int64](repeating: 0, count: n) } + if arcBoundsScratch.count != n { arcBoundsScratch = [CGRect](repeating: .zero, count: n) } + var maxSize: Int64 = 1 + for i in 0.. maxSize { maxSize = s } + } + let denom = Double(maxSize) + let highlightName = selectedExt?.displayName + let hasSearch = highlightName == nil && !searchMatchIDs.isEmpty + instances.removeAll(keepingCapacity: true) + instances.reserveCapacity(n) + for i in 0..(Float(box.minX - rebaseOrigin.x), + Float(box.minY - rebaseOrigin.y), + Float(box.width), Float(box.height)), + arc: SIMD4(Float(arc.a0), Float(arc.a1), Float(arc.r0), Float(arc.r1)), + color: color)) + } + } + + /// Highlight/search change: same arcs, new dims — repack and re-upload. + private func repackInstances() { + packInstances() + renderer.upload(instances: instances, previous: nil) + morphT = 1 + recordMorphState(previous: nil) + } + + private func arcSize(_ arc: SunburstWorld.Arc) -> Int64 { + if let file = arc.file { return file.size } + return arc.isFileBlock ? arc.node.directFilesPhysical : arc.node.sizeOnDisk + } + + /// File arcs for a block that crossed the file-LOD threshold. `nil` = + /// listing still loading (a one-shot rebuild retries shortly). + private func fileArcs(for node: FSNode, controller: ScanController) -> [FileTileInfo]? { + guard controller.isHostScan, !controller.isScanning else { return [] } + let items = controller.filesIn(node) + if items.isEmpty && node.directFileCount > 0 { + let id = ObjectIdentifier(node) + let attempts = fileAttempts[id, default: 0] + if attempts < 3 { + fileAttempts[id] = attempts + 1 + return nil // in flight + } + return [] // failed/blocked: keep the aggregate block + } + return items.map { + FileTileInfo(name: $0.name, size: $0.physical, extName: OutlineRowView.extDisplay($0.name), + divergence: $0.divergence) + } + } + + private func scheduleFileRetry() { + guard !fileRetryScheduled else { return } + fileRetryScheduled = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { [weak self] in + guard let self else { return } + self.fileRetryScheduled = false + self.rebuildArcs(morph: true) + self.presentArcs() + } + } + + /// sRGB RGBA for an arc, memoised in the same bounded `(hueIndex, bucket)` + /// LUT as the treemap — identical colour semantics across the two views. + private func arcColor(for arc: SunburstWorld.Arc, weight: Double) -> SIMD4 { + let hueIdx: Int + if let ext = arc.file?.extName { + hueIdx = Theme.stableIndex(ext, paletteCount) + } else { + let oid = ObjectIdentifier(arc.node) + if let cached = hueIndexCache[oid] { + hueIdx = cached + } else { + hueIdx = Theme.stableIndex(arc.node.dominantExt.displayName, paletteCount) + hueIndexCache[oid] = hueIdx + } + } + let bucket = min(Self.brightnessBuckets - 1, Int(weight * Double(Self.brightnessBuckets))) + let lutKey = hueIdx * Self.brightnessBuckets + bucket + if let cached = colorLUT[lutKey] { return cached } + let ns = NSColor(theme.treemapTypeColor( + hueIndex: hueIdx, weight: (Double(bucket) + 0.5) / Double(Self.brightnessBuckets))) + let s = ns.usingColorSpace(.sRGB) ?? ns + let v = SIMD4(Float(s.redComponent), Float(s.greenComponent), Float(s.blueComponent), 1) + colorLUT[lutKey] = v + return v + } + + /// Push the current world to screen through the camera — a pure GPU render. + /// Never while detached (drawables presented to an uncomposited layer are + /// lost and drain the pool); a dropped frame schedules one retry so the + /// wheel can't stay blank until the next data tick. + private func presentArcs() { + guard window != nil else { return } + let viewport = camera.rect.offsetBy(dx: -rebaseOrigin.x, dy: -rebaseOrigin.y) + let presented = renderer.draw( + into: metalLayer, + camera: Camera.ortho(viewport: viewport), + pointsPerUnit: cameraScale, + center: SIMD2(Float(SunburstWorld.center.x - rebaseOrigin.x), + Float(SunburstWorld.center.y - rebaseOrigin.y)), + morph: morphT, + clearColor: backgroundComps, + borderColor: borderComps) + if !presented, !presentRetryScheduled, bounds.width > 1 { + presentRetryScheduled = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { [weak self] in + guard let self else { return } + self.presentRetryScheduled = false + self.presentArcs() + } + } + } + + // MARK: Camera navigation (same gestures as the treemap) + + override func scrollWheel(with event: NSEvent) { + guard displayRoot != nil else { return } + let isGesture = event.phase != [] || event.momentumPhase != [] + if isGesture { + let delta = CGSize(width: event.scrollingDeltaX, height: event.scrollingDeltaY) + camera.pan(byView: delta, viewSize: bounds.size) + camera.rect = clampedViewport(camera.rect) + fitMode = camera.rect == fitRect(bounds.size) + } else { + guard event.scrollingDeltaY != 0 else { return } + let steps = event.hasPreciseScrollingDeltas + ? event.scrollingDeltaY / 12 + : event.scrollingDeltaY + let factor = min(2.0, max(0.5, pow(1.15, steps))) + zoomCamera(by: factor, anchor: topLeftPoint(event)) + } + cameraMoved() + } + + override func magnify(with event: NSEvent) { + guard displayRoot != nil else { return } + zoomCamera(by: 1 + event.magnification, anchor: topLeftPoint(event)) + cameraMoved() + } + + private func zoomCamera(by factor: CGFloat, anchor: CGPoint) { + guard factor > 0 else { return } + camera.zoom(by: factor, anchorView: anchor, viewSize: bounds.size) + camera.rect = clampedViewport(camera.rect) + fitMode = camera.rect == fitRect(bounds.size) + } + + /// Common postlude of every interactive camera move. (No focus derivation: + /// the wheel's root is explicit — the camera is free inspection.) + private func cameraMoved() { + camAnimating = false + maybeRebuild() + presentArcs() + overlayLayer.setNeedsDisplay() + } + + /// Mouse location in top-left view coordinates (the world's orientation). + private func topLeftPoint(_ event: NSEvent) -> CGPoint { + let p = convert(event.locationInWindow, from: nil) + return CGPoint(x: p.x, y: bounds.height - p.y) + } + + // MARK: Animation clock (camera fits + morphs share one link) + + private func animateCamera(to target: CGRect) { + camFrom = camera.rect + camTo = target + camStart = -1 + camAnimating = true + ensureLink() + } + + private func startMorph() { + morphT = 0 + morphStart = -1 + ensureLink() + } + + private func ensureLink() { + guard animLink == nil else { return } + let link = displayLink(target: self, selector: #selector(animStep(_:))) + link.add(to: .main, forMode: .common) + animLink = link + } + + @objc private func animStep(_ link: CADisplayLink) { + var active = false + if camAnimating { + if camStart < 0 { camStart = link.timestamp } + let t = min(1, (link.timestamp - camStart) / Self.camDuration) + camera.rect = interpolatedViewport(easeInOut(t)) + if t >= 1 { + camAnimating = false + camera.rect = camTo + fitMode = camera.rect == fitRect(bounds.size) + maybeRebuild() + overlayLayer.setNeedsDisplay() + } else { + active = true + } + } + if morphT < 1 { + if morphStart < 0 { morphStart = link.timestamp } + let t = min(1, (link.timestamp - morphStart) / Self.morphDuration) + morphT = Float(easeInOut(t)) + if t < 1 { active = true } else { overlayLayer.setNeedsDisplay() } + } + presentArcs() + if !active { + animLink?.invalidate() + animLink = nil + } + } + + private func cancelAnimations() { + animLink?.invalidate() + animLink = nil + camAnimating = false + morphT = 1 + } + + private func interpolatedViewport(_ e: Double) -> CGRect { + let a = camFrom, b = camTo + let aw = max(Double(a.width), 0.5), ah = max(Double(a.height), 0.5) + let w = aw * pow(Double(b.width) / aw, e) + let h = ah * pow(Double(b.height) / ah, e) + let cx = Double(a.midX) + (Double(b.midX) - Double(a.midX)) * e + let cy = Double(a.midY) + (Double(b.midY) - Double(a.midY)) * e + return CGRect(x: cx - w / 2, y: cy - h / 2, width: w, height: h) + } + + private func easeInOut(_ t: Double) -> Double { + t < 0.5 ? 4 * t * t * t : 1 - pow(-2 * t + 2, 3) / 2 + } + + // MARK: Drawing (CG overlay — follows the camera) + + func draw(_ layer: CALayer, in ctx: CGContext) { + guard layer === overlayLayer else { return } + drawOverlay(in: ctx, size: layer.bounds.size) + } + + /// World point → top-left view coordinates. + private func viewPoint(_ p: CGPoint) -> CGPoint { + let (sx, sy) = camera.scale(viewSize: bounds.size) + return CGPoint(x: (p.x - camera.rect.minX) * sx, + y: (p.y - camera.rect.minY) * sy) + } + + /// Annular-sector path in the context's native bottom-left space. The CG + /// flip negates world angles (y-up vs y-down), so a world sweep a0→a1 is + /// drawn −a0→−a1 clockwise. + private func sectorPath(center: CGPoint, rInner: CGFloat, rOuter: CGFloat, + a0: Double, a1: Double, height: CGFloat) -> CGPath { + let c = CGPoint(x: center.x, y: height - center.y) + let path = CGMutablePath() + if a1 - a0 >= 2 * .pi - 1e-9 { + path.addEllipse(in: CGRect(x: c.x - rOuter, y: c.y - rOuter, + width: rOuter * 2, height: rOuter * 2)) + if rInner > 0 { + path.addEllipse(in: CGRect(x: c.x - rInner, y: c.y - rInner, + width: rInner * 2, height: rInner * 2)) + } + return path + } + path.move(to: CGPoint(x: c.x + cos(-a0) * rOuter, y: c.y + sin(-a0) * rOuter)) + path.addArc(center: c, radius: rOuter, startAngle: -a0, endAngle: -a1, clockwise: true) + if rInner > 0 { + path.addLine(to: CGPoint(x: c.x + cos(-a1) * rInner, y: c.y + sin(-a1) * rInner)) + path.addArc(center: c, radius: rInner, startAngle: -a1, endAngle: -a0, clockwise: false) + } else { + path.addLine(to: c) + } + path.closeSubpath() + return path + } + + private func drawOverlay(in ctx: CGContext, size: CGSize) { + guard !camAnimating else { return } // redrawn when the camera lands + let scale = cameraScale + let center = viewPoint(SunburstWorld.center) + + // The hole: the display root's own disc — a subtle panel over the map + // background, the total in the middle (the dotMemory centre). Drawn + // whatever the morph clock says: the hole is static world geometry, + // and hiding it per data tick made the centre blink on live scans. + let holePts = SunburstWorld.holeRadius * scale + if holePts > 4 { + let c = CGPoint(x: center.x, y: size.height - center.y) + let holeRect = CGRect(x: c.x - holePts, y: c.y - holePts, + width: holePts * 2, height: holePts * 2) + ctx.setFillColor(holeCG) + ctx.fillEllipse(in: holeRect) + ctx.setStrokeColor(separatorCG) + ctx.setLineWidth(1) + ctx.strokeEllipse(in: holeRect.insetBy(dx: 0.5, dy: 0.5)) + if holePts > 30 { + // The hole is the wheel's readout: the selected subtree when one + // is active (dotMemory's retained-size centre), the root otherwise. + if let sel = selectedInWheel { + drawCenterLabel(in: ctx, center: c, radius: holePts, + title: hoverPath(sel), bytes: sel.sizeOnDisk) + } else if let rootNode = displayRoot { + drawCenterLabel(in: ctx, center: c, radius: holePts, + title: rootNode.name, bytes: rootNode.sizeOnDisk) + } + } + } + + if let selection, let span = selectionSpan(for: selection) { + // Spotlight: dim everything outside the selected subtree's sector + // (its arc through to the rim — the descendants live outward). + let path = sectorPath(center: center, + rInner: span.rInner * scale, + rOuter: SunburstWorld.discRadius * scale, + a0: span.a0, a1: span.a1, height: size.height) + ctx.setFillColor(Self.spotlightDimCG) + ctx.addRect(CGRect(origin: .zero, size: size)) + ctx.addPath(path) + ctx.fillPath(using: .evenOdd) + + ctx.setStrokeColor(Self.blackBorderCG) + ctx.setLineWidth(4) + ctx.addPath(path) + ctx.strokePath() + + ctx.saveGState() + ctx.setShadow(offset: .zero, blur: 6, color: accentShadowCG) + ctx.setStrokeColor(Self.whiteCG) + ctx.setLineWidth(2) + ctx.addPath(path) + ctx.strokePath() + ctx.restoreGState() + } + + if let hovered { + let path = sectorPath(center: center, + rInner: hovered.r0 * scale, + rOuter: hovered.r1 * scale, + a0: hovered.a0, a1: hovered.a1, height: size.height) + ctx.setStrokeColor(Self.hoverCG) + ctx.setLineWidth(1.5) + ctx.addPath(path) + ctx.strokePath() + } else if hoveredHole, holePts > 4 { + let c = CGPoint(x: center.x, y: size.height - center.y) + ctx.setStrokeColor(Self.hoverCG) + ctx.setLineWidth(1.5) + ctx.strokeEllipse(in: CGRect(x: c.x - holePts, y: c.y - holePts, + width: holePts * 2, height: holePts * 2) + .insetBy(dx: 0.75, dy: 0.75)) + } + } + + /// Selection currently spotlit in the wheel: the node the centre reads out. + /// `nil` when nothing (or the root itself) is selected, or when the + /// selection lives outside the current wheel. + private var selectedInWheel: FSNode? { + guard let selection, let displayRoot, selection !== displayRoot, + world.worldSpan(of: selection, root: displayRoot) != nil else { return nil } + return selection + } + + /// Title + size in the hole, scaled to its projected size (CoreText, drawn + /// in the context's native y-up space). + private func drawCenterLabel(in ctx: CGContext, center: CGPoint, radius: CGFloat, + title: String, bytes: Int64) { + let maxWidth = radius * 1.6 + let sizeFont = NSFont.systemFont(ofSize: min(28, max(11, radius * 0.26)), weight: .semibold) + let nameFont = NSFont.systemFont(ofSize: min(13, max(9, radius * 0.13)), weight: .medium) + let sizeLine = truncatedLine(Format.bytes(bytes), + font: sizeFont, color: textPrimaryCG, width: maxWidth) + let nameLine = truncatedLine(title, font: nameFont, color: textSecondaryCG, width: maxWidth) + let sizeBounds = CTLineGetBoundsWithOptions(sizeLine, .useOpticalBounds) + let nameBounds = CTLineGetBoundsWithOptions(nameLine, .useOpticalBounds) + let gap = radius * 0.10 + ctx.saveGState() + ctx.textMatrix = .identity + ctx.textPosition = CGPoint(x: center.x - sizeBounds.width / 2, + y: center.y - sizeBounds.height * 0.36) + CTLineDraw(sizeLine, ctx) + ctx.textPosition = CGPoint(x: center.x - nameBounds.width / 2, + y: center.y + gap + sizeBounds.height * 0.55) + CTLineDraw(nameLine, ctx) + ctx.restoreGState() + } + + private func truncatedLine(_ text: String, font: NSFont, color: CGColor, width: CGFloat) -> CTLine { + let attr = NSAttributedString(string: text, attributes: [ + .font: font, + .foregroundColor: NSColor(cgColor: color) ?? .white, + ]) + let line = CTLineCreateWithAttributedString(attr) + if CTLineGetBoundsWithOptions(line, .useOpticalBounds).width <= width { return line } + let ellipsis = CTLineCreateWithAttributedString( + NSAttributedString(string: "…", attributes: [.font: font, + .foregroundColor: NSColor(cgColor: color) ?? .white])) + return CTLineCreateTruncatedLine(line, Double(width), .middle, ellipsis) ?? line + } + + private func isUnderMatch(_ node: FSNode) -> Bool { + var current: FSNode? = node + while let n = current { + if searchMatchIDs.contains(ObjectIdentifier(n)) { return true } + current = n.parent + } + return false + } + + /// Sector of a selected node: exact span composed down from the display + /// root (nil when it isn't in the wheel — an ancestor of the hole, another + /// branch, or the hole itself, which would spotlight everything). + private func selectionSpan(for node: FSNode) -> SunburstSpan? { + guard let displayRoot, node !== displayRoot else { return nil } + return world.worldSpan(of: node, root: displayRoot) + } + + // MARK: Hit testing & interaction + + private enum Hit { + case hole + case arc(SunburstWorld.Arc) + } + + /// The arc (or the hole) at a mouse location in view coordinates. Rings are + /// radially disjoint, so the first containing arc is the arc. Arcs below + /// the sub-pixel cull aren't in the list — can't hover what you can't see. + private func hitAt(_ point: CGPoint) -> Hit? { + let pTop = CGPoint(x: point.x, y: bounds.height - point.y) + let p = camera.viewToWorld(pTop, viewSize: bounds.size) + let dx = p.x - SunburstWorld.center.x + let dy = p.y - SunburstWorld.center.y + let r = sqrt(dx * dx + dy * dy) + if r <= SunburstWorld.holeRadius { return .hole } + let theta = atan2(Double(dy), Double(dx)) + for arc in arcs.reversed() where SunburstWorld.contains(arc, r: r, theta: theta) { + return .arc(arc) + } + return nil + } + + private var trackingArea: NSTrackingArea? + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let trackingArea { removeTrackingArea(trackingArea) } + let area = NSTrackingArea(rect: bounds, + options: [.mouseMoved, .mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect], + owner: self, userInfo: nil) + addTrackingArea(area) + trackingArea = area + } + + override func mouseMoved(with event: NSEvent) { + guard !camAnimating else { return } + let hit = hitAt(convert(event.locationInWindow, from: nil)) + var newArc: SunburstWorld.Arc? + var newHole = false + var info: HoverInfo? + switch hit { + case .arc(let arc): + newArc = arc + info = hoverInfo(for: arc) + case .hole: + newHole = true + if let displayRoot { + info = HoverInfo(title: displayRoot.name, isDirectory: true, + sizeText: Self.sizeText(onDisk: displayRoot.sizeOnDisk, + divergence: displayRoot.divergence)) + } + case nil: + break + } + let arcChanged = newArc?.a0 != hovered?.a0 || newArc?.r0 != hovered?.r0 + || (newArc == nil) != (hovered == nil) + if arcChanged || newHole != hoveredHole { + hovered = newArc + hoveredHole = newHole + overlayLayer.setNeedsDisplay() + onHover?(info) + } + } + + override func mouseExited(with event: NSEvent) { + guard hovered != nil || hoveredHole else { return } + hovered = nil + hoveredHole = false + overlayLayer.setNeedsDisplay() + onHover?(nil) + } + + override func mouseUp(with event: NSEvent) { + guard !camAnimating else { return } + let hit = hitAt(convert(event.locationInWindow, from: nil)) + if event.clickCount >= 2 { + guard let controller else { return } + switch hit { + case .arc(let arc): + if let file = arc.file { + if let base = controller.path(for: arc.node) { + controller.openItem((base == "/" ? "/" : base + "/") + file.name) + } + } else if arc.node.isDirectory, !arc.isFileBlock { + controller.zoom(into: arc.node) // apply() re-roots the wheel + } else { + controller.zoomOut() + } + case .hole, nil: + // The hole is the current root — diving out is the only way down. + controller.zoomOut() + } + } else { + switch hit { + case .arc(let arc): controller?.reveal(arc.node) + case .hole: if let displayRoot { controller?.reveal(displayRoot) } + // Clicking the emptiness around the disc resets the selection, + // exactly like clicking the centre. + case nil: if let displayRoot { controller?.reveal(displayRoot) } + } + } + } + + private func hoverInfo(for arc: SunburstWorld.Arc) -> HoverInfo { + if let file = arc.file { + return HoverInfo(title: file.name, isDirectory: false, + sizeText: Self.sizeText(onDisk: file.size, divergence: file.divergence)) + } + let node = arc.node + let divergence = arc.isFileBlock ? nil : node.divergence + return HoverInfo(title: hoverPath(node), isDirectory: node.isDirectory, + sizeText: Self.sizeText( + onDisk: arc.isFileBlock ? node.directFilesPhysical : node.sizeOnDisk, + divergence: divergence)) + } + + /// "75 MB · 512 GB apparent (sparse)" when the arc hides a divergence; + /// plain on-disk size otherwise. + private static func sizeText(onDisk: Int64, divergence: SizeDivergence?) -> String { + guard let d = divergence else { return Format.bytes(onDisk) } + return "\(Format.bytes(onDisk)) · \(Format.bytes(d.apparent)) apparent (\(d.label))" + } + + /// Path of a node relative to the wheel's root — same disambiguation as the + /// treemap's hover. + private func hoverPath(_ node: FSNode) -> String { + guard let displayRoot, node !== displayRoot else { return node.name } + var parts: [String] = [] + var cur: FSNode? = node + while let n = cur, n !== displayRoot { parts.append(n.name); cur = n.parent } + return parts.reversed().joined(separator: "/") + } + + // MARK: Context menu + + override func menu(for event: NSEvent) -> NSMenu? { + guard !camAnimating, let controller else { return nil } + let hit = hitAt(convert(event.locationInWindow, from: nil)) + let menu = NSMenu() + menu.autoenablesItems = false + switch hit { + case .arc(let arc): + if let file = arc.file { + MapContextMenu.addFileItems(menu, fileName: file.name, node: arc.node, controller: controller) + } else { + MapContextMenu.addDirectoryItems(menu, node: arc.node, controller: controller) + } + case .hole: + if let displayRoot { + MapContextMenu.addDirectoryItems(menu, node: displayRoot, controller: controller) + } + case nil: + return nil + } + return menu.numberOfItems > 0 ? menu : nil + } +} diff --git a/Sources/SpaceMatters/Views/SunburstWorld.swift b/Sources/SpaceMatters/Views/SunburstWorld.swift new file mode 100644 index 0000000..97271d3 --- /dev/null +++ b/Sources/SpaceMatters/Views/SunburstWorld.swift @@ -0,0 +1,521 @@ +import CoreGraphics +import Foundation + +// SPEC-13: the persistent sunburst world — the polar sibling of `TreemapWorld` +// (SPEC-10), over the *same* tree. Every node stores its children's angular +// spans *parent-relative, as fractions of the parent span*, decided once and +// revalidated locally (ε-stable). The disc lives in a fixed square world; the +// viewport is only a camera over it. Depth maps to concentric rings whose +// thickness decays geometrically, so an arbitrarily deep tree fits inside a +// finite disc — zooming in (LOD) is what reveals the outer generations. + +/// Angular span of a node's sector, absolute (radians, in the world's +/// clockwise-from-noon convention), plus the radius its own ring starts at. +/// The subtree occupies (a0…a1) × (rInner…disc edge). +struct SunburstSpan { + var a0: Double + var a1: Double + var rInner: CGFloat +} + +@MainActor +final class SunburstWorld { + + /// A laid-out annular sector in absolute world terms. Angles are radians, + /// increasing clockwise on screen (the world is y-down), starting at noon. + struct Arc { + let a0: Double + let a1: Double + let r0: CGFloat + let r1: CGFloat + let node: FSNode + let depth: Int + let isFileBlock: Bool + let file: FileTileInfo? + /// Aggregate remainder closing a ring over sub-pixel siblings (`node` + /// is then the *parent*) — the polar twin of the treemap's underlay. + var isTail = false + } + + /// Identity of an arc across builds — what the morph pairs on (the polar + /// twin of `TreemapWorld.TileKey`). + struct ArcKey: Hashable { + let id: ObjectIdentifier + let isFileBlock: Bool + let isTail: Bool + let fileName: String? + + init(_ arc: Arc) { + id = ObjectIdentifier(arc.node) + isFileBlock = arc.isFileBlock + isTail = arc.isTail + fileName = arc.file?.name + } + } + + struct BuildResult { + var arcs: [Arc] = [] + var spans: [ObjectIdentifier: SunburstSpan] = [:] + /// A file listing was requested but hasn't landed yet — the caller + /// schedules one follow-up rebuild to pick it up. + var pendingFiles = false + } + + /// LOD thresholds, in projected screen points (arc length along the ring / + /// ring thickness). `expandArc > collapseArc` is the hysteresis that keeps + /// rings from popping at the boundary. + struct LOD { + /// Expand a node's children when its own arc is at least this long. + var expandArc: CGFloat = 14 + var collapseArc: CGFloat = 8 + /// …and only if the children's ring would be at least this thick. + var minRing: CGFloat = 2.5 + /// A file block subdivides into individual file arcs above this. + var filesArc: CGFloat = 400 + /// Arcs shorter or thinner than this are culled (sub-pixel). + var cullSize: CGFloat = 0.5 + /// Safety rail against pathological trees; LOD, not this, is the driver. + var maxDepth: Int = 64 + } + + // MARK: World geometry (fixed — the disc is aspect-free) + + /// The world is a fixed square: resize never re-bakes anything, the camera + /// letterboxes (isotropically) instead. + static let side: CGFloat = 1000 + static let center = CGPoint(x: side / 2, y: side / 2) + /// Outer limit the rings converge to. + static let discRadius: CGFloat = 470 + /// The hole: the display root's own disc (total + name live there). + static let holeRadius: CGFloat = 90 + /// Ring thickness decay: ring d is `k^d` times ring 0. The series converges + /// to `discRadius`, so every depth exists geometrically — outer generations + /// are just too thin to draw until the camera closes in. + static let ringDecay = 0.8 + /// The sweep starts at noon and runs clockwise (dotMemory's convention). + static let startAngle = -Double.pi / 2 + + var worldBounds: CGRect { CGRect(x: 0, y: 0, width: Self.side, height: Self.side) } + + /// Inner radius of ring `d` (children of the display root live in ring 0). + static func ringInner(_ d: Int) -> CGFloat { + guard d > 0 else { return holeRadius } + return holeRadius + (discRadius - holeRadius) * CGFloat(1 - pow(ringDecay, Double(min(d, 512)))) + } + + /// Fraction of the visible rect added on every side when building, so small + /// pans reuse the built set instead of rebuilding per frame. + static let buildMargin: CGFloat = 0.25 + + // MARK: Per-node entries (the world's persistent structure) + + /// One node's children layout: items + weights (data half), the sibling + /// order (discrete half — what a re-sort would jump) and parent-relative + /// span fractions (continuous half). + private final class Entry { + var items: [TreemapLayout.Item] + var weights: [Double] + /// Children spans as fractions of the parent span, in `items` order. + var fractions: [(lo: Double, hi: Double)] = [] + /// Version this entry was last validated against (ε check). + var stamp: UInt64 + /// Weight shares at *decision* time — drift is measured against these, + /// so many small steps can't silently reorder the wheel. + var decisionShares: [Double] = [] + + init(items: [TreemapLayout.Item], weights: [Double], stamp: UInt64) { + self.items = items + self.weights = weights + self.stamp = stamp + } + } + + private var entries: [ObjectIdentifier: Entry] = [:] + /// LOD hysteresis state: nodes currently expanded. + private var expanded: Set = [] + /// Weight drift (relative to the sibling total) below which an entry keeps + /// its sibling order and only re-flows the spans — the "local moves" ε. + static let epsilon = 0.02 + + private var version: UInt64 = 0 + private var rootID: ObjectIdentifier? + + // MARK: File-layout LRU (file LOD inside a block arc) + + private struct FileLayout { + let count: Int64 + let files: [FileTileInfo] + let fractions: [(lo: Double, hi: Double)] + } + private var fileLayouts: [ObjectIdentifier: FileLayout] = [:] + private var fileLayoutOrder: [ObjectIdentifier] = [] + private static let fileLayoutCap = 64 + + // MARK: Synchronisation with the data + + /// Align the world with the current tree state. Entries are keyed by node + /// and parent-relative, so they survive re-roots (diving is a layout-root + /// change, not a new world); only a new *scan* root resets everything. + func sync(root: FSNode, version: UInt64) { + let rid = ObjectIdentifier(root) + if rid != rootID { + entries.removeAll(keepingCapacity: true) + fileLayouts.removeAll(keepingCapacity: true) + fileLayoutOrder.removeAll(keepingCapacity: true) + expanded.removeAll(keepingCapacity: true) + rootID = rid + } + self.version = version + } + + // MARK: Building (the LOD walk) + + /// Produce the drawable arc set for the given camera, rooted at `root` (the + /// current zoom root — its children fill the full circle around the hole). + /// Only arcs intersecting the (margin-inflated) visible rect are walked; + /// expansion is decided on projected arc length with hysteresis. + func build(root: FSNode, + visible: CGRect, + scale: CGFloat, + lod: LOD = LOD(), + needsSpans: Bool, + files: (FSNode) -> [FileTileInfo]?) -> BuildResult { + var result = BuildResult() + result.arcs.reserveCapacity(2048) + let inflated = visible.insetBy(dx: -visible.width * Self.buildMargin, + dy: -visible.height * Self.buildMargin) + if needsSpans { + result.spans[ObjectIdentifier(root)] = SunburstSpan( + a0: Self.startAngle, a1: Self.startAngle + 2 * .pi, rInner: 0) + } + walk(node: root, a0: Self.startAngle, a1: Self.startAngle + 2 * .pi, + childRing: 0, inflated: inflated, scale: scale, lod: lod, + needsSpans: needsSpans, files: files, into: &result) + return result + } + + /// Decide whether `node`'s children get drawn in ring `childRing`, then + /// recurse. `a0…a1` is the node's own span (the full circle for the root). + private func walk(node: FSNode, + a0: Double, + a1: Double, + childRing: Int, + inflated: CGRect, + scale: CGFloat, + lod: LOD, + needsSpans: Bool, + files: (FSNode) -> [FileTileInfo]?, + into result: inout BuildResult) { + let rIn = Self.ringInner(childRing) + let rOut = Self.ringInner(childRing + 1) + // Projected size of what the children would get: their arc runs along + // the ring's inner edge; their thickness is the ring's. + let arcLen = (a1 - a0) * Double(rIn) * Double(scale) + let thickness = (rOut - rIn) * scale + + let id = ObjectIdentifier(node) + var expand: Bool + if childRing >= lod.maxDepth || thickness < lod.minRing { + expand = false + } else if childRing == 0 { + expand = true // the hole always shows its first ring + } else if arcLen > lod.expandArc { + expand = true + } else if arcLen < lod.collapseArc { + expand = false + } else { + expand = expanded.contains(id) + } + if expand { expanded.insert(id) } else { expanded.remove(id) } + guard expand else { return } + + let entry = validatedEntry(for: node) + guard !entry.items.isEmpty else { return } + + let span = a1 - a0 + for i in entry.items.indices { + let item = entry.items[i] + let f = entry.fractions[i] + let ca0 = a0 + f.lo * span + let ca1 = a0 + f.hi * span + // Weights descend, so the first sub-pixel child means everyone + // after it is sub-pixel too: close the ring with one aggregate + // remainder in the parent's colour (the polar underlay) instead of + // leaving a background wedge at the ring's tail. + if (ca1 - ca0) * Double(rOut) * Double(scale) < lod.cullSize { + if a1 - ca0 > 0, + Self.arcBounds(a0: ca0, a1: a1, r0: rIn, r1: rOut).intersects(inflated) { + result.arcs.append(Arc(a0: ca0, a1: a1, r0: rIn, r1: rOut, + node: node, depth: childRing, + isFileBlock: false, file: nil, isTail: true)) + } + break + } + // Reach test on the whole *subtree* sector (through to the disc + // edge): unlike the treemap, descendants extend radially outward — + // they can be on screen while the node's own ring is not. + guard Self.arcBounds(a0: ca0, a1: ca1, r0: rIn, r1: Self.discRadius) + .intersects(inflated) else { continue } + let ownVisible = Self.arcBounds(a0: ca0, a1: ca1, r0: rIn, r1: rOut) + .intersects(inflated) + if needsSpans, item.file == nil, !item.isFileBlock { + result.spans[ObjectIdentifier(item.node)] = SunburstSpan(a0: ca0, a1: ca1, rInner: rIn) + } + if item.isFileBlock { + if ownVisible { + emitFileBlock(node: node, a0: ca0, a1: ca1, r0: rIn, r1: rOut, + depth: childRing, scale: scale, lod: lod, + files: files, into: &result) + } + } else { + if ownVisible { + result.arcs.append(Arc(a0: ca0, a1: ca1, r0: rIn, r1: rOut, + node: item.node, depth: childRing, + isFileBlock: false, file: nil)) + } + // Descend while the subtree can still paint a pixel somewhere: + // the angular extent keeps growing with the radius, so measure + // it at the disc edge, not at this ring. + if item.node.isDirectory, + (ca1 - ca0) * Double(Self.discRadius) * Double(scale) >= lod.cullSize { + walk(node: item.node, a0: ca0, a1: ca1, childRing: childRing + 1, + inflated: inflated, scale: scale, lod: lod, + needsSpans: needsSpans, files: files, into: &result) + } + } + } + } + + /// The node's own-files block: a single arc below `filesArc`, individual + /// file arcs above it (file LOD — SPEC-05 generalised, polar edition). + private func emitFileBlock(node: FSNode, + a0: Double, a1: Double, + r0: CGFloat, r1: CGFloat, + depth: Int, + scale: CGFloat, + lod: LOD, + files: (FSNode) -> [FileTileInfo]?, + into result: inout BuildResult) { + let arcLen = (a1 - a0) * Double(r1) * Double(scale) + let thickness = (r1 - r0) * scale + if min(CGFloat(arcLen), thickness) > lod.filesArc, + let layout = fileLayout(for: node, files: files, pending: &result.pendingFiles) { + let span = a1 - a0 + for i in layout.files.indices { + let f = layout.fractions[i] + let fa0 = a0 + f.lo * span + let fa1 = a0 + f.hi * span + // Files are sorted descending too: close the tail of sub-pixel + // ones with one own-files remainder (see `walk`). + if (fa1 - fa0) * Double(r1) * Double(scale) < lod.cullSize { + if a1 - fa0 > 0 { + result.arcs.append(Arc(a0: fa0, a1: a1, r0: r0, r1: r1, + node: node, depth: depth, + isFileBlock: true, file: nil, isTail: true)) + } + break + } + result.arcs.append(Arc(a0: fa0, a1: fa1, r0: r0, r1: r1, + node: node, depth: depth, + isFileBlock: false, file: layout.files[i])) + } + return + } + result.arcs.append(Arc(a0: a0, a1: a1, r0: r0, r1: r1, + node: node, depth: depth, isFileBlock: true, file: nil)) + } + + private func fileLayout(for node: FSNode, + files: (FSNode) -> [FileTileInfo]?, + pending: inout Bool) -> FileLayout? { + let id = ObjectIdentifier(node) + let count = node.directFileCount + if let cached = fileLayouts[id], cached.count == count { + return cached.files.isEmpty ? nil : cached + } + guard let listing = files(node) else { + pending = true // in flight — caller re-builds when it lands + return nil + } + let sorted = listing.filter { $0.size > 0 }.sorted { $0.size > $1.size } + let total = sorted.reduce(0.0) { $0 + Double($1.size) } + var acc = 0.0 + let fractions: [(lo: Double, hi: Double)] = sorted.map { + let lo = total > 0 ? acc / total : 0 + acc += Double($0.size) + return (lo, total > 0 ? acc / total : 0) + } + let layout = FileLayout(count: count, files: sorted, fractions: fractions) + fileLayouts[id] = layout + fileLayoutOrder.removeAll { $0 == id } + fileLayoutOrder.append(id) + if fileLayoutOrder.count > Self.fileLayoutCap { + fileLayouts.removeValue(forKey: fileLayoutOrder.removeFirst()) + } + return layout.files.isEmpty ? nil : layout + } + + // MARK: Entries — lazy build + ε-stable revalidation ("local moves") + + private func validatedEntry(for node: FSNode) -> Entry { + let id = ObjectIdentifier(node) + if let entry = entries[id] { + if entry.stamp != version { revalidate(entry, node: node) } + return entry + } + let built = TreemapLayout.buildItems(node: node, depth: 1, files: nil) + let entry = Entry(items: built.items, weights: built.weights, stamp: version) + decide(entry) + entries[id] = entry + return entry + } + + /// The version moved under this entry: rebuild the items and compare against + /// the *decision-time* shares. Same children and shares within ε → keep the + /// sibling order and re-flow the span fractions only (exact areas, stable + /// wheel). Otherwise adopt the fresh descending order — a local move, + /// animated by the caller's morph. + private func revalidate(_ entry: Entry, node: FSNode) { + let built = TreemapLayout.buildItems(node: node, depth: 1, files: nil) + defer { entry.stamp = version } + if built.items.count == entry.items.count, + entry.decisionShares.count == entry.items.count { + var newWeight: [ArcIdentity: Double] = [:] + newWeight.reserveCapacity(built.items.count) + for i in built.items.indices { + newWeight[ArcIdentity(built.items[i])] = built.weights[i] + } + var remapped: [Double] = [] + remapped.reserveCapacity(entry.items.count) + var sameSet = true + for item in entry.items { + guard let w = newWeight[ArcIdentity(item)] else { sameSet = false; break } + remapped.append(w) + } + let newTotal = remapped.reduce(0, +) + if sameSet, newTotal > 0 { + var maxDrift = 0.0 + for i in remapped.indices { + let drift = abs(remapped[i] / newTotal - entry.decisionShares[i]) + if drift > maxDrift { maxDrift = drift } + } + if maxDrift <= Self.epsilon { + // Continuous-only refresh: same order, exact new spans. + entry.weights = remapped + place(entry) + return + } + } + } + entry.items = built.items + entry.weights = built.weights + decide(entry) + } + + /// A sibling slot's identity, for weight remapping across rebuilds. + private struct ArcIdentity: Hashable { + let id: ObjectIdentifier + let isFileBlock: Bool + init(_ item: TreemapLayout.Item) { + id = ObjectIdentifier(item.node) + isFileBlock = item.isFileBlock + } + } + + /// Adopt the current (descending) order as the decision and snapshot the + /// shares (ε baseline), then flow the spans. + private func decide(_ entry: Entry) { + let total = entry.weights.reduce(0, +) + entry.decisionShares = total > 0 ? entry.weights.map { $0 / total } : [] + place(entry) + } + + /// The continuous half: children spans as fractions of the parent span, + /// contiguous, in `items` order. + private func place(_ entry: Entry) { + let total = entry.weights.reduce(0, +) + guard total > 0 else { + entry.fractions = entry.weights.map { _ in (0, 0) } + return + } + var acc = 0.0 + entry.fractions = entry.weights.map { w in + let lo = acc / total + acc += w + return (lo, acc / total) + } + } + + // MARK: World queries + + /// Absolute span of a node under `root`: compose the parent-relative + /// fractions down the ancestor chain (entries build on demand along the + /// path). `nil` when the node isn't reachable from the display root — e.g. + /// an ancestor of it, or a freed subtree mid-refresh. + func worldSpan(of node: FSNode, root: FSNode) -> SunburstSpan? { + var chain: [FSNode] = [] + var cur: FSNode? = node + while let n = cur, n !== root { chain.append(n); cur = n.parent } + guard cur === root else { return nil } + var a0 = Self.startAngle + var a1 = Self.startAngle + 2 * .pi + var depth = 0 + for child in chain.reversed() { + let entry = validatedEntry(for: child.parent ?? root) + guard let i = entry.items.firstIndex(where: { $0.node === child && !$0.isFileBlock && $0.file == nil }) + else { return nil } + let f = entry.fractions[i] + let span = a1 - a0 + a1 = a0 + f.hi * span // must read the old a0 — computed before a0 moves + a0 += f.lo * span + depth += 1 + } + return SunburstSpan(a0: a0, a1: a1, rInner: depth == 0 ? 0 : Self.ringInner(depth - 1)) + } + + // MARK: Polar helpers (pure geometry, shared with the view) + + /// Normalise any angle into the world's sweep `[startAngle, startAngle+2π)`. + static func normalized(_ angle: Double) -> Double { + let twoPi = 2 * Double.pi + var a = (angle - startAngle).truncatingRemainder(dividingBy: twoPi) + if a < 0 { a += twoPi } + return startAngle + a + } + + /// Whether the polar point `(r, θ)` lies inside the arc. + static func contains(_ arc: Arc, r: CGFloat, theta: Double) -> Bool { + guard r >= arc.r0, r <= arc.r1 else { return false } + let t = normalized(theta) + return t >= arc.a0 && t <= arc.a1 + } + + /// Tight world-space bounding box of an annular sector (candidates: the four + /// corners plus every axis crossing inside the sweep, all at the outer radius). + static func arcBounds(a0: Double, a1: Double, r0: CGFloat, r1: CGFloat) -> CGRect { + var minX = Double.greatestFiniteMagnitude, minY = Double.greatestFiniteMagnitude + var maxX = -Double.greatestFiniteMagnitude, maxY = -Double.greatestFiniteMagnitude + func add(_ a: Double, _ r: CGFloat) { + let x = cos(a) * Double(r), y = sin(a) * Double(r) + minX = min(minX, x); maxX = max(maxX, x) + minY = min(minY, y); maxY = max(maxY, y) + } + add(a0, r0); add(a0, r1); add(a1, r0); add(a1, r1) + if a1 - a0 >= 2 * .pi - 1e-9 { + add(0, r1); add(.pi / 2, r1); add(.pi, r1); add(3 * .pi / 2, r1) + // A (near-)full ring around the centre: the hole isn't part of the + // box shrink — the box is simply the outer square. + } else { + let halfPi = Double.pi / 2 + var m = (a0 / halfPi).rounded(.up) * halfPi + while m <= a1 { + add(m, r1) + m += halfPi + } + } + return CGRect(x: Self.center.x + CGFloat(minX), + y: Self.center.y + CGFloat(minY), + width: CGFloat(maxX - minX), + height: CGFloat(maxY - minY)) + } +} diff --git a/Sources/SpaceMatters/Views/TreemapMetalRenderer.swift b/Sources/SpaceMatters/Views/TreemapMetalRenderer.swift index 028fed6..2f9357f 100644 --- a/Sources/SpaceMatters/Views/TreemapMetalRenderer.swift +++ b/Sources/SpaceMatters/Views/TreemapMetalRenderer.swift @@ -158,17 +158,20 @@ final class TreemapMetalRenderer { /// alone — no buffer writes, just a new matrix (and morph progress). `contentSize` /// is the displayed pixel size; when the drawable is pooled larger than the view /// (SPEC-10 §3.6) the viewport clamps rendering to the visible region. + /// Returns `false` when no frame was presented (zero-sized layer, dry drawable + /// pool) so the caller can schedule a retry instead of staying blank. + @discardableResult func draw(into layer: CAMetalLayer, camera: Camera, pointsPerUnit: (sx: CGFloat, sy: CGFloat) = (1, 1), morph: Float = 1, clearColor: SIMD4, borderColor: SIMD4, - contentSize: CGSize? = nil) { + contentSize: CGSize? = nil) -> Bool { let size = layer.drawableSize - guard size.width > 0, size.height > 0 else { return } + guard size.width > 0, size.height > 0 else { return false } ensureAttachments(size) - guard let drawable = layer.nextDrawable() else { return } + guard let drawable = layer.nextDrawable() else { return false } inflight.wait() @@ -195,7 +198,7 @@ final class TreemapMetalRenderer { guard let cmd = queue.makeCommandBuffer(), let enc = cmd.makeRenderCommandEncoder(descriptor: rpd) else { inflight.signal() - return + return false } if let buffer = boundInstances, instanceCount > 0 { enc.setRenderPipelineState(pipeline) @@ -228,6 +231,7 @@ final class TreemapMetalRenderer { cmd.present(drawable) cmd.commit() } + return true } private func ensureAttachments(_ size: CGSize) { diff --git a/Sources/SpaceMatters/Views/TreemapView.swift b/Sources/SpaceMatters/Views/TreemapView.swift index 87c85fd..f9cc633 100644 --- a/Sources/SpaceMatters/Views/TreemapView.swift +++ b/Sources/SpaceMatters/Views/TreemapView.swift @@ -57,70 +57,9 @@ struct TreemapView: View { } .accessibilityElement(children: .ignore) .accessibilityLabel("Treemap") - .accessibilityValue(treemapSummary) + .accessibilityValue(mapAccessibilitySummary(controller)) } else { - TreemapUnavailableView() - } - } - - /// A spoken summary of the (drawing-opaque) treemap: the zoomed folder plus its - /// largest children by share — so VoiceOver conveys the shape of the map. - private var treemapSummary: String { - guard let zoom = controller.zoomRoot else { return "" } - let head = "Showing \(zoom.name), \(Format.bytes(zoom.sizeOnDisk))." - let total = max(zoom.sizeOnDisk, 1) - let kids = controller.sortedChildren(zoom).prefix(5).map { - "\($0.name) \(Int((Double($0.sizeOnDisk) / Double(total) * 100).rounded())) percent" - } - return kids.isEmpty ? head : head + " Largest: " + kids.joined(separator: ", ") - } -} - -/// Shown when the Metal renderer can't initialise — no GPU device (a VM without -/// paravirtualisation) or a broken runtime shader compile. Every Mac that runs -/// macOS 15 has a Metal GPU, so this is an error state, not a supported mode. -private struct TreemapUnavailableView: View { - @Environment(\.theme) private var theme - - var body: some View { - VStack(spacing: 8) { - Image(systemName: "exclamationmark.triangle") - .font(.system(size: 26)) - .foregroundStyle(theme.textSecondary) - Text("GPU rendering unavailable") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(theme.textPrimary) - Text("SpaceMatters draws the treemap with Metal, which this machine doesn't provide.") - .font(.system(size: 11)) - .foregroundStyle(theme.textSecondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 320) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .accessibilityElement(children: .combine) - } -} - -/// The hover pill's payload — computed in the NSView, rendered by SwiftUI. -struct HoverInfo: Equatable { - let title: String - let isDirectory: Bool - let sizeText: String -} - -/// Isolates the hover state (see `TreemapView.hoverModel`). -@MainActor @Observable -final class HoverModel { - var info: HoverInfo? -} - -/// The only view that observes `HoverModel.info` — mouse moves invalidate it alone. -private struct HoverPill: View { - let model: HoverModel - var body: some View { - if let hover = model.info { - HoverLabel(title: hover.title, isDirectory: hover.isDirectory, sizeText: hover.sizeText) - .padding(8).allowsHitTesting(false) + MapUnavailableView() } } } @@ -229,6 +168,8 @@ final class TreemapNSView: NSView, CALayerDelegate { /// One-shot follow-up when a file listing was still loading during a build. private var fileRetryScheduled = false private var fileAttempts: [ObjectIdentifier: Int] = [:] + /// One-shot follow-up when a present dropped (dry drawable pool). + private var presentRetryScheduled = false // Layers. private let metalLayer: CAMetalLayer @@ -314,7 +255,12 @@ final class TreemapNSView: NSView, CALayerDelegate { override func viewDidMoveToWindow() { super.viewDidMoveToWindow() - if window == nil { cancelAnimations() } // break the CADisplayLink → self cycle on teardown + guard let window else { cancelAnimations(); return } // break the CADisplayLink → self cycle on teardown + // (Re)attached — e.g. after a map-mode switch: size the drawable for + // this screen and present. Presenting while detached drains the + // drawable pool (frames go to an uncomposited layer) and used to leave + // the map black for seconds after a switch. + setScale(window.backingScaleFactor) } private func setScale(_ scale: CGFloat) { @@ -415,6 +361,14 @@ final class TreemapNSView: NSView, CALayerDelegate { } } camera.rect = world.worldBounds + // A view (re)created while zoomed — a map-mode switch, typically — + // must land on the zoomed folder, not the whole world: the + // breadcrumb, list and the other projection all point there. + if let target = zoomRoot, target !== root, + let rect = world.worldRect(of: target, root: root) { + camera.rect = clampedViewport(fitTarget(for: rect)) + fitMode = false + } rebuildTiles(morph: false) presentTiles() overlayLayer.setNeedsDisplay() @@ -718,14 +672,27 @@ final class TreemapNSView: NSView, CALayerDelegate { } /// Push the current world to screen through the camera — a pure GPU render. + /// Never while detached (drawables presented to an uncomposited layer are + /// lost and drain the pool); a dropped frame schedules one retry so the map + /// can't stay blank until the next data tick. private func presentTiles() { + guard window != nil else { return } let viewport = camera.rect.offsetBy(dx: -rebaseOrigin.x, dy: -rebaseOrigin.y) - renderer.draw(into: metalLayer, - camera: Camera.ortho(viewport: viewport), - pointsPerUnit: camera.scale(viewSize: bounds.size), - morph: morphT, - clearColor: backgroundComps, - borderColor: borderComps) + let presented = renderer.draw( + into: metalLayer, + camera: Camera.ortho(viewport: viewport), + pointsPerUnit: camera.scale(viewSize: bounds.size), + morph: morphT, + clearColor: backgroundComps, + borderColor: borderComps) + if !presented, !presentRetryScheduled, bounds.width > 1 { + presentRetryScheduled = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.12) { [weak self] in + guard let self else { return } + self.presentRetryScheduled = false + self.presentTiles() + } + } } // MARK: Camera navigation (SPEC-10 M2 — the map) @@ -1082,79 +1049,11 @@ final class TreemapNSView: NSView, CALayerDelegate { guard let tile = tileAt(p), let controller else { return nil } let menu = NSMenu() menu.autoenablesItems = false - if let file = tile.file, let base = controller.path(for: tile.node) { - let path = (base == "/" ? "/" : base + "/") + file.name - menu.addItem(ClosureMenuItem(title: "Open", symbol: "arrow.up.forward.app") { controller.openItem(path) }) - menu.addItem(ClosureMenuItem(title: "Reveal in Finder", symbol: "folder") { controller.revealInFinder(path) }) - menu.addItem(ClosureMenuItem(title: "Copy Path", symbol: "doc.on.doc") { controller.copyPath(path) }) + if let file = tile.file { + MapContextMenu.addFileItems(menu, fileName: file.name, node: tile.node, controller: controller) } else { - buildDirMenu(menu, node: tile.node, controller: controller) + MapContextMenu.addDirectoryItems(menu, node: tile.node, controller: controller) } return menu.numberOfItems > 0 ? menu : nil } - - private func buildDirMenu(_ menu: NSMenu, node: FSNode, controller: ScanController) { - if node.isDirectory { - menu.addItem(ClosureMenuItem(title: "Zoom In", symbol: "plus.magnifyingglass") { controller.zoom(into: node) }) - } - if controller.canZoomOut { - menu.addItem(ClosureMenuItem(title: "Zoom Out", symbol: "minus.magnifyingglass") { controller.zoomOut() }) - } - guard let path = controller.path(for: node) else { return } - menu.addItem(.separator()) - if controller.isHostScan { - menu.addItem(ClosureMenuItem(title: "Reveal in Finder", symbol: "folder") { controller.revealInFinder(path) }) - menu.addItem(ClosureMenuItem(title: "Copy Path", symbol: "doc.on.doc") { controller.copyPath(path) }) - menu.addItem(.separator()) - let trash = ClosureMenuItem(title: "Move to Trash", symbol: "trash") { - Task { _ = await controller.remove(directory: node, permanently: false) } - } - trash.isEnabled = !controller.isScanning - menu.addItem(trash) - } else { - menu.addItem(ClosureMenuItem(title: "Copy Path (in VM)", symbol: "doc.on.doc") { controller.copyPath(path) }) - } - } -} - -/// An `NSMenuItem` that runs a closure when chosen. -private final class ClosureMenuItem: NSMenuItem { - private let handler: () -> Void - init(title: String, symbol: String? = nil, handler: @escaping () -> Void) { - self.handler = handler - super.init(title: title, action: #selector(fire), keyEquivalent: "") - target = self - if let symbol { image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil) } - } - @available(*, unavailable) - required init(coder: NSCoder) { fatalError("init(coder:) unavailable") } - @objc private func fire() { handler() } -} - -private struct HoverLabel: View { - let title: String - let isDirectory: Bool - let sizeText: String - @Environment(\.theme) private var theme - - var body: some View { - HStack(spacing: 6) { - Image(systemName: isDirectory ? "folder.fill" : "doc.fill") - .foregroundStyle(theme.accent) - Text(title) - .foregroundStyle(theme.textPrimary) - .lineLimit(1) - .truncationMode(.head) - Text(sizeText) - .foregroundStyle(theme.textSecondary) - } - .font(.system(size: 11, weight: .medium)) - .padding(.horizontal, 9) - .padding(.vertical, 5) - .background( - RoundedRectangle(cornerRadius: 7) - .fill(theme.panelBackground.opacity(0.95)) - .overlay(RoundedRectangle(cornerRadius: 7).strokeBorder(theme.separator)) - ) - } } diff --git a/Tests/SpaceMattersTests/SunburstWorldTests.swift b/Tests/SpaceMattersTests/SunburstWorldTests.swift new file mode 100644 index 0000000..b8ff513 --- /dev/null +++ b/Tests/SpaceMattersTests/SunburstWorldTests.swift @@ -0,0 +1,281 @@ +import Testing +import Foundation +@testable import SpaceMatters + +/// SPEC-13 property tests: children partition their parent's span exactly +/// (largest first), the geometry is camera-independent, entries revalidate +/// ε-locally under version bumps, re-rooting composes (`worldSpan`), rings +/// stay inside the disc, LOD expansion follows projected arc length with +/// hysteresis, and the polar helpers (normalise/contains/bounds) hold at the +/// noon seam. +@MainActor +@Suite struct SunburstWorldTests { + + /// root ── a (600: aa 400 / ab 200) / b (300) / 100 B of direct files. + private func makeTree() -> (root: FSNode, a: FSNode, aa: FSNode, ab: FSNode, b: FSNode) { + let root = FSNode(name: "root", parent: nil) + let a = FSNode(name: "a", parent: root) + let aa = FSNode(name: "aa", parent: a) + let ab = FSNode(name: "ab", parent: a) + let b = FSNode(name: "b", parent: root) + aa.finishScan(children: [], filesLogical: 400, filesPhysical: 400, fileCount: 4) + aa.aggPhysical.store(400, ordering: .relaxed) + ab.finishScan(children: [], filesLogical: 200, filesPhysical: 200, fileCount: 2) + ab.aggPhysical.store(200, ordering: .relaxed) + a.finishScan(children: [aa, ab], filesLogical: 0, filesPhysical: 0, fileCount: 0) + a.aggPhysical.store(600, ordering: .relaxed) + b.finishScan(children: [], filesLogical: 300, filesPhysical: 300, fileCount: 3) + b.aggPhysical.store(300, ordering: .relaxed) + root.finishScan(children: [a, b], filesLogical: 100, filesPhysical: 100, fileCount: 2) + root.aggPhysical.store(1000, ordering: .relaxed) + return (root, a, aa, ab, b) + } + + private func build(_ world: SunburstWorld, root: FSNode, + scale: CGFloat = 1, + lod: SunburstWorld.LOD = SunburstWorld.LOD(), + visible: CGRect? = nil) -> SunburstWorld.BuildResult { + world.build(root: root, visible: visible ?? world.worldBounds, + scale: scale, lod: lod, needsSpans: true, files: { _ in [] }) + } + + private let twoPi = 2 * Double.pi + + // Children spans partition the full circle: contiguous, ordered largest + // first, spans proportional to weights. + @Test func ringPartitionsTheCircle() { + let (root, a, _, _, b) = makeTree() + let world = SunburstWorld() + world.sync(root: root, version: 1) + + let result = build(world, root: root) + let ring0 = result.arcs.filter { $0.depth == 0 } + #expect(ring0.count == 3) // a, b, own-files block + #expect(ring0[0].node === a) + #expect(ring0[1].node === b) + #expect(ring0[2].isFileBlock) + + // Contiguous from noon, covering exactly the circle. + #expect(abs(ring0[0].a0 - SunburstWorld.startAngle) < 1e-12) + for i in 1.. 0) + } + + // ε-stability: a small share drift keeps the sibling order and re-flows the + // spans exactly; a big drift re-decides (the wheel re-sorts). + @Test func epsilonKeepsOrderSmallDriftReflows() { + let (root, a, _, _, b) = makeTree() + let world = SunburstWorld() + world.sync(root: root, version: 1) + _ = build(world, root: root) + + // +4 bytes on b: share drift ≈ 0.1% — order must hold, spans exact. + b.aggPhysical.store(304, ordering: .relaxed) + world.sync(root: root, version: 2) + let small = build(world, root: root) + let ring0 = small.arcs.filter { $0.depth == 0 } + #expect(ring0[0].node === a) + #expect(ring0[1].node === b) + #expect(abs((ring0[1].a1 - ring0[1].a0) / twoPi - 304.0 / 1004.0) < 1e-9) + + // b grows past a: definitely > ε — the order re-decides, b leads. + b.aggPhysical.store(700, ordering: .relaxed) + root.aggPhysical.store(1400, ordering: .relaxed) + world.sync(root: root, version: 3) + let big = build(world, root: root) + let ring0b = big.arcs.filter { $0.depth == 0 } + #expect(ring0b[0].node === b) + #expect(ring0b[1].node === a) + } + + // Re-rooting: the new root's children fill the circle, and worldSpan + // composes to what the build emits. + @Test func reRootAndWorldSpanCompose() { + let (root, a, aa, _, _) = makeTree() + let world = SunburstWorld() + world.sync(root: root, version: 1) + + // In the wheel rooted at `root`, aa is a's first child: it starts at + // noon and covers 2/3 of a's 60% share. + let spanAA = world.worldSpan(of: aa, root: root) + #expect(spanAA != nil) + if let s = spanAA { + #expect(abs(s.a0 - SunburstWorld.startAngle) < 1e-9) + #expect(abs((s.a1 - s.a0) / twoPi - 0.4) < 1e-9) + #expect(abs(s.rInner - SunburstWorld.ringInner(1)) < 1e-9) + } + // The build agrees with the composition. + let result = build(world, root: root) + if let arcAA = result.arcs.first(where: { $0.node === aa }), let s = spanAA { + #expect(abs(arcAA.a0 - s.a0) < 1e-9) + #expect(abs(arcAA.a1 - s.a1) < 1e-9) + } + + // Re-root at a: its children partition the full circle again. + let rerooted = build(world, root: a) + let ring0 = rerooted.arcs.filter { $0.depth == 0 } + #expect(ring0.count == 2) + #expect(ring0[0].node === aa) + #expect(abs((ring0[0].a1 - ring0[0].a0) / twoPi - 2.0 / 3.0) < 1e-9) + #expect(abs(ring0.last!.a1 - (SunburstWorld.startAngle + twoPi)) < 1e-9) + + // A node outside the wheel (the old root) has no span. + #expect(world.worldSpan(of: root, root: a) == nil) + } + + // Rings are monotonic across the whole LOD range and converge inside the + // disc — depth never escapes. (Past ~depth 150 the geometric term + // underflows double precision and the radii saturate at the disc edge — + // fine: LOD culls those rings long before, so only boundedness matters there.) + @Test func ringsStayInsideTheDisc() { + var previous = SunburstWorld.ringInner(0) + #expect(abs(previous - SunburstWorld.holeRadius) < 1e-12) + for d in 1...200 { + let r = SunburstWorld.ringInner(d) + if d <= SunburstWorld.LOD().maxDepth { #expect(r > previous) } + else { #expect(r >= previous) } + #expect(r <= SunburstWorld.discRadius + 1e-9) + previous = r + } + } + + // LOD: children appear only when the parent's projected arc is long enough, + // with hysteresis in the in-between band. + @Test func lodExpansionFollowsProjectedArcWithHysteresis() { + let (root, a, _, _, _) = makeTree() + let world = SunburstWorld() + world.sync(root: root, version: 1) + + // a's arc at its children's ring is ~625 pt at scale 1. A threshold + // above that keeps ring 1 collapsed… + var lod = SunburstWorld.LOD() + lod.expandArc = 700 + lod.collapseArc = 300 + let collapsed = build(world, root: root, scale: 1, lod: lod) + #expect(!collapsed.arcs.contains { $0.depth == 1 }) + #expect(collapsed.arcs.contains { $0.node === a }) // a itself is drawn + + // …zooming in (scale 2 → ~1250 pt) expands it… + let expanded = build(world, root: root, scale: 2, lod: lod) + #expect(expanded.arcs.contains { $0.depth == 1 }) + + // …and back at scale 1 (inside the 300–700 band) hysteresis keeps it open. + let sticky = build(world, root: root, scale: 1, lod: lod) + #expect(sticky.arcs.contains { $0.depth == 1 }) + } + + // Sub-pixel siblings: the ring closes with one aggregate remainder arc in + // the parent's colour — never a background wedge at the tail (the polar + // twin of the treemap's underlay). + @Test func subPixelTailClosesTheRing() { + let root = FSNode(name: "root", parent: nil) + let big = FSNode(name: "big", parent: root) + big.finishScan(children: [], filesLogical: 1000, filesPhysical: 1000, fileCount: 1) + big.aggPhysical.store(1000, ordering: .relaxed) + var children = [big] + for i in 0..<60 { + let tiny = FSNode(name: "t\(i)", parent: root) + tiny.finishScan(children: [], filesLogical: 1, filesPhysical: 1, fileCount: 1) + tiny.aggPhysical.store(1, ordering: .relaxed) + children.append(tiny) + } + root.finishScan(children: children, filesLogical: 0, filesPhysical: 0, fileCount: 0) + root.aggPhysical.store(1060, ordering: .relaxed) + + let world = SunburstWorld() + world.sync(root: root, version: 1) + // At scale 0.5 a 1/1060 share is ~0.49 pt at ring 0's outer edge — culled. + let result = build(world, root: root, scale: 0.5) + let ring0 = result.arcs.filter { $0.depth == 0 } + #expect(ring0.count == 2) + #expect(ring0[0].node === big) + if let tail = ring0.last, ring0.count == 2 { + #expect(tail.isTail) + #expect(tail.node === root) + #expect(abs(tail.a0 - ring0[0].a1) < 1e-9) + #expect(abs(tail.a1 - (SunburstWorld.startAngle + twoPi)) < 1e-9) + } + } + + // A leaf directory re-rooted: its own files become one full-circle block arc. + @Test func leafRootIsAFullCircleFileBlock() { + let (root, _, aa, _, _) = makeTree() + let world = SunburstWorld() + world.sync(root: root, version: 1) + let result = build(world, root: aa) + #expect(result.arcs.count == 1) + if let block = result.arcs.first { + #expect(block.isFileBlock) + #expect(abs((block.a1 - block.a0) - twoPi) < 1e-9) + #expect(abs(block.r0 - SunburstWorld.holeRadius) < 1e-9) + } + } + + // Polar helpers: containment works across the noon seam, and the bounding + // box really bounds the sector. + @Test func polarHelpersHoldAtTheSeam() { + let start = SunburstWorld.startAngle + let arc = SunburstWorld.Arc(a0: start + twoPi - 0.2, a1: start + twoPi, + r0: 100, r1: 200, + node: FSNode(name: "x", parent: nil), + depth: 3, isFileBlock: false, file: nil) + // Just before noon (expressed as a negative offset) is inside… + #expect(SunburstWorld.contains(arc, r: 150, theta: start - 0.1)) + // …just after noon belongs to the seam's other side. + #expect(!SunburstWorld.contains(arc, r: 150, theta: start + 0.1)) + // Radial bounds are exclusive of the neighbouring rings. + #expect(!SunburstWorld.contains(arc, r: 99, theta: start - 0.1)) + #expect(!SunburstWorld.contains(arc, r: 201, theta: start - 0.1)) + + // arcBounds contains a dense sample of the sector (any sweep, any ring). + let sweeps: [(Double, Double)] = [ + (start, start + 0.7), + (start + 1.2, start + 4.0), + (start, start + twoPi), + ] + for (a0, a1) in sweeps { + let box = SunburstWorld.arcBounds(a0: a0, a1: a1, r0: 50, r1: 300) + for i in 0...24 { + let ang = a0 + (a1 - a0) * Double(i) / 24 + for r in [50.0, 175.0, 300.0] { + let p = CGPoint(x: SunburstWorld.center.x + CGFloat(cos(ang) * r), + y: SunburstWorld.center.y + CGFloat(sin(ang) * r)) + #expect(box.insetBy(dx: -1e-6, dy: -1e-6).contains(p), + "(\(ang), \(r)) escaped \(box)") + } + } + } + } +} diff --git a/docs/specs/README.md b/docs/specs/README.md index fd421f4..0149dde 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -23,5 +23,9 @@ Guiding principle (carried over from the plan): **integrity > performance > robu | [SPEC-07](SPEC-07-distribution.md) | **Signed/notarized DMG distribution via GitHub** (v1) | J1.1–J1.5, D4 | 1–2 d | — | | [SPEC-08](SPEC-08-accessibility.md) | Full accessibility & i18n | J10.1–J10.4, J9.5 | 1–2 d | SPEC-01 (partial) | | [SPEC-09](SPEC-09-gpu-treemap-rendering.md) | **3D-native** GPU treemap rendering (Metal, ortho 2D projection) | perf-resize (PR #17) | 2–3 d | PR #17 (NSView seam, done) | +| [SPEC-10](SPEC-10-world-treemap.md) | Persistent **world** treemap: continuous camera, LOD, morph | perf-resize (PR #24) | 3–4 d | SPEC-09 | +| [SPEC-11](SPEC-11-incremental-refresh.md) | Incremental per-directory refresh — the living map | SPEC-02/04 follow-up | 2–3 d | SPEC-02, SPEC-04 | +| [SPEC-12](SPEC-12-auto-update.md) | Auto-updates (Sparkle 2) | J1.x, D4 | 1 d | SPEC-07 | +| [SPEC-13](SPEC-13-sunburst.md) | **Sunburst view** — polar projection of the world (same scan, no re-scan on switch) | feature request | 1–2 d | SPEC-09, SPEC-10 | **Recommended order**: SPEC-02 (unblocks SPEC-04 and closes A6/A7 cleanly) → SPEC-01 → SPEC-03 → SPEC-08 → SPEC-04 → SPEC-05/06/07 depending on product priorities. diff --git a/docs/specs/SPEC-13-sunburst.md b/docs/specs/SPEC-13-sunburst.md new file mode 100644 index 0000000..585def2 --- /dev/null +++ b/docs/specs/SPEC-13-sunburst.md @@ -0,0 +1,65 @@ +# SPEC-13 — Sunburst view: the polar projection of the world (GPU, progressive, interaction parity) + +> **Request**: a dotMemory-style sunburst as a second map mode — always progressive, always GPU-rendered, with the same interactions as the treemap, and **fed by the same data so switching modes never re-scans**. +> **Status**: ✅ **IMPLEMENTED** (branch `feat/sunburst-view`). Builds directly on SPEC-09 (GPU renderer) and SPEC-10 (persistent world + camera + LOD + morph); generalises nothing new in the model layer. + +## 1. Objective + +A second projection of the scanned tree: depth becomes concentric rings, size becomes angular extent, the current zoom root becomes the hole in the middle (name + total inside, like dotMemory's retained-size centre). One toggle in the map pane switches projections instantly — same `ScanController`, same `FSNode` tree, same `version`/`zoomRoot`/`selection`/search/type-highlight state. The sunburst must hold the SPEC-10 bar: camera moves are matrix-only frames, structural changes morph instead of teleporting, detail follows the camera (LOD), and a live scan streams into the wheel at tick rate. + +## 2. Current state of the code it builds on (verified) + +- **The world pattern**: [TreemapWorld](../../Sources/SpaceMatters/Views/TreemapWorld.swift) — per-node entries with parent-relative geometry, ε-stable revalidation ("local moves"), LOD walk over the visible rect, morph pairing by tile identity. +- **The renderer pattern**: [TreemapMetalRenderer](../../Sources/SpaceMatters/Views/TreemapMetalRenderer.swift) — one instanced draw call, triple-buffered instances, previous-buffer + `morph` uniform, camera-only frames that write no buffer. +- **The item source**: [TreemapLayout.buildItems](../../Sources/SpaceMatters/Views/TreemapLayout.swift#L209) — a node's children + own-files block, weighted and sorted descending. Both projections consume exactly this. +- **The controller contract**: `version`, `zoomRoot`/`zoomRequestID`, `selection`, `selectedExt`, `searchMatchIDs`, `zoom(into:)`/`zoomOut()`/`reveal(_:)` — the whole navigation surface is view-agnostic already. + +## 3. Design + +### 3.1 `SunburstWorld` — angles are the new rects +[SunburstWorld](../../Sources/SpaceMatters/Views/SunburstWorld.swift) mirrors `TreemapWorld`, polar: + +- Each node's entry stores its children's spans **parent-relative, as fractions of the parent span** — never absolute, never window-dependent. Absolute spans compose down the ancestor chain (`worldSpan(of:root:)`), exactly like `worldRect(of:root:)`. +- **ε-revalidation**: the discrete decision is the *sibling order* (weights sorted descending). A version bump re-flows exact spans while share drift stays ≤ 2 %; past ε the order re-decides — a local move, animated by the caller's morph. Entries are keyed by node and survive re-roots (diving is a layout-root change, not a new world). +- **Rings**: the world is a fixed 1000×1000 square (the disc is aspect-free — resize never re-bakes). Ring `d` starts at `hole + (R − hole)(1 − k^d)` with `k = 0.8`: thickness decays geometrically, the series converges to the disc edge, so **an arbitrarily deep tree fits inside a finite disc** — outer generations are simply too thin to draw until the camera closes in. Depth is a rendering decision, as SPEC-10 demands. +- **LOD**: expand a node's children when its projected arc length at the children's ring crosses `expandArc` (14 pt), collapse below `collapseArc` (8 pt), hysteresis in between; skip rings thinner than 2.5 pt; cull arcs shorter than 0.5 pt. The visibility test uses the **subtree's** sector (through to the disc edge): descendants extend radially outward, so they can be on screen while the node's own ring is not — the polar difference from the treemap's "children inside the parent rect". +- **Tail underlay**: weights descend, so the first sub-pixel child means the rest of the ring is sub-pixel too — one aggregate remainder arc in the parent's colour closes the ring (same cure as the treemap's under-children tile; without it every ring showed a background wedge before noon). +- **File LOD**: a block arc subdivides into individual file arcs above 400 pt (SPEC-05 generalised, polar edition), same LRU + pending-retry scheme. + +### 3.2 `SunburstMetalRenderer` — bounding quad + polar SDF +[SunburstMetalRenderer](../../Sources/SpaceMatters/Views/SunburstMetalRenderer.swift) keeps the SPEC-09 discipline (triple buffering, morph buffer pair, camera-only draws) with different geometry: each arc is its world-space **bounding quad**, and the fragment shader carves the annular sector by signed distance in polar coordinates (radial + angular edge distances, in screen points). That distance drives coverage alpha (anti-aliasing of the curved edges — no MSAA), the ~0.6 pt border tint, the radial cushion (the treemap ramp, light inner → dark outer), and the highlight/search dim. Blending is on, there is no depth buffer (arcs are 2D; the list arrives in painter's order). During a morph the quad is the **union** of the previous and current sectors, so the interpolated shape never clips; angles/radii/colour lerp in the shader. + +### 3.3 `SunburstNSView` — isotropic camera, explicit re-root +[SunburstView](../../Sources/SpaceMatters/Views/SunburstView.swift) mirrors `TreemapNSView` with two deliberate differences: + +- **The camera is isotropic** — circles must stay circles. The viewport always has the view's aspect and letterboxes the square world (`fitRect`); zoom/pan/clamp/scale-preserving-resize all preserve the equality. No aspect re-bake exists at all. +- **`zoomRoot` is not derived from the camera.** The wheel re-roots explicitly (double-click an arc, double-click the hole/background to pull back, breadcrumb, outline, ⌘↑) — the shared arcs sweep to their new spans (the signature dotMemory animation, which falls out of the generic morph pairing). The free camera is inspection on top; deriving the root from it would re-layout under the user's fingers. + +Everything else is parity: hover pill (same chrome), click reveals, right-click menu (same items via [MapContextMenu](../../Sources/SpaceMatters/Views/MapChrome.swift)), scroll pan / pinch & wheel zoom toward the cursor, selection spotlight (even-odd sector dim from the node's span through the rim), search/type dimming folded into instances, live-scan ticks teleport while scanning and morph after — the SPEC-10 policy verbatim. The hole is drawn in the overlay (panel disc + hairline + CoreText name/total scaled to its projected size) and hit-tests as the display root. + +### 3.4 One scan, two projections +The toggle ([MapModePicker](../../Sources/SpaceMatters/Views/ContentView.swift), `@AppStorage("mapMode")`) swaps the SwiftUI subview; both read the same controller observables, so **switching is instant and re-scans nothing** — zoom root, selection, search and highlight all carry over. Shared chrome (hover pill, unavailable state, menus, a11y summary) moved to [MapChrome.swift](../../Sources/SpaceMatters/Views/MapChrome.swift); the colour semantics are the treemap's exact palette (hue = dominant type, brightness = relative weight) so the wheel matches the File-types legend and the type-highlight interaction keeps meaning across modes. + +### 3.5 Decisions ⚖️ +- **Geometric ring decay** over constant thickness: constant rings either clip deep trees or shrink everything; decay gives dotMemory's look (outer rings visibly thinner) *and* makes zoom-reveals-depth true by construction. +- **Type-hue palette** over dotMemory's hue-by-angle rainbow: colour already means "file type" app-wide (legend, treemap, highlight); the sorted-by-size wheel reads rainbow-ish anyway. +- **A separate renderer class** over generalising `TreemapMetalRenderer`: the shared part is ~80 lines of buffer rotation; the instance formats, uniforms, blending and depth policies all differ. Keeping the flagship renderer untouched beat deduplicating it. + +## 4. Verification + +- `swift test`: **116 tests green**, including 8 sunburst property tests ([SunburstWorldTests](../../Tests/SpaceMattersTests/SunburstWorldTests.swift)): ring partition (contiguous, largest-first, share-exact), camera independence, ε order-keeping vs re-decide, re-root + `worldSpan` composition, ring monotonicity/boundedness, LOD hysteresis, sub-pixel tail closure, seam-safe polar containment and bbox tightness. +- Live run (debug bundle, `--open` on this repo): wheel renders with hole label, multi-ring LOD, palette/legend agreement, borders + cushion; scan stats identical across modes. Driven-gesture verification (dive morph, camera zoom, spotlight) was cut short — synthetic clicks landed in the foreground browser once the user retook the machine — so the gesture checklist below is manual: + - double-click arc → re-root morph; double-click hole/background → pull back; + - wheel/pinch zoom toward cursor reveals deeper rings; scroll pans; resize letterboxes; + - hover pill + outline (arcs and hole); right-click menu; list/breadcrumb sync both ways; + - search and type-highlight dim; selection spotlights the sector; theme toggle recolours. + +## 5. Risks & assumptions 🔬 + +- **Angular AA at extreme zoom**: arc params are Float on the GPU; edge distances multiply by up to the 10⁶× viewport clamp. Same envelope philosophy as the treemap's floating origin; no shimmer seen at realistic zooms — re-check if the clamp ever loosens. +- **Noon seam**: the first and last arcs each draw their own border at 12 o'clock, reading as one continuous radial line hole→rim (dotMemory shows the same). Cosmetic; a half-border at the two seam edges would hide it if it ever grates. +- **Mode switch recreates the NSView** — and that is a *Metal lifecycle hazard*, found the hard way: a view that presents while detached sends its drawables to an uncomposited layer, draining the 3-deep pool; `nextDrawable()` then times out (~1 s each) and the freshly attached view shows black — for seconds on the treemap (until a data tick re-presented) and indefinitely on the sunburst (nothing re-poked it; only the CG overlay drew). Cure, both views: never present while `window == nil`, re-present on attach (`viewDidMoveToWindow` → `setScale`), and `draw()` reports a dropped frame so the view schedules one retry. The camera state itself resets cheaply — except `zoomRoot`, which both views now honour on (re)creation so the projections always agree on where you are. + +## 6. Effort & dependencies + +Done in one pass on `feat/sunburst-view` (~1 500 new lines + shared-chrome extraction). Depends on SPEC-09/SPEC-10 (shipped). Follow-ups that inherit for free later: the 3D extrusion path (SPEC-09 §9) never applied to the sunburst — it stays 2D by design.