"""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, Sequence, TextIO from rich.console import Console from rich.panel import Panel from rich.text import Text from pathlib import Path from SYS.utils import expand_path # Configure Rich pretty-printing to avoid truncating long strings (hashes/paths). # This is version-safe: older Rich versions may not support the max_* arguments. try: 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() -> Console: return _STDOUT_CONSOLE def stderr_console() -> Console: return _STDERR_CONSOLE def console_for(file: TextIO | None) -> Console: if file is None or file is sys.stdout: return _STDOUT_CONSOLE if file is sys.stderr: return _STDERR_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: TextIO, stderr: TextIO) -> 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 previous_stdout = _STDOUT_CONSOLE previous_stderr = _STDERR_CONSOLE try: _STDOUT_CONSOLE = Console(file=stdout) _STDERR_CONSOLE = Console(file=stderr) yield finally: _STDOUT_CONSOLE = previous_stdout _STDERR_CONSOLE = previous_stderr def show_provider_config_panel( provider_name: str, keys: Sequence[str] | None = None, *, config_hint: str = "config.conf" ) -> None: """Show a Rich panel explaining how to configure a provider.""" pass def show_store_config_panel( store_type: str, keys: Sequence[str] | None = None, *, config_hint: str = "config.conf" ) -> None: """Show a Rich panel explaining how to configure a storage backend.""" pass 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 img: img = 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() # 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): r1, g1, b1 = pixels[x, y] r2, g2, b2 = pixels[x, y + 1] # 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: # Silently fail if image cannot be rendered (e.g. missing PIL or corrupted file) pass def render_item_details_panel(item: Dict[str, Any]) -> None: """Render a comprehensive details panel for a result item.""" from rich.table import Table from rich.columns import Columns title = ( item.get("title") or item.get("name") or item.get("TITLE") or "Unnamed Item" ) # Main layout table for the panel details_table = Table.grid(expand=True) details_table.add_column(style="cyan", no_wrap=True, width=15) details_table.add_column(style="white") # Basic Info details_table.add_row("Title", f"[bold]{title}[/bold]") if "store" in item: details_table.add_row("Store", str(item["store"])) if "hash" in item: details_table.add_row("Hash", str(item["hash"])) # Metadata / Path if "path" in item or "target" in item: path = item.get("path") or item.get("target") details_table.add_row("Path", str(path)) if "ext" in item or "extension" in item: ext = item.get("ext") or item.get("extension") details_table.add_row("Extension", str(ext)) if "size_bytes" in item or "size" in item: size = item.get("size_bytes") or item.get("size") if isinstance(size, (int, float)): if size > 1024 * 1024 * 1024: size_str = f"{size / (1024*1024*1024):.1f} GB" elif size > 1024 * 1024: size_str = f"{size / (1024*1024):.1f} MB" elif size > 1024: size_str = f"{size / 1024:.1f} KB" else: size_str = f"{size} bytes" details_table.add_row("Size", size_str) # URL(s) urls = item.get("url") or item.get("URL") or [] if isinstance(urls, str): urls = [urls] if isinstance(urls, list) and urls: url_text = "\n".join(map(str, urls)) details_table.add_row("URL(s)", url_text) # Tags tags = item.get("tag") or item.get("tags") or [] if isinstance(tags, str): tags = [tags] if isinstance(tags, list) and tags: # Sort and filter tags to look nice tags_sorted = sorted(map(str, tags)) # Group tags by namespace if they have them tag_cols = Columns([f"[dim]#[/dim]{t}" for t in tags_sorted], equal=True, expand=True) details_table.add_row("", "") # Spacer details_table.add_row("Tags", tag_cols) # Relationships (if any) rels = item.get("relationships") or item.get("rel") or [] if isinstance(rels, list) and rels: rel_text = "\n".join([f"[dim]→[/dim] {r}" for r in rels]) details_table.add_row("Relations", rel_text) panel = Panel( details_table, title=f"[bold green]Item Details[/bold green]", border_style="green", padding=(1, 2), expand=True ) stdout_console().print() stdout_console().print(panel)