dirplot 0.3.2__tar.gz → 0.3.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. {dirplot-0.3.2 → dirplot-0.3.3}/CHANGELOG.md +14 -0
  2. {dirplot-0.3.2 → dirplot-0.3.3}/PKG-INFO +7 -1
  3. {dirplot-0.3.2 → dirplot-0.3.3}/README.md +6 -0
  4. dirplot-0.3.3/breadcrumbs_test.png +0 -0
  5. dirplot-0.3.3/docs/docker.png +0 -0
  6. dirplot-0.3.3/docs/fastapi.png +0 -0
  7. dirplot-0.3.3/docs/flask.png +0 -0
  8. dirplot-0.3.3/docs/k8s.png +0 -0
  9. dirplot-0.3.3/docs/pypy.png +0 -0
  10. dirplot-0.3.3/docs/python.png +0 -0
  11. dirplot-0.3.3/docs/s3.png +0 -0
  12. {dirplot-0.3.2 → dirplot-0.3.3}/pyproject.toml +1 -1
  13. {dirplot-0.3.2 → dirplot-0.3.3}/src/dirplot/main.py +76 -43
  14. {dirplot-0.3.2 → dirplot-0.3.3}/src/dirplot/render.py +43 -10
  15. {dirplot-0.3.2 → dirplot-0.3.3}/src/dirplot/scanner.py +36 -0
  16. {dirplot-0.3.2 → dirplot-0.3.3}/src/dirplot/svg_render.py +26 -3
  17. {dirplot-0.3.2 → dirplot-0.3.3}/tests/test_cli.py +21 -0
  18. {dirplot-0.3.2 → dirplot-0.3.3}/tests/test_scanner.py +78 -0
  19. {dirplot-0.3.2 → dirplot-0.3.3}/uv.lock +1 -1
  20. dirplot-0.3.2/docs/docker.png +0 -0
  21. dirplot-0.3.2/docs/fastapi.png +0 -0
  22. dirplot-0.3.2/docs/flask.png +0 -0
  23. dirplot-0.3.2/docs/k8s.png +0 -0
  24. dirplot-0.3.2/docs/pypy.png +0 -0
  25. dirplot-0.3.2/docs/python.png +0 -0
  26. dirplot-0.3.2/docs/s3.png +0 -0
  27. {dirplot-0.3.2 → dirplot-0.3.3}/.claude/settings.local.json +0 -0
  28. {dirplot-0.3.2 → dirplot-0.3.3}/.dockerignore +0 -0
  29. {dirplot-0.3.2 → dirplot-0.3.3}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  30. {dirplot-0.3.2 → dirplot-0.3.3}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  31. {dirplot-0.3.2 → dirplot-0.3.3}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  32. {dirplot-0.3.2 → dirplot-0.3.3}/.github/dependabot.yml +0 -0
  33. {dirplot-0.3.2 → dirplot-0.3.3}/.github/workflows/ci.yml +0 -0
  34. {dirplot-0.3.2 → dirplot-0.3.3}/.github/workflows/publish.yml +0 -0
  35. {dirplot-0.3.2 → dirplot-0.3.3}/.gitignore +0 -0
  36. {dirplot-0.3.2 → dirplot-0.3.3}/.ipynb_checkpoints/Untitled-checkpoint.ipynb +0 -0
  37. {dirplot-0.3.2 → dirplot-0.3.3}/.pre-commit-config.yaml +0 -0
  38. {dirplot-0.3.2 → dirplot-0.3.3}/.python-version +0 -0
  39. {dirplot-0.3.2 → dirplot-0.3.3}/CONTRIBUTING.md +0 -0
  40. {dirplot-0.3.2 → dirplot-0.3.3}/Dockerfile +0 -0
  41. {dirplot-0.3.2 → dirplot-0.3.3}/LICENSE +0 -0
  42. {dirplot-0.3.2 → dirplot-0.3.3}/Makefile +0 -0
  43. {dirplot-0.3.2 → dirplot-0.3.3}/SECURITY.md +0 -0
  44. {dirplot-0.3.2 → dirplot-0.3.3}/TASKS.md~ +0 -0
  45. {dirplot-0.3.2 → dirplot-0.3.3}/alpine-pod.png +0 -0
  46. {dirplot-0.3.2 → dirplot-0.3.3}/aws-lambda.png +0 -0
  47. {dirplot-0.3.2 → dirplot-0.3.3}/demo.cast +0 -0
  48. {dirplot-0.3.2 → dirplot-0.3.3}/demo.yaml +0 -0
  49. {dirplot-0.3.2 → dirplot-0.3.3}/demo.yaml~ +0 -0
  50. {dirplot-0.3.2 → dirplot-0.3.3}/demo1.cast +0 -0
  51. {dirplot-0.3.2 → dirplot-0.3.3}/docs/ARCHIVES.md +0 -0
  52. {dirplot-0.3.2 → dirplot-0.3.3}/docs/EXAMPLES.md +0 -0
  53. {dirplot-0.3.2 → dirplot-0.3.3}/docs/SSH_DESIGN.md +0 -0
  54. {dirplot-0.3.2 → dirplot-0.3.3}/docs/dirplot.png +0 -0
  55. {dirplot-0.3.2 → dirplot-0.3.3}/foo.svg +0 -0
  56. {dirplot-0.3.2 → dirplot-0.3.3}/gen_asciicast.py +0 -0
  57. {dirplot-0.3.2 → dirplot-0.3.3}/misc/scan/FILE-SCANNING.md +0 -0
  58. {dirplot-0.3.2 → dirplot-0.3.3}/misc/scan/compare.py +0 -0
  59. {dirplot-0.3.2 → dirplot-0.3.3}/misc/scan/scan_async.py +0 -0
  60. {dirplot-0.3.2 → dirplot-0.3.3}/misc/scan/scan_dir.py +0 -0
  61. {dirplot-0.3.2 → dirplot-0.3.3}/misc/scan/scan_rust.py +0 -0
  62. {dirplot-0.3.2 → dirplot-0.3.3}/misc/scan/scan_threaded.py +0 -0
  63. {dirplot-0.3.2 → dirplot-0.3.3}/misc/scan/scan_walk.py +0 -0
  64. {dirplot-0.3.2 → dirplot-0.3.3}/misc/watch/foo.txt +0 -0
  65. {dirplot-0.3.2 → dirplot-0.3.3}/misc/watch/watch_dir.py +0 -0
  66. {dirplot-0.3.2 → dirplot-0.3.3}/misc/watch/wordcloud1.png +0 -0
  67. {dirplot-0.3.2 → dirplot-0.3.3}/misc-watched.png +0 -0
  68. {dirplot-0.3.2 → dirplot-0.3.3}/openclaw.png +0 -0
  69. {dirplot-0.3.2 → dirplot-0.3.3}/pg.png +0 -0
  70. {dirplot-0.3.2 → dirplot-0.3.3}/scripts/animate_demo.py +0 -0
  71. {dirplot-0.3.2 → dirplot-0.3.3}/scripts/make_docs_images.sh +0 -0
  72. {dirplot-0.3.2 → dirplot-0.3.3}/scripts/make_fixtures.py +0 -0
  73. {dirplot-0.3.2 → dirplot-0.3.3}/scripts/open_terminals.sh +0 -0
  74. {dirplot-0.3.2 → dirplot-0.3.3}/src/dirplot/__init__.py +0 -0
  75. {dirplot-0.3.2 → dirplot-0.3.3}/src/dirplot/__main__.py +0 -0
  76. {dirplot-0.3.2 → dirplot-0.3.3}/src/dirplot/archives.py +0 -0
  77. {dirplot-0.3.2 → dirplot-0.3.3}/src/dirplot/colors.py +0 -0
  78. {dirplot-0.3.2 → dirplot-0.3.3}/src/dirplot/display.py +0 -0
  79. {dirplot-0.3.2 → dirplot-0.3.3}/src/dirplot/docker.py +0 -0
  80. {dirplot-0.3.2 → dirplot-0.3.3}/src/dirplot/fonts/JetBrainsMono-Bold.ttf +0 -0
  81. {dirplot-0.3.2 → dirplot-0.3.3}/src/dirplot/fonts/JetBrainsMono-BoldItalic.ttf +0 -0
  82. {dirplot-0.3.2 → dirplot-0.3.3}/src/dirplot/fonts/JetBrainsMono-Italic.ttf +0 -0
  83. {dirplot-0.3.2 → dirplot-0.3.3}/src/dirplot/fonts/JetBrainsMono-Regular.ttf +0 -0
  84. {dirplot-0.3.2 → dirplot-0.3.3}/src/dirplot/fonts/OFL.txt +0 -0
  85. {dirplot-0.3.2 → dirplot-0.3.3}/src/dirplot/github.py +0 -0
  86. {dirplot-0.3.2 → dirplot-0.3.3}/src/dirplot/k8s.py +0 -0
  87. {dirplot-0.3.2 → dirplot-0.3.3}/src/dirplot/py.typed +0 -0
  88. {dirplot-0.3.2 → dirplot-0.3.3}/src/dirplot/s3.py +0 -0
  89. {dirplot-0.3.2 → dirplot-0.3.3}/src/dirplot/ssh.py +0 -0
  90. {dirplot-0.3.2 → dirplot-0.3.3}/src/dirplot/terminal.py +0 -0
  91. {dirplot-0.3.2 → dirplot-0.3.3}/src/dirplot/watch.py +0 -0
  92. {dirplot-0.3.2 → dirplot-0.3.3}/src-portrait.png +0 -0
  93. {dirplot-0.3.2 → dirplot-0.3.3}/src.pdf +0 -0
  94. {dirplot-0.3.2 → dirplot-0.3.3}/src.png +0 -0
  95. {dirplot-0.3.2 → dirplot-0.3.3}/src.svg +0 -0
  96. {dirplot-0.3.2 → dirplot-0.3.3}/test-animation.png +0 -0
  97. {dirplot-0.3.2 → dirplot-0.3.3}/tests/__init__.py +0 -0
  98. {dirplot-0.3.2 → dirplot-0.3.3}/tests/animation/README.md +0 -0
  99. {dirplot-0.3.2 → dirplot-0.3.3}/tests/animation/changelog.txt +0 -0
  100. {dirplot-0.3.2 → dirplot-0.3.3}/tests/animation/config.toml +0 -0
  101. {dirplot-0.3.2 → dirplot-0.3.3}/tests/animation/data/records.csv +0 -0
  102. {dirplot-0.3.2 → dirplot-0.3.3}/tests/animation/data/schema_v1.json +0 -0
  103. {dirplot-0.3.2 → dirplot-0.3.3}/tests/animation/src/lib/parser.py +0 -0
  104. {dirplot-0.3.2 → dirplot-0.3.3}/tests/animation/src/main.py +0 -0
  105. {dirplot-0.3.2 → dirplot-0.3.3}/tests/conftest.py +0 -0
  106. {dirplot-0.3.2 → dirplot-0.3.3}/tests/example/bar/bar.py +0 -0
  107. {dirplot-0.3.2 → dirplot-0.3.3}/tests/example/bar/baz.json +0 -0
  108. {dirplot-0.3.2 → dirplot-0.3.3}/tests/example/foo/foo.md +0 -0
  109. {dirplot-0.3.2 → dirplot-0.3.3}/tests/fixtures/sample.7z +0 -0
  110. {dirplot-0.3.2 → dirplot-0.3.3}/tests/fixtures/sample.a +0 -0
  111. {dirplot-0.3.2 → dirplot-0.3.3}/tests/fixtures/sample.aab +0 -0
  112. {dirplot-0.3.2 → dirplot-0.3.3}/tests/fixtures/sample.apk +0 -0
  113. {dirplot-0.3.2 → dirplot-0.3.3}/tests/fixtures/sample.cpio +0 -0
  114. {dirplot-0.3.2 → dirplot-0.3.3}/tests/fixtures/sample.ear +0 -0
  115. {dirplot-0.3.2 → dirplot-0.3.3}/tests/fixtures/sample.epub +0 -0
  116. {dirplot-0.3.2 → dirplot-0.3.3}/tests/fixtures/sample.ipa +0 -0
  117. {dirplot-0.3.2 → dirplot-0.3.3}/tests/fixtures/sample.iso +0 -0
  118. {dirplot-0.3.2 → dirplot-0.3.3}/tests/fixtures/sample.jar +0 -0
  119. {dirplot-0.3.2 → dirplot-0.3.3}/tests/fixtures/sample.nupkg +0 -0
  120. {dirplot-0.3.2 → dirplot-0.3.3}/tests/fixtures/sample.rar +0 -0
  121. {dirplot-0.3.2 → dirplot-0.3.3}/tests/fixtures/sample.tar +0 -0
  122. {dirplot-0.3.2 → dirplot-0.3.3}/tests/fixtures/sample.tar.bz2 +0 -0
  123. {dirplot-0.3.2 → dirplot-0.3.3}/tests/fixtures/sample.tar.gz +0 -0
  124. {dirplot-0.3.2 → dirplot-0.3.3}/tests/fixtures/sample.tar.xz +0 -0
  125. {dirplot-0.3.2 → dirplot-0.3.3}/tests/fixtures/sample.tar.zst +0 -0
  126. {dirplot-0.3.2 → dirplot-0.3.3}/tests/fixtures/sample.tbz2 +0 -0
  127. {dirplot-0.3.2 → dirplot-0.3.3}/tests/fixtures/sample.tgz +0 -0
  128. {dirplot-0.3.2 → dirplot-0.3.3}/tests/fixtures/sample.txz +0 -0
  129. {dirplot-0.3.2 → dirplot-0.3.3}/tests/fixtures/sample.tzst +0 -0
  130. {dirplot-0.3.2 → dirplot-0.3.3}/tests/fixtures/sample.vsix +0 -0
  131. {dirplot-0.3.2 → dirplot-0.3.3}/tests/fixtures/sample.war +0 -0
  132. {dirplot-0.3.2 → dirplot-0.3.3}/tests/fixtures/sample.whl +0 -0
  133. {dirplot-0.3.2 → dirplot-0.3.3}/tests/fixtures/sample.xar +0 -0
  134. {dirplot-0.3.2 → dirplot-0.3.3}/tests/fixtures/sample.xpi +0 -0
  135. {dirplot-0.3.2 → dirplot-0.3.3}/tests/fixtures/sample.zip +0 -0
  136. {dirplot-0.3.2 → dirplot-0.3.3}/tests/test_archives.py +0 -0
  137. {dirplot-0.3.2 → dirplot-0.3.3}/tests/test_colors.py +0 -0
  138. {dirplot-0.3.2 → dirplot-0.3.3}/tests/test_display.py +0 -0
  139. {dirplot-0.3.2 → dirplot-0.3.3}/tests/test_docker.py +0 -0
  140. {dirplot-0.3.2 → dirplot-0.3.3}/tests/test_drawing.py +0 -0
  141. {dirplot-0.3.2 → dirplot-0.3.3}/tests/test_github.py +0 -0
  142. {dirplot-0.3.2 → dirplot-0.3.3}/tests/test_k8s.py +0 -0
  143. {dirplot-0.3.2 → dirplot-0.3.3}/tests/test_render.py +0 -0
  144. {dirplot-0.3.2 → dirplot-0.3.3}/tests/test_s3.py +0 -0
  145. {dirplot-0.3.2 → dirplot-0.3.3}/tests/test_ssh.py +0 -0
  146. {dirplot-0.3.2 → dirplot-0.3.3}/tests/test_svg_render.py +0 -0
  147. {dirplot-0.3.2 → dirplot-0.3.3}/tests/test_terminal.py +0 -0
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.3] - 2026-03-14
11
+
12
+ ### Added
13
+
14
+ - **Breadcrumbs mode** (`--breadcrumbs/--no-breadcrumbs`, `-b/-B`, on by default): directories
15
+ that form a single-child chain (one subdirectory, no files) are collapsed into a single tile
16
+ whose header shows the full path separated by ` / ` (e.g. `src / dirplot / fonts`). When the
17
+ label is too wide, middle segments are replaced with `…` (`src / … / fonts`). The root tile
18
+ is never collapsed. Disable with `-B` or `--no-breadcrumbs`.
19
+ - **Tree depth in root label**: the root tile header now includes `depth: N` alongside the
20
+ file, directory, and size summary (e.g. `myproject — 124 files, 18 dirs, 4.0 MB (…), depth: 6`).
21
+ The depth reflects the original tree structure and is invariant to whether breadcrumbs mode
22
+ is active.
23
+
10
24
  ## [0.3.2] - 2026-03-13
11
25
 
12
26
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dirplot
3
- Version: 0.3.2
3
+ Version: 0.3.3
4
4
  Summary: Static treemap bitmaps for directory trees and archives, displayed as inline terminal images
5
5
  Project-URL: Repository, https://github.com/deeplook/dirplot
6
6
  License: MIT
@@ -78,6 +78,8 @@ Description-Content-Type: text/markdown
78
78
  - Works on macOS, Linux, and Windows; WSL2 fully supported.
79
79
  - Scan remote hosts over SSH (`pip install "dirplot[ssh]"`), AWS S3 buckets (`pip install "dirplot[s3]"`), any public/private GitHub repository (including specific branch, tag, commit SHA, or subdirectory), **running Docker containers**, or **Kubernetes pods** — all without extra dependencies beyond the respective CLI/SDK. See [EXAMPLES.md](docs/EXAMPLES.md).
80
80
  - Optional **file-count legend** (`--legend`) — a corner overlay listing the top extensions by number of files, with coloured swatches and counts, automatically sized to fit the image.
81
+ - **Breadcrumbs mode** (on by default) — single-child directory chains are collapsed into one tile with a `foo / bar / baz` header label. Middle segments are replaced with `…` when the tile is too narrow. Disable with `-B` / `--no-breadcrumbs`.
82
+ - **Tree depth** shown in the root tile header alongside the file count and total size (e.g. `myproject — 124 files, 18 dirs, 4.0 MB (…), depth: 6`).
81
83
  - **Wide archive support** — reads zip, tar (gz/bz2/xz/zst), 7z, rar, and via libarchive: iso, cpio, rpm, cab, lha/lzh, xar/pkg, a/ar, and all ZIP-based formats (jar, whl, apk, nupkg, vsix, ipa, …) as virtual directory trees without unpacking. Encrypted archives are handled gracefully: metadata-only reads work without a password for most formats; a password can be supplied with `--password` or entered interactively when needed.
82
84
 
83
85
  ## How It Works
@@ -158,6 +160,9 @@ dirplot map . --legend
158
160
  # Show a file-count legend limited to 10 entries
159
161
  dirplot map . --legend 10
160
162
 
163
+ # Disable breadcrumbs (show full nested hierarchy instead of collapsed chains)
164
+ dirplot map . -B
165
+
161
166
  # Save as an interactive SVG (hover highlight + floating tooltip)
162
167
  dirplot map . --output treemap.svg --no-show
163
168
 
@@ -182,6 +187,7 @@ dirplot map . --format svg --output treemap.svg --no-show
182
187
  | `--header/--no-header` | | `--header` | Print info lines before rendering |
183
188
  | `--cushion/--no-cushion` | | `--cushion` | Apply van Wijk cushion shading for a raised 3-D look |
184
189
  | `--log/--no-log` | | `--no-log` | Use log of file sizes for layout, making small files more visible |
190
+ | `--breadcrumbs/--no-breadcrumbs` | `-b`/`-B` | `--breadcrumbs` | Collapse single-child directory chains into `foo / bar / baz` labels |
185
191
  | `--password` | | — | Password for encrypted archives; prompted interactively if not supplied and needed |
186
192
  | `--github-token` | | `$GITHUB_TOKEN` | GitHub personal access token for private repos or higher rate limits |
187
193
 
@@ -28,6 +28,8 @@
28
28
  - Works on macOS, Linux, and Windows; WSL2 fully supported.
29
29
  - Scan remote hosts over SSH (`pip install "dirplot[ssh]"`), AWS S3 buckets (`pip install "dirplot[s3]"`), any public/private GitHub repository (including specific branch, tag, commit SHA, or subdirectory), **running Docker containers**, or **Kubernetes pods** — all without extra dependencies beyond the respective CLI/SDK. See [EXAMPLES.md](docs/EXAMPLES.md).
30
30
  - Optional **file-count legend** (`--legend`) — a corner overlay listing the top extensions by number of files, with coloured swatches and counts, automatically sized to fit the image.
31
+ - **Breadcrumbs mode** (on by default) — single-child directory chains are collapsed into one tile with a `foo / bar / baz` header label. Middle segments are replaced with `…` when the tile is too narrow. Disable with `-B` / `--no-breadcrumbs`.
32
+ - **Tree depth** shown in the root tile header alongside the file count and total size (e.g. `myproject — 124 files, 18 dirs, 4.0 MB (…), depth: 6`).
31
33
  - **Wide archive support** — reads zip, tar (gz/bz2/xz/zst), 7z, rar, and via libarchive: iso, cpio, rpm, cab, lha/lzh, xar/pkg, a/ar, and all ZIP-based formats (jar, whl, apk, nupkg, vsix, ipa, …) as virtual directory trees without unpacking. Encrypted archives are handled gracefully: metadata-only reads work without a password for most formats; a password can be supplied with `--password` or entered interactively when needed.
32
34
 
33
35
  ## How It Works
@@ -108,6 +110,9 @@ dirplot map . --legend
108
110
  # Show a file-count legend limited to 10 entries
109
111
  dirplot map . --legend 10
110
112
 
113
+ # Disable breadcrumbs (show full nested hierarchy instead of collapsed chains)
114
+ dirplot map . -B
115
+
111
116
  # Save as an interactive SVG (hover highlight + floating tooltip)
112
117
  dirplot map . --output treemap.svg --no-show
113
118
 
@@ -132,6 +137,7 @@ dirplot map . --format svg --output treemap.svg --no-show
132
137
  | `--header/--no-header` | | `--header` | Print info lines before rendering |
133
138
  | `--cushion/--no-cushion` | | `--cushion` | Apply van Wijk cushion shading for a raised 3-D look |
134
139
  | `--log/--no-log` | | `--no-log` | Use log of file sizes for layout, making small files more visible |
140
+ | `--breadcrumbs/--no-breadcrumbs` | `-b`/`-B` | `--breadcrumbs` | Collapse single-child directory chains into `foo / bar / baz` labels |
135
141
  | `--password` | | — | Password for encrypted archives; prompted interactively if not supplied and needed |
136
142
  | `--github-token` | | `$GITHUB_TOKEN` | GitHub personal access token for private repos or higher rate limits |
137
143
 
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "dirplot"
7
- version = "0.3.2"
7
+ version = "0.3.3"
8
8
  description = "Static treemap bitmaps for directory trees and archives, displayed as inline terminal images"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -19,10 +19,12 @@ from dirplot.render import create_treemap
19
19
  from dirplot.s3 import build_tree_s3, is_s3_path, make_s3_client, parse_s3_path
20
20
  from dirplot.scanner import (
21
21
  Node,
22
+ apply_breadcrumbs,
22
23
  apply_log_sizes,
23
24
  build_tree,
24
25
  build_tree_multi,
25
26
  collect_extensions,
27
+ max_depth,
26
28
  prune_to_subtrees,
27
29
  )
28
30
  from dirplot.ssh import build_tree_ssh, connect, is_ssh_path, parse_ssh_path
@@ -175,50 +177,65 @@ def watch_cmd(
175
177
 
176
178
  @app.command(name="read-meta")
177
179
  def read_meta(
178
- file: Path = typer.Argument(..., help="PNG or SVG file to read dirplot metadata from"),
180
+ files: list[Path] = typer.Argument(
181
+ ..., help="PNG or SVG file(s) to read dirplot metadata from"
182
+ ),
179
183
  ) -> None:
180
- """Read dirplot metadata embedded in a PNG or SVG file."""
181
- if not file.exists():
182
- typer.echo(f"Error: file not found: {file}", err=True)
183
- raise typer.Exit(1)
184
-
185
- suffix = file.suffix.lower()
186
-
187
- if suffix == ".png":
188
- from PIL import Image
189
-
190
- img = Image.open(file)
191
- info = img.info
192
- meta_keys = {"Date", "Software", "URL", "Python", "OS", "Command"}
193
- found = {k: v for k, v in info.items() if k in meta_keys}
194
- if not found:
195
- typer.echo("No dirplot metadata found in PNG.", err=True)
196
- raise typer.Exit(1)
197
- for k, v in found.items():
198
- typer.echo(f"{k}: {v}")
184
+ """Read dirplot metadata embedded in one or more PNG or SVG files."""
185
+ any_error = False
186
+
187
+ for file in files:
188
+ if len(files) > 1:
189
+ typer.echo(f"==> {file} <==")
190
+
191
+ if not file.exists():
192
+ typer.echo(f"Error: file not found: {file}", err=True)
193
+ any_error = True
194
+ continue
195
+
196
+ suffix = file.suffix.lower()
197
+
198
+ if suffix == ".png":
199
+ from PIL import Image
200
+
201
+ img = Image.open(file)
202
+ info = img.info
203
+ meta_keys = {"Date", "Software", "URL", "Python", "OS", "Command"}
204
+ found = {k: v for k, v in info.items() if k in meta_keys}
205
+ if not found:
206
+ typer.echo("No dirplot metadata found in PNG.", err=True)
207
+ any_error = True
208
+ continue
209
+ for k, v in found.items():
210
+ typer.echo(f"{k}: {v}")
211
+
212
+ elif suffix == ".svg":
213
+ content = file.read_text(encoding="utf-8")
214
+ try:
215
+ root = ET.fromstring(content)
216
+ except ET.ParseError as exc:
217
+ typer.echo(f"Error parsing SVG: {exc}", err=True)
218
+ any_error = True
219
+ continue
220
+ svg_meta: dict[str, str] = {}
221
+ for desc in root.iter("{http://www.w3.org/1999/02/22-rdf-syntax-ns#}Description"):
222
+ for child in desc:
223
+ local = child.tag.split("}")[-1] if "}" in child.tag else child.tag
224
+ ns_uri = child.tag.split("}")[0].lstrip("{") if "}" in child.tag else ""
225
+ if ns_uri == "https://github.com/deeplook/dirplot#" and child.text:
226
+ svg_meta[local] = child.text
227
+ if not svg_meta:
228
+ typer.echo("No dirplot metadata found in SVG.", err=True)
229
+ any_error = True
230
+ continue
231
+ for k, v in svg_meta.items():
232
+ typer.echo(f"{k}: {v}")
199
233
 
200
- elif suffix == ".svg":
201
- content = file.read_text(encoding="utf-8")
202
- try:
203
- root = ET.fromstring(content)
204
- except ET.ParseError as exc:
205
- typer.echo(f"Error parsing SVG: {exc}", err=True)
206
- raise typer.Exit(1) from exc
207
- svg_meta: dict[str, str] = {}
208
- for desc in root.iter("{http://www.w3.org/1999/02/22-rdf-syntax-ns#}Description"):
209
- for child in desc:
210
- local = child.tag.split("}")[-1] if "}" in child.tag else child.tag
211
- ns_uri = child.tag.split("}")[0].lstrip("{") if "}" in child.tag else ""
212
- if ns_uri == "https://github.com/deeplook/dirplot#" and child.text:
213
- svg_meta[local] = child.text
214
- if not svg_meta:
215
- typer.echo("No dirplot metadata found in SVG.", err=True)
216
- raise typer.Exit(1)
217
- for k, v in svg_meta.items():
218
- typer.echo(f"{k}: {v}")
234
+ else:
235
+ typer.echo(f"Unsupported file type: {suffix!r}. Expected .png or .svg", err=True)
236
+ any_error = True
219
237
 
220
- else:
221
- typer.echo(f"Unsupported file type: {suffix!r}. Expected .png or .svg", err=True)
238
+ if any_error:
222
239
  raise typer.Exit(1)
223
240
 
224
241
 
@@ -331,6 +348,15 @@ def main(
331
348
  "--password",
332
349
  help="Password for encrypted archives. Prompted interactively if not supplied and needed.",
333
350
  ),
351
+ breadcrumbs: bool = typer.Option(
352
+ True,
353
+ "--breadcrumbs/--no-breadcrumbs",
354
+ "-b/-B",
355
+ help=(
356
+ "Collapse single-subdirectory chains into breadcrumb labels"
357
+ " (e.g. foo / bar / baz). Default: on."
358
+ ),
359
+ ),
334
360
  ) -> None:
335
361
  """Create a nested treemap bitmap for a directory tree."""
336
362
  if not roots:
@@ -544,6 +570,11 @@ def main(
544
570
  if subtrees:
545
571
  root_node = prune_to_subtrees(root_node, set(subtrees))
546
572
 
573
+ tree_depth = max_depth(root_node)
574
+
575
+ if breadcrumbs:
576
+ root_node = apply_breadcrumbs(root_node)
577
+
547
578
  t_scan = time.monotonic() - t_scan_start
548
579
  if log:
549
580
  apply_log_sizes(root_node)
@@ -587,10 +618,12 @@ def main(
587
618
  t_render_start = time.monotonic()
588
619
  if use_svg:
589
620
  buf = create_treemap_svg(
590
- root_node, width_px, height_px, font_size, colormap, legend, cushion
621
+ root_node, width_px, height_px, font_size, colormap, legend, cushion, tree_depth
591
622
  )
592
623
  else:
593
- buf = create_treemap(root_node, width_px, height_px, font_size, colormap, legend, cushion)
624
+ buf = create_treemap(
625
+ root_node, width_px, height_px, font_size, colormap, legend, cushion, tree_depth
626
+ )
594
627
  t_render = time.monotonic() - t_render_start
595
628
 
596
629
  if output is not None:
@@ -14,7 +14,7 @@ import squarify
14
14
  from PIL import Image, ImageDraw, ImageFont, PngImagePlugin
15
15
 
16
16
  from dirplot.colors import RGBAColor, assign_colors
17
- from dirplot.scanner import Node, collect_extensions, count_nodes
17
+ from dirplot.scanner import Node, collect_extensions, count_nodes, max_depth
18
18
 
19
19
  DIRPLOT_URL = "https://github.com/deeplook/dirplot"
20
20
 
@@ -160,6 +160,26 @@ def _truncate(
160
160
  return name[:lo] + ellipsis
161
161
 
162
162
 
163
+ def _truncate_breadcrumb(
164
+ name: str, draw: ImageDraw.ImageDraw, font: ImageFont.FreeTypeFont, max_w: int
165
+ ) -> str:
166
+ """Truncate a breadcrumb label (`` / ``-separated parts) to fit *max_w* pixels.
167
+
168
+ Tries the full label first, then collapses middle segments to ``…``, and
169
+ finally falls back to ``_truncate`` for plain names or when even the
170
+ ``first / … / last`` form is too long.
171
+ """
172
+ parts = name.split(" / ")
173
+ if len(parts) <= 1:
174
+ return _truncate(name, draw, font, max_w)
175
+ if _text_w(draw, name, font) <= max_w:
176
+ return name
177
+ candidate = parts[0] + " / … / " + parts[-1]
178
+ if _text_w(draw, candidate, font) <= max_w:
179
+ return candidate
180
+ return _truncate(candidate, draw, font, max_w)
181
+
182
+
163
183
  def _apply_cushion(img: Image.Image, x: int, y: int, w: int, h: int) -> None:
164
184
  """Apply van Wijk-style quadratic cushion shading to a tile in-place."""
165
185
  if w < 4 or h < 4:
@@ -233,30 +253,39 @@ def draw_node(
233
253
  draw.rectangle([x, y, x + w - 1, y + h - 1], outline=dark)
234
254
  # Adaptive label: largest font that fits the tile without overflow
235
255
  if w > 20 and h > 10:
256
+ # Try horizontal first
257
+ ffont_h, label_h = _fit_font(node.name, draw, font_size + 2, w - 4, h - 4)
258
+ # For tall narrow tiles, also try vertical; prefer whichever wraps less
259
+ use_vertical = False
236
260
  if h >= w * 2 and img is not None:
261
+ ffont_v, label_v = _fit_font(node.name, draw, font_size + 2, h - 4, w - 4)
262
+ if label_v:
263
+ h_lines = label_h.count("\n") + 1 if label_h else 999
264
+ v_lines = label_v.count("\n") + 1
265
+ if v_lines < h_lines or (v_lines == h_lines and ffont_v.size > ffont_h.size):
266
+ use_vertical = True
267
+ if use_vertical:
237
268
  # Tall, narrow tile — rotate label 90° CCW so it runs along the height
238
- # available text-run = h-4, constraining dim = w-4
239
- ffont, label = _fit_font(node.name, draw, font_size + 2, h - 4, w - 4)
240
269
  tmp = Image.new("RGBA", (h, w), (0, 0, 0, 0))
241
270
  ImageDraw.Draw(tmp).text(
242
271
  (h // 2, w // 2),
243
- label,
272
+ label_v,
244
273
  fill=_label_color(rgb),
245
- font=ffont,
274
+ font=ffont_v,
246
275
  anchor="mm",
247
276
  align="center",
248
277
  spacing=0,
249
278
  )
250
279
  rotated = tmp.rotate(90, expand=True)
280
+ assert img is not None
251
281
  img.paste(rotated, (x, y), mask=rotated)
252
282
  else:
253
283
  # Horizontal label: available text-run = w-4, constraining dim = h-4
254
- ffont, label = _fit_font(node.name, draw, font_size + 2, w - 4, h - 4)
255
284
  draw.text(
256
285
  (x + w // 2, y + h // 2),
257
- label,
286
+ label_h,
258
287
  fill=_label_color(rgb),
259
- font=ffont,
288
+ font=ffont_h,
260
289
  anchor="mm",
261
290
  align="center",
262
291
  spacing=0,
@@ -271,7 +300,9 @@ def draw_node(
271
300
  # Header label — height driven by the font size
272
301
  header_h = font.size + 4
273
302
  if h > 2 + header_h:
274
- label = _truncate(root_label if root_label is not None else node.name, draw, font, w - 8)
303
+ label = _truncate_breadcrumb(
304
+ root_label if root_label is not None else node.name, draw, font, w - 8
305
+ )
275
306
  draw.text(
276
307
  (x + w // 2, y + 2 + header_h // 2),
277
308
  label,
@@ -436,6 +467,7 @@ def create_treemap(
436
467
  colormap: str = "tab20",
437
468
  legend: int | None = None,
438
469
  cushion: bool = True,
470
+ tree_depth: int | None = None,
439
471
  ) -> io.BytesIO:
440
472
  """Render a nested squarified treemap and return it as a PNG in a BytesIO buffer.
441
473
 
@@ -459,9 +491,10 @@ def create_treemap(
459
491
 
460
492
  n_files, n_dirs = count_nodes(root_node)
461
493
  total_bytes = root_node.original_size if root_node.original_size > 0 else root_node.size
494
+ depth = tree_depth if tree_depth is not None else max_depth(root_node)
462
495
  root_label = (
463
496
  f"{root_node.name} \u2014 {n_files:,} files, {n_dirs:,} dirs,"
464
- f" {_human_bytes(total_bytes)} ({total_bytes:,} bytes)"
497
+ f" {_human_bytes(total_bytes)} ({total_bytes:,} bytes), depth: {depth}"
465
498
  )
466
499
  draw_node(
467
500
  idraw,
@@ -182,6 +182,42 @@ def count_nodes(node: Node) -> tuple[int, int]:
182
182
  return files, dirs
183
183
 
184
184
 
185
+ def _apply_breadcrumbs_recursive(node: Node) -> Node:
186
+ """Recursively collapse single-subdirectory chains (internal helper)."""
187
+ node.children = [_apply_breadcrumbs_recursive(c) for c in node.children]
188
+ dir_children = [c for c in node.children if c.is_dir]
189
+ file_children = [c for c in node.children if not c.is_dir]
190
+ if node.is_dir and len(dir_children) == 1 and len(file_children) == 0:
191
+ child = dir_children[0]
192
+ node.name = f"{node.name} / {child.name}"
193
+ node.children = child.children
194
+ return node
195
+
196
+
197
+ def apply_breadcrumbs(node: Node) -> Node:
198
+ """Collapse single-subdirectory chains into one node with a combined name.
199
+
200
+ A directory that has exactly one directory child and no file children is
201
+ merged with that child: the names are joined with `` / `` and the child's
202
+ children become this node's children. The process is bottom-up so chains
203
+ of any length accumulate naturally.
204
+
205
+ The root node itself is never collapsed — only its descendants are.
206
+ """
207
+ node.children = [_apply_breadcrumbs_recursive(c) for c in node.children]
208
+ return node
209
+
210
+
211
+ def max_depth(node: Node) -> int:
212
+ """Return the maximum depth of the tree rooted at *node*.
213
+
214
+ A leaf node (no children) has depth 0.
215
+ """
216
+ if not node.children:
217
+ return 0
218
+ return 1 + max(max_depth(c) for c in node.children)
219
+
220
+
185
221
  def collect_extensions(node: Node) -> list[str]:
186
222
  """Return a flat list of file extensions under *node*."""
187
223
  if not node.is_dir:
@@ -9,7 +9,7 @@ import squarify
9
9
 
10
10
  from dirplot.colors import RGBAColor, assign_colors
11
11
  from dirplot.render import _human_bytes, build_metadata
12
- from dirplot.scanner import Node, collect_extensions, count_nodes
12
+ from dirplot.scanner import Node, collect_extensions, count_nodes, max_depth
13
13
 
14
14
  _CHAR_ASPECT = 0.6 # approximate width/height ratio for monospace font
15
15
  _FONT_FAMILY = "JetBrains Mono, Consolas, monospace"
@@ -247,6 +247,25 @@ def _truncate(name: str, font_size: int, max_w: float) -> str:
247
247
  return name[: max(0, max_chars - 1)] + "\u2026"
248
248
 
249
249
 
250
+ def _truncate_breadcrumb_svg(name: str, font_size: int, max_w: float) -> str:
251
+ """Truncate a breadcrumb label (`` / ``-separated parts) to fit *max_w*.
252
+
253
+ Tries the full label first, then collapses middle segments to ``…``, and
254
+ finally falls back to ``_truncate`` for plain names or when even the
255
+ ``first / … / last`` form is too long.
256
+ """
257
+ char_w = font_size * _CHAR_ASPECT
258
+ parts = name.split(" / ")
259
+ if len(parts) <= 1:
260
+ return _truncate(name, font_size, max_w)
261
+ if len(name) * char_w <= max_w:
262
+ return name
263
+ candidate = parts[0] + " / \u2026 / " + parts[-1]
264
+ if len(candidate) * char_w <= max_w:
265
+ return candidate
266
+ return _truncate(candidate, font_size, max_w)
267
+
268
+
250
269
  # ---------------------------------------------------------------------------
251
270
  # Recursive draw
252
271
  # ---------------------------------------------------------------------------
@@ -367,7 +386,9 @@ def _draw_node_svg(
367
386
  )
368
387
  d.append(hdr)
369
388
 
370
- label = _truncate(root_label if root_label is not None else node.name, font_size, w - 8)
389
+ label = _truncate_breadcrumb_svg(
390
+ root_label if root_label is not None else node.name, font_size, w - 8
391
+ )
371
392
  hclip = drawsvg.ClipPath()
372
393
  hclip.append(drawsvg.Rectangle(x + 2, y + 2, w - 4, header_h))
373
394
  d.append(hclip)
@@ -535,6 +556,7 @@ def create_treemap_svg(
535
556
  colormap: str = "tab20",
536
557
  legend: int | None = None,
537
558
  cushion: bool = True,
559
+ tree_depth: int | None = None,
538
560
  ) -> io.BytesIO:
539
561
  """Render a nested squarified treemap and return it as SVG in a BytesIO buffer.
540
562
 
@@ -598,9 +620,10 @@ def create_treemap_svg(
598
620
  # 4. Treemap tiles
599
621
  n_files, n_dirs = count_nodes(root_node)
600
622
  total_bytes = root_node.original_size if root_node.original_size > 0 else root_node.size
623
+ depth = tree_depth if tree_depth is not None else max_depth(root_node)
601
624
  root_label = (
602
625
  f"{root_node.name} \u2014 {n_files:,} files, {n_dirs:,} dirs,"
603
- f" {_human_bytes(total_bytes)} ({total_bytes:,} bytes)"
626
+ f" {_human_bytes(total_bytes)} ({total_bytes:,} bytes), depth: {depth}"
604
627
  )
605
628
  _draw_node_svg(
606
629
  d,
@@ -163,6 +163,27 @@ def test_read_meta_svg_no_metadata(tmp_path: Path) -> None:
163
163
  assert "No dirplot metadata" in result.output
164
164
 
165
165
 
166
+ def test_read_meta_multiple_files(sample_tree: Path, tmp_path: Path) -> None:
167
+ out1 = tmp_path / "out1.png"
168
+ out2 = tmp_path / "out2.png"
169
+ runner.invoke(app, ["map", str(sample_tree), "--no-show", "--output", str(out1)])
170
+ runner.invoke(app, ["map", str(sample_tree), "--no-show", "--output", str(out2)])
171
+ result = runner.invoke(app, ["read-meta", str(out1), str(out2)])
172
+ assert result.exit_code == 0
173
+ assert f"==> {out1} <==" in result.output
174
+ assert f"==> {out2} <==" in result.output
175
+ assert result.output.count("Date:") == 2
176
+
177
+
178
+ def test_read_meta_multiple_files_partial_error(sample_tree: Path, tmp_path: Path) -> None:
179
+ out1 = tmp_path / "out1.png"
180
+ runner.invoke(app, ["map", str(sample_tree), "--no-show", "--output", str(out1)])
181
+ result = runner.invoke(app, ["read-meta", str(out1), "/nonexistent/file.png"])
182
+ assert result.exit_code == 1
183
+ assert "Date:" in result.output
184
+ assert "file not found" in result.output
185
+
186
+
166
187
  def test_main_module() -> None:
167
188
  """__main__.py delegates to app."""
168
189
  from dirplot.__main__ import main
@@ -6,6 +6,7 @@ import pytest
6
6
 
7
7
  from dirplot.scanner import (
8
8
  Node,
9
+ apply_breadcrumbs,
9
10
  build_tree,
10
11
  build_tree_multi,
11
12
  collect_extensions,
@@ -229,3 +230,80 @@ def test_build_tree_multi_single_delegates(tmp_path: Path) -> None:
229
230
  root = build_tree_multi([tmp_path])
230
231
  assert root.path == tmp_path
231
232
  assert len(root.children) == 1
233
+
234
+
235
+ # ---------------------------------------------------------------------------
236
+ # apply_breadcrumbs
237
+ # ---------------------------------------------------------------------------
238
+
239
+
240
+ def _make_dir(name: str, children: list[Node] | None = None) -> Node:
241
+ return Node(name=name, path=Path(name), size=1, is_dir=True, children=children or [])
242
+
243
+
244
+ def _make_file(name: str) -> Node:
245
+ return Node(name=name, path=Path(name), size=1, is_dir=False, extension=".txt")
246
+
247
+
248
+ def test_breadcrumbs_collapses_chain() -> None:
249
+ # root → a → b → c → [file.txt]; root is never collapsed, but a/b/c merge
250
+ file_node = _make_file("file.txt")
251
+ c = _make_dir("c", [file_node])
252
+ b = _make_dir("b", [c])
253
+ a = _make_dir("a", [b])
254
+ root = _make_dir("root", [a])
255
+
256
+ result = apply_breadcrumbs(root)
257
+
258
+ assert result.name == "root" # root itself is never collapsed
259
+ assert len(result.children) == 1
260
+ merged = result.children[0]
261
+ assert merged.name == "a / b / c"
262
+ assert len(merged.children) == 1
263
+ assert merged.children[0].name == "file.txt"
264
+
265
+
266
+ def test_breadcrumbs_no_collapse_with_files() -> None:
267
+ # root → a → [file.txt, subdir] — a has file child, must not collapse
268
+ file_node = _make_file("file.txt")
269
+ subdir = _make_dir("subdir", [_make_file("inner.txt")])
270
+ a = _make_dir("a", [file_node, subdir])
271
+ root = _make_dir("root", [a])
272
+
273
+ result = apply_breadcrumbs(root)
274
+
275
+ assert result.name == "root"
276
+ child = result.children[0]
277
+ assert child.name == "a"
278
+ assert {c.name for c in child.children} == {"file.txt", "subdir"}
279
+
280
+
281
+ def test_breadcrumbs_no_collapse_multi_children() -> None:
282
+ # root → a → [dir1, dir2] — a has two dir children, must not collapse
283
+ dir1 = _make_dir("dir1", [_make_file("x.txt")])
284
+ dir2 = _make_dir("dir2", [_make_file("y.txt")])
285
+ a = _make_dir("a", [dir1, dir2])
286
+ root = _make_dir("root", [a])
287
+
288
+ result = apply_breadcrumbs(root)
289
+
290
+ assert result.name == "root"
291
+ child = result.children[0]
292
+ assert child.name == "a"
293
+ assert {c.name for c in child.children} == {"dir1", "dir2"}
294
+
295
+
296
+ def test_breadcrumbs_partial_chain() -> None:
297
+ # root → a → b → [dir1, dir2] — b has two children, so a/b merges but stops there
298
+ dir1 = _make_dir("dir1", [_make_file("x.txt")])
299
+ dir2 = _make_dir("dir2", [_make_file("y.txt")])
300
+ b = _make_dir("b", [dir1, dir2])
301
+ a = _make_dir("a", [b])
302
+ root = _make_dir("root", [a])
303
+
304
+ result = apply_breadcrumbs(root)
305
+
306
+ assert result.name == "root"
307
+ child = result.children[0]
308
+ assert child.name == "a / b"
309
+ assert {c.name for c in child.children} == {"dir1", "dir2"}
@@ -759,7 +759,7 @@ wheels = [
759
759
 
760
760
  [[package]]
761
761
  name = "dirplot"
762
- version = "0.3.2"
762
+ version = "0.3.3"
763
763
  source = { editable = "." }
764
764
  dependencies = [
765
765
  { name = "drawsvg" },
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
dirplot-0.3.2/docs/s3.png DELETED
Binary file
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes