diff options
| -rw-r--r-- | idatui/app.py | 921 |
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: |
