| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303 |
- from __future__ import annotations
- import logging
- import re
- from typing import TYPE_CHECKING
- from rich import box
- from rich._loop import loop_first
- from rich.console import Console, ConsoleOptions, RenderResult
- from rich.markdown import Heading, ListItem, Markdown
- from rich.panel import Panel
- from rich.segment import Segment
- from rich.text import Text
- from .display_icons import IconManager
- from .display_settings import DisplaySettings
- if TYPE_CHECKING:
- from .display_base import BaseDisplay
- logger = logging.getLogger(__name__)
- console_err = Console(stderr=True) # Keep for error output
- class LeftAlignedHeading(Heading):
- """Custom Heading element with left alignment and no extra spacing."""
- def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
- text = self.text
- text.justify = "left" # Override center justification
- if self.tag == "h1":
- # Draw a border around h1s (left-aligned)
- yield Panel(
- text,
- box=box.HEAVY,
- style="markdown.h1.border",
- )
- else:
- # Styled text for h2 and beyond (no blank line before h2)
- yield text
- class IconListItem(ListItem):
- """Custom list item that replaces bullets with colored icons from shortcodes."""
- def render_bullet(self, console: Console, options: ConsoleOptions) -> RenderResult:
- """Render list item with icon replacement if text starts with :shortcode:."""
- # Get the text content from elements
- text_content = ""
- for element in self.elements:
- if hasattr(element, "text"):
- text_content = element.text.plain
- break
- icon_used = None
- icon_color = "cyan" # Default color for icons
- shortcode_found = None
- # Scan for shortcode at the beginning
- for shortcode, icon in IconManager.SHORTCODES.items():
- if text_content.strip().startswith(shortcode):
- icon_used = icon
- shortcode_found = shortcode
- # Map shortcodes to colors
- shortcode_colors = {
- ":warning:": "yellow",
- ":error:": "red",
- ":check:": "green",
- ":success:": "green",
- ":info:": "blue",
- ":docker:": "blue",
- ":kubernetes:": "blue",
- ":rocket:": "magenta",
- ":star:": "yellow",
- ":lightning:": "yellow",
- }
- icon_color = shortcode_colors.get(shortcode, "cyan")
- break
- if icon_used and shortcode_found:
- # Remove the shortcode from the text in all elements
- for element in self.elements:
- if hasattr(element, "text"):
- # Replace the shortcode in the Text object
- plain_text = element.text.plain
- new_text = plain_text.replace(shortcode_found, "", 1).lstrip()
- # Reconstruct the Text object with the same style
- element.text = Text(new_text, style=element.text.style)
- # Render with custom colored icon instead of bullet
- render_options = options.update(width=options.max_width - 3)
- lines = console.render_lines(self.elements, render_options, style=self.style)
- bullet_style = console.get_style(icon_color, default="none")
- bullet = Segment(f" {icon_used} ", bullet_style)
- padding = Segment(" " * 3)
- new_line = Segment("\n")
- for first, line in loop_first(lines):
- yield bullet if first else padding
- yield from line
- yield new_line
- else:
- # No icon found, use default list item rendering
- yield from super().render_bullet(console, options)
- class LeftAlignedMarkdown(Markdown):
- """Custom Markdown renderer with left-aligned headings and icon list items."""
- def __init__(self, markup: str, **kwargs):
- """Initialize with custom heading and list item elements."""
- super().__init__(markup, **kwargs)
- # Replace heading element to use left alignment
- self.elements["heading_open"] = LeftAlignedHeading
- # Replace list item element to use icon replacement
- self.elements["list_item_open"] = IconListItem
- class StatusDisplay:
- """Status messages and error display.
- Provides methods for displaying success, error, warning,
- and informational messages with consistent formatting.
- """
- def __init__(self, settings: DisplaySettings, quiet: bool, base: BaseDisplay):
- """Initialize StatusDisplay.
- Args:
- settings: Display settings for formatting
- quiet: If True, suppress non-error output
- base: BaseDisplay instance
- """
- self.settings = settings
- self.quiet = quiet
- self.base = base
- def _display_message(self, level: str, message: str, context: str | None = None) -> None:
- """Display a message with consistent formatting.
- Args:
- level: Message level (error, warning, success, info)
- message: The message to display
- context: Optional context information
- """
- # Errors and warnings always go to stderr, even in quiet mode
- # Success and info respect quiet mode and go to stdout
- use_stderr = level in ("error", "warning")
- should_print = use_stderr or not self.quiet
- if not should_print:
- return
- settings = self.settings
- colors = {
- "error": settings.COLOR_ERROR,
- "warning": settings.COLOR_WARNING,
- "success": settings.COLOR_SUCCESS,
- }
- color = colors.get(level)
- # Format message based on context
- if context:
- text = (
- f"{level.capitalize()} in {context}: {message}"
- if level in {"error", "warning"}
- else f"{context}: {message}"
- )
- else:
- text = f"{level.capitalize()}: {message}" if level in {"error", "warning"} else message
- # Only use icons and colors for actual status indicators (error, warning, success)
- # Plain info messages use default terminal color (no markup)
- if level in {"error", "warning", "success"}:
- icon = IconManager.get_status_icon(level)
- formatted_text = f"[{color}]{icon} {text}[/{color}]"
- else:
- formatted_text = text
- if use_stderr:
- console_err.print(formatted_text)
- else:
- self.base.text(formatted_text)
- # Log appropriately
- log_message = f"{context}: {message}" if context else message
- log_methods = {
- "error": logger.error,
- "warning": logger.warning,
- "success": logger.info,
- "info": logger.info,
- }
- log_methods.get(level, logger.info)(log_message)
- def error(self, message: str, context: str | None = None, details: str | None = None) -> None:
- """Display an error message.
- Args:
- message: Error message
- context: Optional context
- details: Optional additional details (shown in dim style on same line)
- """
- if details:
- # Combine message and details on same line with different formatting
- settings = self.settings
- color = settings.COLOR_ERROR
- icon = IconManager.get_status_icon("error")
- # Format: Icon Error: Message (details in dim)
- formatted = f"[{color}]{icon} Error: {message}[/{color}] [dim]({details})[/dim]"
- console_err.print(formatted)
- # Log at debug level to avoid duplicate console output (already printed to stderr)
- logger.debug(f"Error displayed: {message} ({details})")
- else:
- # No details, use standard display
- self._display_message("error", message, context)
- def warning(self, message: str, context: str | None = None, details: str | None = None) -> None:
- """Display a warning message.
- Args:
- message: Warning message
- context: Optional context
- details: Optional additional details (shown in dim style on same line)
- """
- if details:
- # Combine message and details on same line with different formatting
- settings = self.settings
- color = settings.COLOR_WARNING
- icon = IconManager.get_status_icon("warning")
- # Format: Icon Warning: Message (details in dim)
- formatted = f"[{color}]{icon} Warning: {message}[/{color}] [dim]({details})[/dim]"
- console_err.print(formatted)
- # Log at debug level to avoid duplicate console output (already printed to stderr)
- logger.debug(f"Warning displayed: {message} ({details})")
- else:
- # No details, use standard display
- self._display_message("warning", message, context)
- def success(self, message: str, context: str | None = None) -> None:
- """Display a success message.
- Args:
- message: Success message
- context: Optional context
- """
- self._display_message("success", message, context)
- def info(self, message: str, context: str | None = None) -> None:
- """Display an informational message.
- Args:
- message: Info message
- context: Optional context
- """
- self._display_message("info", message, context)
- def skipped(self, message: str, reason: str | None = None) -> None:
- """Display a skipped/disabled message.
- Args:
- message: The main message to display
- reason: Optional reason why it was skipped
- """
- if reason:
- self.base.text(f"\n{message} (skipped - {reason})", style="dim")
- else:
- self.base.text(f"\n{message} (skipped)", style="dim")
- def markdown(self, content: str) -> None:
- """Render markdown content with left-aligned headings.
- Replaces emoji-style shortcodes (e.g., :warning:, :info:) with Nerd Font icons
- before rendering, EXCEPT for shortcodes at the start of list items which are
- handled by IconListItem to replace the bullet.
- Args:
- content: Markdown-formatted text to render (may contain shortcodes)
- """
- if not self.quiet:
- # Replace shortcodes with Nerd Font icons, but preserve list item shortcodes
- # Pattern: "- :shortcode:" at start of line should NOT be replaced
- lines = content.split("\n")
- processed_lines = []
- for line in lines:
- # Check if line is a list item starting with a shortcode
- if re.match(r"^\s*-\s+:[a-z]+:", line):
- # Keep the line as-is, IconListItem will handle it
- processed_lines.append(line)
- else:
- # Replace shortcodes normally
- processed_lines.append(IconManager.replace_shortcodes(line))
- processed_content = "\n".join(processed_lines)
- self.base._print_markdown(LeftAlignedMarkdown(processed_content))
|