aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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,