From 45ba9cf2f448b8c0e9548fce08ced7cea3d9cf69 Mon Sep 17 00:00:00 2001 From: blasty Date: Thu, 6 Aug 2026 17:02:18 +0200 Subject: splash: blasty's transparent logo, and stop guessing the cell aspect The artwork is now a proper transparent PNG with soft edges (24% of its pixels carry partial alpha) instead of opaque art on black with a stray full-width scan line along the bottom. Cropped to its content and resized 1024 -> 768px, which halves the file and costs nothing visible; logo-trans.png keeps the master for future re-renders. Two things the new art exposed, both wrong before it: fit() assumed cells were 1:2. This terminal reports 9x22, i.e. 1:2.44. The old logo was 474x516 -- close enough to the assumption that nobody noticed -- but a square image at the hardcoded 60x33 would have been visibly stretched. The graphics query now asks for the cell size too (CSI 16 t rides along in the same round trip, before the DA1 that already synchronises it) and fit() uses the answer. The footprint was a constant. logo_cells() derives it from the artwork and the measured cell size, so the art can be replaced without anyone remembering to edit a number. logo.ans was stale: the block-art fallback for terminals that can't draw an image was still the OLD artwork, scan line included. tools/make_logo_ans.py regenerates it from logo.png so the two cannot drift again. It understands alpha -- a transparent cell emits no colour and lets the terminal background through, and a cell with only one opaque half uses the matching half block so the pixel lands on the correct side. --- idatui/app.py | 27 ++++++++++++++++++++++----- idatui/kittygfx.py | 36 +++++++++++++++++++++++++++++++----- 2 files changed, 53 insertions(+), 10 deletions(-) (limited to 'idatui') diff --git a/idatui/app.py b/idatui/app.py index b3367c4..fc19fa5 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -3757,9 +3757,25 @@ class ConfirmScreen(ModalScreen): _REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) _LOGO_PATH = os.path.join(_REPO_ROOT, "logo.ans") #: The same artwork as a real image, for terminals that can draw one. logo.ans -#: is 60x33 cells of half-blocks, i.e. 60x66 pixels; this is 474x516. +#: is half-blocks (two pixels per cell); this is a transparent PNG at 768px. LOGO_PNG = os.path.join(_REPO_ROOT, "logo.png") -_LOGO_CELLS = (60, 33) # what logo.ans occupies, so either path lays out the same +_LOGO_BOX = (60, 33) # the most room the splash will give the art +_logo_cells: tuple[int, int] | None = None + + +def logo_cells() -> tuple[int, int]: + """Cell footprint for the image, derived from the artwork and the terminal's + real cell size rather than hardcoded. + + Cells are nowhere near square (9x22 px here, 1:2.44), so a fixed box picked + for one aspect ratio stretches any other. Recomputing means the art can be + replaced without anyone remembering to edit a constant. + """ + global _logo_cells + if _logo_cells is None: + px = kittygfx.png_size(LOGO_PNG) + _logo_cells = kittygfx.fit(px, *_LOGO_BOX) if px else _LOGO_BOX + return _logo_cells _logo_cache: object = False # False == not yet loaded (None == absent/unreadable) @@ -3805,9 +3821,10 @@ class LoadingScreen(ModalScreen): # The image is anchored to screen cells rather than composited by # Textual (no unicode-placeholder support here), so the widget is # only reserved blank space -- see _place_logo. - cols, rows = _LOGO_CELLS + cols, rows = logo_cells() kittygfx.log(f"compose: supported={kittygfx.supported()} " - f"app.size={self.app.size} fits={self._fits(rows)}") + f"app.size={self.app.size} cells={cols}x{rows} " + f"fits={self._fits(rows)}") if kittygfx.supported() and self._fits(rows): self._image = True blank = Static("\n" * (rows - 1), id="loading-image") @@ -3859,7 +3876,7 @@ class LoadingScreen(ModalScreen): kittygfx.log(f"place_logo: region={region}") if not region.width or not region.height: return - cols, rows = _LOGO_CELLS + cols, rows = logo_cells() col = region.x + max((region.width - cols) // 2, 0) # centre it kittygfx.place(region.y, col, min(cols, region.width), rows) diff --git a/idatui/kittygfx.py b/idatui/kittygfx.py index b8afeb4..e53fba3 100644 --- a/idatui/kittygfx.py +++ b/idatui/kittygfx.py @@ -48,6 +48,10 @@ LOGO_ID = 0x1DA7 _supported: bool | None = None _uploaded: dict[int, tuple[int, int]] = {} # image id -> (pixel w, pixel h) +#: Terminal cell size in pixels, asked for in the same round trip as the +#: graphics query. Cells are nothing like a fixed 1:2 -- this box reports 9x22, +#: i.e. 1:2.44 -- and getting it wrong stretches the image. +_cell: tuple[int, int] | None = None def log(msg: str) -> None: @@ -81,7 +85,9 @@ def _query_tty(timeout: float = 2.0) -> bool: return False try: ttymod.setraw(fd) - os.write(fd, b"\033_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\033\\\033[c") + # graphics query + cell-size query + DA1. DA1 is answered by everything, + # so it marks the end of the replies and nothing has to be timed. + os.write(fd, b"\033_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\033\\\033[16t\033[c") buf = b"" deadline = time.monotonic() + timeout while time.monotonic() < deadline: @@ -92,8 +98,15 @@ def _query_tty(timeout: float = 2.0) -> bool: if not chunk: break buf += chunk - if re.search(rb"\033\[\?[0-9;]*c", buf): # DA1: the answer is in + if re.search(rb"\033\[\?[0-9;]*c", buf): # DA1: the answers are in break + global _cell + m = re.search(rb"\033\[6;(\d+);(\d+)t", buf) # CSI 6 ; height ; width t + if m: + ch, cw = int(m.group(1)), int(m.group(2)) + if 0 < cw < 100 and 0 < ch < 200: + _cell = (cw, ch) + log(f"cell size {cw}x{ch}px") return bool(re.search(rb"\033_G[^\033]*;OK\033\\", buf)) except OSError: return False @@ -226,13 +239,26 @@ def delete(image_id: int = LOGO_ID) -> None: _uploaded.pop(image_id, None) +def cell_size() -> tuple[int, int]: + """(width, height) of a terminal cell in pixels. + + Measured during the graphics query when the terminal answers CSI 16 t; + otherwise a 10x20 guess, which is only ever used to keep the aspect ratio + honest. + """ + return _cell or (10, 20) + + def fit(px: tuple[int, int], max_cols: int, max_rows: int, - cell: tuple[int, int] = (10, 20)) -> tuple[int, int]: + cell: tuple[int, int] | None = None) -> tuple[int, int]: """Cell size that fits ``max_cols`` x ``max_rows`` keeping the aspect ratio. - Cells are about twice as tall as they are wide, so a naive cols=rows box - would squash the image; ``cell`` is that ratio in pixels. + Cells are far from square -- this box reports 9x22 px -- so a naive + cols==rows box stretches the image; ``cell`` is that ratio in pixels and + defaults to what the terminal actually said. """ + if cell is None: + cell = cell_size() w, h = px if w <= 0 or h <= 0: return (max_cols, max_rows) -- cgit v1.3.1-sl0p