2025-12-20 23:57:44 -08:00
|
|
|
"""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
|
|
|
|
|
|
2025-12-24 02:13:21 -08:00
|
|
|
import contextlib
|
2025-12-20 23:57:44 -08:00
|
|
|
import sys
|
2026-01-01 20:37:27 -08:00
|
|
|
from typing import Any, Iterator, Sequence, TextIO
|
2025-12-20 23:57:44 -08:00
|
|
|
|
|
|
|
|
from rich.console import Console
|
2026-01-01 20:37:27 -08:00
|
|
|
from rich.panel import Panel
|
|
|
|
|
from rich.text import Text
|
2026-01-11 02:26:39 -08:00
|
|
|
from pathlib import Path
|
|
|
|
|
from SYS.utils import expand_path
|
2025-12-20 23:57:44 -08:00
|
|
|
|
2025-12-23 16:36:39 -08:00
|
|
|
# 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
|
|
|
|
|
|
2025-12-20 23:57:44 -08:00
|
|
|
_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)
|
2025-12-24 02:13:21 -08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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
|
2026-01-01 20:37:27 -08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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."""
|
2026-01-11 01:14:45 -08:00
|
|
|
pass
|
2026-01-10 17:30:18 -08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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."""
|
2026-01-11 01:14:45 -08:00
|
|
|
pass
|
2026-01-10 17:30:18 -08:00
|
|
|
|
2026-01-11 02:26:39 -08:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|