"""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_names: str | List[str], ) -> None: """Show a Rich panel explaining how to configure providers.""" from rich.table import Table from rich.text import Text from rich.console import Group if isinstance(provider_names, str): providers = [p.strip() for p in provider_names.split(",")] else: providers = provider_names table = Table.grid(padding=(0, 1)) table.add_column(style="bold red") for provider in providers: table.add_row(f" • {provider}") group = Group( Text("The following providers 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 from rich.text import Text from rich.console import Group if isinstance(store_names, str): stores = [s.strip() for s in store_names.split(",")] else: stores = store_names table = Table.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_providers_panel(provider_names: List[str]) -> None: """Show a Rich panel listing available/configured providers.""" from rich.columns import Columns from rich.console import Group from rich.text import Text if not provider_names: return # Use Columns to display them efficiently in the panel cols = Columns( [f"[bold green] \u2713 [/bold green]{p}" for p in sorted(provider_names)], equal=True, column_first=True, expand=True ) group = Group( Text("The following providers are configured and ready to use:\n"), cols ) panel = Panel( group, title="[bold green]Configured Providers[/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 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)