display.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  1. from __future__ import annotations
  2. import logging
  3. from pathlib import Path
  4. from typing import TYPE_CHECKING
  5. from rich.console import Console
  6. from rich.table import Table
  7. from rich.tree import Tree
  8. if TYPE_CHECKING:
  9. from .template import Template
  10. logger = logging.getLogger(__name__)
  11. console = Console()
  12. class DisplayManager:
  13. """Handles all rich rendering for the CLI."""
  14. def display_templates_table(
  15. self, templates: list[dict], module_name: str, title: str
  16. ) -> None:
  17. """Display a table of templates."""
  18. if not templates:
  19. logger.info(f"No templates found for module '{module_name}'")
  20. return
  21. logger.info(f"Listing {len(templates)} templates for module '{module_name}'")
  22. table = Table(title=title)
  23. table.add_column("ID", style="bold", no_wrap=True)
  24. table.add_column("Name")
  25. table.add_column("Tags")
  26. table.add_column("Version", no_wrap=True)
  27. table.add_column("Library", no_wrap=True)
  28. for template_info in templates:
  29. template = template_info["template"]
  30. indent = template_info["indent"]
  31. name = template.metadata.name or "Unnamed Template"
  32. tags_list = template.metadata.tags or []
  33. tags = ", ".join(tags_list) if tags_list else "-"
  34. version = template.metadata.version or ""
  35. library = template.metadata.library or ""
  36. template_id = f"{indent}{template.id}"
  37. table.add_row(template_id, name, tags, version, library)
  38. console.print(table)
  39. def display_template_details(self, template: Template, template_id: str) -> None:
  40. """Display template information panel and variables table."""
  41. self._display_template_header(template, template_id)
  42. self._display_file_tree(template)
  43. self._display_variables_table(template)
  44. def display_section_header(self, title: str, description: str | None) -> None:
  45. """Display a section header."""
  46. console.print(f"\n[bold cyan]{title}[/bold cyan]")
  47. if description:
  48. console.print(f"[dim]{description}[/dim]")
  49. console.print("─" * 40, style="dim")
  50. def display_validation_error(self, message: str) -> None:
  51. """Display a validation error message."""
  52. console.print(f"[red]{message}[/red]")
  53. def _display_template_header(self, template: Template, template_id: str) -> None:
  54. """Display the header for a template."""
  55. template_name = template.metadata.name or "Unnamed Template"
  56. version = template.metadata.version or "Not specified"
  57. description = template.metadata.description or "No description available"
  58. console.print(
  59. f"[bold blue]{template_name} ({template_id} - [cyan]{version}[/cyan])[/bold blue]"
  60. )
  61. console.print(description)
  62. def _display_file_tree(self, template: Template) -> None:
  63. """Display the file structure of a template."""
  64. # Preserve the heading, then use the template id as the root directory label
  65. console.print()
  66. console.print("[bold blue]Template File Structure:[/bold blue]")
  67. # Use the template id as the root directory label (folder glyph + white name)
  68. file_tree = Tree(f"\uf07b [white]{template.id}[/white]")
  69. tree_nodes = {Path("."): file_tree}
  70. for template_file in sorted(
  71. template.template_files, key=lambda f: f.relative_path
  72. ):
  73. parts = template_file.relative_path.parts
  74. current_path = Path(".")
  75. current_node = file_tree
  76. for part in parts[:-1]:
  77. current_path = current_path / part
  78. if current_path not in tree_nodes:
  79. new_node = current_node.add(f"\uf07b [white]{part}[/white]")
  80. tree_nodes[current_path] = new_node
  81. current_node = new_node
  82. else:
  83. current_node = tree_nodes[current_path]
  84. # Determine display name (use output_path to detect final filename)
  85. display_name = template_file.output_path.name if hasattr(template_file, 'output_path') else template_file.relative_path.name
  86. # Default icons: use Nerd Font private-use-area codepoints (PUA).
  87. # Docker (Font Awesome) is typically U+F308. Default file U+F15B.
  88. docker_icon = "\uf308"
  89. default_file_icon = "\uf15b"
  90. j2_icon = "\ue235"
  91. if template_file.file_type == "j2":
  92. # Detect common docker compose filenames from the resulting output path
  93. lower_name = display_name.lower()
  94. compose_names = {"docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"}
  95. if lower_name in compose_names or lower_name.startswith("docker-compose") or "compose" in lower_name:
  96. icon = docker_icon
  97. else:
  98. icon = j2_icon
  99. current_node.add(f"[white]{icon} {display_name}[/white]")
  100. elif template_file.file_type == "static":
  101. current_node.add(f"[white]{default_file_icon} {display_name}[/white]")
  102. if file_tree.children:
  103. console.print(file_tree)
  104. def _display_variables_table(self, template: Template) -> None:
  105. """Display a table of variables for a template."""
  106. if not (template.variables and template.variables.has_sections()):
  107. return
  108. console.print()
  109. console.print("[bold blue]Template Variables:[/bold blue]")
  110. variables_table = Table(show_header=True, header_style="bold blue")
  111. variables_table.add_column("Variable", style="cyan", no_wrap=True)
  112. variables_table.add_column("Type", style="magenta")
  113. variables_table.add_column("Default", style="green")
  114. variables_table.add_column("Description", style="white")
  115. variables_table.add_column("Origin", style="yellow")
  116. first_section = True
  117. for section in template.variables.get_sections().values():
  118. if not section.variables:
  119. continue
  120. if not first_section:
  121. variables_table.add_row("", "", "", "", "", style="dim")
  122. first_section = False
  123. # Use section's native is_enabled() method
  124. is_dimmed = not section.is_enabled()
  125. disabled_text = " (disabled)" if is_dimmed else ""
  126. required_text = " [yellow](required)[/yellow]" if section.required else ""
  127. header_text = f"[bold dim]{section.title}{required_text}{disabled_text}[/bold dim]" if is_dimmed else f"[bold]{section.title}{required_text}{disabled_text}[/bold]"
  128. variables_table.add_row(header_text, "", "", "", "")
  129. for var_name, variable in section.variables.items():
  130. row_style = "dim" if is_dimmed else None
  131. # Use variable's native get_display_value() method
  132. default_val = variable.get_display_value(mask_sensitive=True, max_length=30)
  133. variables_table.add_row(
  134. f" {var_name}",
  135. variable.type or "str",
  136. default_val,
  137. variable.description or "",
  138. variable.origin or "unknown",
  139. style=row_style,
  140. )
  141. console.print(variables_table)
  142. def display_config_tree(self, spec: dict, module_name: str, show_all: bool = False) -> None:
  143. """Display configuration spec as a tree view.
  144. Args:
  145. spec: The configuration spec dictionary
  146. module_name: Name of the module
  147. show_all: If True, show all details including descriptions
  148. """
  149. if not spec:
  150. console.print(f"[yellow]No configuration found for module '{module_name}'[/yellow]")
  151. return
  152. # Create root tree node
  153. tree = Tree(f"[bold blue]\ue5fc {str.capitalize(module_name)} Configuration[/bold blue]")
  154. for section_name, section_data in spec.items():
  155. if not isinstance(section_data, dict):
  156. continue
  157. # Determine if this is a section with variables
  158. # Guard against None from empty YAML sections
  159. section_vars = section_data.get("vars") or {}
  160. section_desc = section_data.get("description", "")
  161. section_required = section_data.get("required", False)
  162. section_toggle = section_data.get("toggle", None)
  163. # Build section label
  164. section_label = f"[cyan]{section_name}[/cyan]"
  165. if section_required:
  166. section_label += " [yellow](required)[/yellow]"
  167. if section_toggle:
  168. section_label += f" [dim](toggle: {section_toggle})[/dim]"
  169. if show_all and section_desc:
  170. section_label += f"\n [dim]{section_desc}[/dim]"
  171. section_node = tree.add(section_label)
  172. # Add variables
  173. if section_vars:
  174. for var_name, var_data in section_vars.items():
  175. if isinstance(var_data, dict):
  176. var_type = var_data.get("type", "string")
  177. var_default = var_data.get("default", "")
  178. var_desc = var_data.get("description", "")
  179. var_sensitive = var_data.get("sensitive", False)
  180. # Build variable label
  181. var_label = f"[green]{var_name}[/green] [dim]({var_type})[/dim]"
  182. if var_default is not None and var_default != "":
  183. display_val = "********" if var_sensitive else str(var_default)
  184. if not var_sensitive and len(display_val) > 30:
  185. display_val = display_val[:27] + "..."
  186. var_label += f" = [yellow]{display_val}[/yellow]"
  187. if show_all and var_desc:
  188. var_label += f"\n [dim]{var_desc}[/dim]"
  189. section_node.add(var_label)
  190. else:
  191. # Simple key-value pair
  192. section_node.add(f"[green]{var_name}[/green] = [yellow]{var_data}[/yellow]")
  193. console.print(tree)