aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-08-06 16:20:29 +0200
committerblasty <blasty@local>2026-08-06 16:20:29 +0200
commite103f6b6c2399bf46301c3c6f9f5c4cecd456ef8 (patch)
treee111ae66a96244117a7c603784d6e17ed6ad8ea8
parentsplash: draw the real logo on terminals that can (diff)
downloadida-tui-e103f6b6c2399bf46301c3c6f9f5c4cecd456ef8.tar.gz
ida-tui-e103f6b6c2399bf46301c3c6f9f5c4cecd456ef8.tar.xz
ida-tui-e103f6b6c2399bf46301c3c6f9f5c4cecd456ef8.zip
graph: the minimap is clickable, and stops swallowing clicks
Click it to jump the view to that part of the graph, drag to scrub. If the point you clicked is over a block the cursor lands in it, so the keyboard carries on from where you pointed instead of snapping back. This also fixes a real bug rather than only adding a feature. The minimap FLOATS over the canvas -- it is pinned to the viewport, not drawn into the graph -- so a click on it was being translated into canvas coordinates and dropping the cursor into whatever block happened to lie underneath. It has to be hit-tested before the canvas, which is what on_click now does. _minimap_rect() is the one source of truth for where it is: the renderer and the hit-test both take the position from it, so the two-column inset that keeps it clear of the ScrollView's scrollbar can't drift between them.
-rw-r--r--docs/GRAPH_VIEW.md8
-rw-r--r--idatui/app.py83
-rw-r--r--tests/test_scenarios.py61
3 files changed, 144 insertions, 8 deletions
diff --git a/docs/GRAPH_VIEW.md b/docs/GRAPH_VIEW.md
index ccddfe7..6bbcf83 100644
--- a/docs/GRAPH_VIEW.md
+++ b/docs/GRAPH_VIEW.md
@@ -37,6 +37,7 @@ extra work.
| `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.
@@ -109,6 +110,13 @@ 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 jumps the view to that part of the graph (and lands the cursor on a
+block if one is there, so the keyboard carries on from where you pointed);
+dragging scrubs. 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
diff --git a/idatui/app.py b/idatui/app.py
index 464f5c0..7a575b7 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -2057,6 +2057,7 @@ class GraphView(NavMixin, ScrollView, can_focus=True):
self._show_minimap = True
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
self._hl_word = ""
self.trail: dict[int, str] | None = None
@@ -2366,22 +2367,83 @@ class GraphView(NavMixin, ScrollView, can_focus=True):
self.scroll_to(y=y, x=x, animate=False)
self.call_after_refresh(_again)
- # -- mouse ------------------------------------------------------------ #
+ # -- minimap hit-testing ----------------------------------------------- #
+ def _minimap_rect(self) -> tuple[int, int, int, int] | None:
+ """(left, top, w, h) of the minimap in CONTENT coordinates, or None.
+
+ The minimap is pinned to the viewport, not the canvas, so these are
+ screen-relative and the scroll offset must NOT be added. Must agree with
+ _draw_minimap_row, which is why both take the inset from here.
+ """
+ if not self._show_minimap or self.lay is None:
+ return None
+ w, h = self.size.width, self.size.height
+ if w < _MINI_W + 10 or h < _MINI_H + 2:
+ return None
+ return (w - _MINI_W - 2, 0, _MINI_W, _MINI_H)
+
+ def _minimap_seek(self, x: int, y: int, move_cursor: bool = False) -> bool:
+ """Treat (x, y) as a point on the minimap and centre the view there.
+
+ Returns False if the point isn't on the minimap, so the caller can fall
+ through to ordinary canvas hit-testing.
+ """
+ rect = self._minimap_rect()
+ if rect is None or self.lay is None:
+ return False
+ left, top, _w, _h = rect
+ gw, gh = _MINI_W - 2, _MINI_H - 2
+ c, r = x - left - 1, y - top - 1 # inside the border
+ if not (0 <= c < gw and 0 <= r < gh):
+ return False
+ lay = self.lay
+ sx = max(lay.width / gw, 1e-9)
+ sy = max(lay.height / gh, 1e-9)
+ cx, cy = (c + 0.5) * sx, (r + 0.5) * sy # centre of that mini-cell
+ self.scroll_to(x=max(0, int(cx - self.size.width / 2)),
+ y=max(0, int(cy - self.size.height / 2)), animate=False)
+ if move_cursor:
+ # Land the cursor on a block if the click was over one, so the
+ # keyboard carries on from where you pointed instead of snapping
+ # back to wherever it was.
+ n = lay.node_at(int(cy), int(cx))
+ if n is not None and n.block is not None:
+ self.cursor_node = n.id
+ self.cursor_row = 0
+ self.cursor_x = 0
+ self._clamp_cursor()
+ self.post_message(
+ self.CursorMoved(self._cursor_ea(), self.cursor_node))
+ self.refresh()
+ return True
+
+ # -- mouse ------------------------------------------------------------- #
def on_mouse_down(self, event) -> None: # type: ignore[no-untyped-def]
off = event.get_content_offset(self)
if off is None:
return
+ if self._minimap_seek(off.x, off.y):
+ self._drag = None
+ self._drag_map = True # keep scrubbing while the button is held
+ return
+ self._drag_map = False
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
+ self._drag_map = False
def on_mouse_move(self, event) -> None: # type: ignore[no-untyped-def]
- if self._drag is None or not event.button:
+ if not event.button:
return
off = event.get_content_offset(self)
if off is None:
return
+ if self._drag_map:
+ self._minimap_seek(off.x, off.y) # drag = scrub the overview
+ return
+ if self._drag 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)
@@ -2392,6 +2454,12 @@ class GraphView(NavMixin, ScrollView, can_focus=True):
off = event.get_content_offset(self)
if off is None:
return
+ # The minimap floats over the canvas, so it has to be tested FIRST --
+ # otherwise a click on it is read as canvas coordinates and drops the
+ # cursor into whatever block happens to lie underneath.
+ if self._minimap_seek(off.x, off.y, move_cursor=True):
+ self.focus()
+ return
row = off.y + int(self.scroll_offset.y)
col = off.x + int(self.scroll_offset.x)
n = self.lay.node_at(row, col)
@@ -2546,13 +2614,11 @@ class GraphView(NavMixin, ScrollView, can_focus=True):
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
+ # Inset by two: a ScrollView paints its vertical scrollbar over the last
# column, which otherwise eats the minimap's right border.
- left = width - _MINI_W - 2
+ if self._minimap_rect() is None or not (0 <= y < _MINI_H):
+ return
+ left = self._minimap_rect()[0] # one source of truth with the hit-test
grid = self._minimap()
gw, gh = _MINI_W - 2, _MINI_H - 2
lay = self.lay
@@ -3024,6 +3090,7 @@ _HELP = (
("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"),
+ ("click minimap", "jump the view there (drag to scrub)"),
)),
)
diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py
index 4b1c7a2..5c541f9 100644
--- a/tests/test_scenarios.py
+++ b/tests/test_scenarios.py
@@ -2956,6 +2956,67 @@ async def s_graph_click(c: Ctx):
f"node={gv.cursor_node} want={target.id}")
+@scenario("graph_minimap")
+async def s_graph_minimap(c: Ctx):
+ """The minimap floats over the canvas, so it must be hit-tested BEFORE the
+ canvas -- otherwise a click on it reads as canvas coordinates and drops the
+ cursor into whatever block happens to lie underneath."""
+ app = c.app
+ fn, gv = await _open_graph(c)
+ if gv.lay is None:
+ c.check("graph loaded", False)
+ return
+ rect = gv._minimap_rect()
+ c.check("the minimap has a hit-box while it's shown", rect is not None,
+ f"size={gv.size} shown={gv._show_minimap}")
+ if rect is None:
+ return
+ left, top, mw, mh = rect
+ c.check("it sits inside the pane, clear of the scrollbar",
+ left + mw <= gv.size.width - 1,
+ f"left={left} w={mw} pane={gv.size.width}")
+
+ # a big graph, so the overview actually maps to somewhere far away
+ big = c.find_func(lambda f: f.size > 0x300) or fn
+ await c.open(big.addr, "listing")
+ c.lst.focus()
+ await c.press("space")
+ await c.wait(lambda: app._active == "graph" and gv.lay is not None, 60)
+ if gv.lay is None or gv.lay.height < gv.size.height * 2:
+ c.check("a graph tall enough to scrub", True, "(skipped: too small)")
+ return
+
+ gv.scroll_to(y=0, x=0, animate=False)
+ await c.pause(0.1)
+ node_before = gv.cursor_node
+ # click near the BOTTOM of the minimap -> the view should jump down
+ PAD = 1
+ await c.pilot.click(GraphView, offset=(PAD + left + mw // 2, top + mh - 2))
+ await c.pause(0.2)
+ c.check("clicking low on the minimap scrolls the view down",
+ gv.scroll_offset.y > 0, f"scroll_y={gv.scroll_offset.y}")
+ landed = gv.lay.by_id.get(gv.cursor_node)
+ c.check("the cursor moved to a block near where we pointed, not a stray one",
+ landed is not None
+ and (gv.cursor_node == node_before
+ or landed.y >= int(gv.scroll_offset.y) - gv.size.height),
+ f"node={gv.cursor_node} y={landed.y if landed else None} "
+ f"scroll={gv.scroll_offset.y}")
+
+ # and the top of the minimap brings it back
+ await c.pilot.click(GraphView, offset=(PAD + left + mw // 2, top + 1))
+ await c.pause(0.2)
+ c.check("clicking high on the minimap scrolls back up",
+ gv.scroll_offset.y == 0, f"scroll_y={gv.scroll_offset.y}")
+
+ # with the minimap hidden the same click is an ordinary canvas click
+ await c.press("m")
+ await c.pause(0.1)
+ c.check("no hit-box once it's hidden", gv._minimap_rect() is None)
+ await c.press("m")
+ await c.pause(0.1)
+
+
@scenario("graph_rename")
async def s_graph_rename(c: Ctx):
"""Editing verbs must work from inside a box -- that is the whole point of