"""Central Rich output helpers. Opinionated: `rich` is a required dependency. This module centralizes Console instances so tables/panels render consistently and so callers can choose stdout vs stderr explicitly (important for pipeline-safe output). """ from __future__ import annotations import contextlib import sys from typing import Any, Iterator, TextIO, List, Dict, Optional, Tuple, cast from pathlib import Path from SYS.utils import expand_path # rich imports are deferred to first Console use to avoid ~100ms startup cost. # They are loaded the first time any Console function is called. _STDOUT_CONSOLE: Any = None _STDERR_CONSOLE: Any = None def _ensure_consoles() -> None: global _STDOUT_CONSOLE, _STDERR_CONSOLE if _STDOUT_CONSOLE is None: from rich.console import Console from rich.pretty import install as _pretty_install try: _pretty_install(max_string=100_000, max_length=100_000) except TypeError: _pretty_install() except Exception: pass _STDOUT_CONSOLE = Console(file=sys.stdout) _STDERR_CONSOLE = Console(file=sys.stderr) def stdout_console() -> Any: _ensure_consoles() return _STDOUT_CONSOLE def stderr_console() -> Any: _ensure_consoles() return _STDERR_CONSOLE def console_for(file: Any) -> Any: if file is None or file is sys.stdout: _ensure_consoles() return _STDOUT_CONSOLE if file is sys.stderr: _ensure_consoles() return _STDERR_CONSOLE from rich.console import Console return Console(file=file) def rprint(renderable: Any = "", *, file: TextIO | None = None) -> None: console_for(file).print(renderable) @contextlib.contextmanager def capture_rich_output(*, stdout: Any, stderr: Any) -> Iterator[None]: """Temporarily redirect Rich output helpers to provided streams. Note: `stdout_console()` / `stderr_console()` use global Console instances, so `contextlib.redirect_stdout` alone will not capture Rich output. """ global _STDOUT_CONSOLE, _STDERR_CONSOLE _ensure_consoles() previous_stdout = _STDOUT_CONSOLE previous_stderr = _STDERR_CONSOLE from rich.console import Console try: _STDOUT_CONSOLE = Console(file=stdout) _STDERR_CONSOLE = Console(file=stderr) yield finally: _STDOUT_CONSOLE = previous_stdout _STDERR_CONSOLE = previous_stderr def show_plugin_config_panel( plugin_names: str | List[str], ) -> None: """Show a Rich panel explaining how to configure plugins.""" from rich.table import Table as RichTable from rich.console import Group from rich.panel import Panel from rich.text import Text if isinstance(plugin_names, str): plugins = [p.strip() for p in plugin_names.split(",")] else: plugins = plugin_names table = RichTable.grid(padding=(0, 1)) table.add_column(style="bold red") for plugin in plugins: table.add_row(f" • {plugin}") group = Group( Text("The following plugins are not configured and cannot be used:\n"), table, Text.from_markup("\nTo configure them, run the command with [bold cyan].config[/bold cyan] or use the [bold green]TUI[/bold green] config menu.") ) panel = Panel( group, title="[bold red]Configuration Required[/bold red]", border_style="red", padding=(1, 2) ) stdout_console().print() stdout_console().print(panel) def show_store_config_panel( store_names: str | List[str], ) -> None: """Show a Rich panel explaining how to configure storage backends.""" from rich.table import Table as RichTable from rich.console import Group from rich.panel import Panel from rich.text import Text if isinstance(store_names, str): stores = [s.strip() for s in store_names.split(",")] else: stores = store_names table = RichTable.grid(padding=(0, 1)) table.add_column(style="bold yellow") for store in stores: table.add_row(f" • {store}") group = Group( Text("The following stores are not configured or available:\n"), table, Text.from_markup("\nInitialize them using [bold cyan].config[/bold cyan] or ensure they are properly set up.") ) panel = Panel( group, title="[bold yellow]Store Not Configured[/bold yellow]", border_style="yellow", padding=(1, 2) ) stdout_console().print() stdout_console().print(panel) def show_available_plugins_panel(plugin_names: List[str]) -> None: """Show a Rich panel listing available/configured plugins.""" from rich.columns import Columns from rich.console import Group from rich.panel import Panel from rich.text import Text if not plugin_names: return # Use Columns to display them efficiently in the panel cols = Columns( [f"[bold green] \u2713 [/bold green]{p}" for p in sorted(plugin_names)], equal=True, column_first=True, expand=True ) group = Group( Text("The following plugins are configured and ready to use:\n"), cols ) panel = Panel( group, title="[bold green]Configured Plugins[/bold green]", border_style="green", padding=(1, 2) ) stdout_console().print() stdout_console().print(panel) IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".tiff"} def render_image_to_console(image_path: str | Path, max_width: int | None = None) -> None: """Render an image to the console using Rich and half-block characters. Requires Pillow (PIL). If not available or loading fails, does nothing. """ try: from PIL import Image from rich.color import Color from rich.style import Style from rich.text import Text # Ensure we expand environment variables and resolve to an absolute path. path = expand_path(image_path).resolve() if not path.exists() or not path.is_file(): # If absolute path fails, try relative to current directory as a last resort. path = Path(image_path).resolve() if not path.exists() or not path.is_file(): return with Image.open(path) as opened_img: img = opened_img.convert("RGB") orig_w, orig_h = img.size # Determine target dimensions console = stdout_console() if max_width is None: max_width = console.width - 4 # Ensure max_width is at least something reasonable if max_width < 10: max_width = 80 # Terminal cells are approx 2x taller than wide. # Half-block characters (upper half block) let us treat one cell as two vertical pixels. aspect = orig_h / orig_w target_w = min(orig_w, max_width) # Ensure target_h is even so we always have pairs of vertical pixels target_h = int(target_w * aspect) if target_h < 2: target_h = 2 target_h = (target_h // 2) * 2 # Ensure we don't exceed a reasonable height either (e.g. 40 characters) max_height_chars = 40 if (target_h // 2) > max_height_chars: target_h = max_height_chars * 2 target_w = int(target_h / aspect) target_h = (target_h // 2) * 2 # Re-ensure even if target_w < 1: target_w = 1 img = img.resize((target_w, target_h), Image.Resampling.BILINEAR) pixels = img.load() if pixels is None: return # Render using upper half block (U+2580) # Each character row in terminal represents 2 pixel rows in image. for y in range(0, target_h - 1, 2): line = Text() for x in range(target_w): rgb1 = cast(tuple, pixels[x, y]) rgb2 = cast(tuple, pixels[x, y + 1]) try: r1, g1, b1 = int(rgb1[0]), int(rgb1[1]), int(rgb1[2]) r2, g2, b2 = int(rgb2[0]), int(rgb2[1]), int(rgb2[2]) except Exception: r1 = g1 = b1 = r2 = g2 = b2 = 0 # Foreground is top pixel, background is bottom pixel line.append( "▀", style=Style( color=Color.from_rgb(r1, g1, b1), bgcolor=Color.from_rgb(r2, g2, b2), ), ) console.print(line) except Exception: # Emit logs to help diagnose rendering failures (PIL missing, corrupt file, terminal limitations) from SYS.logger import logger logger.exception("Failed to render image to console: %s", image_path) return def render_item_details_panel(item: Dict[str, Any], *, title: Optional[str] = None) -> None: """Render a comprehensive details panel for a result item using unified ItemDetailView.""" from SYS.result_table import ItemDetailView, extract_item_metadata metadata = extract_item_metadata(item) # Create a specialized view with no results rows (only the metadata panel) # We set no_choice=True to hide the "#" column (not that there are any rows). view = ItemDetailView(item_metadata=metadata, detail_title=title)._interactive(True) # Ensure no title leaks in (prevents an empty "No results" table from rendering). try: view.title = "" view.header_lines = [] except Exception: from SYS.logger import logger logger.exception("Failed to sanitize ItemDetailView title/header before printing") # We want to print ONLY the elements from ItemDetailView, so we don't use stdout_console().print(view) # as that would include the (empty) results panel. # Actually, let's just use to_rich and print it. stdout_console().print() stdout_console().print(view.to_rich())