diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e550a95 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..0c1c90a --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,61 @@ +name: Deploy Pages + +on: + push: + branches: [main] + paths: + - "guide/**" + - "mkdocs.yml" + - "docs/**" + - ".github/workflows/pages.yml" + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Install MkDocs Material + run: pip install mkdocs-material + + # Builds the docs-as-code site (tutorial/how-to/reference) from guide/, + # per mkdocs.yml's `site_dir: site`. + - name: Build docs site + run: mkdocs build --strict + + # Combine the existing landing page (docs/index.html, screenshots, logo) + # with the freshly built docs site, so the same GitHub Pages URL keeps + # serving the landing page at / while the docs move to /guide/. + - name: Assemble Pages artifact + run: | + mkdir -p _site/guide + cp docs/index.html docs/privacy.html docs/logo.png docs/screenshot*.png _site/ + cp -r site/. _site/guide/ + + - uses: actions/upload-pages-artifact@v3 + with: + path: _site + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 4d161be..45f6a39 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,9 @@ DerivedData/ # Test results *.xcresult +# MkDocs build output (built by CI, not committed) +/site/ + # Amore / Sparkle private signing keys — NEVER commit these *.private sparkle_private_key diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..e094785 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "16096b517bff461c6fe8f1407ae92f21b2980ceea27de1c7be2b00618e486ccd", + "pins" : [ + { + "identity" : "sparkle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/sparkle-project/Sparkle", + "state" : { + "revision" : "d46d456107feacc80711b21847b82b07bd9fb46e", + "version" : "2.9.3" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift index 810ca58..61f3c68 100644 --- a/Package.swift +++ b/Package.swift @@ -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") diff --git a/README.md b/README.md index f0c389c..6b4aa8b 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@

See where your disk space went.
A fast, beautiful macOS disk usage visualizer — built entirely in Swift.

+[![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) @@ -72,6 +73,10 @@ Pure Swift + SwiftUI — no Electron, no web views, no dependencies. | Haptics | `NSHapticFeedbackManager` — intensity scales with file size | | Duplicates | SHA-256 content hashing on a background actor | +## Documentation + +Tutorial, how-to guides, and API reference: **[docs site](https://ti-03.github.io/MacDirStat/guide/)**. Architecture decisions are recorded in [`docs/adr/`](docs/adr). + ## Privacy MacDirStat collects zero data. No network access. No analytics. No tracking. Everything runs on your device. [Full privacy policy](https://ti-03.github.io/MacDirStat/privacy.html). diff --git a/Sources/App/GlassCompat.swift b/Sources/App/GlassCompat.swift index c5104ec..a8aef08 100644 --- a/Sources/App/GlassCompat.swift +++ b/Sources/App/GlassCompat.swift @@ -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 } } diff --git a/Tests/TreemapLayoutTests.swift b/Tests/TreemapLayoutTests.swift index 872e749..51c77b2 100644 --- a/Tests/TreemapLayoutTests.swift +++ b/Tests/TreemapLayoutTests.swift @@ -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) @@ -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() { @@ -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..=6.2)`, around the existing runtime +`#available` check: + +```swift +@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 +} +``` + +`#if compiler(>=6.2)` is resolved by the compiler before type-checking, so +on an older toolchain the entire `glassEffect` branch is never parsed, let +alone type-checked. The runtime `#available` check stays inside the +compile-time branch, so a build made with Xcode 26 still back-deploys +correctly to macOS 14/15 at runtime. + +We also dropped `macos-14` from the CI matrix: its SDK predates both +`glassEffect` and the `ContentUnavailableView` backport check, so it cannot +build this project at all regardless of guards, and keeping it would only +produce a permanently red, unfixable job. + +## Consequences + +- Every new Liquid Glass call site must use the same double guard + (`#if compiler(>=6.2)` wrapping `#available(macOS 26, *)`), not + `#available` alone. A future contributor who copies an existing + `#available`-only pattern from elsewhere in SwiftUI code will reintroduce + this bug; there is no compiler warning for it. +- The CI matrix (`macos-15`, `macos-26`) is now load-bearing: it is the + only thing that would have caught this before it shipped, since a + single-SDK local build cannot reproduce the failure. +- Slightly more boilerplate per call site (two nested conditionals instead + of one), in exchange for the app actually compiling on the SDK most + users' Macs currently have. diff --git a/docs/ci-journal.md b/docs/ci-journal.md new file mode 100644 index 0000000..135e986 --- /dev/null +++ b/docs/ci-journal.md @@ -0,0 +1,99 @@ +# CI workflow journal + +Week 5 (Testing, CI/CD & GitHub Actions). Notes from adding `.github/workflows/ci.yml` +to MacDirStat. + +## The workflow + +A single matrixed job, `build-test`, runs on every push to `main` and every PR: + +- **runs-on:** `macos-15` and `macos-26` (the matrix, MacDirStat is a macOS-only + SwiftUI app, so it cannot build on Linux runners). `macos-15` ships Xcode 16 + (Swift 6.1) and exercises the older-SDK fallback compile path; `macos-26` ships + Xcode 26 (Swift 6.2), the real shipping toolchain with Liquid Glass. See the + matrix note at the bottom for why `macos-14` was dropped. +- **Caching:** SwiftPM `.build` + `~/Library/Caches/org.swift.swiftpm`, keyed on `Package.resolved`. +- **Build (static-analysis gate):** `swift build`. The Swift compiler type-checks and, with + `StrictConcurrency` enabled in `Package.swift`, concurrency-checks all production code. This + is the Swift equivalent of the "static analysis" base of the Testing Trophy. +- **Test:** `swift test` (20 tests). +- **Badge:** added to `README.md`. + +## Why no `swift-format` lint gate + +`swift format` is available, but the existing code has ~3,700 violations against swift-format's +defaults (3,404 are indentation alone). A formatting gate would have meant reformatting the entire +codebase, which is out of scope for "add CI." The compile step (with StrictConcurrency) serves as +the static-analysis gate instead. Adopting swift-format with an agreed `.swift-format` config is a +sensible future follow-up. + +## Bugs found while wiring up CI + +Getting the suite to even compile surfaced three real problems: + +1. **`Sparkle` missing from `Package.swift`.** The source does `import Sparkle`, but the dependency + was only declared in the Xcode project, not the SPM manifest. So `swift build` / `swift test` + failed from the command line (and would fail in any SPM-based CI). **Fix:** added Sparkle 2.9.1 + to `Package.swift` and linked it to the target. + +2. **Stale `TreemapLayoutTests`.** Six tests asserted on `TreemapCell.rect`, but the treemap was + redesigned from a rectangular layout into a radial sunburst (`startAngle` / `endAngle` / + `innerRadius` / `outerRadius`). **Fix:** rewrote the six tests against the radial model, + preserving each test's intent (e.g. "larger items get larger cells" → larger angular span). + +3. **Wrong `ByteFormatter` test expectation.** `test_byte_formatter_gb` expected `1.0 GB` for + `1024^3` bytes, but `ByteFormatter` defaults to decimal (SI) units, so it returns `1.1 GB`. + The KB/MB cases happened to round to `1.0` and hid the bug. **Fix:** switched the tests to + decimal inputs and pinned the `useBinarySize` default in `setUp` for determinism. + +None of these were caught before because the suite never compiled (the Sparkle gap). + +## `act` and macOS: the Task-3 limitation + +`act` runs GitHub Actions locally using **Linux Docker containers**. A macOS job +(`runs-on: macos-14`) cannot be reproduced with `act`, there is no macOS container image (Apple +licensing forbids macOS in Docker). Running `act -j build-test` only offers Linux base images, +which can't build a macOS SwiftUI + Sparkle app. + +**Conclusion:** for a macOS app, the local-`act` feedback loop is not available. Verification is +done by pushing and watching GitHub Actions, or by running `swift build` / `swift test` directly +on a Mac (which is what was done here, all 20 tests pass locally). + +## The matrix earned its keep: a "works on my machine" SDK bug + +The very first CI run was **red on both `macos-14` and `macos-15`**, despite a clean local +`swift build`/`swift test`. The cause is exactly the class of bug the OS matrix exists to catch: + +- `GlassCompat.swift` calls `glassEffect(...)` and `.buttonStyle(.glass)`, which are **macOS 26 + (Liquid Glass) APIs**. They only exist in the macOS 26 SDK (Xcode 26 / Swift 6.2). +- My local machine runs macOS 26 with the Xcode 26 SDK, so it compiled fine. GitHub's `macos-14` + / `macos-15` runners ship Xcode 16.x with the macOS 14/15 SDK, where those symbols **do not + exist**, so the build failed (~300 errors, plus cascading errors in `ContentView` which calls + the helpers). +- The original code guarded the calls with `if #available(macOS 26, *)`. That is a **runtime** + check, it does not stop the compiler from needing the symbol at build time. So `#available` + alone cannot make code compile against an SDK that lacks the API. + +**Fix:** wrap each macOS-26-only call in a **compile-time** `#if compiler(>=6.2)` (Swift 6.2 ships +with the Xcode 26 / macOS 26 SDK). Older toolchains compile only the `NSVisualEffectView`-based +fallback; the runtime `#available` stays inside the new-SDK branch so an Xcode-26 build still +back-deploys correctly to macOS 14/15. After this fix the matrix goes green on all runners. + +**Lesson:** a green local build proves nothing about other SDKs. The OS matrix turned a latent +portability bug (that would have bitten anyone building the package without the macOS 26 SDK) into +a one-PR fix. + +### ...and then it caught a second one: `macos-14` can't build the app at all + +With the glass code guarded, `macos-15` went green but `macos-14` still failed, this time on +`ContentView.swift`: `cannot find 'MeshGradient' in scope`. `MeshGradient` is a **macOS 15** API, +so the macOS 14 SDK doesn't have it either (and, like `glassEffect`, it's behind a runtime +`if #available(macOS 15, *)` that can't help at compile time). The app genuinely uses macOS 15 and +macOS 26 APIs, so it **requires the macOS 15+ SDK to compile**; `macos-14` was simply the wrong +runner. I would rather guard `MeshGradient` than gut a real piece of UI, so the fix here was the +matrix itself: dropped `macos-14` and settled on **`[macos-15, macos-26]`**, which compiles cleanly +and still tests both the fallback path (15) and the real Liquid Glass path (26). + +**Takeaway:** the deployment target (macOS 14, via `#available`) and the *build* SDK are different +things. You back-deploy at runtime, but you must compile against an SDK new enough to contain every +symbol you reference. diff --git a/guide/how-to/read-the-treemap.md b/guide/how-to/read-the-treemap.md new file mode 100644 index 0000000..6ae0ff1 --- /dev/null +++ b/guide/how-to/read-the-treemap.md @@ -0,0 +1,61 @@ +# How to read the treemap + +This guide is for MacDirStat users who have already run a scan and want to +understand what the chart is showing them. + +## Understand what an arc represents + +Each arc in the ring is one file or folder. Two things about an arc carry +information: + +- **Angle.** A wider arc holds more disk space. Angle is proportional to + size within its parent, not to the whole disk. +- **Radius (distance from center).** Deeper arcs sit farther from the + center. A file three folders deep sits in the third ring band out. + +## Understand the default colors ("By Type") + +By default, MacDirStat colors files by category, not by individual +extension: + +| Color | Category | Example extensions | +| ------- | -------------------- | -------------------------- | +| Orange | Video | `mp4`, `mov`, `mkv` | +| Emerald | Images | `jpg`, `png`, `heic` | +| Cyan | Audio | `mp3`, `wav`, `flac` | +| Blue | Code | `swift`, `py`, `js` | +| Violet | Documents | `pdf`, `docx`, `md` | +| Red | Archives | `zip`, `dmg`, `pkg` | +| Yellow | Data / config | `json`, `yaml`, `sqlite` | +| Teal | Web | `html`, `css` | +| Pink | Fonts | `ttf`, `otf`, `woff` | +| Lime | 3D / models | `obj`, `fbx`, `gltf` | + +An extension MacDirStat does not recognize gets a stable color derived from +its name, so the same unknown extension is always the same color across a +scan. Folders are colored separately from files: each folder gets a muted, +low-saturation tint based on its name, so folders never compete visually +with the files inside them. Files also darken slightly with depth, so you +can tell a top-level file from one buried five folders down at a glance. + +## Change the color scheme or size units + +1. Click the gear icon (**Settings & About**) in the dashboard. +2. Under **Appearance**: + - **Color scheme**: choose **By Type** (default), **Rainbow** (one hue + per extension, no category grouping), or **Mono** (grayscale, useful + for spotting size outliers without color as a distraction). + - **File size units**: choose **Decimal** (KB/MB/GB, 1000-based) or + **Binary** (KiB/MiB/GiB, 1024-based). + +## Zoom in and out + +- Click an arc that represents a folder to make it the new center ring. +- Click the center circle to zoom back out one level. + +## See also + +- [Scan your first folder](../tutorial/first-scan.md) if you have not run a + scan yet. +- [TreemapLayout reference](../reference/treemap-layout.md) for exactly how + angles and colors are computed. diff --git a/guide/index.md b/guide/index.md new file mode 100644 index 0000000..d384af5 --- /dev/null +++ b/guide/index.md @@ -0,0 +1,16 @@ +# MacDirStat + +MacDirStat is a native macOS app that scans a folder and shows what is taking up +disk space, as an interactive radial treemap. + +This guide is for **macOS users who have MacDirStat installed** (or built it +from source) and want to scan a folder, read the resulting chart, or look up +what a specific API does. You do not need any Swift experience to follow the +tutorial or how-to guide; the reference section assumes you are reading the +Swift source. + +- New to MacDirStat? Start with the [tutorial](tutorial/first-scan.md). +- Trying to make sense of the colors in the chart? Read + [How to read the treemap](how-to/read-the-treemap.md). +- Working on the codebase? See the [reference](reference/byte-formatter.md) + section for the exact behavior of `ByteFormatter` and `TreemapLayout`. diff --git a/guide/reference/byte-formatter.md b/guide/reference/byte-formatter.md new file mode 100644 index 0000000..025a838 --- /dev/null +++ b/guide/reference/byte-formatter.md @@ -0,0 +1,39 @@ +# ByteFormatter + +`ByteFormatter` is a stateless enum in +[`Sources/Views/Shared/ByteFormatter.swift`](https://github.com/Ti-03/MacDirStat/blob/main/Sources/Views/Shared/ByteFormatter.swift) +that turns a raw byte count into a human-readable string. + +## `ByteFormatter.string(from:)` + +```swift +public static func string(from bytes: Int64) -> String +``` + +Formats `bytes` using the unit style the user chose in **Settings → +Appearance → File size units**, read from +`UserDefaults.standard.bool(forKey: "useBinarySize")`. + +| Setting | Base | Units | +| -------- | ---- | ------------------------------- | +| Decimal (default) | 1000 | B, KB, MB, GB, TB | +| Binary | 1024 | B, KiB, MiB, GiB, TiB | + +Returns a string with one decimal place for any unit above bytes, for +example `"4.2 GB"` or `"512 B"`. + +### Examples + +```swift +ByteFormatter.string(from: 512) // "512 B" +ByteFormatter.string(from: 1_500_000) // "1.5 MB" (decimal) +ByteFormatter.string(from: 1_572_864) // "1.5 MiB" (binary) +``` + +## Notes for contributors + +`ByteFormatter` reads `UserDefaults` directly rather than taking a +parameter, so it always reflects the user's current setting without +threading it through every call site. If you add a call to +`ByteFormatter.string(from:)` in a `View`, no extra wiring is needed; it +already reacts to the setting the same way the rest of the app does. diff --git a/guide/reference/treemap-layout.md b/guide/reference/treemap-layout.md new file mode 100644 index 0000000..e3c633f --- /dev/null +++ b/guide/reference/treemap-layout.md @@ -0,0 +1,69 @@ +# TreemapLayout + +`TreemapLayout` is a stateless struct in +[`Sources/Layout/TreemapLayout.swift`](https://github.com/Ti-03/MacDirStat/blob/main/Sources/Layout/TreemapLayout.swift) +that turns a file-system tree into the arcs drawn on screen. It has one +public entry point. + +## `TreemapLayout.compute(root:in:colorMap:)` + +```swift +public static func compute( + root: FSNode, + in rect: CGRect, + colorMap: ExtensionColorMap +) -> [TreemapCell] +``` + +Computes one [`TreemapCell`](#treemapcell) per visible file or folder under +`root`, laid out to fit inside `rect`. + +- `root`: the folder currently zoomed into. Only its direct and nested + children are laid out, up to a fixed maximum depth of 5. +- `rect`: the drawing area. The available radius is derived from + `min(rect.width, rect.height)`. +- `colorMap`: the active [`ExtensionColorMap`](#color-assignment), which + encodes the user's chosen color scheme (By Type, Rainbow, or Mono). + +Returns an empty array if `rect` is too small to fit a ring, or if `root` +has no children with `size > 0`. + +### Layout rule + +Each child's arc angle is proportional to its share of its parent's total +size: + +```swift +let fraction = Double(child.size) / Double(totalSize) +let arcAngle = fraction * totalAngle +``` + +An arc smaller than `minArcAngle` (about 1 degree) is skipped entirely +rather than drawn as an unreadable sliver. Folders recurse into their own +children at the next radius band; files do not. + +## `TreemapCell` + +```swift +public struct TreemapCell: Identifiable { + public let node: FSNode + public let startAngle: Double // radians; 0 = right, clockwise + public let endAngle: Double + public let innerRadius: CGFloat + public let outerRadius: CGFloat + public let color: Color + public let depth: Int +} +``` + +One cell per arc. `startAngle`/`endAngle` are in radians; `innerRadius`/ +`outerRadius` define the ring band for the cell's depth. + +## Color assignment + +Colors are not assigned by `TreemapLayout` directly; it delegates to +`ExtensionColorMap.color(for:)` for files and computes a muted per-folder +tint by hashing the folder's name for directories. Files also darken by a +fixed amount per depth (from 0% at depth 0 up to 38% at depth 5+), so a +`.mp4` five folders deep is visibly darker than one at the top level, even +though both are "orange". diff --git a/guide/tutorial/first-scan.md b/guide/tutorial/first-scan.md new file mode 100644 index 0000000..2b342f4 --- /dev/null +++ b/guide/tutorial/first-scan.md @@ -0,0 +1,39 @@ +# Scan your first folder + +This tutorial is for anyone opening MacDirStat for the first time. By the end, +you will have scanned a real folder and seen its contents as a treemap. + +## Before you start + +You need MacDirStat installed and a folder you are curious about, for +example your Downloads folder. + +## 1. Open MacDirStat + +Launch the app. You see an empty dashboard with a **Scan** button. + +## 2. Choose a folder + +Click **Scan**. A folder picker opens. + +1. Navigate to the folder you want to inspect. +2. Select it and click **Scan** in the picker. + +MacDirStat starts walking the folder tree immediately. Large folders take +longer; a progress indicator shows the scan is still running. + +## 3. Read the result + +When the scan finishes, you see a radial treemap: a ring of colored arcs +around a center point. Each arc is a file or folder; the size of the arc is +proportional to how much disk space it uses. + +- Hover over an arc to see its name and size. +- Click an arc that represents a folder to zoom into it. +- Click the center to zoom back out. + +## Next step + +The colors are not random. Continue to +[How to read the treemap](../how-to/read-the-treemap.md) to learn what each +color means and how to change the color scheme. diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..c5754d1 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,27 @@ +site_name: MacDirStat Docs +site_url: https://ti-03.github.io/MacDirStat/guide/ +repo_url: https://github.com/Ti-03/MacDirStat +docs_dir: guide +site_dir: site + +theme: + name: material + palette: + scheme: slate + primary: deep orange + features: + - navigation.sections + - content.code.copy + +nav: + - Home: index.md + - Tutorial: tutorial/first-scan.md + - How-to guides: + - Read the treemap: how-to/read-the-treemap.md + - Reference: + - ByteFormatter: reference/byte-formatter.md + - TreemapLayout: reference/treemap-layout.md + +markdown_extensions: + - admonition + - pymdownx.superfences