display_manager.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482
  1. """Main display coordinator for the CLI."""
  2. from __future__ import annotations
  3. import logging
  4. from pathlib import Path
  5. from typing import TYPE_CHECKING
  6. from jinja2 import Template as Jinja2Template
  7. from rich.console import Console
  8. from rich.progress import Progress
  9. from rich.syntax import Syntax
  10. from rich.table import Table
  11. from rich.tree import Tree
  12. from .display_settings import DisplaySettings
  13. from .icon_manager import IconManager
  14. from .status_display import StatusDisplayManager
  15. from .table_display import TableDisplayManager
  16. from .template_display import TemplateDisplayManager
  17. from .variable_display import VariableDisplayManager
  18. if TYPE_CHECKING:
  19. from ..exceptions import TemplateRenderError
  20. from ..template import Template
  21. logger = logging.getLogger(__name__)
  22. console = Console()
  23. class DisplayManager:
  24. """Main display coordinator with shared resources.
  25. This class acts as a facade that delegates to specialized display managers.
  26. External code should use DisplayManager methods which provide backward
  27. compatibility while internally using the specialized managers.
  28. Design Principles:
  29. - All display logic should go through DisplayManager methods
  30. - IconManager is ONLY used internally by display managers
  31. - External code should never directly call IconManager or console.print
  32. - Consistent formatting across all display types
  33. """
  34. def __init__(self, quiet: bool = False, settings: DisplaySettings | None = None):
  35. """Initialize DisplayManager with specialized sub-managers.
  36. Args:
  37. quiet: If True, suppress all non-error output
  38. settings: Optional DisplaySettings instance for customization
  39. """
  40. self.quiet = quiet
  41. self.settings = settings or DisplaySettings()
  42. # Initialize specialized managers
  43. self.variables = VariableDisplayManager(self)
  44. self.templates = TemplateDisplayManager(self)
  45. self.status = StatusDisplayManager(self)
  46. self.tables = TableDisplayManager(self)
  47. # ===== Shared Helper Methods =====
  48. def _format_library_display(self, library_name: str, library_type: str) -> str:
  49. """Format library name with appropriate icon and color.
  50. Args:
  51. library_name: Name of the library
  52. library_type: Type of library ('static' or 'git')
  53. Returns:
  54. Formatted library display string with Rich markup
  55. """
  56. if library_type == "static":
  57. color = self.settings.COLOR_LIBRARY_STATIC
  58. icon = IconManager.UI_LIBRARY_STATIC
  59. else:
  60. color = self.settings.COLOR_LIBRARY_GIT
  61. icon = IconManager.UI_LIBRARY_GIT
  62. return f"[{color}]{icon} {library_name}[/{color}]"
  63. def _truncate_value(self, value: str, max_length: int | None = None) -> str:
  64. """Truncate a string value if it exceeds maximum length.
  65. Args:
  66. value: String value to truncate
  67. max_length: Maximum length (uses default if None)
  68. Returns:
  69. Truncated string with suffix if needed
  70. """
  71. if max_length is None:
  72. max_length = self.settings.VALUE_MAX_LENGTH_DEFAULT
  73. if max_length > 0 and len(value) > max_length:
  74. return (
  75. value[: max_length - len(self.settings.TRUNCATION_SUFFIX)]
  76. + self.settings.TRUNCATION_SUFFIX
  77. )
  78. return value
  79. def _format_file_size(self, size_bytes: int) -> str:
  80. """Format file size in human-readable format (B, KB, MB).
  81. Args:
  82. size_bytes: Size in bytes
  83. Returns:
  84. Formatted size string (e.g., "1.5KB", "2.3MB")
  85. """
  86. if size_bytes < self.settings.SIZE_KB_THRESHOLD:
  87. return f"{size_bytes}B"
  88. elif size_bytes < self.settings.SIZE_MB_THRESHOLD:
  89. kb = size_bytes / self.settings.SIZE_KB_THRESHOLD
  90. return f"{kb:.{self.settings.SIZE_DECIMAL_PLACES}f}KB"
  91. else:
  92. mb = size_bytes / self.settings.SIZE_MB_THRESHOLD
  93. return f"{mb:.{self.settings.SIZE_DECIMAL_PLACES}f}MB"
  94. # ===== Backward Compatibility Delegation Methods =====
  95. # These methods delegate to specialized managers for backward compatibility
  96. def display_templates_table(
  97. self, templates: list, module_name: str, title: str
  98. ) -> None:
  99. """Delegate to TableDisplayManager."""
  100. return self.tables.render_templates_table(templates, module_name, title)
  101. def display_template(self, template: Template, template_id: str) -> None:
  102. """Delegate to TemplateDisplayManager."""
  103. return self.templates.render_template(template, template_id)
  104. def display_section(self, title: str, description: str | None) -> None:
  105. """Delegate to VariableDisplayManager."""
  106. return self.variables.render_section(title, description)
  107. def display_validation_error(self, message: str) -> None:
  108. """Delegate to StatusDisplayManager."""
  109. return self.status.display_validation_error(message)
  110. def display_message(
  111. self, level: str, message: str, context: str | None = None
  112. ) -> None:
  113. """Delegate to StatusDisplayManager."""
  114. return self.status.display_message(level, message, context)
  115. def display_error(self, message: str, context: str | None = None) -> None:
  116. """Delegate to StatusDisplayManager."""
  117. return self.status.display_error(message, context)
  118. def display_warning(self, message: str, context: str | None = None) -> None:
  119. """Delegate to StatusDisplayManager."""
  120. return self.status.display_warning(message, context)
  121. def display_success(self, message: str, context: str | None = None) -> None:
  122. """Delegate to StatusDisplayManager."""
  123. return self.status.display_success(message, context)
  124. def display_info(self, message: str, context: str | None = None) -> None:
  125. """Delegate to StatusDisplayManager."""
  126. return self.status.display_info(message, context)
  127. def display_version_incompatibility(
  128. self, template_id: str, required_version: str, current_version: str
  129. ) -> None:
  130. """Delegate to StatusDisplayManager."""
  131. return self.status.display_version_incompatibility(
  132. template_id, required_version, current_version
  133. )
  134. def display_file_generation_confirmation(
  135. self,
  136. output_dir: Path,
  137. files: dict[str, str],
  138. existing_files: list[Path] | None = None,
  139. ) -> None:
  140. """Delegate to TemplateDisplayManager."""
  141. return self.templates.render_file_generation_confirmation(
  142. output_dir, files, existing_files
  143. )
  144. def display_config_tree(
  145. self, spec: dict, module_name: str, show_all: bool = False
  146. ) -> None:
  147. """Delegate to TableDisplayManager."""
  148. return self.tables.render_config_tree(spec, module_name, show_all)
  149. def display_status_table(
  150. self,
  151. title: str,
  152. rows: list[tuple[str, str, bool]],
  153. columns: tuple[str, str] = ("Item", "Status"),
  154. ) -> None:
  155. """Delegate to TableDisplayManager."""
  156. return self.tables.render_status_table(title, rows, columns)
  157. def display_summary_table(self, title: str, items: dict[str, str]) -> None:
  158. """Delegate to TableDisplayManager."""
  159. return self.tables.render_summary_table(title, items)
  160. def display_file_operation_table(self, files: list[tuple[str, int, str]]) -> None:
  161. """Delegate to TableDisplayManager."""
  162. return self.tables.render_file_operation_table(files)
  163. def display_warning_with_confirmation(
  164. self, message: str, details: list[str] | None = None, default: bool = False
  165. ) -> bool:
  166. """Delegate to StatusDisplayManager."""
  167. return self.status.display_warning_with_confirmation(message, details, default)
  168. def display_skipped(self, message: str, reason: str | None = None) -> None:
  169. """Delegate to StatusDisplayManager."""
  170. return self.status.display_skipped(message, reason)
  171. def display_template_render_error(
  172. self, error: TemplateRenderError, context: str | None = None
  173. ) -> None:
  174. """Delegate to StatusDisplayManager."""
  175. return self.status.display_template_render_error(error, context)
  176. # ===== Internal Helper Methods =====
  177. def _render_file_tree_internal(
  178. self, root_label: str, files: list, get_file_info: callable
  179. ) -> Tree:
  180. """Render a file tree structure.
  181. Args:
  182. root_label: Label for root node
  183. files: List of files to display
  184. get_file_info: Function that takes a file and returns (path, display_name, color, extra_text)
  185. Returns:
  186. Tree object ready for display
  187. """
  188. file_tree = Tree(root_label)
  189. tree_nodes = {Path("."): file_tree}
  190. for file_item in sorted(files, key=lambda f: get_file_info(f)[0]):
  191. path, display_name, color, extra_text = get_file_info(file_item)
  192. parts = path.parts
  193. current_path = Path(".")
  194. current_node = file_tree
  195. # Build directory structure
  196. for part in parts[:-1]:
  197. current_path = current_path / part
  198. if current_path not in tree_nodes:
  199. new_node = current_node.add(
  200. f"{IconManager.folder()} [white]{part}[/white]"
  201. )
  202. tree_nodes[current_path] = new_node
  203. current_node = tree_nodes[current_path]
  204. # Add file
  205. icon = IconManager.get_file_icon(display_name)
  206. file_label = f"{icon} [{color}]{display_name}[/{color}]"
  207. if extra_text:
  208. file_label += f" {extra_text}"
  209. current_node.add(file_label)
  210. return file_tree
  211. # ===== Additional Methods =====
  212. def heading(self, text: str, style: str | None = None) -> None:
  213. """Display a standardized heading.
  214. Args:
  215. text: Heading text
  216. style: Optional style override (defaults to STYLE_HEADER from settings)
  217. """
  218. if style is None:
  219. style = self.settings.STYLE_HEADER
  220. console.print(f"[{style}]{text}[/{style}]")
  221. def text(self, text: str, style: str | None = None) -> None:
  222. """Display plain text with optional styling.
  223. Args:
  224. text: Text to display
  225. style: Optional Rich style markup
  226. """
  227. if style:
  228. console.print(f"[{style}]{text}[/{style}]")
  229. else:
  230. console.print(text)
  231. def error(self, text: str, style: str | None = None) -> None:
  232. """Display error text to stderr.
  233. Args:
  234. text: Error text to display
  235. style: Optional Rich style markup (defaults to red)
  236. """
  237. console_err = Console(stderr=True)
  238. if style is None:
  239. style = "red"
  240. console_err.print(f"[{style}]{text}[/{style}]")
  241. def warning(self, text: str, style: str | None = None) -> None:
  242. """Display warning text to stderr.
  243. Args:
  244. text: Warning text to display
  245. style: Optional Rich style markup (defaults to yellow)
  246. """
  247. console_err = Console(stderr=True)
  248. if style is None:
  249. style = "yellow"
  250. console_err.print(f"[{style}]{text}[/{style}]")
  251. def table(
  252. self,
  253. headers: list[str] | None = None,
  254. rows: list[tuple] | None = None,
  255. title: str | None = None,
  256. show_header: bool = True,
  257. borderless: bool = False,
  258. ) -> None:
  259. """Display a standardized table.
  260. Args:
  261. headers: Column headers (if None, no headers)
  262. rows: List of tuples, one per row
  263. title: Optional table title
  264. show_header: Whether to show header row
  265. borderless: If True, use borderless style (box=None)
  266. """
  267. table = Table(
  268. title=title,
  269. show_header=show_header and headers is not None,
  270. header_style=self.settings.STYLE_TABLE_HEADER,
  271. box=None,
  272. padding=self.settings.PADDING_TABLE_NORMAL if borderless else (0, 1),
  273. )
  274. # Add columns
  275. if headers:
  276. for header in headers:
  277. table.add_column(header)
  278. elif rows and len(rows) > 0:
  279. # No headers, but need columns for data
  280. for _ in range(len(rows[0])):
  281. table.add_column()
  282. # Add rows
  283. if rows:
  284. for row in rows:
  285. table.add_row(*[str(cell) for cell in row])
  286. console.print(table)
  287. def tree(self, root_label: str, nodes: dict | list) -> None:
  288. """Display a tree structure.
  289. Args:
  290. root_label: Label for the root node
  291. nodes: Hierarchical structure (dict or list)
  292. """
  293. tree = Tree(root_label)
  294. self._build_tree_nodes(tree, nodes)
  295. console.print(tree)
  296. def _build_tree_nodes(self, parent, nodes):
  297. """Recursively build tree nodes.
  298. Args:
  299. parent: Parent tree node
  300. nodes: Dict or list of child nodes
  301. """
  302. if isinstance(nodes, dict):
  303. for key, value in nodes.items():
  304. if isinstance(value, (dict, list)):
  305. branch = parent.add(str(key))
  306. self._build_tree_nodes(branch, value)
  307. else:
  308. parent.add(f"{key}: {value}")
  309. elif isinstance(nodes, list):
  310. for item in nodes:
  311. if isinstance(item, (dict, list)):
  312. self._build_tree_nodes(parent, item)
  313. else:
  314. parent.add(str(item))
  315. def _print_tree(self, tree) -> None:
  316. """Print a pre-built Rich Tree object.
  317. Args:
  318. tree: Rich Tree object to print
  319. """
  320. console.print(tree)
  321. def _print_table(self, table) -> None:
  322. """Print a pre-built Rich Table object.
  323. Enforces consistent header styling for all tables.
  324. Args:
  325. table: Rich Table object to print
  326. """
  327. # Enforce consistent header style for all tables
  328. table.header_style = self.settings.STYLE_TABLE_HEADER
  329. console.print(table)
  330. def code(self, code_text: str, language: str | None = None) -> None:
  331. """Display code with optional syntax highlighting.
  332. Args:
  333. code_text: Code to display
  334. language: Programming language for syntax highlighting
  335. """
  336. if language:
  337. syntax = Syntax(code_text, language, theme="monokai", line_numbers=False)
  338. console.print(syntax)
  339. else:
  340. # Plain code block without highlighting
  341. console.print(f"[dim]{code_text}[/dim]")
  342. def progress(self, *columns):
  343. """Create a Rich Progress context manager with standardized console.
  344. Args:
  345. *columns: Progress columns (e.g., SpinnerColumn(), TextColumn())
  346. Returns:
  347. Progress context manager
  348. Example:
  349. with display.progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}")) as progress:
  350. task = progress.add_task("Processing...", total=None)
  351. # do work
  352. progress.remove_task(task)
  353. """
  354. return Progress(*columns, console=console)
  355. def get_lock_icon(self) -> str:
  356. """Get the lock icon for sensitive variables.
  357. Returns:
  358. Lock icon unicode character
  359. """
  360. return IconManager.lock()
  361. def _get_icon_by_type(self, icon_type: str) -> str:
  362. """Get icon by semantic type name.
  363. Args:
  364. icon_type: Type of icon (e.g., 'folder', 'file', 'config', 'lock')
  365. Returns:
  366. Icon unicode character
  367. """
  368. icon_map = {
  369. "folder": IconManager.folder(),
  370. "file": IconManager.FILE_DEFAULT,
  371. "config": IconManager.config(),
  372. "lock": IconManager.lock(),
  373. "arrow": IconManager.arrow_right(),
  374. }
  375. return icon_map.get(icon_type, "")
  376. def display_next_steps(self, next_steps: str, variable_values: dict) -> None:
  377. """Display next steps after template generation, rendering them as a Jinja2 template.
  378. Args:
  379. next_steps: The next_steps string from template metadata (may contain Jinja2 syntax)
  380. variable_values: Dictionary of variable values to use for rendering
  381. """
  382. if not next_steps:
  383. return
  384. console.print("\n[bold cyan]Next Steps:[/bold cyan]")
  385. try:
  386. next_steps_template = Jinja2Template(next_steps)
  387. rendered_next_steps = next_steps_template.render(variable_values)
  388. console.print(rendered_next_steps)
  389. except Exception as e:
  390. logger.warning(f"Failed to render next_steps as template: {e}")
  391. # Fallback to plain text if rendering fails
  392. console.print(next_steps)