rostree 0.1.0__py3-none-any.whl → 0.2.1__py3-none-any.whl

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.
rostree/__init__.py CHANGED
@@ -1,5 +1,7 @@
1
1
  """rostree: visualize ROS 2 package dependencies as a tree (library, TUI, CLI)."""
2
2
 
3
+ from importlib.metadata import version, PackageNotFoundError
4
+
3
5
  from rostree.api import (
4
6
  build_tree,
5
7
  get_package_info,
@@ -16,5 +18,10 @@ __all__ = [
16
18
  "list_known_packages_by_source",
17
19
  "scan_workspaces",
18
20
  "WorkspaceInfo",
21
+ "__version__",
19
22
  ]
20
- __version__ = "0.1.0"
23
+
24
+ try:
25
+ __version__ = version("rostree")
26
+ except PackageNotFoundError:
27
+ __version__ = "0.0.0+unknown" # Not installed as package
rostree/cli.py CHANGED
@@ -4,6 +4,8 @@ from __future__ import annotations
4
4
 
5
5
  import argparse
6
6
  import json
7
+ import shutil
8
+ import subprocess
7
9
  import sys
8
10
  from pathlib import Path
9
11
 
@@ -143,13 +145,456 @@ def cmd_tui(args: argparse.Namespace) -> int:
143
145
  return 0
144
146
 
145
147
 
148
+ def _collect_edges(
149
+ node: DependencyNode,
150
+ edges: set[tuple[str, str]],
151
+ visited: set[str] | None = None,
152
+ ) -> None:
153
+ """Recursively collect all edges (parent -> child) from a dependency tree."""
154
+ if visited is None:
155
+ visited = set()
156
+ if node.name in visited:
157
+ return
158
+ visited.add(node.name)
159
+ for child in node.children:
160
+ # Skip special markers
161
+ if child.description in ("(cycle)", "(not found)", "(parse error)"):
162
+ continue
163
+ edges.add((node.name, child.name))
164
+ _collect_edges(child, edges, visited)
165
+
166
+
167
+ def _collect_edges_multi(
168
+ trees: list[DependencyNode],
169
+ root_names: set[str],
170
+ ) -> tuple[set[tuple[str, str]], set[str]]:
171
+ """Collect edges from multiple trees, tracking which nodes are roots."""
172
+ edges: set[tuple[str, str]] = set()
173
+ all_nodes: set[str] = set()
174
+ for tree in trees:
175
+ _collect_edges(tree, edges)
176
+ all_nodes.add(tree.name)
177
+ # Also collect all node names from edges
178
+ for parent, child in edges:
179
+ all_nodes.add(parent)
180
+ all_nodes.add(child)
181
+ return edges, all_nodes
182
+
183
+
184
+ def _generate_dot(
185
+ roots: list[DependencyNode],
186
+ title: str | None = None,
187
+ highlight_roots: bool = True,
188
+ ) -> str:
189
+ """Generate DOT (Graphviz) format from dependency trees."""
190
+ root_names = {r.name for r in roots}
191
+ edges: set[tuple[str, str]] = set()
192
+ for root in roots:
193
+ _collect_edges(root, edges)
194
+
195
+ lines = [
196
+ "digraph dependencies {",
197
+ " rankdir=LR;",
198
+ ' node [shape=box, style=rounded, fontname="sans-serif"];',
199
+ ]
200
+ if title:
201
+ lines.insert(1, f' label="{title}";')
202
+ lines.insert(2, " labelloc=t;")
203
+
204
+ # Highlight root nodes
205
+ if highlight_roots:
206
+ for name in sorted(root_names):
207
+ lines.append(f' "{name}" [style="rounded,filled", fillcolor=lightblue];')
208
+
209
+ for parent, child in sorted(edges):
210
+ lines.append(f' "{parent}" -> "{child}";')
211
+
212
+ lines.append("}")
213
+ return "\n".join(lines)
214
+
215
+
216
+ def _generate_mermaid(
217
+ roots: list[DependencyNode],
218
+ title: str | None = None,
219
+ highlight_roots: bool = True,
220
+ ) -> str:
221
+ """Generate Mermaid format from dependency trees."""
222
+ root_names = {r.name for r in roots}
223
+ edges: set[tuple[str, str]] = set()
224
+ for root in roots:
225
+ _collect_edges(root, edges)
226
+
227
+ lines = ["graph LR"]
228
+ if title:
229
+ lines[0] = f"---\ntitle: {title}\n---\ngraph LR"
230
+
231
+ # Style root nodes
232
+ if highlight_roots:
233
+ for name in sorted(root_names):
234
+ lines.append(f" {_mermaid_id(name)}[{name}]")
235
+ lines.append(f" style {_mermaid_id(name)} fill:#lightblue")
236
+
237
+ for parent, child in sorted(edges):
238
+ lines.append(f" {_mermaid_id(parent)} --> {_mermaid_id(child)}")
239
+
240
+ return "\n".join(lines)
241
+
242
+
243
+ def _mermaid_id(name: str) -> str:
244
+ """Convert a package name to a valid Mermaid node ID."""
245
+ # Replace characters that are problematic in Mermaid
246
+ return name.replace("-", "_").replace(".", "_")
247
+
248
+
249
+ def _get_workspace_packages(workspace_path: Path | None = None) -> list[str]:
250
+ """Get packages from a workspace. If None, use current environment."""
251
+ if workspace_path:
252
+ # Scan the specified workspace
253
+ ws_path = Path(workspace_path).resolve()
254
+ src_path = ws_path / "src" if (ws_path / "src").exists() else ws_path
255
+ if not src_path.exists():
256
+ return []
257
+ from rostree.core.finder import _list_packages_in_src
258
+
259
+ return _list_packages_in_src(src_path)
260
+ else:
261
+ # Use packages from current environment's workspace (not system)
262
+ by_source = list_packages_by_source()
263
+ packages = []
264
+ for label, names in by_source.items():
265
+ # Only include Workspace and Source packages, not System
266
+ if "System" not in label:
267
+ packages.extend(names)
268
+ return packages
269
+
270
+
271
+ # Default depth limit for graph to prevent hangs
272
+ GRAPH_DEFAULT_DEPTH = 4
273
+ GRAPH_MAX_PACKAGES = 50
274
+
275
+
276
+ def _check_graphviz() -> bool:
277
+ """Check if Graphviz (dot) is available."""
278
+ return shutil.which("dot") is not None
279
+
280
+
281
+ def _check_matplotlib() -> bool:
282
+ """Check if matplotlib and networkx are available."""
283
+ try:
284
+ import matplotlib # noqa: F401
285
+ import networkx # noqa: F401
286
+
287
+ return True
288
+ except ImportError:
289
+ return False
290
+
291
+
292
+ def _render_with_matplotlib(
293
+ edges: set[tuple[str, str]],
294
+ root_names: set[str],
295
+ output_path: Path,
296
+ format: str,
297
+ title: str | None = None,
298
+ ) -> bool:
299
+ """Render graph using matplotlib and networkx (pure Python, no system deps)."""
300
+ try:
301
+ import matplotlib.pyplot as plt
302
+ import networkx as nx
303
+ except ImportError:
304
+ print(
305
+ "Error: matplotlib/networkx not installed. Install with:\n"
306
+ " pip install rostree[viz]\n"
307
+ " # or: pip install matplotlib networkx",
308
+ file=sys.stderr,
309
+ )
310
+ return False
311
+
312
+ try:
313
+ # Create directed graph
314
+ G = nx.DiGraph()
315
+ G.add_edges_from(edges)
316
+
317
+ # Add isolated root nodes (roots with no deps)
318
+ for root in root_names:
319
+ if root not in G:
320
+ G.add_node(root)
321
+
322
+ if len(G.nodes()) == 0:
323
+ print("Error: Graph is empty", file=sys.stderr)
324
+ return False
325
+
326
+ # Create figure
327
+ fig_width = max(12, len(G.nodes()) * 0.5)
328
+ fig_height = max(8, len(G.nodes()) * 0.3)
329
+ fig, ax = plt.subplots(figsize=(fig_width, fig_height))
330
+
331
+ # Layout - hierarchical for dependency graphs
332
+ try:
333
+ # Try graphviz layout if available (best for DAGs)
334
+ pos = nx.nx_agraph.graphviz_layout(G, prog="dot", args="-Grankdir=LR")
335
+ except Exception:
336
+ try:
337
+ # Fall back to spring layout with more iterations
338
+ pos = nx.spring_layout(G, k=2, iterations=50, seed=42)
339
+ except Exception:
340
+ # Last resort: shell layout
341
+ pos = nx.shell_layout(G)
342
+
343
+ # Node colors: root nodes are highlighted
344
+ node_colors = ["lightblue" if n in root_names else "lightgray" for n in G.nodes()]
345
+
346
+ # Draw the graph
347
+ nx.draw_networkx_nodes(
348
+ G,
349
+ pos,
350
+ node_color=node_colors,
351
+ node_size=2000,
352
+ alpha=0.9,
353
+ ax=ax,
354
+ )
355
+ nx.draw_networkx_labels(
356
+ G,
357
+ pos,
358
+ font_size=8,
359
+ font_weight="bold",
360
+ ax=ax,
361
+ )
362
+ nx.draw_networkx_edges(
363
+ G,
364
+ pos,
365
+ edge_color="gray",
366
+ arrows=True,
367
+ arrowsize=15,
368
+ alpha=0.7,
369
+ ax=ax,
370
+ )
371
+
372
+ if title:
373
+ ax.set_title(title, fontsize=14, fontweight="bold")
374
+
375
+ ax.axis("off")
376
+ plt.tight_layout()
377
+
378
+ # Save to file
379
+ plt.savefig(output_path, format=format, dpi=150, bbox_inches="tight")
380
+ plt.close(fig)
381
+ return True
382
+
383
+ except Exception as e:
384
+ print(f"Error rendering with matplotlib: {e}", file=sys.stderr)
385
+ return False
386
+
387
+
388
+ def _render_dot(dot_content: str, output_path: Path, format: str) -> bool:
389
+ """Render DOT content to an image file using Graphviz."""
390
+ if not _check_graphviz():
391
+ print(
392
+ "Error: Graphviz not found. Install it with:\n"
393
+ " Ubuntu/Debian: sudo apt install graphviz\n"
394
+ " macOS: brew install graphviz\n"
395
+ " Or download from: https://graphviz.org/download/",
396
+ file=sys.stderr,
397
+ )
398
+ return False
399
+
400
+ try:
401
+ result = subprocess.run(
402
+ ["dot", f"-T{format}", "-o", str(output_path)],
403
+ input=dot_content,
404
+ capture_output=True,
405
+ text=True,
406
+ timeout=60,
407
+ )
408
+ if result.returncode != 0:
409
+ print(f"Graphviz error: {result.stderr}", file=sys.stderr)
410
+ return False
411
+ return True
412
+ except subprocess.TimeoutExpired:
413
+ print("Error: Graphviz timed out (graph may be too large)", file=sys.stderr)
414
+ return False
415
+ except Exception as e:
416
+ print(f"Error running Graphviz: {e}", file=sys.stderr)
417
+ return False
418
+
419
+
420
+ def _open_file(path: Path) -> bool:
421
+ """Open a file with the system default application."""
422
+ import platform
423
+
424
+ system = platform.system()
425
+ try:
426
+ if system == "Darwin": # macOS
427
+ subprocess.run(["open", str(path)], check=True)
428
+ elif system == "Windows":
429
+ subprocess.run(["start", "", str(path)], shell=True, check=True)
430
+ else: # Linux and others
431
+ subprocess.run(["xdg-open", str(path)], check=True)
432
+ return True
433
+ except Exception as e:
434
+ print(f"Could not open file: {e}", file=sys.stderr)
435
+ return False
436
+
437
+
438
+ def cmd_graph(args: argparse.Namespace) -> int:
439
+ """Generate a dependency graph in DOT or Mermaid format."""
440
+ extra_roots = [Path(p) for p in args.source] if args.source else None
441
+
442
+ # Determine packages to graph
443
+ packages_to_graph: list[str] = []
444
+
445
+ if args.package:
446
+ packages_to_graph = [args.package]
447
+ elif args.workspace:
448
+ # Scan specified workspace
449
+ packages_to_graph = _get_workspace_packages(Path(args.workspace))
450
+ if not packages_to_graph:
451
+ print(f"No packages found in workspace: {args.workspace}", file=sys.stderr)
452
+ return 1
453
+ else:
454
+ # Use current environment's non-system packages
455
+ packages_to_graph = _get_workspace_packages(None)
456
+ if not packages_to_graph:
457
+ print(
458
+ "No workspace packages found. Specify a package or use --workspace.",
459
+ file=sys.stderr,
460
+ )
461
+ return 1
462
+
463
+ # Limit packages for performance
464
+ if len(packages_to_graph) > GRAPH_MAX_PACKAGES and not args.package:
465
+ print(
466
+ f"Warning: Limiting to first {GRAPH_MAX_PACKAGES} packages "
467
+ f"(found {len(packages_to_graph)}). Use -d to limit depth.",
468
+ file=sys.stderr,
469
+ )
470
+ packages_to_graph = packages_to_graph[:GRAPH_MAX_PACKAGES]
471
+
472
+ # Use default depth for workspace graphs (prevent hangs), unlimited for single package
473
+ if args.depth is not None:
474
+ depth = args.depth
475
+ elif args.package:
476
+ depth = None # Unlimited for single package
477
+ else:
478
+ depth = GRAPH_DEFAULT_DEPTH # Limited for workspace-wide
479
+
480
+ # Build trees for all packages
481
+ trees: list[DependencyNode] = []
482
+ for i, pkg in enumerate(packages_to_graph):
483
+ if len(packages_to_graph) > 1:
484
+ print(f"Processing {pkg} ({i + 1}/{len(packages_to_graph)})...", file=sys.stderr)
485
+ tree = build_dependency_tree(
486
+ pkg,
487
+ max_depth=depth,
488
+ runtime_only=args.runtime,
489
+ extra_source_roots=extra_roots,
490
+ )
491
+ if tree is not None:
492
+ trees.append(tree)
493
+
494
+ if not trees:
495
+ print("No valid package trees found.", file=sys.stderr)
496
+ return 1
497
+
498
+ # Generate title
499
+ if args.no_title:
500
+ title = None
501
+ elif args.package:
502
+ title = f"{args.package} dependencies"
503
+ elif args.workspace:
504
+ title = f"Workspace: {Path(args.workspace).name}"
505
+ else:
506
+ title = "Workspace dependencies"
507
+
508
+ if args.format == "mermaid":
509
+ output = _generate_mermaid(trees, title=title)
510
+ else: # dot
511
+ output = _generate_dot(trees, title=title)
512
+
513
+ # Handle rendering to image
514
+ render_format = getattr(args, "render", None)
515
+ if render_format:
516
+ if args.format == "mermaid":
517
+ print(
518
+ "Error: --render only works with DOT format (not mermaid). "
519
+ "Remove -f mermaid or use mermaid.live for rendering.",
520
+ file=sys.stderr,
521
+ )
522
+ return 1
523
+
524
+ # Determine output path
525
+ if args.output:
526
+ # If output specified, use it with proper extension
527
+ out_path = Path(args.output)
528
+ if out_path.suffix.lower() not in (f".{render_format}", ".dot"):
529
+ out_path = out_path.with_suffix(f".{render_format}")
530
+ else:
531
+ # Default filename based on package or workspace
532
+ if args.package:
533
+ base_name = args.package.replace("/", "_")
534
+ elif args.workspace:
535
+ base_name = Path(args.workspace).name
536
+ else:
537
+ base_name = "workspace_deps"
538
+ out_path = Path(f"{base_name}.{render_format}")
539
+
540
+ print(f"Rendering graph to {out_path}...", file=sys.stderr)
541
+
542
+ # Try Graphviz first (best quality), fall back to matplotlib
543
+ rendered = False
544
+ if _check_graphviz():
545
+ rendered = _render_dot(output, out_path, render_format)
546
+ else:
547
+ # Collect edges for matplotlib rendering
548
+ root_names = {t.name for t in trees}
549
+ edges: set[tuple[str, str]] = set()
550
+ for tree in trees:
551
+ _collect_edges(tree, edges)
552
+
553
+ if _check_matplotlib():
554
+ print("Graphviz not found, using matplotlib...", file=sys.stderr)
555
+ rendered = _render_with_matplotlib(
556
+ edges, root_names, out_path, render_format, title
557
+ )
558
+ else:
559
+ print(
560
+ "Error: No rendering backend available.\n"
561
+ "Install one of:\n"
562
+ " 1. Graphviz (system): sudo apt install graphviz\n"
563
+ " 2. matplotlib (pip): pip install rostree[viz]",
564
+ file=sys.stderr,
565
+ )
566
+ return 1
567
+
568
+ if not rendered:
569
+ return 1
570
+
571
+ print(f"Graph image saved to: {out_path}", file=sys.stderr)
572
+
573
+ # Open the file if requested
574
+ if getattr(args, "open", False):
575
+ _open_file(out_path)
576
+
577
+ return 0
578
+
579
+ # Just output text (DOT or Mermaid)
580
+ if args.output:
581
+ Path(args.output).write_text(output)
582
+ print(f"Graph written to: {args.output}", file=sys.stderr)
583
+ else:
584
+ print(output)
585
+
586
+ return 0
587
+
588
+
146
589
  def main(argv: list[str] | None = None) -> int:
147
590
  """Main entry point for the rostree CLI."""
148
591
  parser = argparse.ArgumentParser(
149
592
  prog="rostree",
150
593
  description="Explore ROS 2 package dependencies from the command line.",
151
594
  )
152
- parser.add_argument("--version", action="version", version="%(prog)s 0.1.0")
595
+ from rostree import __version__
596
+
597
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
153
598
 
154
599
  subparsers = parser.add_subparsers(dest="command", help="Available commands")
155
600
 
@@ -262,6 +707,78 @@ def main(argv: list[str] | None = None) -> int:
262
707
  )
263
708
  tree_parser.set_defaults(func=cmd_tree)
264
709
 
710
+ # rostree graph
711
+ graph_parser = subparsers.add_parser(
712
+ "graph",
713
+ help="Generate a dependency graph (DOT/Mermaid format)",
714
+ description=(
715
+ "Generate a visual dependency graph. "
716
+ "Without arguments, graphs all workspace packages. "
717
+ "Specify a package name to graph just that package."
718
+ ),
719
+ )
720
+ graph_parser.add_argument(
721
+ "package",
722
+ nargs="?",
723
+ help="Package name to graph (optional; without it, graphs workspace)",
724
+ )
725
+ graph_parser.add_argument(
726
+ "-w",
727
+ "--workspace",
728
+ metavar="PATH",
729
+ help="Scan and graph packages from this workspace path",
730
+ )
731
+ graph_parser.add_argument(
732
+ "-f",
733
+ "--format",
734
+ choices=["dot", "mermaid"],
735
+ default="dot",
736
+ help="Output format: dot (Graphviz) or mermaid (default: dot)",
737
+ )
738
+ graph_parser.add_argument(
739
+ "-o",
740
+ "--output",
741
+ metavar="FILE",
742
+ help="Output file (default: stdout)",
743
+ )
744
+ graph_parser.add_argument(
745
+ "-d",
746
+ "--depth",
747
+ type=int,
748
+ default=None,
749
+ help=f"Maximum tree depth (default: {GRAPH_DEFAULT_DEPTH} for workspace, unlimited for single package)",
750
+ )
751
+ graph_parser.add_argument(
752
+ "-r",
753
+ "--runtime",
754
+ action="store_true",
755
+ help="Show only runtime dependencies (depend, exec_depend)",
756
+ )
757
+ graph_parser.add_argument(
758
+ "-s",
759
+ "--source",
760
+ action="append",
761
+ metavar="PATH",
762
+ help="Additional source directories to scan (can be repeated)",
763
+ )
764
+ graph_parser.add_argument(
765
+ "--no-title",
766
+ action="store_true",
767
+ help="Don't include a title in the graph",
768
+ )
769
+ graph_parser.add_argument(
770
+ "--render",
771
+ choices=["png", "svg", "pdf"],
772
+ metavar="FORMAT",
773
+ help="Render to image (png, svg, pdf). Requires Graphviz installed.",
774
+ )
775
+ graph_parser.add_argument(
776
+ "--open",
777
+ action="store_true",
778
+ help="Open the rendered image after creation (use with --render)",
779
+ )
780
+ graph_parser.set_defaults(func=cmd_graph)
781
+
265
782
  # rostree tui (default if no command)
266
783
  tui_parser = subparsers.add_parser(
267
784
  "tui",
rostree/tui/app.py CHANGED
@@ -8,34 +8,27 @@ from typing import Any
8
8
 
9
9
  from textual.app import App, ComposeResult
10
10
  from textual.binding import Binding
11
- from textual.containers import Horizontal, Vertical
11
+ from textual.containers import Container, Vertical
12
12
  from textual.screen import ModalScreen
13
- from textual.widgets import Button, Footer, Header, Input, Static, Tree
13
+ from textual.widgets import Footer, Header, Input, Static, Tree
14
14
  from textual.widgets.tree import TreeNode
15
15
 
16
16
  from rostree.api import build_tree, list_known_packages_by_source
17
17
 
18
- # Welcome banner: ROSTREE
19
- WELCOME_BANNER = """
18
+ # Welcome banner: ROSTREE (all lines must be same length for proper centering)
19
+ WELCOME_BANNER = """\
20
20
  [bold cyan]
21
21
  ██████╗ ██████╗ ███████╗████████╗██████╗ ███████╗███████╗
22
22
  ██╔══██╗██╔═══██╗██╔════╝╚══██╔══╝██╔══██╗██╔════╝██╔════╝
23
- ██████╔╝██║ ██║███████╗ ██║ ██████╔╝█████╗ █████╗
24
- ██╔══██╗██║ ██║╚════██║ ██║ ██╔══██╗██╔══╝ ██╔══╝
23
+ ██████╔╝██║ ██║███████╗ ██║ ██████╔╝█████╗ █████╗
24
+ ██╔══██╗██║ ██║╚════██║ ██║ ██╔══██╗██╔══╝ ██╔══╝
25
25
  ██║ ██║╚██████╔╝███████║ ██║ ██║ ██║███████╗███████╗
26
26
  ╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝╚══════╝╚══════╝
27
- [/bold cyan]
28
- """
27
+ [/bold cyan]"""
29
28
 
30
- WELCOME_BODY = """
31
- [dim]Visualize ROS 2 package dependencies as a navigable tree.[/]
32
-
33
- • [bold]CLI[/]: rostree scan, rostree list, rostree tree <pkg>
34
- • [bold]TUI[/]: browse packages, expand/collapse, see details
35
- • [bold]Library[/]: Python API for scripts and automation
36
-
37
- [dim]Requires ROS 2 env (source install/setup.bash).[/]
38
- """
29
+ WELCOME_DESC = """[dim]Navigate and visualize ROS 2 package dependency trees.
30
+ Discover packages from your workspace, system installs, and custom paths.
31
+ Search, expand, and explore the full dependency graph interactively.[/]"""
39
32
 
40
33
  # Limits to avoid huge trees and crashes
41
34
  MAX_PACKAGES_PER_SOURCE = 80 # max package names per source section
@@ -123,50 +116,67 @@ def _expand_to_depth(tn: TreeNode, depth: int, current: int = 0) -> None:
123
116
  pass
124
117
 
125
118
 
126
- class WelcomeScreen(ModalScreen[bool]):
127
- """Welcome / presentation screen. Modal so Enter/q always work."""
119
+ class SearchScreen(ModalScreen[str | None]):
120
+ """Modal to search for packages/nodes in the tree. Keyboard-only."""
128
121
 
129
122
  BINDINGS = [
130
- Binding("enter", "start", "Start", show=True),
131
- Binding("q", "quit", "Quit", show=True),
123
+ Binding("escape", "cancel", "Cancel", show=True),
132
124
  ]
133
125
 
134
126
  DEFAULT_CSS = """
135
- WelcomeScreen {
127
+ SearchScreen {
136
128
  align: center middle;
137
129
  padding: 2 4;
138
130
  }
139
- WelcomeScreen #banner {
131
+ SearchScreen #search_title {
140
132
  text-align: center;
141
133
  padding-bottom: 1;
142
134
  }
143
- WelcomeScreen #welcome_body {
144
- padding: 1 2;
135
+ SearchScreen #search_input {
145
136
  width: 60;
137
+ margin: 1 0;
146
138
  }
147
- WelcomeScreen #welcome_footer {
139
+ SearchScreen #search_hint {
148
140
  text-align: center;
149
- padding-top: 2;
141
+ padding-top: 1;
150
142
  }
151
143
  """
152
144
 
145
+ def __init__(self, **kwargs: Any) -> None:
146
+ super().__init__(**kwargs)
147
+ self._input: Input | None = None
148
+
153
149
  def compose(self) -> ComposeResult:
154
- yield Static(WELCOME_BANNER, id="banner", markup=True)
155
- yield Static(WELCOME_BODY, id="welcome_body", markup=True)
156
- yield Static(
157
- "[bold]This screen has focus.[/] Press [cyan]Enter[/] to start · [dim]q[/] to quit",
158
- id="welcome_footer",
159
- markup=True,
160
- )
150
+ with Vertical():
151
+ yield Static(
152
+ "[bold cyan]Search[/]\n\n"
153
+ "Type a package name or partial match to find in the tree.",
154
+ id="search_title",
155
+ markup=True,
156
+ )
157
+ yield Input(
158
+ placeholder="package name...",
159
+ id="search_input",
160
+ )
161
+ yield Static(
162
+ "[dim]Enter[/] = Search · [dim]Escape[/] = Cancel\n"
163
+ "[dim]After search: [bold]n[/bold] = next match, [bold]N[/bold] = previous[/]",
164
+ id="search_hint",
165
+ markup=True,
166
+ )
161
167
 
162
168
  def on_mount(self) -> None:
163
- self.sub_title = "Enter = start · q = quit"
169
+ self._input = self.query_one("#search_input", Input)
170
+ self._input.focus()
164
171
 
165
- def action_start(self) -> None:
166
- self.dismiss(True)
172
+ def on_input_submitted(self, event: Input.Submitted) -> None:
173
+ if event.input.id != "search_input":
174
+ return
175
+ value = self._input.value.strip() if self._input else ""
176
+ self.dismiss(value if value else None)
167
177
 
168
- def action_quit(self) -> None:
169
- self.dismiss(False)
178
+ def action_cancel(self) -> None:
179
+ self.dismiss(None)
170
180
 
171
181
 
172
182
  class AddSourceScreen(ModalScreen[Path | None]):
@@ -250,9 +260,15 @@ class DepTreeApp(App[None]):
250
260
 
251
261
  TITLE = "rostree"
252
262
  BINDINGS = [
263
+ Binding("enter", "start_main", "Start", show=False),
253
264
  Binding("escape", "back", "Back", show=True),
254
265
  Binding("b", "back", "Back", show=False),
255
266
  Binding("a", "add_source", "Add source"),
267
+ Binding("/", "search", "Search"),
268
+ Binding("f", "search", "Search", show=False),
269
+ Binding("n", "next_match", "Next match", show=False),
270
+ Binding("N", "prev_match", "Prev match", show=False),
271
+ Binding("d", "toggle_details", "Details"),
256
272
  Binding("q", "quit", "Quit"),
257
273
  Binding("r", "refresh", "Refresh"),
258
274
  Binding("e", "expand_all", "Expand all"),
@@ -265,13 +281,41 @@ class DepTreeApp(App[None]):
265
281
  self._root_node: Any = None
266
282
  self._main_started = False
267
283
  self._extra_source_roots: list[Path] = []
284
+ self._search_query: str = ""
285
+ self._search_matches: list[TreeNode] = []
286
+ self._search_index: int = 0
287
+ self._details_visible: bool = True
268
288
 
269
289
  DEFAULT_CSS = """
270
- #back_bar {
290
+ /* Welcome screen styles */
291
+ #welcome_container {
292
+ align: center middle;
293
+ width: 100%;
294
+ height: 100%;
295
+ }
296
+ #welcome_banner {
297
+ text-align: center;
298
+ content-align: center middle;
299
+ width: 100%;
300
+ }
301
+ #welcome_desc {
302
+ text-align: center;
303
+ padding: 2 4;
304
+ }
305
+ #welcome_hint {
306
+ text-align: center;
307
+ padding-top: 1;
308
+ }
309
+ /* Main view styles */
310
+ #main_container {
311
+ display: none;
312
+ }
313
+ #nav_hint {
271
314
  display: none;
272
315
  height: auto;
273
316
  padding: 0 1;
274
317
  margin-bottom: 1;
318
+ color: $text-muted;
275
319
  }
276
320
  #details {
277
321
  padding: 1 2;
@@ -283,25 +327,50 @@ class DepTreeApp(App[None]):
283
327
 
284
328
  def compose(self) -> ComposeResult:
285
329
  yield Header(show_clock=False)
286
- with Horizontal(id="back_bar"):
287
- yield Button("← Back to package list", id="back_btn", variant="primary")
288
- yield Tree("Dependencies", id="dep_tree")
289
- yield Static(
290
- "[dim]↑/↓[/] move · [dim]Enter[/]/[dim]Space[/] select · [dim]Tab[/] = switch focus · [dim]Esc[/]/[dim]b[/] = Back",
291
- id="details",
292
- )
330
+ # Welcome view (initial)
331
+ with Container(id="welcome_container"):
332
+ yield Static(WELCOME_BANNER, id="welcome_banner", markup=True)
333
+ yield Static(WELCOME_DESC, id="welcome_desc", markup=True)
334
+ yield Static(
335
+ "[cyan]Enter[/] to explore · [dim]q[/] to quit",
336
+ id="welcome_hint",
337
+ markup=True,
338
+ )
339
+ # Main view (hidden initially)
340
+ with Container(id="main_container"):
341
+ yield Static(
342
+ "[dim]← Press [bold]Esc[/bold] or [bold]b[/bold] to return to package list[/]",
343
+ id="nav_hint",
344
+ )
345
+ yield Tree("Dependencies", id="dep_tree")
346
+ yield Static(
347
+ "[dim]↑/↓[/] move · [dim]Enter[/]/[dim]Space[/] select · [dim]Esc[/]/[dim]b[/] = Back",
348
+ id="details",
349
+ )
293
350
  yield Footer()
294
351
 
295
352
  def on_mount(self) -> None:
296
- self.sub_title = "Tab = move focus · Esc = Back · Keys shown in footer"
297
- self.push_screen(WelcomeScreen(), self._on_welcome_done)
298
-
299
- def _on_welcome_done(self, start: bool) -> None:
300
- if not start:
301
- self.exit(0)
353
+ self.sub_title = "Dependency Tree Explorer"
354
+
355
+ def on_key(self, event: Any) -> None:
356
+ """Handle key events - specifically Enter on welcome screen."""
357
+ if not self._main_started and event.key == "enter":
358
+ event.prevent_default()
359
+ event.stop()
360
+ self.action_start_main()
361
+
362
+ def action_start_main(self) -> None:
363
+ """Transition from welcome screen to main view."""
364
+ if self._main_started:
302
365
  return
303
366
  self._main_started = True
304
- self._start_main()
367
+ # Hide welcome, show main
368
+ try:
369
+ self.query_one("#welcome_container").styles.display = "none"
370
+ self.query_one("#main_container").styles.display = "block"
371
+ except Exception:
372
+ pass
373
+ self._load_main_view()
305
374
 
306
375
  def _source_color(self, label: str) -> str:
307
376
  if "System" in label:
@@ -314,10 +383,10 @@ class DepTreeApp(App[None]):
314
383
  return COLOR_ADDED
315
384
  return COLOR_SOURCE
316
385
 
317
- def _start_main(self) -> None:
386
+ def _load_main_view(self) -> None:
318
387
  try:
319
388
  try:
320
- self.query_one("#back_bar").styles.display = "none"
389
+ self.query_one("#nav_hint").styles.display = "none"
321
390
  except Exception:
322
391
  pass
323
392
  tree = self.query_one("#dep_tree", Tree)
@@ -413,12 +482,9 @@ class DepTreeApp(App[None]):
413
482
  _expand_to_depth(tree.root, EXPAND_DEPTH_DEFAULT)
414
483
  except Exception:
415
484
  pass
416
- self._set_details(
417
- self._format_node(self._root_node)
418
- + "\n\n[dim]Esc[/] or [dim]b[/] = Back to package list · [dim]Tab[/] then [dim]Enter[/] = Back button"
419
- )
485
+ self._set_details(self._format_node(self._root_node))
420
486
  try:
421
- self.query_one("#back_bar").styles.display = "block"
487
+ self.query_one("#nav_hint").styles.display = "block"
422
488
  self.query_one("#dep_tree", Tree).focus()
423
489
  except Exception:
424
490
  pass
@@ -468,16 +534,12 @@ class DepTreeApp(App[None]):
468
534
  self._root_package = None
469
535
  self._root_node = None
470
536
  try:
471
- self.query_one("#back_bar").styles.display = "none"
537
+ self.query_one("#nav_hint").styles.display = "none"
472
538
  except Exception:
473
539
  pass
474
540
  tree = self.query_one("#dep_tree", Tree)
475
541
  self._clear_tree(tree)
476
- self._start_main()
477
-
478
- def on_button_pressed(self, event: Button.Pressed) -> None:
479
- if event.button.id == "back_btn":
480
- self.action_back()
542
+ self._load_main_view()
481
543
 
482
544
  def action_refresh(self) -> None:
483
545
  if not self._main_started:
@@ -487,7 +549,7 @@ class DepTreeApp(App[None]):
487
549
  else:
488
550
  tree = self.query_one("#dep_tree", Tree)
489
551
  self._clear_tree(tree)
490
- self._start_main()
552
+ self._load_main_view()
491
553
 
492
554
  def action_expand_all(self) -> None:
493
555
  tree = self.query_one("#dep_tree", Tree)
@@ -524,6 +586,100 @@ class DepTreeApp(App[None]):
524
586
  except Exception:
525
587
  pass
526
588
 
589
+ def action_search(self) -> None:
590
+ """Open search modal."""
591
+ if not self._main_started:
592
+ return
593
+ self.push_screen(SearchScreen(), self._on_search_done)
594
+
595
+ def _on_search_done(self, query: str | None) -> None:
596
+ if not query:
597
+ return
598
+ self._search_query = query.lower()
599
+ self._search_matches = []
600
+ self._search_index = 0
601
+
602
+ tree = self.query_one("#dep_tree", Tree)
603
+ self._collect_matches(tree.root, query.lower())
604
+
605
+ if not self._search_matches:
606
+ self.notify(f"No matches for '{query}'", severity="warning", timeout=2)
607
+ return
608
+
609
+ self.notify(
610
+ f"Found {len(self._search_matches)} match(es) for '{query}'",
611
+ severity="information",
612
+ timeout=2,
613
+ )
614
+ self._goto_match(0)
615
+
616
+ def _collect_matches(self, node: TreeNode, query: str) -> None:
617
+ """Recursively collect nodes matching the search query."""
618
+ label = str(node.label).lower()
619
+ # Also check the data if it's a string (package name)
620
+ data_str = str(node.data).lower() if node.data else ""
621
+ if query in label or query in data_str:
622
+ self._search_matches.append(node)
623
+ for child in node.children:
624
+ self._collect_matches(child, query)
625
+
626
+ def _goto_match(self, index: int) -> None:
627
+ """Navigate to and select a specific match."""
628
+ if not self._search_matches:
629
+ return
630
+ self._search_index = index % len(self._search_matches)
631
+ match_node = self._search_matches[self._search_index]
632
+
633
+ # Expand all ancestors so the node is visible
634
+ self._expand_ancestors(match_node)
635
+
636
+ # Select the node
637
+ tree = self.query_one("#dep_tree", Tree)
638
+ tree.select_node(match_node)
639
+ tree.scroll_to_node(match_node)
640
+
641
+ # Show match info
642
+ total = len(self._search_matches)
643
+ current = self._search_index + 1
644
+ self.notify(
645
+ f"Match {current}/{total}: {match_node.label}",
646
+ severity="information",
647
+ timeout=2,
648
+ )
649
+
650
+ def _expand_ancestors(self, node: TreeNode) -> None:
651
+ """Expand all ancestor nodes to make the target visible."""
652
+ ancestors = []
653
+ parent = node.parent
654
+ while parent is not None:
655
+ ancestors.append(parent)
656
+ parent = parent.parent
657
+ for ancestor in reversed(ancestors):
658
+ ancestor.expand()
659
+
660
+ def action_next_match(self) -> None:
661
+ """Go to next search match."""
662
+ if not self._search_matches:
663
+ self.notify("No active search. Press / to search.", severity="information", timeout=2)
664
+ return
665
+ self._goto_match(self._search_index + 1)
666
+
667
+ def action_prev_match(self) -> None:
668
+ """Go to previous search match."""
669
+ if not self._search_matches:
670
+ self.notify("No active search. Press / to search.", severity="information", timeout=2)
671
+ return
672
+ self._goto_match(self._search_index - 1)
673
+
674
+ def action_toggle_details(self) -> None:
675
+ """Toggle visibility of the details panel."""
676
+ self._details_visible = not self._details_visible
677
+ try:
678
+ details = self.query_one("#details", Static)
679
+ details.styles.display = "block" if self._details_visible else "none"
680
+ except Exception:
681
+ pass
682
+
527
683
  def action_quit(self) -> None:
528
684
  self.exit()
529
685
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: rostree
3
- Version: 0.1.0
3
+ Version: 0.2.1
4
4
  Summary: Explore ROS 2 package dependencies from the command line (CLI, TUI, library)
5
5
  Author: rostree contributors
6
6
  License: MIT
@@ -21,9 +21,19 @@ Requires-Dist: black==25.1.0; extra == 'dev'
21
21
  Requires-Dist: pytest-cov>=4.0; extra == 'dev'
22
22
  Requires-Dist: pytest>=7.0; extra == 'dev'
23
23
  Requires-Dist: ruff>=0.1.0; extra == 'dev'
24
+ Provides-Extra: viz
25
+ Requires-Dist: matplotlib>=3.7; extra == 'viz'
26
+ Requires-Dist: networkx>=3.0; extra == 'viz'
24
27
  Description-Content-Type: text/markdown
25
28
 
26
- # rostree
29
+ ```
30
+ ██████╗ ██████╗ ███████╗████████╗██████╗ ███████╗███████╗
31
+ ██╔══██╗██╔═══██╗██╔════╝╚══██╔══╝██╔══██╗██╔════╝██╔════╝
32
+ ██████╔╝██║ ██║███████╗ ██║ ██████╔╝█████╗ █████╗
33
+ ██╔══██╗██║ ██║╚════██║ ██║ ██╔══██╗██╔══╝ ██╔══╝
34
+ ██║ ██║╚██████╔╝███████║ ██║ ██║ ██║███████╗███████╗
35
+ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝╚══════╝╚══════╝
36
+ ```
27
37
 
28
38
  [![CI](https://github.com/guilyx/rostree/actions/workflows/ci.yml/badge.svg)](https://github.com/guilyx/rostree/actions/workflows/ci.yml)
29
39
  [![codecov](https://codecov.io/gh/guilyx/rostree/graph/badge.svg)](https://codecov.io/gh/guilyx/rostree)
@@ -54,6 +64,10 @@ rostree list --by-source # List packages grouped by source
54
64
  rostree tree rclpy # Show dependency tree for a package
55
65
  rostree tree rclpy --depth 3 # Limit tree depth
56
66
  rostree tree rclpy --json # Output as JSON
67
+ rostree graph rclpy --render png # Generate PNG image (requires graphviz)
68
+ rostree graph rclpy --render svg --open # Create SVG and open it
69
+ rostree graph -w ~/ros2_ws --render png # Graph entire workspace to image
70
+ rostree graph rclpy -f mermaid # Mermaid format (text)
57
71
  ```
58
72
 
59
73
  ### TUI mode
@@ -1,14 +1,14 @@
1
- rostree/__init__.py,sha256=6kFb52bjBJQMUbYeGOGuRDKpZjwKPEj1d91pw80xtD8,437
1
+ rostree/__init__.py,sha256=VLoW0ZPZl-J_B_8a1oKQWHq4ZWwco3vxl7-5AoPSx-o,630
2
2
  rostree/api.py,sha256=A2L7uuj3efJXwqazsL9j6y6hgkwxPBnwGv2dYNtFTwg,3646
3
- rostree/cli.py,sha256=gdRS-Bj_25KQ95fZhD-HRbmQFEJGl7NqnAV4kSnGEGg,9073
3
+ rostree/cli.py,sha256=GbgF0I0-gA2SmNrkdzcLwLOEPjVzA6r9PF4HlqivyMs,25456
4
4
  rostree/core/__init__.py,sha256=glLk6MYQvGz0jTre7cVaQhQ6PiAAWa1lXFFMFnWbHbo,582
5
5
  rostree/core/finder.py,sha256=lmfzjjHX-bDHprs3x-GKPztpCS4hVsZUq2fZOg64DTg,16801
6
6
  rostree/core/parser.py,sha256=_Yhyjrg7Ir-N80KqJnxPYRcejWepDZDFbw_BTKLBySs,3094
7
7
  rostree/core/tree.py,sha256=jBvd7_Pzk4ciO9dWV1r4NyU9q6BaXKiYRimbmK72oD4,4060
8
8
  rostree/tui/__init__.py,sha256=hHtp0ZeBL6drxVxnBi1pm2uRds5O-UbNASQ6xRzOJrs,64
9
- rostree/tui/app.py,sha256=_vj47K70w5ZJFANRisENHk9UXlwH8YBVh_qKwfxgQv8,18678
10
- rostree-0.1.0.dist-info/METADATA,sha256=2uYP2pwyhSwBw5F1ye-p3jU5L3a3fk6YkiXLI_nggZg,3218
11
- rostree-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
12
- rostree-0.1.0.dist-info/entry_points.txt,sha256=3FqDola110oRFImx5-IF3CEnfCXA3CDSM2fTzbES2Zo,45
13
- rostree-0.1.0.dist-info/licenses/LICENSE,sha256=mcuqLv_cT8O1n1fIsOkUNDMnKlrPgP5LVCMyyVl-tGk,1530
14
- rostree-0.1.0.dist-info/RECORD,,
9
+ rostree/tui/app.py,sha256=MOKWDaSdQW1eJzkNY9juDoFN2Pu-HHOqtZaqq6-UAvE,24327
10
+ rostree-0.2.1.dist-info/METADATA,sha256=KcfJWnZsVyCgKRE2Rj9eBhiGbtU1OqvQEvwqIfQqg_k,4552
11
+ rostree-0.2.1.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
12
+ rostree-0.2.1.dist-info/entry_points.txt,sha256=3FqDola110oRFImx5-IF3CEnfCXA3CDSM2fTzbES2Zo,45
13
+ rostree-0.2.1.dist-info/licenses/LICENSE,sha256=mcuqLv_cT8O1n1fIsOkUNDMnKlrPgP5LVCMyyVl-tGk,1530
14
+ rostree-0.2.1.dist-info/RECORD,,