Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
build-test:
name: Build & Test (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
# macos-15 ships Xcode 16 (Swift 6.1): exercises the older-SDK fallback
# compile path. macos-26 ships Xcode 26 (Swift 6.2): the real shipping
# toolchain, with the macOS 26 SDK and Liquid Glass. The app uses
# MeshGradient (macOS 15) and glassEffect (macOS 26), so it needs the
# macOS 15+ SDK to compile, macos-14 cannot build it.
os: [macos-15, macos-26]
steps:
- uses: actions/checkout@v4

- name: Cache SwiftPM
uses: actions/cache@v4
with:
path: |
.build
~/Library/Caches/org.swift.swiftpm
key: ${{ matrix.os }}-spm-${{ hashFiles('Package.resolved') }}
restore-keys: |
${{ matrix.os }}-spm-

# Static-analysis gate: the Swift compiler (with StrictConcurrency enabled
# in Package.swift) type-checks and concurrency-checks all production code.
- name: Build
run: swift build -v

- name: Test
run: swift test
15 changes: 15 additions & 0 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,15 @@ let package = Package(
products: [
.executable(name: "MacDirStat", targets: ["MacDirStat"])
],
dependencies: [
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.1")
],
targets: [
.executableTarget(
name: "MacDirStat",
dependencies: [
.product(name: "Sparkle", package: "Sparkle")
],
path: "Sources",
swiftSettings: [
.enableExperimentalFeature("StrictConcurrency")
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<p><strong>See where your disk space went.</strong><br/>
A fast, beautiful macOS disk usage visualizer — built entirely in Swift.</p>

[![CI](https://github.com/Ti-03/MacDirStat/actions/workflows/ci.yml/badge.svg)](https://github.com/Ti-03/MacDirStat/actions/workflows/ci.yml)
[![App Store](https://img.shields.io/badge/Mac_App_Store-Download-0D96F6?style=flat-square&logo=apple)](https://apps.apple.com/app/dirstat/id6766033292?mt=12)
[![macOS](https://img.shields.io/badge/macOS-14%2B-black?style=flat-square&logo=apple)](https://www.apple.com/macos/)
[![License](https://img.shields.io/badge/license-AGPL--3.0-blue?style=flat-square)](./LICENSE)
Expand Down
37 changes: 37 additions & 0 deletions Sources/App/GlassCompat.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,77 +3,114 @@ import SwiftUI
// Compatibility shims for macOS 26 Liquid Glass and macOS 14/15 APIs.
// On older OS versions these fall back to NSVisualEffectView-based materials.

// `glassEffect` / `.glass` button styles only exist in the macOS 26 SDK (Xcode 26,
// Swift 6.2). `#available` is a *runtime* check, it does not stop the compiler from
// needing the symbol, so on an older SDK these references fail to compile. Gate them
// at *compile* time with `#if compiler(>=6.2)`; the runtime `#available` stays inside
// so an Xcode-26 build still back-deploys correctly to macOS 14/15.
extension View {
@ViewBuilder
func glassCapsule() -> some View {
#if compiler(>=6.2)
if #available(macOS 26, *) {
self.glassEffect(in: .capsule)
} else {
self.background(.ultraThinMaterial, in: Capsule())
}
#else
self.background(.ultraThinMaterial, in: Capsule())
#endif
}

@ViewBuilder
func glassCircle() -> some View {
#if compiler(>=6.2)
if #available(macOS 26, *) {
self.glassEffect(.regular, in: .circle)
} else {
self.background(.ultraThinMaterial, in: Circle())
}
#else
self.background(.ultraThinMaterial, in: Circle())
#endif
}

@ViewBuilder
func glassCard(cornerRadius: CGFloat) -> some View {
#if compiler(>=6.2)
if #available(macOS 26, *) {
self.glassEffect(.regular, in: .rect(cornerRadius: cornerRadius))
} else {
self.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: cornerRadius))
}
#else
self.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: cornerRadius))
#endif
}

@ViewBuilder
func glassInteractiveCard(cornerRadius: CGFloat) -> some View {
#if compiler(>=6.2)
if #available(macOS 26, *) {
self.glassEffect(.regular.interactive(), in: .rect(cornerRadius: cornerRadius))
} else {
self.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: cornerRadius))
}
#else
self.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: cornerRadius))
#endif
}

@ViewBuilder
func glassTintedCard(tint: Color, cornerRadius: CGFloat) -> some View {
#if compiler(>=6.2)
if #available(macOS 26, *) {
self.glassEffect(.regular.tint(tint), in: .rect(cornerRadius: cornerRadius))
} else {
self.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: cornerRadius))
}
#else
self.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: cornerRadius))
#endif
}

@ViewBuilder
func glassTintedInteractiveCapsule(tint: Color) -> some View {
#if compiler(>=6.2)
if #available(macOS 26, *) {
self.glassEffect(.regular.tint(tint).interactive(), in: .capsule)
} else {
self.background(.ultraThinMaterial, in: Capsule())
}
#else
self.background(.ultraThinMaterial, in: Capsule())
#endif
}

@ViewBuilder
func glassButton() -> some View {
#if compiler(>=6.2)
if #available(macOS 26, *) {
self.buttonStyle(.glass)
} else {
self.buttonStyle(.bordered)
}
#else
self.buttonStyle(.bordered)
#endif
}

@ViewBuilder
func glassProminentButton() -> some View {
#if compiler(>=6.2)
if #available(macOS 26, *) {
self.buttonStyle(.glassProminent)
} else {
self.buttonStyle(.borderedProminent)
}
#else
self.buttonStyle(.borderedProminent)
#endif
}
}

Expand Down
72 changes: 45 additions & 27 deletions Tests/TreemapLayoutTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ import SwiftUI

final class TreemapLayoutTests: XCTestCase {

override func setUp() {
super.setUp()
// ByteFormatter reads "useBinarySize" from UserDefaults. Pin it to the
// default (decimal / SI) so the formatter tests are deterministic
// regardless of any value the app left on this machine.
UserDefaults.standard.set(false, forKey: "useBinarySize")
}

func test_extension_color_map_returns_consistent_color() {
let root = makeTree([("a.pdf", 100), ("b.pdf", 200), ("c.mp4", 300)])
let map = ExtensionColorMap(root: root)
Expand All @@ -19,15 +27,15 @@ final class TreemapLayoutTests: XCTestCase {
}

func test_byte_formatter_kb() {
XCTAssertEqual(ByteFormatter.string(from: 1024), "1.0 KB")
XCTAssertEqual(ByteFormatter.string(from: 1_000), "1.0 KB")
}

func test_byte_formatter_mb() {
XCTAssertEqual(ByteFormatter.string(from: 1024 * 1024), "1.0 MB")
XCTAssertEqual(ByteFormatter.string(from: 1_000_000), "1.0 MB")
}

func test_byte_formatter_gb() {
XCTAssertEqual(ByteFormatter.string(from: 1024 * 1024 * 1024), "1.0 GB")
XCTAssertEqual(ByteFormatter.string(from: 1_000_000_000), "1.0 GB")
}

func test_byte_formatter_bytes() {
Expand All @@ -47,66 +55,76 @@ final class TreemapLayoutTests: XCTestCase {
return root
}

func test_layout_single_item_fills_rect() {
// The layout is a radial sunburst: a node's children fill the angular range
// [-pi/2, 1.5*pi] (a full 2*pi circle), each child's arc proportional to its
// size. Cells sit in radial bands. These tests assert on angles/radii.
// Note: compute() needs min(width, height) > ~212 to produce any cells.

func test_layout_single_item_spans_full_circle() {
let root = makeTree([("a.pdf", 1000)])
let map = ExtensionColorMap(root: root)
let rect = CGRect(x: 0, y: 0, width: 200, height: 100)
let rect = CGRect(x: 0, y: 0, width: 400, height: 400)
let cells = TreemapLayout.compute(root: root, in: rect, colorMap: map)
XCTAssertEqual(cells.count, 1)
XCTAssertTrue(cells[0].rect.intersects(rect))
XCTAssertEqual(cells[0].endAngle - cells[0].startAngle, 2 * .pi, accuracy: 0.001)
}

func test_layout_two_equal_items_fill_rect() {
func test_layout_two_equal_items_split_circle_evenly() {
let root = makeTree([("a.pdf", 500), ("b.mp4", 500)])
let map = ExtensionColorMap(root: root)
let rect = CGRect(x: 0, y: 0, width: 200, height: 100)
let rect = CGRect(x: 0, y: 0, width: 400, height: 400)
let cells = TreemapLayout.compute(root: root, in: rect, colorMap: map)
XCTAssertEqual(cells.count, 2)
let totalArea = cells.reduce(0.0) { $0 + $1.rect.width * $1.rect.height }
XCTAssertEqual(totalArea, rect.width * rect.height, accuracy: 10)
for cell in cells {
XCTAssertEqual(cell.endAngle - cell.startAngle, .pi, accuracy: 0.001)
}
let totalArc = cells.reduce(0.0) { $0 + ($1.endAngle - $1.startAngle) }
XCTAssertEqual(totalArc, 2 * .pi, accuracy: 0.001)
}

func test_layout_cells_dont_overlap() {
func test_layout_sibling_arcs_dont_overlap() {
let root = makeTree([("a.pdf", 300), ("b.mp4", 200), ("c.zip", 100), ("d.txt", 400)])
let map = ExtensionColorMap(root: root)
let rect = CGRect(x: 0, y: 0, width: 400, height: 300)
let rect = CGRect(x: 0, y: 0, width: 500, height: 500)
let cells = TreemapLayout.compute(root: root, in: rect, colorMap: map)
for i in 0..<cells.count {
for j in (i+1)..<cells.count {
let intersection = cells[i].rect.intersection(cells[j].rect)
XCTAssertTrue(intersection.isEmpty || intersection.width < 2 || intersection.height < 2,
"cells \(i) and \(j) overlap: \(intersection)")
}
.sorted { $0.startAngle < $1.startAngle }
for i in 1..<cells.count {
XCTAssertGreaterThanOrEqual(cells[i].startAngle, cells[i - 1].endAngle - 0.001,
"arc \(i) overlaps the previous sibling arc")
}
}

func test_layout_cells_within_parent_rect() {
func test_layout_cells_within_radial_bounds() {
let root = makeTree([("a.pdf", 100), ("b.mp4", 200), ("c.zip", 300)])
let map = ExtensionColorMap(root: root)
let rect = CGRect(x: 0, y: 0, width: 300, height: 200)
let rect = CGRect(x: 0, y: 0, width: 400, height: 400)
let maxR = min(rect.width, rect.height) / 2 - 10
let cells = TreemapLayout.compute(root: root, in: rect, colorMap: map)
XCTAssertFalse(cells.isEmpty)
for cell in cells {
XCTAssertTrue(rect.contains(cell.rect) || rect.insetBy(dx: -2, dy: -2).contains(cell.rect),
"cell \(cell.node.name) out of bounds: \(cell.rect)")
XCTAssertGreaterThanOrEqual(cell.innerRadius, TreemapLayout.centerRadius)
XCTAssertLessThanOrEqual(cell.outerRadius, maxR + 0.001)
XCTAssertGreaterThanOrEqual(cell.startAngle, -.pi / 2 - 0.001)
XCTAssertLessThanOrEqual(cell.endAngle, 1.5 * .pi + 0.001)
}
}

func test_layout_empty_children_returns_no_cells() {
let root = FSNode(url: URL(fileURLWithPath: "/"), name: "/", isDirectory: true, size: 0, fileExtension: "", parent: nil)
let map = ExtensionColorMap(root: root)
let cells = TreemapLayout.compute(root: root, in: CGRect(x: 0, y: 0, width: 200, height: 100), colorMap: map)
let cells = TreemapLayout.compute(root: root, in: CGRect(x: 0, y: 0, width: 400, height: 400), colorMap: map)
XCTAssertTrue(cells.isEmpty)
}

func test_layout_larger_items_get_larger_cells() {
func test_layout_larger_items_get_larger_arcs() {
let root = makeTree([("small.txt", 100), ("large.pdf", 900)])
let map = ExtensionColorMap(root: root)
let rect = CGRect(x: 0, y: 0, width: 400, height: 200)
let rect = CGRect(x: 0, y: 0, width: 400, height: 400)
let cells = TreemapLayout.compute(root: root, in: rect, colorMap: map)
XCTAssertEqual(cells.count, 2)
let largeCell = cells.first { $0.node.name == "large.pdf" }!
let smallCell = cells.first { $0.node.name == "small.txt" }!
XCTAssertGreaterThan(largeCell.rect.width * largeCell.rect.height,
smallCell.rect.width * smallCell.rect.height)
XCTAssertGreaterThan(largeCell.endAngle - largeCell.startAngle,
smallCell.endAngle - smallCell.startAngle)
}
}
Loading
Loading