aboutsummaryrefslogtreecommitdiffstats
path: root/idatui/app.py
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-09 12:59:18 +0200
committeruser <user@clank>2026-08-09 12:59:18 +0200
commit1cf127f5c1cc5d7862385df14b5c49ba028ade7c (patch)
treee36114b4c5ce315f2d5b89e633abb40de02ed550 /idatui/app.py
parentsplash: scale the logo to the pane instead of dropping it (diff)
downloadida-tui-1cf127f5c1cc5d7862385df14b5c49ba028ade7c.tar.gz
ida-tui-1cf127f5c1cc5d7862385df14b5c49ba028ade7c.tar.xz
ida-tui-1cf127f5c1cc5d7862385df14b5c49ba028ade7c.zip
Graph: a second layout engine, triskel's SESE decomposition
`e` in graph mode cycles auto -> native -> triskel, and `auto` prefers triskel where it is installed and the function is at most 250 blocks. Why: our layered engine draws wide-and-short pictures with a lot of crossings on anything branchy. Triskel splits the CFG into Single-Entry Single-Exit regions first and lays each out on its own, which on the 128-function corpus means fewer crossings on 12 functions, equal on 9, worse on 3 -- and the wins are the hairballs (sub_5CA0 41 -> 6, sub_2C90 32 -> 7, sub_2C00 12 -> 0). It also routes loop edges around the side of the graph the way IDA does, which was a known gap here. It is not free: ~2x slower at 87 blocks, 10x at 424, hence the cap. The library needed a fork (~/dev/triskel, branch idatui) before it could be used from Python at all -- its get_waypoints() threw on every published version, an empty graph segfaulted the interpreter, and its spacing constants were pixels baked in at compile time. Making those settable is what makes this integration cheap: we hand it CELLS, so its output is integral and two edge lanes can never round onto the same row. The feared quantisation problem measured out backwards -- cells claimed by more than one edge: native 131, triskel 35. Not trusted with degenerate input, all handled before the call: self-loops and disconnected components make it throw, and one corpus edge comes back routed through a block, which we detour and re-verify. A triskel failure is never fatal; it falls back to native. Two things the second engine flushed out of the existing code: - the canvas was sized from boxes alone, which is exact only because native's dummy nodes reserve the space. Triskel routes outside that bounding box and the edges were being clipped. - arrowhead placement read e.back, conflating "this is a loop edge" (style) with "this polyline runs against control flow" (geometry). Now Edge.flipped, which is also a latent fix for residual-cycle edges whose succ/pred were being reported backwards. tests/test_graph.py runs its whole suite once per available engine (943 checks); new graph_engine scenario covers the live toggle.
Diffstat (limited to 'idatui/app.py')
-rw-r--r--idatui/app.py31
1 files changed, 29 insertions, 2 deletions
diff --git a/idatui/app.py b/idatui/app.py
index 8baff74..76aff62 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -2356,6 +2356,7 @@ class GraphView(NavMixin, ScrollView, can_focus=True):
Binding("0", "goto_entry", "Entry", show=False),
Binding("z", "zoom", "Zoom"),
Binding("m", "minimap", "Minimap", show=False),
+ Binding("e", "engine", "Engine", show=False),
Binding("f", "center", "Centre", show=False),
Binding("ctrl+d", "pan(12)", "½↓", show=False),
Binding("ctrl+u", "pan(-12)", "½↑", show=False),
@@ -2388,6 +2389,9 @@ class GraphView(NavMixin, ScrollView, can_focus=True):
self._blocks: dict[int, object] = {}
self._zoom = 0
self._show_minimap = True
+ #: layout backend; "auto" prefers triskel where it is installed and the
+ #: function is small enough for it. Cycled with `e`.
+ self._engine = "auto"
self._mini_cache: tuple | None = None
self._drag: tuple[int, int, float, float] | None = None
self._drag_map = False # the drag started on the minimap
@@ -2423,7 +2427,8 @@ class GraphView(NavMixin, ScrollView, can_focus=True):
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.lay = graph.layout(blocks, self._sizer, entry=self.fc.entry,
+ engine=self._engine)
self.virtual_size = Size(self.lay.width + 2, self.lay.height + 1)
def _rows(self, nid: int):
@@ -2657,6 +2662,26 @@ class GraphView(NavMixin, ScrollView, can_focus=True):
self.refresh()
self.app._status(f"graph: minimap {'on' if self._show_minimap else 'off'}")
+ def action_engine(self) -> None:
+ """Cycle the layout engine and redraw the same function with it.
+
+ The two engines disagree about shape more than about correctness --
+ native draws wide and short, triskel narrow and tall with far fewer
+ crossings -- and which one reads better genuinely depends on the
+ function. Cheaper to look than to argue.
+ """
+ from . import graph_triskel
+ choices = ["auto", "native"] + (["triskel"] if graph_triskel.available()
+ else [])
+ self._engine = choices[(choices.index(self._engine) + 1) % len(choices)]
+ self._relayout()
+ self._clamp_cursor()
+ self._center_cursor()
+ self.refresh(layout=True)
+ got = self.lay.stats["engine"] if self.lay else "?"
+ note = "" if graph_triskel.available() else " (pytriskel not installed)"
+ self.app._status(f"graph: engine {self._engine} \u2192 {got}{note}")
+
def action_center(self) -> None:
self._center_cursor()
self.refresh()
@@ -3664,6 +3689,7 @@ _HELP = (
("0", "jump to the entry block"),
("z", "zoom: full \u2192 compact \u2192 collapsed"),
("m", "show/hide the minimap"),
+ ("e", "layout engine: auto \u2192 native \u2192 triskel"),
("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"),
@@ -6502,9 +6528,10 @@ class IdaTui(App):
return
s = gv.lay.stats
loops = f", {s['back']} loop{'s' if s['back'] != 1 else ''}" if s["back"] else ""
+ eng = "" if s.get("engine") == "native" else f", {s.get('engine')}"
self._status(
f"{gv.fc.name} @ {gv.fc.func_ea:#x} [graph: {s['blocks']} blocks, "
- f"{s['edges']} edges{loops}] "
+ f"{s['edges']} edges{loops}{eng}] "
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: