diff options
| -rw-r--r-- | experiments/splash_place_count.py | 97 | ||||
| -rw-r--r-- | idatui/app.py | 16 | ||||
| -rw-r--r-- | idatui/kittygfx.py | 19 | ||||
| -rw-r--r-- | tests/test_kittygfx.py | 152 |
4 files changed, 278 insertions, 6 deletions
diff --git a/experiments/splash_place_count.py b/experiments/splash_place_count.py new file mode 100644 index 0000000..6d8322a --- /dev/null +++ b/experiments/splash_place_count.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python +"""Count what the splash actually SENDS to the terminal. + +Pushes a real LoadingScreen onto a bare Textual app with kitty graphics forced +on and kittygfx._write captured, then drives it the way the app does (a status +write per progress tick) and tallies the escapes. + + PYTHONPATH=. ~/ida-venv/bin/python experiments/splash_place_count.py +""" +from __future__ import annotations + +import asyncio +import os +import re +import sys + +os.environ["IDATUI_KITTY"] = "1" + +from textual.app import App, ComposeResult # noqa: E402 +from textual.widgets import Static # noqa: E402 + +from idatui import kittygfx # noqa: E402 + +SENT: list[str] = [] + + +def _fake_write(data: str) -> bool: + SENT.append(data) + return True + + +kittygfx._write = _fake_write # type: ignore[assignment] +kittygfx._cell = (9, 22) + +from idatui.app import LoadingScreen # noqa: E402 + + +def tally() -> dict[str, int]: + blob = "".join(SENT) + return { + "uploads (a=t)": len(re.findall(r"\x1b_G[^;]*a=t", blob)), + "placements (a=p)": len(re.findall(r"\x1b_G[^;]*a=p", blob)), + "deletes (a=d)": len(re.findall(r"\x1b_G[^;]*a=d", blob)), + "bytes": len(blob), + } + + +class Host(App): + CSS = "#loading-box { width: 70; height: auto; }" + + def compose(self) -> ComposeResult: + yield Static("host") + + +async def main() -> None: + ticks = int(sys.argv[1]) if len(sys.argv) > 1 else 40 + app = Host() + async with app.run_test(size=(100, 45)) as pilot: + screen = LoadingScreen("target") + app.push_screen(screen) + await pilot.pause() + await pilot.pause() + print(f"image mode: {screen._image}") + after_mount = tally() + print("after mount:", after_mount) + + # what the app does: _status() -> loading_screen.update_note(), once + # per progress write. Spread over ~4s of wall clock like a real load. + for i in range(ticks): + screen.update_note(f"analyzing… {i}") + await asyncio.sleep(0.1) + await pilot.pause() + print(f"after {ticks} progress notes:", tally()) + + screen.dismiss() + await pilot.pause() + print("after dismiss:", tally()) + + d = tally() + blob = "".join(SENT) + cmds = re.findall(r"\x1b_G([^;\x1b]*)", blob) + ids = {dict(kv.split("=", 1) for kv in c.split(",") if "=" in kv).get("p") + for c in cmds if "a=p" in c.split(",")} + onscreen = "unbounded (anonymous)" if None in ids else len(ids) + print() + print(f"=> {d['placements (a=p)']} place escapes sent, " + f"{d['deletes (a=d)']} deletes") + print(f" images actually on screen: {onscreen}") + print(" A placement is identified by (image id, placement id). An a=p with") + print(" no p= key is ANONYMOUS and stacks a fresh copy every time; with a") + print(" p= key the terminal replaces the previous one. logo.png is RGBA, so") + print(" stacking also composites its soft edges towards solid.") + m = re.search(r"\x1b_G(a=p[^;\x1b]*)", blob) + print(" placement escape:", m.group(1) if m else "(none)") + + +asyncio.run(main()) diff --git a/idatui/app.py b/idatui/app.py index 3f67756..d0bf403 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -4425,8 +4425,11 @@ class LoadingScreen(ModalScreen): except Exception: # noqa: BLE001 -- not mounted yet / already gone pass # Textual doesn't know the image is there, so a repaint can drop it. - # Re-anchoring is one short escape with no image data; throttled so a - # chatty progress callback can't turn it into a flicker. + # Re-anchoring is one short escape with no image data, and it REPLACES + # the placement rather than adding one (kittygfx.LOGO_PLACEMENT), so a + # long load ends with one image on screen instead of a stack of them. + # Still throttled: a chatty progress callback shouldn't drive the + # terminal's image compositor at status-write rate. if self._image: now = time.monotonic() if now - self._last_place > 0.2: @@ -4439,6 +4442,8 @@ class LoadingScreen(ModalScreen): Deferred to after a refresh because a widget has no screen region until it has been laid out, and re-run on resize because the region moves. + Idempotent: the placement carries an id, so calling this a hundred times + during a slow load leaves exactly one image on the screen. """ if not self._image: return @@ -4446,9 +4451,13 @@ class LoadingScreen(ModalScreen): region = self.query_one("#loading-image", Static).region except Exception as e: # noqa: BLE001 -- gone already kittygfx.log(f"place_logo: no widget ({e})") + kittygfx.clear() return kittygfx.log(f"place_logo: region={region}") if not region.width or not region.height: + # No room left to draw into -- drop the placement rather than leave + # the old, bigger one anchored over whatever now occupies the cells. + kittygfx.clear() return # The reserved region is the truth about how much room there is; the # image is scaled into exactly it, so a resize needs no relayout. @@ -4470,8 +4479,9 @@ class LoadingScreen(ModalScreen): self.call_after_refresh(self._place_logo) def on_resize(self) -> None: + # No clear() first: re-placing with the same placement id replaces the + # old one atomically, where delete-then-draw shows a hole for a frame. if self._image: - kittygfx.clear() self.call_after_refresh(self._place_logo) def on_unmount(self) -> None: diff --git a/idatui/kittygfx.py b/idatui/kittygfx.py index e53fba3..2f6766d 100644 --- a/idatui/kittygfx.py +++ b/idatui/kittygfx.py @@ -45,6 +45,14 @@ import time #: One id for the splash. Ids are a terminal-wide namespace shared with whatever #: else the user is running, so this is deliberately not 1. LOGO_ID = 0x1DA7 +#: Placement id for the splash. A placement is identified by the PAIR (image id, +#: placement id): re-placing with the same pair REPLACES the placement, while a +#: placement with no ``p`` key is anonymous and every one of those stacks a new +#: copy on the screen. The splash re-anchors itself on every progress note, so +#: without this the terminal ends a long load holding hundreds of placements of +#: the same image at the same cell -- alpha-compositing the (RGBA) logo over +#: itself until its soft edges go solid, and re-rendering all of them per frame. +LOGO_PLACEMENT = 1 _supported: bool | None = None _uploaded: dict[int, tuple[int, int]] = {} # image id -> (pixel w, pixel h) @@ -210,21 +218,26 @@ def is_uploaded(image_id: int = LOGO_ID) -> bool: def place(row: int, col: int, cols: int, rows: int, - image_id: int = LOGO_ID) -> bool: + image_id: int = LOGO_ID, placement_id: int = LOGO_PLACEMENT) -> bool: """Draw the uploaded image at (``row``, ``col``), 0-based, sized in cells. Saves and restores the cursor, and asks the terminal not to move it (``C=1``), so Textual's idea of where the cursor is stays true. + + Always carries a placement id (``p``), so calling this again REPLACES the + previous placement instead of adding another one underneath it -- see + ``LOGO_PLACEMENT``. Callers re-anchor freely; the screen holds exactly one. """ size = _uploaded.get(image_id) if size is None or cols <= 0 or rows <= 0: log(f"place: refused size={size} cols={cols} rows={rows}") return False w, h = size - log(f"place row={row} col={col} c={cols} r={rows}") + log(f"place row={row} col={col} c={cols} r={rows} p={placement_id}") return _write( f"\033[s\033[{row + 1};{col + 1}H" - f"\033_Ga=p,i={image_id},s={w},v={h},c={cols},r={rows},C=1,q=2\033\\" + f"\033_Ga=p,i={image_id},p={placement_id}," + f"s={w},v={h},c={cols},r={rows},C=1,q=2\033\\" f"\033[u") diff --git a/tests/test_kittygfx.py b/tests/test_kittygfx.py new file mode 100644 index 0000000..b12817d --- /dev/null +++ b/tests/test_kittygfx.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""Kitty graphics escapes: what we actually send to the terminal (no IDA). + +The splash re-anchors itself on every progress note, so the escape it sends has +to be a REPLACEMENT, not another copy. That is one key (``p``) and it is +invisible in every screenshot, which is exactly why it needs a test. +""" + +#: pure stdlib escape-construction checks; no IDA, no Textual. +#: Read by tests/run.py (--fast skips every NEEDS_IDA file). +NEEDS_IDA = False +import os +import re +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from idatui import kittygfx # noqa: E402 + +PASS = FAIL = 0 + + +def check(name, ok, detail=""): + global PASS, FAIL + if ok: + PASS += 1 + print(f" ok {name}") + else: + FAIL += 1 + print(f" FAIL {name} {detail}") + + +class Tty: + """Capture what kittygfx writes, in place of the real stdout.""" + + def __init__(self): + self.sent = [] + self._real = kittygfx._write + + def __enter__(self): + kittygfx._write = lambda data: (self.sent.append(data), True)[1] + return self + + def __exit__(self, *exc): + kittygfx._write = self._real + + @property + def blob(self): + return "".join(self.sent) + + def cmds(self, action): + """Every graphics command with the given ``a=`` action.""" + return [c for c in re.findall(r"\x1b_G([^;\x1b]*)", self.blob) + if f"a={action}" in c.split(",")] + + +def keys(cmd): + return dict(kv.split("=", 1) for kv in cmd.split(",") if "=" in kv) + + +def main() -> int: + kittygfx._uploaded[kittygfx.LOGO_ID] = (768, 801) # pretend it's uploaded + + # -- the bug: anonymous placements STACK ------------------------------- # + # A placement is identified by (image id, placement id). With no p key + # every place() adds another copy at the same cell: a long load left the + # terminal compositing hundreds of copies of an RGBA image over itself. + with Tty() as tty: + for _ in range(50): + kittygfx.place(4, 10, 60, 26) + placements = tty.cmds("p") + check("place() emits one command per call", len(placements) == 50, + f"{len(placements)}") + check("every placement carries a placement id (replaces, not stacks)", + all("p" in keys(c) for c in placements), + f"{placements[0] if placements else '(none)'}") + check("the placement id is the same every time (one image on screen)", + len({keys(c)["p"] for c in placements}) == 1, + f"{sorted({keys(c).get('p') for c in placements})}") + check("...and it is non-zero (p=0 means anonymous)", + keys(placements[0])["p"] not in ("0", ""), f"{placements[0]}") + + # -- the rest of the escape still says what it used to ------------------ # + with Tty() as tty: + ok = kittygfx.place(4, 10, 60, 26) + k = keys(tty.cmds("p")[0]) + check("place() reports success", ok) + check("image id, source pixels and cell box are unchanged", + (k["i"], k["s"], k["v"], k["c"], k["r"]) + == (str(kittygfx.LOGO_ID), "768", "801", "60", "26"), f"{k}") + check("the terminal is told not to move the cursor (C=1)", + k.get("C") == "1", f"{k}") + check("the cursor is saved and restored around the placement", + tty.blob.startswith("\x1b[s") and tty.blob.endswith("\x1b[u"), + repr(tty.blob[:8] + "..." + tty.blob[-8:])) + check("the placement is positioned 1-based (row 4 -> line 5)", + "\x1b[5;11H" in tty.blob, repr(tty.blob[:24])) + + # -- deleting still removes EVERY placement of the image ---------------- # + # d=i is by image id, so it takes the placement with us regardless of p. + with Tty() as tty: + kittygfx.clear() + k = keys(tty.cmds("d")[0]) + check("clear() deletes by image id (d=i), keeping the upload", + k.get("d") == "i" and k.get("i") == str(kittygfx.LOGO_ID), f"{k}") + check("clear() does not free the image data (lowercase d)", + kittygfx.is_uploaded(), "upload was dropped") + + with Tty() as tty: + kittygfx.delete() + k = keys(tty.cmds("d")[0]) + check("delete() frees the image data too (d=I)", k.get("d") == "I", f"{k}") + check("...and forgets the upload, so the next place() refuses", + not kittygfx.is_uploaded() and kittygfx.place(0, 0, 10, 10) is False) + + # -- refusals ----------------------------------------------------------- # + kittygfx._uploaded[kittygfx.LOGO_ID] = (768, 801) + with Tty() as tty: + check("a zero-sized box is refused, not sent", + kittygfx.place(0, 0, 0, 10) is False + and kittygfx.place(0, 0, 10, 0) is False and not tty.sent, + f"{tty.sent}") + kittygfx._uploaded.pop(kittygfx.LOGO_ID, None) + + # -- fit(): aspect ratio against non-square cells ----------------------- # + check("fit() keeps the aspect ratio for 9x22 cells", + kittygfx.fit((768, 801), 60, 99, cell=(9, 22)) == (60, 26), + f"{kittygfx.fit((768, 801), 60, 99, cell=(9, 22))}") + check("fit() shrinks to the row budget instead of overflowing", + kittygfx.fit((768, 801), 60, 10, cell=(9, 22))[1] == 10, + f"{kittygfx.fit((768, 801), 60, 10, cell=(9, 22))}") + check("fit() never returns a zero dimension", + all(v >= 1 for v in kittygfx.fit((768, 801), 1, 1, cell=(9, 22)))) + check("fit() survives a degenerate image size", + kittygfx.fit((0, 0), 60, 26) == (60, 26)) + + # -- png_size() reads the header, not the pixels ------------------------ # + logo = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "logo.png") + if os.path.exists(logo): + check("png_size() reads logo.png's IHDR", + kittygfx.png_size(logo) == (768, 801), f"{kittygfx.png_size(logo)}") + check("png_size() returns None for a non-PNG", kittygfx.png_size(__file__) is None) + check("png_size() returns None for a missing file", + kittygfx.png_size("/nonexistent/nope.png") is None) + + print(f"\n{PASS} passed, {FAIL} failed") + return 1 if FAIL else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) |
