git/orchard/main
tools/tui_poc.py
236 lines9 KB last change raw · history
#!/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}")