aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-08-06 15:11:54 +0200
committerblasty <blasty@local>2026-08-06 15:11:54 +0200
commitf4fed15e05213b580f00f8958fa73b46f9838354 (patch)
tree963d3e0feed1cfb59bd3c743a5c1183d33eee9ec
parentdomain: the control-flow graph, with each block's rows (diff)
downloadida-tui-f4fed15e05213b580f00f8958fa73b46f9838354.tar.gz
ida-tui-f4fed15e05213b580f00f8958fa73b46f9838354.tar.xz
ida-tui-f4fed15e05213b580f00f8958fa73b46f9838354.zip
app: the graph view
Space swaps the code view for the function's basic blocks, IDA's own key. Boxes hold the SAME Head rows the listing renders, so IDA's colour tags, the word highlight, the execution trail and every editing verb work inside them for free. Nothing is pre-painted: each screen row is composed on demand from the edge index plus whichever boxes cover it, so cost tracks the viewport and not the graph. z cycles three zoom levels, m toggles a minimap, J/K walk edges, and the mode is sticky -- following a call lands in the callee's graph. Above 400 blocks it declines and says so, because nothing readable comes out at that size. Edges follow IDA's colours, and the ones touching the block under the cursor are brightened. Adding a value to _active means every consumer has to learn it: the one that was missed (_active_code_view returning None) crashed the app the first time a prompt closed in graph mode. A stale async load is also guarded now -- without it, a rename's queued rebuild landed later and dragged the user back into a graph they had already dismissed.
-rw-r--r--idatui/app.py921
1 files changed, 911 insertions, 10 deletions
diff --git a/idatui/app.py b/idatui/app.py
index e0b0e8b..ca356d3 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -43,6 +43,7 @@ from textual.widgets import (
)
from textual.widgets.option_list import Option
+from . import graph
from .highlight import highlight_c
from .errors import IDAToolError, IDAConnectionError
@@ -1915,6 +1916,685 @@ class HexView(ScrollView, can_focus=True):
# --------------------------------------------------------------------------- #
+# Graph view
+# --------------------------------------------------------------------------- #
+_S_GBORDER = Style(color="#4b5565") # box border, idle
+_S_GBORDER_CUR = Style(color="#7aa2f7", bold=True) # box border, cursor block
+_S_GLABEL = Style(color="#7aa2f7", bold=True) # loc_XXXX in the border
+_S_GLABEL_CUR = Style(color="#c0caf5", bold=True)
+_S_GDIM = Style(color="#5e6875")
+_S_GENTRY = Style(color="#9ece6a", bold=True) # the entry block's label
+#: Edge colours follow IDA's convention: green = branch taken, red = falls
+#: through, blue = the block's only successor, purple = loops back.
+_S_EDGE = {
+ graph.E_TRUE: Style(color="#5fbf5f"),
+ graph.E_FALSE: Style(color="#cf5f5f"),
+ graph.E_UNCOND: Style(color="#5f87d7"),
+ graph.E_SWITCH: Style(color="#c9a227"),
+ graph.E_BACK: Style(color="#a06fd0"),
+}
+#: Same hues, brightened: the edges touching the block you're on.
+_S_EDGE_HOT = {
+ graph.E_TRUE: Style(color="#8ff08f", bold=True),
+ graph.E_FALSE: Style(color="#ff8f8f", bold=True),
+ graph.E_UNCOND: Style(color="#9fc4ff", bold=True),
+ graph.E_SWITCH: Style(color="#ffd75f", bold=True),
+ graph.E_BACK: Style(color="#d0a0ff", bold=True),
+}
+_S_MINI_BG = Style(bgcolor="#161b22", color="#3b4453")
+_S_MINI_NODE = Style(bgcolor="#161b22", color="#5f7799")
+_S_MINI_CUR = Style(bgcolor="#161b22", color="#9ece6a", bold=True)
+_S_MINI_VIEW = Style(bgcolor="#233044", color="#c0caf5")
+_S_MINI_EDGE = Style(bgcolor="#161b22", color="#2f3945")
+
+_GPAD = 1 # columns of padding inside a box
+_MINI_W, _MINI_H = 30, 14
+
+
+class _CellRow:
+ """A row of (char, style) cells that coalesces into a Strip.
+
+ The graph is drawn per screen row from three independent sources -- edge
+ cells, boxes, then the minimap -- which overwrite each other. Composing into
+ a flat cell array and coalescing once at the end is both simpler and cheaper
+ than splicing Strips three times.
+ """
+
+ __slots__ = ("ch", "st", "width")
+
+ def __init__(self, width: int, base: Style) -> None:
+ self.width = max(width, 0)
+ self.ch = [" "] * self.width
+ self.st = [base] * self.width
+
+ def put(self, i: int, ch: str, style: Style) -> None:
+ if 0 <= i < self.width:
+ self.ch[i] = ch
+ self.st[i] = style
+
+ def text(self, i: int, s: str, style: Style) -> None:
+ for k, c in enumerate(s):
+ self.put(i + k, c, style)
+
+ def restyle(self, a: int, b: int, style: Style) -> None:
+ """Merge ``style`` over the cells in [a, b) (keeps the characters)."""
+ for i in range(max(a, 0), min(b, self.width)):
+ self.st[i] = self.st[i] + style
+
+ def strip(self) -> Strip:
+ segs: list[Segment] = []
+ if not self.width:
+ return Strip([])
+ run_start = 0
+ cur = self.st[0]
+ for i in range(1, self.width):
+ if self.st[i] is not cur and self.st[i] != cur:
+ segs.append(Segment("".join(self.ch[run_start:i]), cur))
+ run_start, cur = i, self.st[i]
+ segs.append(Segment("".join(self.ch[run_start:]), cur))
+ return Strip(segs)
+
+
+class GraphView(NavMixin, ScrollView, can_focus=True):
+ """IDA-style control-flow graph of one function, in character cells.
+
+ The layout comes from ``idatui.graph`` (pure, offline-testable); this class
+ is only presentation, navigation and hit-testing. Nothing is pre-painted: a
+ big function is millions of cells, so each screen row is composed on demand
+ from the edge index plus whichever boxes cover that row -- the same
+ discipline as ``ListingView.render_line``.
+
+ Box contents are the SAME ``Head`` rows the listing renders, so IDA's own
+ colour tags, names and operand text come along for free instead of this
+ growing a second disassembler renderer.
+ """
+
+ BINDINGS = [
+ Binding("j,down", "cursor_down", "Down", show=False),
+ Binding("k,up", "cursor_up", "Up", show=False),
+ Binding("h,left", "cursor_left", "Left", show=False),
+ Binding("l,right", "cursor_right", "Right", show=False),
+ Binding("J", "succ_block", "Next block", show=False),
+ Binding("K", "pred_block", "Prev block", show=False),
+ Binding("w", "next_block", "Block →", show=False),
+ Binding("b", "prev_block", "Block ←", show=False),
+ Binding("0", "goto_entry", "Entry", show=False),
+ Binding("z", "zoom", "Zoom"),
+ Binding("m", "minimap", "Minimap", show=False),
+ Binding("f", "center", "Centre", show=False),
+ Binding("ctrl+d", "pan(12)", "½↓", show=False),
+ Binding("ctrl+u", "pan(-12)", "½↑", show=False),
+ Binding("pagedown", "pan(24)", "PgDn", show=False),
+ Binding("pageup", "pan(-24)", "PgUp", show=False),
+ Binding("home", "col_home", "bol", show=False),
+ Binding("end", "col_end", "eol", show=False),
+ *NavMixin.NAV_BINDINGS,
+ ]
+
+ cursor_node = reactive(-1, repaint=False)
+ cursor_row = reactive(0, repaint=False)
+ cursor_x = reactive(0, repaint=False)
+
+ ZOOMS = ("full", "compact", "collapsed")
+
+ class CursorMoved(Message):
+ """Posted when the graph cursor lands on a new address."""
+
+ def __init__(self, ea: int | None, block: int) -> None:
+ super().__init__()
+ self.ea = ea
+ self.block = block
+
+ def __init__(self) -> None:
+ super().__init__()
+ self.fc = None # domain.Flowchart
+ self.lay: graph.Layout | None = None
+ self.loaded_ea: int | None = None
+ self._blocks: dict[int, object] = {}
+ self._zoom = 0
+ self._show_minimap = True
+ self._mini_cache: tuple | None = None
+ self._drag: tuple[int, int, float, float] | None = None
+ self._hl_word = ""
+ self.trail: dict[int, str] | None = None
+
+ # -- content ---------------------------------------------------------- #
+ def set_graph(self, fc, ea: int | None = None) -> None:
+ """Install a flowchart and lay it out at the current zoom."""
+ self.fc = fc
+ self._blocks = {b.id: b for b in fc.blocks} if fc else {}
+ self.loaded_ea = fc.func_ea if fc else None
+ self._relayout()
+ if fc:
+ blk = fc.block_at(ea) if ea is not None else None
+ self.cursor_node = blk.id if blk else fc.entry
+ self.cursor_row = 0
+ if blk is not None and ea is not None:
+ rows = self._rows(blk.id)
+ for i, h in enumerate(rows):
+ if h is not None and h.ea == ea:
+ self.cursor_row = i
+ break
+ self.cursor_x = 0
+ self._center_cursor()
+ self.refresh(layout=True)
+
+ def _relayout(self) -> None:
+ self._mini_cache = None
+ if not self.fc or not self.fc.blocks:
+ self.lay = None
+ self.virtual_size = Size(0, 0)
+ return
+ blocks = [graph.Block(id=b.id, start=b.start, end=b.end,
+ succs=list(b.succs)) for b in self.fc.blocks]
+ self.lay = graph.layout(blocks, self._sizer, entry=self.fc.entry)
+ self.virtual_size = Size(self.lay.width + 2, self.lay.height + 1)
+
+ def _rows(self, nid: int):
+ """The Head rows shown inside block ``nid`` at the current zoom."""
+ b = self._blocks.get(nid)
+ if b is None:
+ return []
+ if self._zoom == 2:
+ return [None] # one synthetic summary row
+ return b.rows
+
+ def _row_plain(self, nid: int, i: int) -> str:
+ b = self._blocks.get(nid)
+ if b is None:
+ return ""
+ if self._zoom == 2:
+ n = len(b.rows)
+ return f"{n} instruction{'s' if n != 1 else ''}"
+ rows = b.rows
+ if not (0 <= i < len(rows)):
+ return ""
+ h = rows[i]
+ if self._zoom == 0:
+ return f"{h.ea:08X} {self._head_text(h)}"
+ return self._head_text(h)
+
+ @staticmethod
+ def _head_text(h) -> str:
+ return (f"{h.name} {h.text}" if h.name else h.text)
+
+ def _sizer(self, b: graph.Block) -> tuple[int, int]:
+ nid = b.id
+ rows = self._rows(nid)
+ n = max(len(rows), 1)
+ label = f"loc_{b.start:X}"
+ widest = max([len(label) + 4]
+ + [len(self._row_plain(nid, i)) for i in range(n)])
+ return (widest + 2 * _GPAD + 2, n + 2)
+
+ # -- geometry --------------------------------------------------------- #
+ def _cur_node(self) -> graph.Node | None:
+ if self.lay is None:
+ return None
+ return self.lay.by_id.get(self.cursor_node)
+
+ def cur_head(self):
+ rows = self._rows(self.cursor_node)
+ if self._zoom == 2 or not rows:
+ b = self._blocks.get(self.cursor_node)
+ return b.rows[0] if (b and b.rows) else None
+ i = max(0, min(self.cursor_row, len(rows) - 1))
+ return rows[i]
+
+ def _cursor_ea(self) -> int | None:
+ h = self.cur_head()
+ return h.ea if h is not None else None
+
+ def _next_ea(self) -> int | None:
+ """The address after the cursor's instruction -- what ``follow`` uses to
+ skip the fall-through edge and land on a real branch target."""
+ b = self._blocks.get(self.cursor_node)
+ if b is None:
+ return None
+ rows = b.rows
+ i = max(0, min(self.cursor_row, len(rows) - 1)) if rows else 0
+ if rows and i + 1 < len(rows):
+ return rows[i + 1].ea
+ return b.end
+
+ def _line_plain(self, _idx=None) -> str:
+ return self._row_plain(self.cursor_node, self.cursor_row)
+
+ def word_under_cursor(self) -> str:
+ plain = self._line_plain()
+ if not plain:
+ return ""
+ a, b = _word_bounds(plain, self.cursor_x)
+ return plain[a:b] if b > a else ""
+
+ def set_highlight(self, word: str) -> None:
+ if word != self._hl_word:
+ self._hl_word = word
+ self.refresh()
+
+ def set_trail(self, trail: dict[int, str] | None) -> None:
+ self.trail = trail
+ self.refresh()
+
+ # -- cursor motion ---------------------------------------------------- #
+ def _clamp_cursor(self) -> None:
+ if self.lay is None or not self.lay.nodes:
+ return
+ if self.cursor_node not in self.lay.by_id:
+ self.cursor_node = self.lay.nodes[0].id
+ rows = self._rows(self.cursor_node)
+ self.cursor_row = max(0, min(self.cursor_row, max(len(rows) - 1, 0)))
+ plain = self._line_plain()
+ self.cursor_x = max(0, min(self.cursor_x, max(len(plain) - 1, 0)))
+
+ def _order(self) -> list[int]:
+ return [n.id for n in self.lay.nodes] if self.lay else []
+
+ def _moved(self) -> None:
+ self._clamp_cursor()
+ self._scroll_to_cursor()
+ self.refresh()
+ self.post_message(self.CursorMoved(self._cursor_ea(), self.cursor_node))
+
+ def action_cursor_down(self) -> None:
+ rows = self._rows(self.cursor_node)
+ if self.cursor_row + 1 < len(rows):
+ self.cursor_row += 1
+ else:
+ order = self._order()
+ if self.cursor_node in order:
+ i = order.index(self.cursor_node)
+ if i + 1 < len(order):
+ self.cursor_node = order[i + 1]
+ self.cursor_row = 0
+ self._moved()
+
+ def action_cursor_up(self) -> None:
+ if self.cursor_row > 0:
+ self.cursor_row -= 1
+ else:
+ order = self._order()
+ if self.cursor_node in order:
+ i = order.index(self.cursor_node)
+ if i > 0:
+ self.cursor_node = order[i - 1]
+ self.cursor_row = max(len(self._rows(self.cursor_node)) - 1, 0)
+ self._moved()
+
+ def action_cursor_left(self) -> None:
+ self.cursor_x = max(0, self.cursor_x - 1)
+ self._moved()
+
+ def action_cursor_right(self) -> None:
+ self.cursor_x = min(len(self._line_plain()), self.cursor_x + 1)
+ self._moved()
+
+ def action_col_home(self) -> None:
+ self.cursor_x = 0
+ self._moved()
+
+ def action_col_end(self) -> None:
+ self.cursor_x = max(len(self._line_plain()) - 1, 0)
+ self._moved()
+
+ def _hop(self, table: dict) -> None:
+ tgt = table.get(self.cursor_node) or []
+ if not tgt:
+ self.app._status("no edge that way")
+ return
+ self.cursor_node = tgt[0][0]
+ self.cursor_row = 0
+ self._moved()
+
+ def action_succ_block(self) -> None:
+ if self.lay:
+ self._hop(self.lay.succ)
+
+ def action_pred_block(self) -> None:
+ if self.lay:
+ self._hop(self.lay.pred)
+
+ def _step_order(self, delta: int) -> None:
+ order = self._order()
+ if not order:
+ return
+ i = order.index(self.cursor_node) if self.cursor_node in order else 0
+ self.cursor_node = order[max(0, min(len(order) - 1, i + delta))]
+ self.cursor_row = 0
+ self._moved()
+
+ def action_next_block(self) -> None:
+ self._step_order(1)
+
+ def action_prev_block(self) -> None:
+ self._step_order(-1)
+
+ def action_goto_entry(self) -> None:
+ if self.fc:
+ self.cursor_node = self.fc.entry
+ self.cursor_row = 0
+ self._moved()
+ self._center_cursor()
+
+ def action_pan(self, rows: int) -> None:
+ self.scroll_to(y=max(0, self.scroll_offset.y + rows), animate=False)
+
+ def action_zoom(self) -> None:
+ self._zoom = (self._zoom + 1) % len(self.ZOOMS)
+ self._relayout()
+ self._clamp_cursor()
+ self._center_cursor()
+ self.refresh(layout=True)
+ self.app._graph_status() # keeps the function name; names the zoom
+
+ def action_minimap(self) -> None:
+ self._show_minimap = not self._show_minimap
+ self.refresh()
+ self.app._status(f"graph: minimap {'on' if self._show_minimap else 'off'}")
+
+ def action_center(self) -> None:
+ self._center_cursor()
+ self.refresh()
+
+ def action_copy_line(self) -> None:
+ plain = self._line_plain()
+ if not plain:
+ return
+ n = self.app._copy(plain)
+ self.app._status(f"copied line ({n} chars) to clipboard")
+
+ def goto_ea(self, ea: int) -> bool:
+ """Put the cursor on ``ea`` if it lives in this graph."""
+ if not self.fc:
+ return False
+ b = self.fc.block_at(ea)
+ if b is None:
+ return False
+ self.cursor_node = b.id
+ self.cursor_row = 0
+ for i, h in enumerate(self._rows(b.id)):
+ if h is not None and h.ea == ea:
+ self.cursor_row = i
+ break
+ self.cursor_x = 0
+ self._clamp_cursor()
+ self._center_cursor()
+ self.refresh()
+ return True
+
+ # -- scrolling -------------------------------------------------------- #
+ def _cursor_cell(self) -> tuple[int, int] | None:
+ n = self._cur_node()
+ if n is None:
+ return None
+ row = n.y + 1 + max(0, min(self.cursor_row, n.h - 3))
+ col = n.x + 1 + _GPAD + self.cursor_x
+ return (row, col)
+
+ def _scroll_to_cursor(self) -> None:
+ cell = self._cursor_cell()
+ if cell is None:
+ return
+ row, col = cell
+ w, h = self.size.width, self.size.height
+ if w <= 0 or h <= 0:
+ return
+ y, x = self.scroll_offset.y, self.scroll_offset.x
+ if row < y:
+ y = row
+ elif row >= y + h:
+ y = row - h + 1
+ if col < x + 2:
+ x = max(0, col - 2)
+ elif col >= x + w - 2:
+ x = col - w + 3
+ if (y, x) != (self.scroll_offset.y, self.scroll_offset.x):
+ self.scroll_to(y=max(0, y), x=max(0, x), animate=False)
+
+ def _center_cursor(self) -> None:
+ n = self._cur_node()
+ if n is None or self.size.width <= 0:
+ return
+ y = max(0, n.y - max(self.size.height // 2 - n.h // 2, 0))
+ x = max(0, int(n.cx) - self.size.width // 2)
+ self.scroll_to(y=y, x=x, animate=False)
+ # Setting virtual_size then scrolling immediately clamps to 0 (max_scroll
+ # isn't recomputed until layout), so apply it again after the refresh.
+ def _again() -> None:
+ self.scroll_to(y=y, x=x, animate=False)
+ self.call_after_refresh(_again)
+
+ # -- mouse ------------------------------------------------------------ #
+ def on_mouse_down(self, event) -> None: # type: ignore[no-untyped-def]
+ off = event.get_content_offset(self)
+ if off is None:
+ return
+ self._drag = (off.x, off.y, self.scroll_offset.x, self.scroll_offset.y)
+
+ def on_mouse_up(self, event) -> None: # type: ignore[no-untyped-def]
+ self._drag = None
+
+ def on_mouse_move(self, event) -> None: # type: ignore[no-untyped-def]
+ if self._drag is None or not event.button:
+ return
+ off = event.get_content_offset(self)
+ if off is None:
+ return
+ x0, y0, sx, sy = self._drag
+ self.scroll_to(x=max(0, sx + (x0 - off.x)), y=max(0, sy + (y0 - off.y)),
+ animate=False)
+
+ def on_click(self, event) -> None: # type: ignore[no-untyped-def]
+ if self.lay is None:
+ return
+ off = event.get_content_offset(self)
+ if off is None:
+ return
+ row = off.y + int(self.scroll_offset.y)
+ col = off.x + int(self.scroll_offset.x)
+ n = self.lay.node_at(row, col)
+ if n is None or n.block is None:
+ return
+ self.focus()
+ self.cursor_node = n.id
+ self.cursor_row = max(0, min(row - n.y - 1,
+ max(len(self._rows(n.id)) - 1, 0)))
+ self.cursor_x = max(0, col - n.x - 1 - _GPAD)
+ self._clamp_cursor()
+ self.refresh()
+ self.post_message(self.CursorMoved(self._cursor_ea(), self.cursor_node))
+ if getattr(event, "chain", 1) >= 2:
+ self.post_message(FollowRequested(self))
+
+ # -- rendering -------------------------------------------------------- #
+ def _edge_styles(self) -> tuple[dict, set]:
+ hot = self.lay.incident.get(self.cursor_node, set()) if self.lay else set()
+ return (_S_EDGE, hot)
+
+ def render_line(self, y: int) -> Strip:
+ width = self.size.width
+ if self.lay is None or not self.lay.nodes:
+ return Strip([Segment("".ljust(width), _S_GDIM)])
+ row = int(self.scroll_offset.y) + y
+ col0 = int(self.scroll_offset.x)
+ out = _CellRow(width, _S_INSN)
+ base, hot = self._edge_styles()
+
+ # 1. edge cells (an index query, never a painted canvas)
+ for col, (ch, kind, eid) in self.lay.painting.cells_at_row(
+ row, col0, col0 + width).items():
+ st = (_S_EDGE_HOT if eid in hot else base).get(kind, _S_GDIM)
+ out.put(col - col0, ch, st)
+
+ # 2. boxes covering this row (they win over edges: nothing routes inside)
+ for n in self.lay.nodes_at_row(row):
+ self._draw_node_row(out, n, row, col0)
+
+ # 3. minimap, last, over everything
+ if self._show_minimap:
+ self._draw_minimap_row(out, y, width)
+ return out.strip().adjust_cell_length(width, _S_INSN)
+
+ def _draw_node_row(self, out: _CellRow, n: graph.Node, row: int,
+ col0: int) -> None:
+ cur = n.id == self.cursor_node
+ bs = _S_GBORDER_CUR if cur else _S_GBORDER
+ left = n.x - col0
+ w = n.w
+ b = self._blocks.get(n.id)
+ if row == n.y:
+ # top border carries the label: ┌─ loc_1234 ─────┐
+ label = f"loc_{n.block.start:X}" if n.block else ""
+ if b is not None and b.rows and b.rows[0].name:
+ label = b.rows[0].name
+ out.put(left, graph.BOX["tl"], bs)
+ for i in range(1, w - 1):
+ out.put(left + i, graph.BOX["h"], bs)
+ out.put(left + w - 1, graph.BOX["tr"], bs)
+ tag = f" {label} "
+ if len(tag) <= w - 4:
+ st = _S_GENTRY if (self.fc and n.id == self.fc.entry) else (
+ _S_GLABEL_CUR if cur else _S_GLABEL)
+ out.text(left + 2, tag, st)
+ if n.block is not None and n.block.selfloop:
+ out.put(left + w - 2, "↺", _S_EDGE[graph.E_BACK])
+ return
+ if row == n.y + n.h - 1:
+ out.put(left, graph.BOX["bl"], bs)
+ for i in range(1, w - 1):
+ out.put(left + i, graph.BOX["h"], bs)
+ out.put(left + w - 1, graph.BOX["br"], bs)
+ return
+ out.put(left, graph.BOX["v"], bs)
+ out.put(left + w - 1, graph.BOX["v"], bs)
+ for i in range(1, w - 1):
+ out.put(left + i, " ", _S_INSN)
+ i = row - n.y - 1
+ rows = self._rows(n.id)
+ if not (0 <= i < len(rows)):
+ return
+ text_col = left + 1 + _GPAD
+ h = rows[i]
+ plain = self._row_plain(n.id, i)
+ if h is None: # collapsed summary
+ out.text(text_col, plain, _S_GDIM)
+ else:
+ c = text_col
+ if self._zoom == 0:
+ out.text(c, f"{h.ea:08X} ", _S_ADDR)
+ c += 10
+ if h.name:
+ out.text(c, f"{h.name} ", _S_LABEL)
+ c += len(h.name) + 2
+ fallback = {"code": _S_INSN, "data": _S_DATA}.get(h.kind, _S_UNK)
+ if h.spans:
+ for kind, t in h.spans:
+ out.text(c, t, _S_SPAN.get(kind, fallback))
+ c += len(t)
+ else:
+ out.text(c, h.text, fallback)
+ inner_a, inner_b = left + 1, left + w - 1
+ # execution trail (a loaded Tenet trace), same palette as the listing
+ if self.trail is not None and h is not None:
+ k = self.trail.get(h.ea)
+ if k is not None:
+ out.restyle(inner_a, inner_b,
+ _S_TRAIL_NOW if k == "now" else
+ _S_TRAIL_PAST if k == "past" else _S_TRAIL_FUTURE)
+ if self._hl_word and plain:
+ for a, bb in _word_occurrences(plain, self._hl_word):
+ out.restyle(text_col + a, text_col + bb, _S_WORD)
+ if cur and i == self.cursor_row:
+ out.restyle(inner_a, inner_b, _S_CURSOR)
+ x = max(0, min(self.cursor_x, max(len(plain) - 1, 0)))
+ wa, wb = _word_bounds(plain, x)
+ if wb > wa:
+ out.restyle(text_col + wa, text_col + wb, _S_WORD)
+ if self.has_focus:
+ out.restyle(text_col + x, text_col + x + 1, _S_CELL)
+
+ # -- minimap ---------------------------------------------------------- #
+ def _minimap(self) -> list[list[int]]:
+ """A coarse occupancy grid of the whole graph: 0 empty, 1 edge, 2 block,
+ 3 the cursor's block. Cached per (layout, zoom, cursor block)."""
+ key = (id(self.lay), self._zoom, self.cursor_node)
+ if self._mini_cache and self._mini_cache[0] == key:
+ return self._mini_cache[1]
+ gw, gh = _MINI_W - 2, _MINI_H - 2
+ grid = [[0] * gw for _ in range(gh)]
+ lay = self.lay
+ if lay is not None and lay.width and lay.height:
+ sx = max(lay.width / gw, 1e-9)
+ sy = max(lay.height / gh, 1e-9)
+ for lo, hi, col, _kind, _eid in lay.painting.vruns:
+ c = min(int(col / sx), gw - 1)
+ for r in range(min(int(lo / sy), gh - 1),
+ min(int(hi / sy), gh - 1) + 1):
+ if not grid[r][c]:
+ grid[r][c] = 1
+ for n in lay.nodes:
+ mark = 3 if n.id == self.cursor_node else 2
+ r0, r1 = int(n.y / sy), int((n.y + n.h - 1) / sy)
+ c0, c1 = int(n.x / sx), int(n.right / sx)
+ for r in range(max(r0, 0), min(r1, gh - 1) + 1):
+ for c in range(max(c0, 0), min(c1, gw - 1) + 1):
+ if grid[r][c] < mark:
+ grid[r][c] = mark
+ self._mini_cache = (key, grid)
+ return grid
+
+ def _draw_minimap_row(self, out: _CellRow, y: int, width: int) -> None:
+ if width < _MINI_W + 10 or self.size.height < _MINI_H + 2 or self.lay is None:
+ return
+ if not (0 <= y < _MINI_H):
+ return
+ # Inset by one: a ScrollView paints its vertical scrollbar over the last
+ # column, which otherwise eats the minimap's right border.
+ left = width - _MINI_W - 2
+ grid = self._minimap()
+ gw, gh = _MINI_W - 2, _MINI_H - 2
+ lay = self.lay
+ sx = max(lay.width / gw, 1e-9)
+ sy = max(lay.height / gh, 1e-9)
+ vy0 = int(self.scroll_offset.y / sy)
+ vy1 = int((self.scroll_offset.y + self.size.height) / sy)
+ vx0 = int(self.scroll_offset.x / sx)
+ vx1 = int((self.scroll_offset.x + width) / sx)
+
+ if y == 0:
+ out.put(left, graph.BOX["tl"], _S_MINI_BG)
+ for i in range(1, _MINI_W - 1):
+ out.put(left + i, graph.BOX["h"], _S_MINI_BG)
+ out.put(left + _MINI_W - 1, graph.BOX["tr"], _S_MINI_BG)
+ tag = f" {len(lay.nodes)} blocks "
+ out.text(left + 2, tag, _S_MINI_BG)
+ return
+ if y == _MINI_H - 1:
+ out.put(left, graph.BOX["bl"], _S_MINI_BG)
+ for i in range(1, _MINI_W - 1):
+ out.put(left + i, graph.BOX["h"], _S_MINI_BG)
+ out.put(left + _MINI_W - 1, graph.BOX["br"], _S_MINI_BG)
+ return
+ r = y - 1
+ out.put(left, graph.BOX["v"], _S_MINI_BG)
+ out.put(left + _MINI_W - 1, graph.BOX["v"], _S_MINI_BG)
+ for c in range(gw):
+ v = grid[r][c] if r < len(grid) else 0
+ inview = vy0 <= r <= vy1 and vx0 <= c <= vx1
+ if v == 3:
+ ch, st = "█", _S_MINI_CUR
+ elif v == 2:
+ ch, st = "█", _S_MINI_NODE
+ elif v == 1:
+ ch, st = "·", _S_MINI_EDGE
+ else:
+ ch, st = " ", _S_MINI_BG
+ if inview and v != 3:
+ st = _S_MINI_VIEW if v == 0 else st + Style(bgcolor="#233044")
+ out.put(left + 1 + c, ch, st)
+
+
+# --------------------------------------------------------------------------- #
# Function list panel
# --------------------------------------------------------------------------- #
class FunctionsPanel(Vertical):
@@ -2292,6 +2972,7 @@ _HELP = (
)),
("Views", (
("Tab / F5", "disassembly \u21c4 pseudocode"),
+ ("Space", "control-flow graph \u21c4 text"),
("s", "split view: listing + pseudocode"),
("Tab", "in split: switch the driving pane"),
("\\", "hex view"),
@@ -2330,6 +3011,18 @@ _HELP = (
("F1", "this cheatsheet"),
("q", "quit"),
)),
+ ("Graph (Space)", (
+ ("j / k", "line up/down, crossing blocks"),
+ ("h / l", "column left / right"),
+ ("J / K", "follow an edge to a successor / predecessor"),
+ ("w / b", "next / previous block in layout order"),
+ ("0", "jump to the entry block"),
+ ("z", "zoom: full \u2192 compact \u2192 collapsed"),
+ ("m", "show/hide the minimap"),
+ ("f", "centre on the current block"),
+ ("Enter", "follow (stays in the graph if it lands here)"),
+ ("drag / click", "pan / put the cursor in a block"),
+ )),
)
@@ -3291,6 +3984,14 @@ class IdaCommands(Provider):
("Hex view", "raw bytes at the cursor (\\)", app.action_hex),
("Split view (listing ⇄ pseudocode)",
"side-by-side synced views (s)", app.action_toggle_split),
+ ("Graph view (control flow)",
+ "the function's basic blocks as a graph (Space)",
+ app.action_toggle_graph),
+ ("Graph: cycle zoom",
+ "full → compact → collapsed (z, in the graph)",
+ lambda: va("zoom")),
+ ("Graph: toggle minimap",
+ "the overview box (m, in the graph)", lambda: va("minimap")),
("Rename symbol…", "rename the symbol under the cursor (n)",
lambda: va("rename")),
("Set type / prototype…", "retype the symbol under the cursor (y)",
@@ -3354,6 +4055,7 @@ class IdaTui(App):
ListingView { width: 1fr; padding: 0 1; }
#panes.split ListingView { border-right: tall $panel-lighten-2; }
HexView { width: 1fr; padding: 0 1; }
+ GraphView { width: 1fr; padding: 0 1; }
#search {
height: 1; border: none; padding: 0 1;
background: $primary-darken-2; color: $text;
@@ -3461,6 +4163,9 @@ class IdaTui(App):
Binding("ctrl+t", "structs", "Structs"),
Binding("backslash", "hex", "Hex"),
Binding("s", "toggle_split", "Split", show=False),
+ # IDA's own key for text/graph. Graph mode is opt-in and self-contained:
+ # with it off nothing else in the app does any extra work.
+ Binding("space", "toggle_graph", "Graph", show=False),
Binding("quotation_mark,shift+f12", "strings", "Strings", show=False),
Binding("ctrl+o", "switch_binary", "Binaries", show=False),
Binding("ctrl+l", "load_options", "Reload as…", show=False),
@@ -3552,6 +4257,7 @@ class IdaTui(App):
# _pref, but it was only ever assigned "listing" — see _code_mode().
self._active = "listing" # currently shown view (in split: the focused pane)
self._split = False # side-by-side listing + pseudocode
+ self._graph_sticky = False # stay in graph mode across navigations
self._split_eamap: list[list[int]] = [] # split: decomp line -> instr EAs
self._split_ea2line: dict[int, int] = {} # split: instr EA -> decomp line
self._split_range: tuple[int, int] | None = None # decomp'd fn ea span
@@ -3586,6 +4292,9 @@ class IdaTui(App):
hx = HexView()
hx.display = False
yield hx
+ gv = GraphView()
+ gv.display = False
+ yield gv
# Docked right and only shown once a trace is loaded, so a normal
# session looks exactly as it did.
td = TraceDock()
@@ -4313,8 +5022,7 @@ class IdaTui(App):
left = self.query_one("#left", FunctionsPanel)
left.display = not left.display
if not left.display:
- self.query_one(DecompView if self._active == "decomp"
- else ListingView).focus()
+ self._focus_code_view()
else:
self.query_one("#func-table", DataTable).focus()
@@ -4609,6 +5317,24 @@ class IdaTui(App):
self._active = self._code_mode()
self._show_active()
return
+ if self._active == "graph":
+ # F5/Tab out of the graph lands in the pseudocode at the block the
+ # cursor was on (Space is the key that returns to the listing).
+ gv = self.query_one(GraphView)
+ ea = gv._cursor_ea()
+ self._graph_sticky = False
+ if ea is None:
+ self._active = "listing"
+ self._show_active()
+ return
+ gv.display = False
+ dec = self.query_one(DecompView)
+ dec.display = True
+ dec.loading = True
+ dec.focus()
+ self._status("decompiling…")
+ self._decomp_from_listing(ea)
+ return
if self._active == "listing":
# F5/Tab in the continuous listing: decompile the function under the
# cursor (IDA-style), if the cursor is inside a defined routine.
@@ -4722,6 +5448,108 @@ class IdaTui(App):
else:
self._show_active()
+ # -- graph mode -------------------------------------------------------- #
+ #: Above this, a CFG graph stops being a picture and becomes a hairball --
+ #: IDA's own is unreadable at this size too. Refusing beats rendering soup.
+ GRAPH_MAX_BLOCKS = 400
+
+ def action_toggle_graph(self) -> None:
+ """Space: swap the code view for the function's control-flow graph."""
+ if self._prompt_active():
+ return
+ if self._active == "graph":
+ self._graph_sticky = False
+ self._active = self._code_mode()
+ self._show_active()
+ return
+ if self._cur is None or self.program is None:
+ self._status("open a function first")
+ return
+ self._graph_sticky = True
+ gv = self.query_one(GraphView)
+ ea = self._graph_target_ea()
+ if gv.loaded_ea is not None and gv.fc is not None \
+ and gv.fc.func_ea == self._cur.ea:
+ self._active = "graph"
+ self._split = False
+ self._show_active()
+ if ea is not None:
+ gv.goto_ea(ea)
+ self._graph_status()
+ return
+ self._status(f"{self._cur.name} — building graph…")
+ self._load_graph(self._cur.ea, ea)
+
+ def _graph_target_ea(self) -> int | None:
+ """The address the graph should land on: wherever the code view's cursor
+ is, so Space doesn't lose your place."""
+ try:
+ if self._active in ("listing", "disasm"):
+ return self.query_one(ListingView)._cursor_ea()
+ if self._active == "decomp":
+ dv = self.query_one(DecompView)
+ return dv._line_ea(dv.cursor)
+ except Exception: # noqa: BLE001
+ pass
+ return self._cur.ea if self._cur else None
+
+ @work(thread=True, exclusive=True, group="graph")
+ def _load_graph(self, func_ea: int, want_ea: int | None) -> None:
+ assert self.program is not None
+ err = ""
+ fc = None
+ try:
+ fc = self.program.flowchart(func_ea)
+ except IDAConnectionError:
+ raise
+ except Exception as e: # noqa: BLE001
+ err = f"{type(e).__name__}: {e}"
+ self.app.call_from_thread(self._apply_graph, func_ea, want_ea, fc, err)
+
+ def _apply_graph(self, func_ea: int, want_ea: int | None, fc, # type: ignore[no-untyped-def]
+ err: str) -> None:
+ if self._cur is None or self._cur.ea != func_ea:
+ return # a newer navigation won
+ if not self._graph_sticky and self._active != "graph":
+ # The load lost its race: the user has since left graph mode (or a
+ # rename's reload queued one behind their back). Forcing the view
+ # here drags them back into a graph they already dismissed.
+ return
+ if fc is None:
+ self._status(err or "no control-flow graph for this function "
+ "(is it a thunk or an import?)")
+ return
+ if len(fc.blocks) > self.GRAPH_MAX_BLOCKS:
+ self._status(
+ f"{fc.name}: {len(fc.blocks)} blocks — too many to graph "
+ f"(limit {self.GRAPH_MAX_BLOCKS}); staying in the listing")
+ return
+ gv = self.query_one(GraphView)
+ gv.set_graph(fc, want_ea)
+ self._active = "graph"
+ self._split = False
+ self._show_active()
+ self._graph_status()
+
+ def _graph_status(self) -> None:
+ gv = self.query_one(GraphView)
+ if gv.lay is None or gv.fc is None:
+ return
+ s = gv.lay.stats
+ loops = f", {s['back']} loop{'s' if s['back'] != 1 else ''}" if s["back"] else ""
+ self._status(
+ f"{gv.fc.name} @ {gv.fc.func_ea:#x} [graph: {s['blocks']} blocks, "
+ f"{s['edges']} edges{loops}] "
+ f"z=zoom({gv.ZOOMS[gv._zoom]}) m=map J/K=edge space=text")
+
+ def on_graph_view_cursor_moved(self, msg: "GraphView.CursorMoved") -> None:
+ gv = self.query_one(GraphView)
+ gv.set_highlight(gv.word_under_cursor())
+ if msg.ea is not None and gv.fc is not None:
+ b = gv.fc.block_at(msg.ea)
+ extra = f" block {b.start:#x}" if b else ""
+ self._status(f"{gv.fc.name} @ {msg.ea:#x}{extra} [graph]")
+
@work(thread=True, group="split")
def _enter_split(self, ea: int, name: str) -> None:
# Load the listing for the current function (bg) then reveal both panes.
@@ -4751,6 +5579,8 @@ class IdaTui(App):
return
if self._active in ("listing", "disasm"):
ea = self.query_one(ListingView)._cursor_ea()
+ elif self._active == "graph":
+ ea = self.query_one(GraphView)._cursor_ea()
else:
dec = self.query_one(DecompView)
ea = dec._line_ea(dec.cursor)
@@ -4804,8 +5634,7 @@ class IdaTui(App):
elif self.query_one("#left", FunctionsPanel).display:
table.focus()
else:
- self.query_one(DecompView if self._active == "decomp"
- else ListingView).focus()
+ self._focus_code_view()
# -- input submit (filter / goto) ------------------------------------- #
def on_search_requested(self, msg: SearchRequested) -> None:
@@ -4823,6 +5652,19 @@ class IdaTui(App):
def on_follow_requested(self, msg: FollowRequested) -> None:
view = msg.view
word = view.word_under_cursor()
+ if isinstance(view, GraphView):
+ ea = view._cursor_ea()
+ if ea is None:
+ return
+ # Inside the graph, a jump to a block of THIS function should move
+ # the cursor, not navigate away and rebuild the whole picture.
+ tgt = self._graph_local_target(view, ea, word)
+ if tgt is not None:
+ view.goto_ea(tgt)
+ self.post_message(GraphView.CursorMoved(tgt, view.cursor_node))
+ return
+ self._follow_disasm(ea, word, view._next_ea())
+ return
if isinstance(view, ListingView):
ea = view._cursor_ea()
if ea is not None:
@@ -4834,11 +5676,39 @@ class IdaTui(App):
self._follow_decomp(view._texts[view.cursor], word,
view._line_ea(view.cursor))
+ def _graph_local_target(self, view: "GraphView", ea: int,
+ word: str) -> int | None:
+ """If the cursor's instruction branches somewhere inside this same
+ graph, return that address."""
+ if view.fc is None or self.program is None:
+ return None
+ try:
+ if word and self._looks_like_symbol(word):
+ t = self.program.resolve(word)
+ if t and view.fc.block_at(t) is not None:
+ return t
+ except Exception: # noqa: BLE001
+ pass
+ try:
+ for xr in self.program.xrefs_from(ea):
+ t = xr.to
+ if t and t != view._next_ea() and view.fc.block_at(t) is not None:
+ return t
+ except Exception: # noqa: BLE001
+ pass
+ return None
+
def on_xrefs_requested(self, msg: XrefsRequested) -> None:
if self._xref_active: # one gather at a time; ignore a second 'x'
return
view = msg.view
word = view.word_under_cursor()
+ if isinstance(view, GraphView):
+ ea = view._cursor_ea()
+ if ea is not None:
+ self._push_busy("finding xrefs…")
+ self._xrefs_disasm(ea, word, ea, view._next_ea())
+ return
if isinstance(view, ListingView):
ea = view._cursor_ea()
if ea is not None:
@@ -5173,7 +6043,7 @@ class IdaTui(App):
# label), not a symbol-by-name. This is what lets you name a bare/
# undefined byte — e.g. the free byte at addr+1 after shrinking a u16 to
# a u8 — which the word-under-cursor path can't do (no symbol to rename).
- if isinstance(msg.view, ListingView):
+ if isinstance(msg.view, (ListingView, GraphView)):
ea = msg.view._cursor_ea()
if ea is None:
self._status("no address on this line to name")
@@ -6551,7 +7421,7 @@ class IdaTui(App):
event.prevent_default()
self._end_goto()
(self.query_one(HexView) if self._active == "hex"
- else self._code_view()).focus()
+ else (self._code_view() or self.query_one(ListingView))).focus()
return
fi = self.query_one("#func-filter", Input)
if fi.display:
@@ -6608,7 +7478,7 @@ class IdaTui(App):
if inp.id == "goto":
self._end_goto()
(self.query_one(HexView) if self._active == "hex"
- else self._code_view()).focus()
+ else (self._code_view() or self.query_one(ListingView))).focus()
if value:
self._goto(value)
return
@@ -6625,13 +7495,25 @@ class IdaTui(App):
return self._active_code_view()
def _active_code_view(self): # type: ignore[no-untyped-def]
- """The currently-shown code widget (for reading the cursor address)."""
+ """The currently-shown code widget (for reading the cursor address).
+
+ Everything downstream trusts ``_active``, so a new mode that forgets to
+ appear here doesn't degrade -- it returns None and the first caller that
+ dereferences it crashes the app (which is exactly how graph mode
+ announced itself the first time it was driven).
+ """
if self._active in ("listing", "disasm"):
return self.query_one(ListingView)
if self._active == "decomp":
return self.query_one(DecompView)
+ if self._active == "graph":
+ return self.query_one(GraphView)
return None
+ def _focus_code_view(self) -> None:
+ """Put focus back on whichever code view is showing."""
+ (self._active_code_view() or self.query_one(ListingView)).focus()
+
def _palette_action(self, name: str, *args) -> None:
"""Run a cursor-scoped code-view action (rename/xrefs/follow/…) picked from
the command palette against the active code view."""
@@ -6683,6 +7565,13 @@ class IdaTui(App):
if self._active == "hex":
self.app.call_from_thread(self._hex_goto, ea)
return
+ # A goto that lands inside the graph you're already looking at should
+ # move the cursor, not tear the picture down and build the same one.
+ if self._active == "graph":
+ gv = self.query_one(GraphView)
+ if gv.fc is not None and gv.fc.block_at(ea) is not None:
+ self.app.call_from_thread(gv.goto_ea, ea)
+ return
# Navigate to the containing function at the right line (handles both a
# function name and a mid-function address).
self._do_navigate(ea, push=True)
@@ -6769,6 +7658,11 @@ class IdaTui(App):
cursor_x=entry.cursor_x, scroll_y=sy, focus=focus)
self._active = "listing"
self._show_active()
+ # Graph mode is sticky: following a call from the graph should land in
+ # the callee's graph, not dump you back into the listing. The rebuild is
+ # async and re-checks _cur, so a fast second jump just drops the stale one.
+ if self._graph_sticky and entry.ea:
+ self._load_graph(entry.ea, entry.ea)
# Prompt overlays that own the keyboard while visible; a background
# navigation must not yank focus out from under them (else typed keys leak
@@ -6788,12 +7682,13 @@ class IdaTui(App):
dec = self.query_one(DecompView)
lst = self.query_one(ListingView)
hx = self.query_one(HexView)
+ gv = self.query_one(GraphView)
# Don't steal focus from an open prompt (search/rename/…) — a late async
# navigation completing here would otherwise pull it into the code view.
grab = not self._prompt_active()
if self._split and self._active in ("listing", "decomp"):
# Side-by-side: listing (left) + pseudocode (right), one focused.
- hx.display = False
+ hx.display = gv.display = False
lst.display = dec.display = True
self.query_one("#panes").set_class(True, "split")
busy = self._cur is not None and dec.loaded_ea != self._cur.ea
@@ -6819,7 +7714,13 @@ class IdaTui(App):
self._split_eamap = []
self._split_ea2line = {}
self._split_range = None
- dec.display = lst.display = hx.display = False
+ dec.display = lst.display = hx.display = gv.display = False
+ if self._active == "graph":
+ gv.display = True
+ if grab:
+ gv.focus()
+ self._graph_status()
+ return
if self._active in ("listing", "disasm"):
lst.display = True
if grab: