Files
browser-cli/browser_cli/commands/rendering/common.py
T
daniel156161 8dece7800f
Testing / remote-protocol-compat (0.9.3) (push) Successful in 52s
Testing / test (push) Successful in 1m2s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 1m0s
Package Extension / package-extension (push) Successful in 1m11s
Build & Publish Package / publish (push) Successful in 1m7s
feat: group multi-browser output by source
- Add browser source grouping metadata to SDK-created tabs, groups,
  list results, and aggregate count results.
- Render grouped local/remote browser tables consistently for clients,
  tabs, groups, windows, sessions, and remote status output.
- Document remote control, auth, HTTP gateway usage, and the refreshed
  project structure in the README.
- Add coverage for grouped output and BrowserCounts browser_groups.
- Bump the Python package, extension manifest, and lockfile to 0.15.6.
- Add a just publish helper for building and publishing release artifacts.
2026-06-18 00:52:04 +02:00

138 lines
4.9 KiB
Python

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