aboutsummaryrefslogtreecommitdiffstats
path: root/idatui
diff options
context:
space:
mode:
Diffstat (limited to 'idatui')
-rw-r--r--idatui/app.py111
1 files changed, 91 insertions, 20 deletions
diff --git a/idatui/app.py b/idatui/app.py
index 7a575b7..0779b93 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -2281,6 +2281,35 @@ class GraphView(NavMixin, ScrollView, can_focus=True):
def action_pan(self, rows: int) -> None:
self.scroll_to(y=max(0, self.scroll_offset.y + rows), animate=False)
+ self._snap_into_view()
+
+ def _viewport_has_block(self) -> bool:
+ if self.lay is None:
+ return False
+ y0 = int(self.scroll_offset.y)
+ x0 = int(self.scroll_offset.x)
+ y1, x1 = y0 + self.size.height, x0 + self.size.width
+ return any(n.y <= y1 and y0 <= n.bottom and n.x <= x1 and x0 <= n.right
+ for n in self.lay.nodes)
+
+ def _snap_into_view(self) -> None:
+ """After a pan, if the viewport holds no block at all, ease to the
+ nearest one.
+
+ Blocks cover a few percent of a laid-out graph -- 4.6% on an 87-block
+ function, under 1% on a 424-block one -- the rest being the padding that
+ keeps edges apart. Panning therefore lands in empty space more often
+ than not, and an empty screen gives you nothing to navigate back by.
+ Only fires when nothing is visible, so it never fights a deliberate pan.
+ """
+ if self.lay is None or self._viewport_has_block():
+ return
+ cy = self.scroll_offset.y + self.size.height / 2
+ cx = self.scroll_offset.x + self.size.width / 2
+ n = self._nearest_node(cy, cx)
+ if n is not None:
+ self._center_on(n, defer=False)
+ self.refresh()
def action_zoom(self) -> None:
self._zoom = (self._zoom + 1) % len(self.ZOOMS)
@@ -2354,19 +2383,30 @@ class GraphView(NavMixin, ScrollView, can_focus=True):
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()
+ def _center_on(self, n: graph.Node, defer: bool = True) -> None:
+ """Bring block ``n`` into the middle of the viewport.
+
+ ``defer=False`` during a drag: layout is already valid then, and
+ queueing a callback per mouse-move makes the scrub lag behind.
+ """
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)
+ if not defer:
+ return
# 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)
+ def _center_cursor(self) -> None:
+ n = self._cur_node()
+ if n is not None:
+ self._center_on(n)
+
# -- 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.
@@ -2382,8 +2422,35 @@ class GraphView(NavMixin, ScrollView, can_focus=True):
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.
+ def _nearest_node(self, row: float, col: float) -> graph.Node | None:
+ """The block nearest a canvas point (distance 0 if the point is inside).
+
+ Cells are about twice as tall as they are wide, so the column distance
+ is halved -- otherwise "nearest" means nearest in cells, which does not
+ look nearest on screen.
+ """
+ if self.lay is None:
+ return None
+ best, best_d = None, None
+ for n in self.lay.nodes:
+ dx = 0.0 if n.x <= col <= n.right else min(abs(col - n.x),
+ abs(col - n.right))
+ dy = 0.0 if n.y <= row <= n.bottom else min(abs(row - n.y),
+ abs(row - n.bottom))
+ d = (dx * 0.5) ** 2 + dy ** 2
+ if best_d is None or d < best_d:
+ best, best_d = n, d
+ return best
+
+ def _minimap_seek(self, x: int, y: int, defer: bool = True) -> bool:
+ """Treat (x, y) as a point on the minimap and go to the block there.
+
+ Deliberately snaps to the NEAREST BLOCK rather than scrolling to the raw
+ coordinate. Most of a laid-out graph is the padding that keeps edges
+ apart, so a coordinate-accurate jump usually parks the viewport in empty
+ space -- and the cursor, which only moved when the point landed exactly
+ on a block, stayed behind. Snapping means every click lands on something
+ and the keyboard carries on from there.
Returns False if the point isn't on the minimap, so the caller can fall
through to ordinary canvas hit-testing.
@@ -2400,21 +2467,21 @@ class GraphView(NavMixin, ScrollView, can_focus=True):
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))
+ n = self._nearest_node(cy, cx)
+ if n is None:
+ self.scroll_to(x=max(0, int(cx - self.size.width / 2)),
+ y=max(0, int(cy - self.size.height / 2)),
+ animate=False)
+ return True
+ if n.id == self.cursor_node:
+ return True # already there; don't churn while dragging
+ self.cursor_node = n.id
+ self.cursor_row = 0
+ self.cursor_x = 0
+ self._clamp_cursor()
+ self._center_on(n, defer=defer)
self.refresh()
+ self.post_message(self.CursorMoved(self._cursor_ea(), self.cursor_node))
return True
# -- mouse ------------------------------------------------------------- #
@@ -2430,8 +2497,11 @@ class GraphView(NavMixin, ScrollView, can_focus=True):
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]
+ was_pan = self._drag is not None and not self._drag_map
self._drag = None
self._drag_map = False
+ if was_pan:
+ self._snap_into_view() # don't leave them adrift in the padding
def on_mouse_move(self, event) -> None: # type: ignore[no-untyped-def]
if not event.button:
@@ -2440,7 +2510,8 @@ class GraphView(NavMixin, ScrollView, can_focus=True):
if off is None:
return
if self._drag_map:
- self._minimap_seek(off.x, off.y) # drag = scrub the overview
+ # drag = scrub block to block through the overview
+ self._minimap_seek(off.x, off.y, defer=False)
return
if self._drag is None:
return
@@ -2457,7 +2528,7 @@ class GraphView(NavMixin, ScrollView, can_focus=True):
# 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):
+ if self._minimap_seek(off.x, off.y):
self.focus()
return
row = off.y + int(self.scroll_offset.y)