#!/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}")