Files
Medios-Macina/SYS/rich_display.py

350 lines
11 KiB
Python
Raw Permalink Normal View History

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(
2026-01-11 04:54:27 -08:00
provider_names: str | List[str],
2026-01-01 20:37:27 -08:00
) -> None:
2026-01-11 04:54:27 -08:00
"""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)
2026-01-10 17:30:18 -08:00
def show_store_config_panel(
2026-01-11 04:54:27 -08:00
store_names: str | List[str],
2026-01-10 17:30:18 -08:00
) -> None:
2026-01-11 04:54:27 -08:00
"""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)
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)