diff options
| author | blasty <blasty@local> | 2026-08-06 16:20:29 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-08-06 16:20:29 +0200 |
| commit | e103f6b6c2399bf46301c3c6f9f5c4cecd456ef8 (patch) | |
| tree | e111ae66a96244117a7c603784d6e17ed6ad8ea8 | |
| parent | splash: draw the real logo on terminals that can (diff) | |
| download | ida-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.md | 8 | ||||
| -rw-r--r-- | idatui/app.py | 83 | ||||
| -rw-r--r-- | tests/test_scenarios.py | 61 |
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 |
