aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-08-06 16:01:57 +0200
committerblasty <blasty@local>2026-08-06 16:12:13 +0200
commit6978c38b8bcc2dc7fb631a2223475a3164add22b (patch)
tree39869e6da1ca29ee95d04d853ffbf90c2d984fa4
parentadd the logo source image (diff)
downloadida-tui-6978c38b8bcc2dc7fb631a2223475a3164add22b.tar.gz
ida-tui-6978c38b8bcc2dc7fb631a2223475a3164add22b.tar.xz
ida-tui-6978c38b8bcc2dc7fb631a2223475a3164add22b.zip
splash: draw the real logo on terminals that can
logo.ans is 60x33 cells of half-blocks -- a 60x66 pixel image. logo.png is 474x516. On a terminal that speaks the kitty graphics protocol we now send the real thing, in the same cell footprint (fit() lands on exactly 60x33, so the layout is unchanged), and fall back to the block art everywhere else. Three findings, each of which cost a round of "it renders nothing": Support cannot be sniffed from the environment. Under a multiplexer that passes the protocol through, TERM is xterm-256color and KITTY_WINDOW_ID, TERM_PROGRAM and COLORTERM are all empty while the protocol answers OK -- detection by terminal name would disable graphics on exactly the terminal that supports them. So we ask: a 1x1 graphics query plus a Primary Device Attributes request, with DA1 as the sync point. Unicode placeholders are not usable. The tidy way to put an image in a TUI is a virtual placement plus U+10EEEE cells that the compositor clips and moves like text -- and it is what every Textual image library builds on -- but this terminal answers ENOTSUPPORTED for placeholders while supporting everything else. So the image is placed directly, anchored to screen cells Textual knows nothing about. The splash therefore owns its lifetime: place after layout, re-anchor when the note repaints (throttled; a placement is one short escape with no image data), delete on unmount, or a leftover would sit on top of the disassembly forever. The query and the upload go on OPPOSITE sides of the alternate screen. The query must run before Textual starts, which reads stdin on its own thread and would eat the reply. The image must be uploaded after Textual has switched to the alternate screen: an image uploaded to the primary screen cannot be placed from the alternate one, and the placement reports success while drawing nothing. That silent failure is why detection lives in launch.py and upload lives in the splash's on_mount. $IDATUI_KITTY_LOG traces the decisions, since none of this is visible to a test -- correct escape sequences and visible pixels are not the same thing here. Off-tty (the pilot suite, a pipe) detection returns False and the block art is used, so the tests are unaffected.
-rw-r--r--README.md5
-rw-r--r--idatui/app.py97
-rw-r--r--idatui/kittygfx.py245
-rw-r--r--idatui/launch.py12
4 files changed, 349 insertions, 10 deletions
diff --git a/README.md b/README.md
index ba72e59..e3710d9 100644
--- a/README.md
+++ b/README.md
@@ -32,6 +32,11 @@ don't file expectations. **Use at your own risk.**
undefined heads) as the default code view; `F5`/`Tab` drops into the
**decompiler (pseudocode)** for the function under the cursor. Both are
line-virtualized and page lazily over the worker.
+- The startup splash draws the **real logo image** on terminals that speak the
+ kitty graphics protocol (~10× the resolution of the block art), and falls back
+ to `logo.ans` everywhere else. Support is detected by *asking the terminal*,
+ not by sniffing `$TERM` — under a multiplexer that passes the protocol through,
+ every environment variable you'd test is empty while the protocol works fine.
- A **control-flow graph** (`space`, IDA's own key): the current function's basic
blocks as boxes with routed, colour-coded edges (green taken / red fall-through
/ blue unconditional / purple loop), laid out with a proper layered
diff --git a/idatui/app.py b/idatui/app.py
index ca356d3..464f5c0 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -20,6 +20,7 @@ import asyncio
import os
import re
import subprocess
+import time
from dataclasses import dataclass, field
from rich.align import Align
@@ -44,6 +45,7 @@ from textual.widgets import (
from textual.widgets.option_list import Option
from . import graph
+from . import kittygfx
from .highlight import highlight_c
from .errors import IDAToolError, IDAConnectionError
@@ -3614,8 +3616,12 @@ class ConfirmScreen(ModalScreen):
self.dismiss(False)
-_LOGO_PATH = os.path.join(
- os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "logo.ans")
+_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.
+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_cache: object = False # False == not yet loaded (None == absent/unreadable)
@@ -3646,17 +3652,35 @@ class LoadingScreen(ModalScreen):
super().__init__()
self._title = title
self._note = note
+ self._image = False # drawing the real image, not the block art
+ self._last_place = 0.0 # throttles re-anchoring after a repaint
+
+ def _fits(self, rows: int) -> bool:
+ """Room for the art plus the title/note/help lines and box chrome."""
+ sz = self.app.size
+ return sz.height >= rows + 9 and sz.width >= 64
def compose(self) -> ComposeResult:
with Vertical(id="loading-box"):
- logo = _load_logo()
- # Only show the splash art when the terminal can fit it plus the
- # title/note/help + box chrome; otherwise fall back to a text-only
- # overlay so nothing important is clipped off a small screen.
- if logo is not None:
- sz = self.app.size
- n = len(logo.split("\n"))
- if sz.height >= n + 9 and sz.width >= 64:
+ # A terminal that can draw a real image gets one: same artwork,
+ # same cell footprint, ~10x the linear resolution of the block art.
+ # 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
+ kittygfx.log(f"compose: supported={kittygfx.supported()} "
+ f"app.size={self.app.size} fits={self._fits(rows)}")
+ if kittygfx.supported() and self._fits(rows):
+ self._image = True
+ blank = Static("\n" * (rows - 1), id="loading-image")
+ blank.styles.height = rows
+ yield blank
+ else:
+ logo = _load_logo()
+ # Only show the splash art when the terminal can fit it plus
+ # the title/note/help + box chrome; otherwise fall back to a
+ # text-only overlay so nothing important is clipped.
+ if logo is not None and self._fits(len(logo.split("\n"))):
# Align.center, not the box's align-horizontal: the 1fr
# title/note siblings make the child group span the full
# width, so container alignment has nothing left to centre.
@@ -3671,6 +3695,58 @@ class LoadingScreen(ModalScreen):
self.query_one("#loading-note", Static).update(text)
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.
+ if self._image:
+ now = time.monotonic()
+ if now - self._last_place > 0.2:
+ self._last_place = now
+ self._place_logo()
+
+ # -- the image, which Textual knows nothing about ---------------------- #
+ def _place_logo(self) -> None:
+ """Anchor the image over the blank cells reserved for it.
+
+ 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.
+ """
+ if not self._image:
+ return
+ try:
+ region = self.query_one("#loading-image", Static).region
+ except Exception as e: # noqa: BLE001 -- gone already
+ kittygfx.log(f"place_logo: no widget ({e})")
+ return
+ kittygfx.log(f"place_logo: region={region}")
+ if not region.width or not region.height:
+ return
+ 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)
+
+ def on_mount(self) -> None:
+ if not self._image:
+ return
+ # Upload HERE, not from the launcher: Textual is on the alternate screen
+ # by now, and an image uploaded to the primary screen cannot be placed
+ # from the alternate one -- the placement reports success and draws
+ # nothing at all.
+ if not kittygfx.upload(LOGO_PNG):
+ self._image = False
+ return
+ self.call_after_refresh(self._place_logo)
+
+ def on_resize(self) -> None:
+ if self._image:
+ kittygfx.clear()
+ self.call_after_refresh(self._place_logo)
+
+ def on_unmount(self) -> None:
+ # The image is anchored to the screen, not owned by the compositor, so
+ # it would sit there over the disassembly forever if we didn't say so.
+ if self._image:
+ kittygfx.clear()
def action_hide(self) -> None:
self.dismiss()
@@ -4147,6 +4223,7 @@ class IdaTui(App):
#loading-box { width: 72; height: auto; border: thick $accent;
background: $panel; padding: 1 2; }
#loading-logo { width: 100%; height: auto; margin-bottom: 1; }
+ #loading-image { width: 100%; margin-bottom: 1; }
#loading-title { width: 1fr; height: 1; text-style: bold; }
#loading-note { height: auto; color: $text-muted; margin-top: 1; }
#loading-help { height: auto; color: $text-muted; margin-top: 1; }
diff --git a/idatui/kittygfx.py b/idatui/kittygfx.py
new file mode 100644
index 0000000..b8afeb4
--- /dev/null
+++ b/idatui/kittygfx.py
@@ -0,0 +1,245 @@
+"""Kitty graphics protocol: detect it, upload an image, place it on screen.
+
+Used for the startup splash, which otherwise falls back to the block-art
+``logo.ans``. Two things about this were expensive to find out, so they are
+written down here rather than rediscovered.
+
+**Support cannot be sniffed from the environment.** A multiplexer that passes
+the protocol through (recent zellij, tmux with allow-passthrough) leaves TERM as
+``xterm-256color`` with ``KITTY_WINDOW_ID``, ``TERM_PROGRAM`` and ``COLORTERM``
+all empty, while the protocol answers perfectly. Detection by terminal name
+would disable graphics on exactly the terminals that support them. So we ask:
+send a 1x1 graphics query together with a Primary Device Attributes request.
+Every terminal answers DA1, so that reply is the sync point -- a ``_G...OK``
+before it means yes, DA1 alone means no. No timeouts to tune, no allowlist.
+
+**Unicode placeholders are not usable.** The tidy way to put an image in a TUI
+is a virtual placement (``U=1``) plus U+10EEEE placeholder cells, which the
+compositor then moves and clips like ordinary text. It is also what every
+Textual image library is built on -- and this terminal answers
+``ENOTSUPPORTED:unicode placeholders are not supported`` while supporting
+everything else. So we use ordinary placement: the image is anchored at a screen
+cell and stays there until deleted, which means the caller owns its lifetime
+(place on mount and resize, delete on unmount) and must reserve blank cells
+underneath. That is fine for a splash and deliberately not built up into a
+general image widget.
+
+**Uploading and drawing happen on opposite sides of the alternate screen.** The
+detection query must run BEFORE the app starts, because it needs a reply and
+Textual reads stdin on its own thread. The IMAGE, though, must be uploaded AFTER
+Textual has switched to the alternate screen: an image uploaded to the primary
+screen cannot be placed from the alternate one -- placement reports no error, it
+simply draws nothing. That combination is why the splash calls ``supported()``
+from the launcher and ``upload()`` from its own ``on_mount``.
+"""
+from __future__ import annotations
+
+import base64
+import os
+import re
+import select
+import struct
+import sys
+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
+
+_supported: bool | None = None
+_uploaded: dict[int, tuple[int, int]] = {} # image id -> (pixel w, pixel h)
+
+
+def log(msg: str) -> None:
+ """Trace to ``$IDATUI_KITTY_LOG``. The splash lives inside a full-screen TUI
+ on a tty we can't print to, so this is the only way to see what it decided."""
+ path = os.environ.get("IDATUI_KITTY_LOG")
+ if not path:
+ return
+ try:
+ with open(path, "a") as f:
+ f.write(f"{time.time():.3f} {msg}\n")
+ except OSError:
+ pass
+
+
+# --------------------------------------------------------------------------- #
+# Detection
+# --------------------------------------------------------------------------- #
+def _query_tty(timeout: float = 2.0) -> bool:
+ import termios
+ import tty as ttymod
+
+ try:
+ fd = os.open("/dev/tty", os.O_RDWR | os.O_NOCTTY)
+ except OSError:
+ return False
+ try:
+ old = termios.tcgetattr(fd)
+ except termios.error:
+ os.close(fd)
+ 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")
+ buf = b""
+ deadline = time.monotonic() + timeout
+ while time.monotonic() < deadline:
+ r, _, _ = select.select([fd], [], [], 0.15)
+ if not r:
+ continue
+ chunk = os.read(fd, 4096)
+ if not chunk:
+ break
+ buf += chunk
+ if re.search(rb"\033\[\?[0-9;]*c", buf): # DA1: the answer is in
+ break
+ return bool(re.search(rb"\033_G[^\033]*;OK\033\\", buf))
+ except OSError:
+ return False
+ finally:
+ try:
+ termios.tcsetattr(fd, termios.TCSANOW, old)
+ finally:
+ os.close(fd)
+
+
+def supported() -> bool:
+ """True if the terminal speaks the kitty graphics protocol.
+
+ ``$IDATUI_KITTY=0/1`` forces the answer, for a terminal that swallows the
+ query and for tests. Cached: the query costs a round trip and must not run
+ once Textual owns stdin.
+ """
+ global _supported
+ if _supported is not None:
+ return _supported
+ env = os.environ.get("IDATUI_KITTY", "").strip().lower()
+ if env in ("1", "yes", "true", "on"):
+ _supported = True
+ elif env in ("0", "no", "false", "off"):
+ _supported = False
+ elif not (sys.__stdout__ and sys.__stdout__.isatty()):
+ _supported = False # pilot tests, pipes, redirected output
+ log("supported: stdout is not a tty")
+ else:
+ _supported = _query_tty()
+ log(f"supported() -> {_supported}")
+ return _supported
+
+
+# --------------------------------------------------------------------------- #
+# Upload / place / delete
+# --------------------------------------------------------------------------- #
+def png_size(path: str) -> tuple[int, int] | None:
+ """(width, height) from a PNG's IHDR, without decoding it."""
+ try:
+ with open(path, "rb") as f:
+ head = f.read(26)
+ except OSError:
+ return None
+ if len(head) < 24 or head[:8] != b"\x89PNG\r\n\x1a\n":
+ return None
+ w, h = struct.unpack(">II", head[16:24])
+ return (w, h)
+
+
+def _write(data: str) -> bool:
+ """Write escapes to the same stream Textual writes frames to, so the two
+ can't be reordered. Called only from the app's own loop."""
+ out = sys.__stdout__
+ if out is None:
+ return False
+ try:
+ out.write(data)
+ out.flush()
+ return True
+ except (OSError, ValueError):
+ return False
+
+
+def upload(path: str, image_id: int = LOGO_ID) -> bool:
+ """Send the PNG to the terminal WITHOUT placing it (``a=t``).
+
+ Must be called once the app is already on the ALTERNATE screen -- an image
+ uploaded to the primary screen can't be placed from the alternate one, and
+ the placement fails silently. Idempotent, so callers can just ask.
+ """
+ if image_id in _uploaded:
+ return True
+ size = png_size(path)
+ if size is None:
+ return False
+ try:
+ with open(path, "rb") as f:
+ payload = base64.standard_b64encode(f.read())
+ except OSError:
+ return False
+ parts = [payload[i:i + 4096] for i in range(0, len(payload), 4096)]
+ if not parts:
+ return False
+ buf = []
+ for i, part in enumerate(parts):
+ more = 1 if i < len(parts) - 1 else 0
+ ctrl = (f"a=t,f=100,t=d,i={image_id},q=2,m={more}" if i == 0
+ else f"m={more}")
+ buf.append("\033_G" + ctrl + ";" + part.decode("ascii") + "\033\\")
+ if not _write("".join(buf)):
+ log("upload: write failed")
+ return False
+ _uploaded[image_id] = size
+ log(f"upload -> ok id={image_id} px={size} chunks={len(parts)}")
+ return True
+
+
+def is_uploaded(image_id: int = LOGO_ID) -> bool:
+ return image_id in _uploaded
+
+
+def place(row: int, col: int, cols: int, rows: int,
+ image_id: int = LOGO_ID) -> 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.
+ """
+ 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}")
+ 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[u")
+
+
+def clear(image_id: int = LOGO_ID) -> None:
+ """Remove the image's placements from the screen (it stays uploaded)."""
+ _write(f"\033_Ga=d,d=i,i={image_id},q=2\033\\")
+
+
+def delete(image_id: int = LOGO_ID) -> None:
+ """Remove the placements AND free the image data in the terminal."""
+ _write(f"\033_Ga=d,d=I,i={image_id},q=2\033\\")
+ _uploaded.pop(image_id, None)
+
+
+def fit(px: tuple[int, int], max_cols: int, max_rows: int,
+ cell: tuple[int, int] = (10, 20)) -> 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.
+ """
+ w, h = px
+ if w <= 0 or h <= 0:
+ return (max_cols, max_rows)
+ cw, ch = cell
+ cols = max_cols
+ rows = max(int(round((h / w) * cols * cw / ch)), 1)
+ if rows > max_rows:
+ rows = max_rows
+ cols = max(int(round((w / h) * rows * ch / cw)), 1)
+ return (max(cols, 1), max(rows, 1))
diff --git a/idatui/launch.py b/idatui/launch.py
index 64e1546..1f9c51c 100644
--- a/idatui/launch.py
+++ b/idatui/launch.py
@@ -156,6 +156,18 @@ def main(argv: list[str] | None = None) -> int:
except ImportError as e:
_log(f"the TUI needs textual; run with ~/ida-venv/bin/python ({e})")
return 1
+ # Ask the terminal about graphics support NOW: the query needs a reply from
+ # stdin, and once Textual starts it reads stdin on its own thread and would
+ # swallow it. Only the ANSWER is wanted here -- the image itself is uploaded
+ # later, by the splash, because an image uploaded to the primary screen
+ # cannot be placed once Textual has switched to the alternate one. Costs one
+ # round trip, and only when attached to a tty.
+ try:
+ from . import kittygfx
+ kittygfx.supported()
+ except Exception: # noqa: BLE001 -- graphics are decoration, never fatal
+ pass
+
rpc_path = os.path.abspath(os.path.expanduser(args.rpc)) if args.rpc else None
IdaTui(open_path=binary, keepalive=not args.no_keepalive,
rpc_path=rpc_path, ttl=args.ttl, project=project,