aboutsummaryrefslogtreecommitdiffstats

Graph view — the function's control flow, in character cells

Space swaps the code view for an IDA-style basic-block graph of the current function. Space again returns to the text. It is opt-in, off by default, and lives in its own module: with graph mode off nothing else in the app does any extra work.

                    ┌─ sub_61D0 ──────────────────────┐
                     000061D0  sub_61D0  endbr64             ┌─ 9 blocks ──────┐
                     000061D4  push rbp                          █████████    
                     ...                                        ·███████████  
                     00006200  jmp short loc_622C              ████·███████   
                    └─────────────────┬───────────────┘        └─────────────────┘
                                      
                   ┌─ loc_622C ───────▼─────────────────┐
                    0000622C  loc_622C  mov eax, [rcx] 
                    00006231  jbe short loc_6208       
                   └──────────┬─────────────┬───────────┘
                    ╭─────────╯             ╰──────────╮

Keys

key
Space graph ⇄ text
j k line up/down, crossing into the next/previous block
h l column left / right
J K follow an edge to a successor / predecessor block
w b next / previous block in layout order
0 jump to the entry block
z zoom: full → compact → collapsed
m show / hide the minimap
f centre on the current block
Enter follow — stays in the graph when the target is a block of this function
x n y ; xrefs / rename / retype / comment, exactly as in the listing
Tab leave for the pseudocode of the block you're on
mouse drag to pan, click to place the cursor, double-click to follow
minimap click to jump the view there, drag to scrub

Graph mode is sticky: following a call from the graph lands in the callee's graph rather than dumping you back in the listing.

Why the boxes are cheap

A node's body is the same Head rows the listing renders — fetched with the same heads tool, carrying IDA's own colour tags, names and operand text. That is the whole reason this feature is a few hundred lines instead of a rewrite: syntax highlighting, the word-under-cursor highlight, the execution trail and every editing verb work inside a box because they are working on listing rows. Growing a second disassembly renderer for graph mode would have been the real cost.

The backend adds exactly one tool, flowchart(addr) in server/patch_server.py, which returns block ranges and typed edges — not text.

Layout (idatui/graph.py)

Pure python: no IDA, no Textual, no I/O, so it is unit-tested offline in milliseconds (tests/test_graph.py, which needs no worker). Textbook Sugiyama, the same shape IDA's own graph uses:

step what notes
1 break cycles DFS gray-set; back edges reversed for layout only
2 layer longest-path ranking
3 dummies a k-layer edge becomes k−1 dummy nodes
4 order median sweeps + adjacent transposition
5 x-coords priority/median sweeps, variable node widths
6 route ports per border, one lane-packed channel per layer gap

Step 3 is what makes step 6 tractable: because every long edge occupies real horizontal space as dummy nodes, no edge ever has to cross a box. That is measured, not hoped — tests/test_graph.py counts edge cells landing inside a box across a 128-function corpus and requires 0.

Edges are coloured by IDA's convention: green = branch taken, red = falls through, blue = the block's only successor, purple = loops back, amber = one arm of an n-way switch. The edges touching the block under the cursor are brightened. A self-loop is a on the block's top border rather than an edge.

Two traps worth remembering

  • Self-loops deadlock the Kahn ranking. A block that jumps to itself never drains its own in-degree, so ranking stalls and every downstream block stays at rank 0 — the graph collapses into three layers and comes out 280 columns wide. They are dropped from the layout graph and drawn as a marker. _assign_ranks also force-releases the most-constrained survivor if the queue ever drains early, so a residual cycle degrades instead of exploding.
  • Crossing minimisation is where the time goes. The naive transposition pass recounts crossings globally per candidate swap: O(n³), which made a 424-block function take 20.4 seconds. Counting inversions with a Fenwick tree and computing only the local O(deg(a)·deg(b)) delta per swap took the same function to 152 ms, and the whole 128-function corpus from 21 s to 224 ms.

Rendering

Nothing is pre-painted. A 424-block function lays out to ~13M cells, so graph.Painting is an index — per-row horizontal runs, a bucketed interval index of vertical runs, and point marks — and GraphView.render_line(y) asks it for one row at a time, exactly like ListingView. Cost per frame is proportional to the viewport, not the graph.

Three zoom levels (z) trade detail for shape: full (address gutter + instructions), compact (instructions only), collapsed (one summary row per block). On main (87 blocks) that is a 1378×518 canvas down to 545×289.

The minimap (m) is a coarse occupancy grid of the whole graph with the viewport marked, drawn top-right and inset two columns — a ScrollView paints its scrollbar over the last column, which otherwise eats the minimap's border. Clicking it snaps to the nearest block and takes the cursor with it; dragging scrubs from block to block. It deliberately does not scroll to the coordinate you clicked: blocks cover only a few percent of a laid-out graph (4.6% on an 87-block function, under 1% on a 424-block one) and the rest is the padding that keeps edges apart, so a coordinate-accurate jump parks you in empty space with the cursor left behind. For the same reason, a drag-pan or a ctrl+d/pageup that ends with no block on screen at all eases to the nearest one — only when nothing is visible, so it never fights a deliberate pan. Because it floats over the canvas rather than living in it, on_click has to test the minimap's hit-box before translating the click into canvas coordinates — otherwise a click on the overview reads as a click on whatever block happens to lie underneath it. _minimap_rect() is the single source of truth for both the drawing and the hit-test.

Limits

Above 400 blocks the graph is refused with a message and you stay in the listing. A CFG that size is not a picture anyone can read — IDA's own is a hairball there too (1853 crossings on the worst function in targets/echo). This is a feature, not a shortcoming.

Known cosmetic gap: a back edge leaves its tail's top border () and arrows up into the head's bottom (). Correct and readable, but IDA runs loop edges around the side of the graph.

Driving it

graph is an RPC verb (see docs/RPC.md), and it reports structure, not box drawing characters — a driver wants blocks and edges, not glyphs:

drive raw graph action=open              # Space
drive raw graph action=show              # blocks, edges, ranks, cursor
drive raw graph action=block target=0x6250
drive raw graph action=succ              # J
drive raw graph action=zoom

Offline tools