aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <peter@haxx.in>2026-08-21 12:08:42 +0200
committerblasty <peter@haxx.in>2026-08-21 12:08:42 +0200
commit7a3b198a904da186cc2860df059b063db24282ea (patch)
treefd5a754169522c0ccd8b7e0d70b2238395d81b09
parentdocs: be honest that the triskel fork is unpublished (diff)
downloadida-tui-7a3b198a904da186cc2860df059b063db24282ea.tar.gz
ida-tui-7a3b198a904da186cc2860df059b063db24282ea.tar.xz
ida-tui-7a3b198a904da186cc2860df059b063db24282ea.zip
splash: give the logo a placement id so re-anchoring replaces, not stacks
A kitty placement is identified by (image id, placement id); an a=p with no p= key is anonymous and every one stacks another copy. The splash re-anchors on every progress note (~5/s), so a 60s load ended with ~300 placements of an RGBA logo alpha-compositing over each other -- soft edges creeping to solid, and the terminal re-rendering all of them per frame. Same pair every time (LOGO_PLACEMENT) = the terminal REPLACES the placement, so re-anchoring is free and atomic. That is also why on_resize no longer clear()s first (that showed a hole for a frame), and why a shrunk-to-nothing region now drops the placement instead of leaving a stale one anchored. tests/test_kittygfx.py pins the escapes (pure, stdlib); experiments/splash_place_count.py counts what a real splash sends.
-rw-r--r--experiments/splash_place_count.py97
-rw-r--r--idatui/app.py16
-rw-r--r--idatui/kittygfx.py19
-rw-r--r--tests/test_kittygfx.py152
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())