"""Reusable rendering helpers for CLI command modules.""" from __future__ import annotations import shutil from collections.abc import Callable, Iterable, Sequence from typing import Any from rich.console import Console from rich.table import Table from rich.text import Text from rich.tree import Tree Column = tuple[str, Callable[[Any], Any]] def item_value(item: Any, name: str, default: Any = None) -> Any: """Read *name* from a dict-like or attribute object.""" if isinstance(item, dict): return item.get(name, default) return getattr(item, name, 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 no_wrap_text() -> Text: """Text configured for one-line tree labels with edge ellipsis.""" return Text(no_wrap=True, overflow="ellipsis") def tab_tree_label(tab: Any, *, title_limit: int, show_urls: bool = False, url_limit: int = 55) -> Text: """Reusable one-line label for a browser tab in tree views.""" label = no_wrap_text() label.append(f"[{item_value(tab, 'id')}] ", style="dim") label.append(shorten(item_value(tab, 'title') or "(untitled)", title_limit)) if item_value(tab, "active", False): label.append(" *", style="green") url = item_value(tab, "url") if show_urls and url: label.append(" — ", style="dim") label.append(shorten(url, url_limit), style="dim") return label def group_tree_label(group_id: int, group: Any, *, title_limit: int) -> Text: """Reusable one-line label for a browser tab group in tree views.""" title = item_value(group, "title", "") or f"Group {group_id}" color = item_value(group, "color", "") or "group" count = item_value(group, "tab_count", item_value(group, "tabCount", 0)) or 0 collapsed = bool(item_value(group, "collapsed", False)) label = no_wrap_text() label.append(shorten(title, title_limit), style="bold") meta = [color] if count: meta.append(f"{count} tab" + ("" if count == 1 else "s")) if collapsed: meta.append("collapsed") label.append(" (" + ", ".join(meta) + ")", style="dim") return label def tab_sort_key(tab: Any) -> tuple: """Stable tab ordering across multi-browser responses.""" group_id = item_value(tab, "group_id", item_value(tab, "groupId")) return ( item_value(tab, "browser") or "", item_value(tab, "window_id", item_value(tab, "windowId", 0)), item_value(tab, "index", 0) or 0, group_id if group_id is not None else -1, item_value(tab, "id", 0), ) 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[Any], 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(*[str(getter(row) or "") for _header, getter in columns]) Console(width=terminal_width(console)).print(table) def build_tabs_tree( tabs: Iterable[Any], groups: Iterable[Any], *, console: Console, show_urls: bool = False, ) -> Tree: """Build a browser → window → group/tab tree from tab and group responses.""" tabs = sorted(tabs, key=tab_sort_key) show_browser = any(item_value(tab, "browser") for tab in tabs) title_limit = tree_title_limit(console=console, show_browser=show_browser, show_urls=show_urls) url_limit = tree_url_limit(title_limit, console=console) group_info = { ( item_value(group, "browser") or "local", item_value(group, "window_id", item_value(group, "windowId")), item_value(group, "id"), ): group for group in groups } root = Tree("[bold]Tabs[/bold]") browser_nodes: dict[str, Tree] = {} window_nodes: dict[tuple[str, int], Tree] = {} group_nodes: dict[tuple[str, int, int], Tree] = {} for tab in tabs: browser_key = item_value(tab, "browser") or "local" browser_node = browser_nodes.get(browser_key) if browser_node is None: browser_node = root.add(Text(browser_key, style="bold cyan")) if show_browser else root browser_nodes[browser_key] = browser_node window_id = item_value(tab, "window_id", item_value(tab, "windowId", 0)) window_key = (browser_key, window_id) window_node = window_nodes.get(window_key) if window_node is None: window_node = browser_node.add(f"Window {window_id}") window_nodes[window_key] = window_node group_id = item_value(tab, "group_id", item_value(tab, "groupId")) if group_id is None: window_node.add(tab_tree_label(tab, title_limit=title_limit, show_urls=show_urls, url_limit=url_limit)) continue group_key = (browser_key, window_id, group_id) group_node = group_nodes.get(group_key) if group_node is None: group = group_info.get(group_key) or group_info.get((browser_key, None, group_id)) group_node = window_node.add(group_tree_label(group_id, group, title_limit=title_limit)) group_nodes[group_key] = group_node group_node.add(tab_tree_label(tab, title_limit=title_limit, show_urls=show_urls, url_limit=url_limit)) return root def build_windows_tree(windows: Iterable[dict], tabs: Iterable[Any], *, console: Console) -> Tree: """Build a window → tab tree from window and tab responses.""" windows = list(windows) tabs = list(tabs) title_limit = tree_title_limit(console=console, show_browser=any("browser" in w for w in windows), show_urls=True) url_limit = tree_url_limit(title_limit, console=console) root = Tree("[bold]Windows[/bold]") for window in sorted(windows, key=lambda item: (item.get("browser", ""), item.get("id", 0))): window_id = window.get("id") label = f"Window {window_id}" if window.get("alias"): label += f" ({window['alias']})" if window.get("browser"): label = f"{window['browser']}: " + label node = root.add(label) window_tabs = [ tab for tab in tabs if item_value(tab, "window_id", item_value(tab, "windowId")) == window_id and (not window.get("browser") or item_value(tab, "browser") == window.get("browser")) ] for tab in sorted(window_tabs, key=lambda item: item_value(item, "index", 0) or 0): node.add(tab_tree_label(tab, title_limit=title_limit, show_urls=True, url_limit=url_limit)) return root