display_status.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303
  1. from __future__ import annotations
  2. import logging
  3. import re
  4. from typing import TYPE_CHECKING
  5. from rich import box
  6. from rich._loop import loop_first
  7. from rich.console import Console, ConsoleOptions, RenderResult
  8. from rich.markdown import Heading, ListItem, Markdown
  9. from rich.panel import Panel
  10. from rich.segment import Segment
  11. from rich.text import Text
  12. from .display_icons import IconManager
  13. from .display_settings import DisplaySettings
  14. if TYPE_CHECKING:
  15. from .display_base import BaseDisplay
  16. logger = logging.getLogger(__name__)
  17. console_err = Console(stderr=True) # Keep for error output
  18. class LeftAlignedHeading(Heading):
  19. """Custom Heading element with left alignment and no extra spacing."""
  20. def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
  21. text = self.text
  22. text.justify = "left" # Override center justification
  23. if self.tag == "h1":
  24. # Draw a border around h1s (left-aligned)
  25. yield Panel(
  26. text,
  27. box=box.HEAVY,
  28. style="markdown.h1.border",
  29. )
  30. else:
  31. # Styled text for h2 and beyond (no blank line before h2)
  32. yield text
  33. class IconListItem(ListItem):
  34. """Custom list item that replaces bullets with colored icons from shortcodes."""
  35. def render_bullet(self, console: Console, options: ConsoleOptions) -> RenderResult:
  36. """Render list item with icon replacement if text starts with :shortcode:."""
  37. # Get the text content from elements
  38. text_content = ""
  39. for element in self.elements:
  40. if hasattr(element, "text"):
  41. text_content = element.text.plain
  42. break
  43. icon_used = None
  44. icon_color = "cyan" # Default color for icons
  45. shortcode_found = None
  46. # Scan for shortcode at the beginning
  47. for shortcode, icon in IconManager.SHORTCODES.items():
  48. if text_content.strip().startswith(shortcode):
  49. icon_used = icon
  50. shortcode_found = shortcode
  51. # Map shortcodes to colors
  52. shortcode_colors = {
  53. ":warning:": "yellow",
  54. ":error:": "red",
  55. ":check:": "green",
  56. ":success:": "green",
  57. ":info:": "blue",
  58. ":docker:": "blue",
  59. ":kubernetes:": "blue",
  60. ":rocket:": "magenta",
  61. ":star:": "yellow",
  62. ":lightning:": "yellow",
  63. }
  64. icon_color = shortcode_colors.get(shortcode, "cyan")
  65. break
  66. if icon_used and shortcode_found:
  67. # Remove the shortcode from the text in all elements
  68. for element in self.elements:
  69. if hasattr(element, "text"):
  70. # Replace the shortcode in the Text object
  71. plain_text = element.text.plain
  72. new_text = plain_text.replace(shortcode_found, "", 1).lstrip()
  73. # Reconstruct the Text object with the same style
  74. element.text = Text(new_text, style=element.text.style)
  75. # Render with custom colored icon instead of bullet
  76. render_options = options.update(width=options.max_width - 3)
  77. lines = console.render_lines(self.elements, render_options, style=self.style)
  78. bullet_style = console.get_style(icon_color, default="none")
  79. bullet = Segment(f" {icon_used} ", bullet_style)
  80. padding = Segment(" " * 3)
  81. new_line = Segment("\n")
  82. for first, line in loop_first(lines):
  83. yield bullet if first else padding
  84. yield from line
  85. yield new_line
  86. else:
  87. # No icon found, use default list item rendering
  88. yield from super().render_bullet(console, options)
  89. class LeftAlignedMarkdown(Markdown):
  90. """Custom Markdown renderer with left-aligned headings and icon list items."""
  91. def __init__(self, markup: str, **kwargs):
  92. """Initialize with custom heading and list item elements."""
  93. super().__init__(markup, **kwargs)
  94. # Replace heading element to use left alignment
  95. self.elements["heading_open"] = LeftAlignedHeading
  96. # Replace list item element to use icon replacement
  97. self.elements["list_item_open"] = IconListItem
  98. class StatusDisplay:
  99. """Status messages and error display.
  100. Provides methods for displaying success, error, warning,
  101. and informational messages with consistent formatting.
  102. """
  103. def __init__(self, settings: DisplaySettings, quiet: bool, base: BaseDisplay):
  104. """Initialize StatusDisplay.
  105. Args:
  106. settings: Display settings for formatting
  107. quiet: If True, suppress non-error output
  108. base: BaseDisplay instance
  109. """
  110. self.settings = settings
  111. self.quiet = quiet
  112. self.base = base
  113. def _display_message(self, level: str, message: str, context: str | None = None) -> None:
  114. """Display a message with consistent formatting.
  115. Args:
  116. level: Message level (error, warning, success, info)
  117. message: The message to display
  118. context: Optional context information
  119. """
  120. # Errors and warnings always go to stderr, even in quiet mode
  121. # Success and info respect quiet mode and go to stdout
  122. use_stderr = level in ("error", "warning")
  123. should_print = use_stderr or not self.quiet
  124. if not should_print:
  125. return
  126. settings = self.settings
  127. colors = {
  128. "error": settings.COLOR_ERROR,
  129. "warning": settings.COLOR_WARNING,
  130. "success": settings.COLOR_SUCCESS,
  131. }
  132. color = colors.get(level)
  133. # Format message based on context
  134. if context:
  135. text = (
  136. f"{level.capitalize()} in {context}: {message}"
  137. if level in {"error", "warning"}
  138. else f"{context}: {message}"
  139. )
  140. else:
  141. text = f"{level.capitalize()}: {message}" if level in {"error", "warning"} else message
  142. # Only use icons and colors for actual status indicators (error, warning, success)
  143. # Plain info messages use default terminal color (no markup)
  144. if level in {"error", "warning", "success"}:
  145. icon = IconManager.get_status_icon(level)
  146. formatted_text = f"[{color}]{icon} {text}[/{color}]"
  147. else:
  148. formatted_text = text
  149. if use_stderr:
  150. console_err.print(formatted_text)
  151. else:
  152. self.base.text(formatted_text)
  153. # Log appropriately
  154. log_message = f"{context}: {message}" if context else message
  155. log_methods = {
  156. "error": logger.error,
  157. "warning": logger.warning,
  158. "success": logger.info,
  159. "info": logger.info,
  160. }
  161. log_methods.get(level, logger.info)(log_message)
  162. def error(self, message: str, context: str | None = None, details: str | None = None) -> None:
  163. """Display an error message.
  164. Args:
  165. message: Error message
  166. context: Optional context
  167. details: Optional additional details (shown in dim style on same line)
  168. """
  169. if details:
  170. # Combine message and details on same line with different formatting
  171. settings = self.settings
  172. color = settings.COLOR_ERROR
  173. icon = IconManager.get_status_icon("error")
  174. # Format: Icon Error: Message (details in dim)
  175. formatted = f"[{color}]{icon} Error: {message}[/{color}] [dim]({details})[/dim]"
  176. console_err.print(formatted)
  177. # Log at debug level to avoid duplicate console output (already printed to stderr)
  178. logger.debug(f"Error displayed: {message} ({details})")
  179. else:
  180. # No details, use standard display
  181. self._display_message("error", message, context)
  182. def warning(self, message: str, context: str | None = None, details: str | None = None) -> None:
  183. """Display a warning message.
  184. Args:
  185. message: Warning message
  186. context: Optional context
  187. details: Optional additional details (shown in dim style on same line)
  188. """
  189. if details:
  190. # Combine message and details on same line with different formatting
  191. settings = self.settings
  192. color = settings.COLOR_WARNING
  193. icon = IconManager.get_status_icon("warning")
  194. # Format: Icon Warning: Message (details in dim)
  195. formatted = f"[{color}]{icon} Warning: {message}[/{color}] [dim]({details})[/dim]"
  196. console_err.print(formatted)
  197. # Log at debug level to avoid duplicate console output (already printed to stderr)
  198. logger.debug(f"Warning displayed: {message} ({details})")
  199. else:
  200. # No details, use standard display
  201. self._display_message("warning", message, context)
  202. def success(self, message: str, context: str | None = None) -> None:
  203. """Display a success message.
  204. Args:
  205. message: Success message
  206. context: Optional context
  207. """
  208. self._display_message("success", message, context)
  209. def info(self, message: str, context: str | None = None) -> None:
  210. """Display an informational message.
  211. Args:
  212. message: Info message
  213. context: Optional context
  214. """
  215. self._display_message("info", message, context)
  216. def skipped(self, message: str, reason: str | None = None) -> None:
  217. """Display a skipped/disabled message.
  218. Args:
  219. message: The main message to display
  220. reason: Optional reason why it was skipped
  221. """
  222. if reason:
  223. self.base.text(f"\n{message} (skipped - {reason})", style="dim")
  224. else:
  225. self.base.text(f"\n{message} (skipped)", style="dim")
  226. def markdown(self, content: str) -> None:
  227. """Render markdown content with left-aligned headings.
  228. Replaces emoji-style shortcodes (e.g., :warning:, :info:) with Nerd Font icons
  229. before rendering, EXCEPT for shortcodes at the start of list items which are
  230. handled by IconListItem to replace the bullet.
  231. Args:
  232. content: Markdown-formatted text to render (may contain shortcodes)
  233. """
  234. if not self.quiet:
  235. # Replace shortcodes with Nerd Font icons, but preserve list item shortcodes
  236. # Pattern: "- :shortcode:" at start of line should NOT be replaced
  237. lines = content.split("\n")
  238. processed_lines = []
  239. for line in lines:
  240. # Check if line is a list item starting with a shortcode
  241. if re.match(r"^\s*-\s+:[a-z]+:", line):
  242. # Keep the line as-is, IconListItem will handle it
  243. processed_lines.append(line)
  244. else:
  245. # Replace shortcodes normally
  246. processed_lines.append(IconManager.replace_shortcodes(line))
  247. processed_content = "\n".join(processed_lines)
  248. self.base._print_markdown(LeftAlignedMarkdown(processed_content))