Skip to content

Commit 839ff42

Browse files
committed
feat: add breadcrumbs mode to collapse single-child directory chains
Directories that form a single-child chain (one subdir, no files) are collapsed into a single tile whose header shows the full path separated by ' / ' (e.g. src / dirplot / fonts). Middle segments are replaced by '…' when the label is too wide to fit. - scanner: apply_breadcrumbs() collapses chains bottom-up; root is never merged - render: _truncate_breadcrumb() elides middle segments for PNG labels - svg_render: _truncate_breadcrumb_svg() same logic for SVG labels - main: --breadcrumbs/--no-breadcrumbs (-b/-B), default on - tests: four unit tests covering chain, no-file-sibling, multi-child, and partial-chain cases - fix: assert img is not None before paste to satisfy mypy
1 parent fe265b3 commit 839ff42

5 files changed

Lines changed: 163 additions & 2 deletions

File tree

src/dirplot/main.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from dirplot.s3 import build_tree_s3, is_s3_path, make_s3_client, parse_s3_path
2020
from dirplot.scanner import (
2121
Node,
22+
apply_breadcrumbs,
2223
apply_log_sizes,
2324
build_tree,
2425
build_tree_multi,
@@ -346,6 +347,15 @@ def main(
346347
"--password",
347348
help="Password for encrypted archives. Prompted interactively if not supplied and needed.",
348349
),
350+
breadcrumbs: bool = typer.Option(
351+
True,
352+
"--breadcrumbs/--no-breadcrumbs",
353+
"-b/-B",
354+
help=(
355+
"Collapse single-subdirectory chains into breadcrumb labels"
356+
" (e.g. foo / bar / baz). Default: on."
357+
),
358+
),
349359
) -> None:
350360
"""Create a nested treemap bitmap for a directory tree."""
351361
if not roots:
@@ -559,6 +569,9 @@ def main(
559569
if subtrees:
560570
root_node = prune_to_subtrees(root_node, set(subtrees))
561571

572+
if breadcrumbs:
573+
root_node = apply_breadcrumbs(root_node)
574+
562575
t_scan = time.monotonic() - t_scan_start
563576
if log:
564577
apply_log_sizes(root_node)

src/dirplot/render.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,26 @@ def _truncate(
160160
return name[:lo] + ellipsis
161161

162162

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+
163183
def _apply_cushion(img: Image.Image, x: int, y: int, w: int, h: int) -> None:
164184
"""Apply van Wijk-style quadratic cushion shading to a tile in-place."""
165185
if w < 4 or h < 4:
@@ -257,6 +277,7 @@ def draw_node(
257277
spacing=0,
258278
)
259279
rotated = tmp.rotate(90, expand=True)
280+
assert img is not None
260281
img.paste(rotated, (x, y), mask=rotated)
261282
else:
262283
# Horizontal label: available text-run = w-4, constraining dim = h-4
@@ -279,7 +300,9 @@ def draw_node(
279300
# Header label — height driven by the font size
280301
header_h = font.size + 4
281302
if h > 2 + header_h:
282-
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+
)
283306
draw.text(
284307
(x + w // 2, y + 2 + header_h // 2),
285308
label,

src/dirplot/scanner.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,32 @@ def count_nodes(node: Node) -> tuple[int, int]:
182182
return files, dirs
183183

184184

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+
185211
def collect_extensions(node: Node) -> list[str]:
186212
"""Return a flat list of file extensions under *node*."""
187213
if not node.is_dir:

src/dirplot/svg_render.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,25 @@ def _truncate(name: str, font_size: int, max_w: float) -> str:
247247
return name[: max(0, max_chars - 1)] + "\u2026"
248248

249249

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+
250269
# ---------------------------------------------------------------------------
251270
# Recursive draw
252271
# ---------------------------------------------------------------------------
@@ -367,7 +386,9 @@ def _draw_node_svg(
367386
)
368387
d.append(hdr)
369388

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+
)
371392
hclip = drawsvg.ClipPath()
372393
hclip.append(drawsvg.Rectangle(x + 2, y + 2, w - 4, header_h))
373394
d.append(hclip)

tests/test_scanner.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
from dirplot.scanner import (
88
Node,
9+
apply_breadcrumbs,
910
build_tree,
1011
build_tree_multi,
1112
collect_extensions,
@@ -229,3 +230,80 @@ def test_build_tree_multi_single_delegates(tmp_path: Path) -> None:
229230
root = build_tree_multi([tmp_path])
230231
assert root.path == tmp_path
231232
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"}

0 commit comments

Comments
 (0)