Source code for kstlib.ui.panels

"""Config-driven helpers for rendering Rich panels."""

from __future__ import annotations

# pylint: disable=duplicate-code
import asyncio
import copy
from collections.abc import Iterable, Mapping, Sequence
from numbers import Number
from typing import Any, TypeGuard, cast

from box import Box
from rich import box as rich_box
from rich.console import Console, RenderableType
from rich.panel import Panel
from rich.pretty import Pretty
from rich.table import Table
from rich.text import Text

from kstlib.config import ConfigNotLoadedError, get_config
from kstlib.ui.exceptions import PanelRenderingError
from kstlib.utils.dict import deep_merge

PanelPayload = RenderableType | Mapping[str, Any] | Sequence[tuple[Any, Any]] | str | None

PAIR_ENTRY_LENGTH = 2
DEFAULT_PADDING: tuple[int, int] = (1, 2)
VALID_PADDING_LENGTHS = {1, 2, 4}
DEFAULT_PRETTY_INDENT = 2

DEFAULT_PANEL_CONFIG: dict[str, Any] = {
    "defaults": {
        "panel": {
            "border_style": "bright_blue",
            "title_align": "left",
            "subtitle_align": "left",
            "padding": [1, 2],
            "expand": True,
            "highlight": False,
            "box": "ROUNDED",
            "icon": None,
            "width": None,
        },
        "content": {
            "box": "SIMPLE",
            "expand": True,
            "show_header": False,
            "key_label": "Key",
            "value_label": "Value",
            "key_style": "bold white",
            "value_style": None,
            "header_style": "bold",
            "pad_edge": False,
            "sort_keys": False,
            "use_markup": True,
            "use_pretty": True,
            "pretty_indent": 2,
        },
    },
    "presets": {
        "info": {
            "panel": {
                "border_style": "cyan",
                "title": "Information",
                "icon": "[i]",
            },
        },
        "success": {
            "panel": {
                "border_style": "sea_green3",
                "title": "Success",
                "icon": "[ok]",
            },
        },
        "warning": {
            "panel": {
                "border_style": "orange3",
                "title": "Warning",
                "icon": "[!]",
            },
        },
        "error": {
            "panel": {
                "border_style": "red3",
                "title": "Error",
                "icon": "[x]",
            },
        },
        "summary": {
            "panel": {
                "border_style": "blue_violet",
                "title": "Execution Summary",
                "icon": "[summary]",
            },
            "content": {
                "sort_keys": True,
                "key_style": "bold cyan",
                "value_style": "bold white",
            },
        },
    },
}


[docs] class PanelManager: """Render Rich panels using config-driven presets. Panel definitions are composed of defaults, named presets, and runtime overrides. The merge order is ``kwargs > config preset > defaults``. Payloads can be plain text, existing Rich renderables, mappings (rendered as two-column tables), or sequences of ``(key, value)`` pairs. Args: config: Optional configuration mapping (typically output of ``get_config()``). console: Optional Rich console used for printing panels. Attributes: console: Console instance used for synchronous printing. Examples: Create a panel manager: >>> pm = PanelManager() >>> pm.console is None True Render a simple text panel: >>> panel = pm.render_panel(payload="Hello, World!") >>> panel.title is None True Render with a preset: >>> panel = pm.render_panel("info", payload="System status: OK") >>> "Information" in str(panel.title) True Render a mapping as a table: >>> panel = pm.render_panel(payload={"name": "Alice", "age": 30}) Override preset values: >>> panel = pm.render_panel("error", payload="Oops!", title="Custom Title") >>> "Custom Title" in str(panel.title) True """
[docs] def __init__(self, config: Mapping[str, Any] | Box | None = None, console: Console | None = None) -> None: """Initialize the manager with optional config and console.""" self.console = console self._config = self._prepare_config(config)
[docs] def render_panel( self, kind: str | None = None, payload: PanelPayload = None, **overrides: Any, ) -> Panel: """Build a ``Panel`` instance without printing it. Args: kind: Name of the preset to use. If not found, defaults are used. payload: Panel body (text, Rich renderable, mapping, or sequence of pairs). **overrides: Runtime overrides applied on top of preset/default values. Returns: Configured Rich ``Panel`` ready for rendering. Raises: PanelRenderingError: If the payload type is unsupported. """ panel_config = self._resolve_panel_config(kind, overrides) renderable = self._build_renderable(payload, panel_config["content"]) panel_parameters = panel_config["panel"] padding = self._coerce_padding(panel_parameters.get("padding")) panel_box = self._resolve_box(panel_parameters.get("box")) icon = panel_parameters.get("icon") title = panel_parameters.get("title") panel_title = self._compose_title(title, icon) panel_kwargs: dict[str, Any] = { "title": panel_title, "title_align": panel_parameters.get("title_align", "left"), "subtitle": panel_parameters.get("subtitle"), "subtitle_align": panel_parameters.get("subtitle_align", "left"), "border_style": panel_parameters.get("border_style"), "padding": padding, "expand": panel_parameters.get("expand", True), "highlight": panel_parameters.get("highlight", False), "box": panel_box, "width": panel_parameters.get("width"), } style_override = panel_parameters.get("style") if style_override is not None: panel_kwargs["style"] = style_override try: return Panel(renderable, **panel_kwargs) except Exception as exc: # pragma: no cover - defensive guard raise PanelRenderingError("Failed to render panel") from exc
[docs] def print_panel( self, kind: str | None = None, payload: PanelPayload = None, *, console: Console | None = None, **overrides: Any, ) -> Panel: """Render and print a panel synchronously. Args: kind: Name of the preset to use. payload: Panel body. console: Optional console overriding the manager-level console. **overrides: Runtime overrides applied on top of preset/default values. Returns: The rendered ``Panel``. """ target_console = self._ensure_console(console) panel = self.render_panel(kind=kind, payload=payload, **overrides) target_console.print(panel) return panel
[docs] async def print_panel_async( self, kind: str | None = None, payload: PanelPayload = None, *, console: Console | None = None, **overrides: Any, ) -> Panel: """Render and print a panel using an executor for async compatibility. Args: kind: Name of the preset to use. payload: Panel body. console: Optional console overriding the manager-level console. **overrides: Runtime overrides applied on top of preset/default values. Returns: The rendered ``Panel``. """ target_console = self._ensure_console(console) return await asyncio.to_thread( self.print_panel, kind, payload, console=target_console, **overrides, )
# ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ def _prepare_config(self, config: Mapping[str, Any] | Box | None) -> dict[str, Any]: base_config = copy.deepcopy(DEFAULT_PANEL_CONFIG) user_config = self._load_runtime_config(config) if user_config: deep_merge(base_config, user_config) return base_config def _load_runtime_config(self, config: Mapping[str, Any] | Box | None) -> dict[str, Any]: if config is None: try: config = get_config() except ConfigNotLoadedError: return {} if isinstance(config, Box): config_mapping: Mapping[str, Any] = config.to_dict() else: config_mapping = dict(config) ui_config = config_mapping.get("ui", {}) if not isinstance(ui_config, Mapping): return {} panels_config = ui_config.get("panels", {}) if isinstance(panels_config, Box): return panels_config.to_dict() if isinstance(panels_config, Mapping): return dict(panels_config) return {} def _resolve_panel_config(self, kind: str | None, overrides: Mapping[str, Any]) -> dict[str, Any]: defaults = copy.deepcopy(self._config["defaults"]) if not isinstance(defaults, dict): raise PanelRenderingError("Panel defaults configuration must be a mapping") config: dict[str, Any] = defaults preset: Mapping[str, Any] = {} raw_presets = self._config.get("presets", {}) if isinstance(raw_presets, Mapping): candidate = raw_presets.get(kind or "", {}) if isinstance(candidate, Mapping): preset = candidate deep_merge(config, preset) if overrides: normalized = self._normalize_overrides(overrides) deep_merge(config, normalized) return config def _normalize_overrides(self, overrides: Mapping[str, Any]) -> dict[str, dict[str, Any]]: normalized: dict[str, dict[str, Any]] = {"panel": {}, "content": {}} panel_overrides = normalized["panel"] content_overrides = normalized["content"] direct_panel_keys = { "title", "title_align", "subtitle", "subtitle_align", "border_style", "padding", "expand", "highlight", "box", "icon", "width", "style", } direct_content_keys = { "box", "expand", "show_header", "key_label", "value_label", "key_style", "value_style", "header_style", "pad_edge", "sort_keys", "use_markup", "use_pretty", "pretty_indent", } for key, value in overrides.items(): if key in ("panel", "content") and isinstance(value, Mapping): deep_merge(normalized[key], value) continue if key in direct_panel_keys: panel_overrides[key] = value elif key in direct_content_keys: content_overrides[key] = value return normalized def _build_renderable(self, payload: PanelPayload, content_config: dict[str, Any]) -> RenderableType: if payload is None: return Text("") if self._is_renderable(payload): return payload if isinstance(payload, str): if content_config.get("use_markup", True): return Text.from_markup(payload) return Text(payload) if isinstance(payload, Mapping): return self._mapping_to_table(payload, content_config) if isinstance(payload, Sequence): pairs = [tuple(item) for item in payload] if all(len(pair) == PAIR_ENTRY_LENGTH for pair in pairs): return self._pairs_to_table(pairs, content_config) raise PanelRenderingError(f"Unsupported payload type: {type(payload)!r}") def _mapping_to_table(self, payload: Mapping[str, Any], content_config: dict[str, Any]) -> Table: items: Iterable[tuple[str, Any]] = payload.items() if content_config.get("sort_keys", False): items = sorted(items, key=lambda item: str(item[0])) return self._pairs_to_table(list(items), content_config) def _pairs_to_table(self, pairs: Sequence[tuple[Any, Any]], content_config: dict[str, Any]) -> Table: table_box = self._resolve_box(content_config.get("box"), default="SIMPLE") table = Table( show_header=content_config.get("show_header", False), header_style=content_config.get("header_style"), box=table_box, expand=content_config.get("expand", True), pad_edge=content_config.get("pad_edge", False), ) key_label = content_config.get("key_label", "Key") value_label = content_config.get("value_label", "Value") key_style = cast("str | None", content_config.get("key_style")) value_style = cast("str | None", content_config.get("value_style")) table.add_column(key_label, style=key_style) table.add_column(value_label, style=value_style) for key, value in pairs: key_renderable = self._to_text(key, key_style) value_renderable = self._render_value(value, content_config) table.add_row(key_renderable, value_renderable) return table def _render_value(self, value: Any, content_config: dict[str, Any]) -> RenderableType: if self._is_renderable(value): return value value_style = cast("str | None", content_config.get("value_style")) if isinstance(value, str): return self._render_string_value(value, value_style, content_config) if isinstance(value, Number): return self._render_numeric_value(value, value_style) if content_config.get("use_pretty", True): indent = content_config.get("pretty_indent", DEFAULT_PRETTY_INDENT) return Pretty(value, indent_guides=indent) return self._render_repr_value(value, value_style) @staticmethod def _render_string_value(value: str, value_style: str | None, content_config: dict[str, Any]) -> Text: use_markup = content_config.get("use_markup", True) if use_markup: if value_style: return Text.from_markup(value, style=value_style) return Text.from_markup(value) if value_style: return Text(value, style=value_style) return Text(value) @staticmethod def _render_numeric_value(value: Number, value_style: str | None) -> Text: formatted = Text(str(value)) if value_style: formatted.stylize(value_style) return formatted @staticmethod def _render_repr_value(value: Any, value_style: str | None) -> Text: representation = repr(value) if value_style: return Text(representation, style=value_style) return Text(representation) @staticmethod def _to_text(value: Any, style: str | None = None) -> Text: text = Text(str(value)) if style: text.stylize(style) return text @staticmethod def _compose_title(title: str | None, icon: str | None) -> str | None: if title and icon: return f"{icon} {title}" if icon: return icon return title @staticmethod def _is_renderable(candidate: Any) -> TypeGuard[RenderableType]: return hasattr(candidate, "__rich_console__") or hasattr(candidate, "__rich__") @staticmethod def _coerce_padding(padding: Any) -> tuple[int, ...]: if padding is None: return DEFAULT_PADDING if isinstance(padding, list | tuple): coerced = tuple(int(part) for part in padding) if len(coerced) in VALID_PADDING_LENGTHS: return coerced raise PanelRenderingError("Padding must contain 1, 2, or 4 integers.") value = int(padding) return (value, value) @staticmethod def _resolve_box(box_name: str | None, default: str = "ROUNDED") -> rich_box.Box: if not box_name: box_name = default try: return cast("rich_box.Box", getattr(rich_box, box_name)) except AttributeError as exc: raise PanelRenderingError(f"Unknown box style '{box_name}'") from exc def _ensure_console(self, console: Console | None) -> Console: if console is not None: return console if self.console is None: self.console = Console() if self.console is None: # pragma: no cover - defensive guard raise PanelRenderingError("Console instance could not be created") return self.console