"""Common Rich rendering helpers for CLI command modules.""" from __future__ import annotations import shutil from collections.abc import Callable, Mapping, Sequence from typing import TypeVar, cast from rich.console import Console from rich.table import Table from rich.tree import Tree Row = object CellValue = object Column = tuple[str, Callable[[Row], CellValue]] T = TypeVar("T") def item_value(item: Row, name: str, default: T | None = None) -> CellValue | T | None: """Read *name* from a dict-like or attribute object.""" if isinstance(item, Mapping): return cast(Mapping[str, CellValue], item).get(name, default) return getattr(item, name, default) def text_value(value: CellValue | None, default: str = "") -> str: """Coerce a nullable cell value to display text.""" return default if value is None else str(value) def int_value(value: CellValue | None, default: int = 0) -> int: """Coerce a cell value to int, falling back when conversion is not possible.""" try: return int(cast(int | str | float | bool, value)) except (TypeError, ValueError): return default def shorten(value: str | None, limit: int) -> str: """Return *value* shortened to *limit* cells-ish, using an ellipsis.""" value = value or "" return value if len(value) <= limit else value[:max(0, limit - 1)] + "…" def terminal_width(console: Console | None = None, *, fallback: int = 120) -> int: """Best-effort terminal width for interactive and redirected output. Rich falls back to 80 columns when stdout is redirected. browser-cli output is often piped into files for inspection, so also consult ``shutil``/``COLUMNS`` and prefer the wider value. """ rich_width = (console.width if console is not None else 0) or 0 shell_width = shutil.get_terminal_size((fallback, 20)).columns return max(rich_width, shell_width) def tree_title_limit(*, console: Console | None = None, show_browser: bool = False, show_urls: bool = False) -> int: """Title width for tree labels, reserving space for branches/IDs/metadata.""" reserve = 48 if show_urls else 32 if show_browser: reserve += 4 return max(50, terminal_width(console) - reserve) def tree_url_limit(title_limit: int, *, console: Console | None = None) -> int: """URL width for tree labels when URLs are displayed.""" return max(35, terminal_width(console) - title_limit - 40) def print_tree(tree: Tree, *, console: Console | None = None) -> None: """Render a Rich tree using the detected full terminal width.""" Console(width=terminal_width(console)).print(tree) def print_table_rows( rows: Sequence[Row], columns: Sequence[Column], *, console: Console, empty_message: str, show_header: bool = True, header_style: str = "bold cyan", ) -> None: """Render a small Rich table from arbitrary row objects.""" if not rows: console.print(empty_message) return table = Table(show_header=show_header, header_style=header_style) for header, _getter in columns: table.add_column(header) for row in rows: table.add_row(*[text_value(getter(row)) for _header, getter in columns]) Console(width=terminal_width(console)).print(table) def print_browser_grouped_table_rows( rows: Sequence[Row], columns: Sequence[Column], *, console: Console, empty_message: str, browser_getter: Callable[[Row], CellValue | None] = lambda row: item_value(row, "browser"), group_getter: Callable[[Row], CellValue | None] = lambda row: item_value(row, "browser_group", item_value(row, "browserGroup")), browser_header: str = "Browser", show_header: bool = True, header_style: str = "bold cyan", ) -> None: """Render rows with optional local/remote browser grouping. Rows without a browser label are rendered as a normal table. Rows with ``browser_group``/``browserGroup`` get a group header (for example ``local`` or a remote host) and a short indented profile label below it. """ if not rows: console.print(empty_message) return show_browser = any(bool(browser_getter(row)) for row in rows) if not show_browser: print_table_rows( rows, columns, console=console, empty_message=empty_message, show_header=show_header, header_style=header_style, ) return table = Table(show_header=show_header, header_style=header_style) table.add_column(browser_header, no_wrap=True) for header, _getter in columns: table.add_column(header) rendered_groups: set[str] = set() for row in rows: browser = text_value(browser_getter(row)) group = text_value(group_getter(row)) if group: if group not in rendered_groups: table.add_row(f"[bold]{group}[/bold]", *["" for _header, _getter in columns]) rendered_groups.add(group) browser = browser.removeprefix(f"{group}:") browser = f" {browser}" table.add_row(browser, *[text_value(getter(row)) for _header, getter in columns]) Console(width=terminal_width(console)).print(table)