#!/usr/bin/env python3 """Proof of concept: the existing code driven by a Textual TUI. Not wired into the CLI. This exists to answer one question with evidence rather than opinion: can the renderers and data built so far drive a real TUI, without rewriting the parts that are actually hard (libalpm agreement, the audit rules, the dependency graph)? Three things it is here to demonstrate: 1. `cli.gather()` -- the real, unmodified planning path -- feeds the UI. 2. `render.audit.build()` -- the real audit renderer -- is dropped straight into a widget. Textual renders Rich renderables, so the screens are not rewritten. 3. The ANSI-16 constraint survives: Textual accepts `ansi_*` colour names, so the terminal still decides what every colour looks like. """ from __future__ import annotations import asyncio import sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT)) from rich.console import Console # noqa: E402 from rich.text import Text # noqa: E402 from textual.app import App, ComposeResult # noqa: E402 from textual.binding import Binding # noqa: E402 from textual.containers import Horizontal, VerticalScroll # noqa: E402 from textual.widgets import Footer, Header, Static, Tree # noqa: E402 from updater import cli, icons # noqa: E402 from updater.audit import Severity # noqa: E402 from updater.render import audit_screen, human_bytes # noqa: E402 from updater.theme import Theme # noqa: E402 #: Headless screenshots have no terminal to resolve `ansi_*` against, so every #: colour collapses to the default and the capture misrepresents the design. #: Binding the same slots to concrete values is only for capturing an image; the #: app itself always emits ANSI names and lets the terminal decide. ANSI_PALETTE = { "fg": "ansi_default", "dim": "ansi_bright_black", "rule": "ansi_bright_black", "accent": "ansi_bright_green", "old": "ansi_red", "new": "ansi_green", "warn": "ansi_yellow", "err": "ansi_red", "ver": "ansi_cyan", "bg": "ansi_default", "sel": "ansi_bright_black", } CAPTURE_PALETTE = { "fg": "#f2f2f3", "dim": "#6b6574", "rule": "#3a3540", "accent": "#9f8bc1", "old": "#fd4663", "new": "#9f8bc1", "warn": "#ad81b1", "err": "#fd4663", "ver": "#cdafd0", "bg": "#0b0910", "sel": "#28242d", } SEVERITY_ROLE = {Severity.INFO: "dim", Severity.LOOK: "warn", Severity.BLOCK: "err"} def css(pal: dict[str, str]) -> str: return f""" Screen {{ background: {pal['bg']}; color: {pal['fg']}; }} Header {{ background: {pal['bg']}; color: {pal['accent']}; }} Footer {{ background: {pal['bg']}; color: {pal['dim']}; }} Footer > .footer--key {{ color: {pal['accent']}; }} Footer > .footer--description {{ color: {pal['dim']}; }} #body {{ height: 1fr; }} #tree {{ width: 44%; padding: 1 1; border-right: solid {pal['rule']}; scrollbar-color: {pal['rule']}; scrollbar-background: {pal['bg']}; }} #detail {{ padding: 1 2; scrollbar-color: {pal['rule']}; scrollbar-background: {pal['bg']}; }} Tree {{ background: {pal['bg']}; }} Tree > .tree--guides {{ color: {pal['rule']}; }} Tree > .tree--cursor {{ background: {pal['sel']}; color: {pal['fg']}; }} """ def gather(): """The real planning path, with only the slow optional scans disabled.""" options = cli.Options(hygiene_phase=False, security_phase=False) run = cli.Run(options=options, glyphs=icons.glyphs("unicode"), console=Console(file=open("/dev/null", "w"))) return run, cli.gather(run) class UpdaterApp(App[None]): BINDINGS = [ Binding("a", "apply", "apply"), Binding("d", "diff", "diff"), Binding("s", "skip", "skip"), Binding("q", "quit", "quit"), ] def __init__(self, run: cli.Run, review, palette: dict[str, str] | None = None, *, rich_glyphs=None) -> None: super().__init__() self.run = run self.review = review self.pal = palette or ANSI_PALETTE self.CSS = css(self.pal) self.theme_obj = Theme.default() self.rich_glyphs = rich_glyphs or icons.glyphs("nerd") self.current: str | None = None # -- layout ------------------------------------------------------------ def compose(self) -> ComposeResult: total = self.review.repo_total + self.review.aur_total yield Header(show_clock=False) with Horizontal(id="body"): yield Tree("plan", id="tree") with VerticalScroll(id="detail"): yield Static(id="detail-body") yield Footer() def on_mount(self) -> None: self.title = "updater" self.sub_title = (f"{self.review.repo_total} repo · " f"{self.review.aur_total} AUR · " f"{human_bytes(self.review.repo_download)} to download") tree = self.query_one("#tree", Tree) tree.show_root = False tree.root.expand() repo = tree.root.add( Text(f"Repository {self.review.repo_total} packages", style="bold"), expand=False) for row in self.review.repo: repo.add_leaf(Text.assemble( (f"{row.name}", ""), (" ", ""), (row.old, self.pal["old"]), (" → ", self.pal["dim"]), (row.new, self.pal["new"]))) if self.review.repo_total > len(self.review.repo): repo.add_leaf(Text(f"+ {self.review.repo_total - len(self.review.repo)} more", style=self.pal["dim"])) aur = tree.root.add( Text(f"AUR {self.review.aur_total} packages " f"{len(self.review.blocked)} need attention", style="bold"), expand=True) for row in self.review.aur: node = aur.add(self._aur_label(row), data=row.name) report = self.run.audits.get(row.name) if report: for finding in report.findings: colour = self.pal[SEVERITY_ROLE[finding.severity]] node.add_leaf(Text(f"{finding.severity.glyph} {finding.title}", style=colour)) node.expand() vcs = tree.root.add( Text(f"VCS / devel {len(self.review.vcs)} installed", style="bold"), expand=False) for row in self.review.vcs: vcs.add_leaf(Text.assemble((row.name, self.pal["dim"]), (" ", ""), (row.delta, self.pal["ver"]))) checks = tree.root.add(Text("Checks", style="bold"), expand=True) for check in self.review.checks: colour = self.pal["new"] if check.ok else self.pal["warn"] checks.add_leaf(Text(f"{'ok' if check.ok else '!!'} {check.label}" f" {check.detail}", style=colour)) tree.focus() self._show_detail(self.review.aur[0].name if self.review.aur else "") def _aur_label(self, row) -> Text: colour = {"!!": self.pal["old"], "!": self.pal["warn"], "D": self.pal["ver"]}.get(row.tag, self.pal["new"]) label = Text.assemble( (f"{row.tag} ", colour), (row.name, "bold"), (" ", ""), (row.old, self.pal["old"]), (" → ", self.pal["dim"]), (row.new, self.pal["new"])) if row.note: label.append(f" {row.note}", style=self.pal["dim"]) return label # -- detail pane: the existing renderer, unchanged --------------------- def _show_detail(self, package: str) -> None: self.current = package body = self.query_one("#detail-body", Static) report = self.run.audits.get(package) if not report: body.update(Text("select a package", style=self.pal["dim"])) return lines = audit_screen( report, theme=self.theme_obj, glyphs=self.rich_glyphs, width=64, title=f"{package} · AUR", limit=24) body.update(Text("\n").join(lines)) def on_tree_node_highlighted(self, event: Tree.NodeHighlighted) -> None: data = getattr(event.node, "data", None) if data: self._show_detail(str(data)) # -- actions ----------------------------------------------------------- def action_apply(self) -> None: self.notify("apply would run here", title="not implemented") def action_diff(self) -> None: report = self.run.audits.get(self.current or "") if report: self.notify(f"{len(report.findings)} finding(s) for {self.current}", title="diff") else: self.notify("no audit for this package", severity="warning") def action_skip(self) -> None: self.notify(f"would skip {self.current or 'nothing'}", title="skip") async def screenshot(path: Path, size: tuple[int, int] = (132, 42), palette: dict[str, str] | None = None) -> None: run, review = gather() app = UpdaterApp(run, review, palette) async with app.run_test(size=size) as pilot: await pilot.pause() app.save_screenshot(str(path)) if __name__ == "__main__": out = ROOT / "preview" / "tui-poc.svg" out.parent.mkdir(exist_ok=True) asyncio.run(screenshot(out, palette=CAPTURE_PALETTE)) print(f"wrote {out}")