Files
Medios-Macina/SYS/rich_display.py
2026-01-11 02:26:39 -08:00

265 lines
8.4 KiB
Python

"""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)