aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-07-24 16:07:36 +0200
committerblasty <blasty@local>2026-07-24 16:07:36 +0200
commitd2d5d31073d3cbe54fdb1447d8e8380a43293453 (patch)
treef1d14084369eee860f6b738e9af109593f32eabf
parentux: render the logo.ans ANSI-art splash in the loading overlay (diff)
downloadida-tui-d2d5d31073d3cbe54fdb1447d8e8380a43293453.tar.gz
ida-tui-d2d5d31073d3cbe54fdb1447d8e8380a43293453.tar.xz
ida-tui-d2d5d31073d3cbe54fdb1447d8e8380a43293453.zip
hex: freeze the cursor's screen position on scroll (not clamp-to-edge)
Replace the clamp-to-nearest-edge follow with a true screen-position freeze: watch_scroll_y now shifts the byte cursor by the exact scroll delta on a user scroll (wheel/scrollbar), so it points at a new byte but stays on the same screen row. Cursor-driven scrolls (_scroll_to_cursor via _apply_scroll) are marked with _internal_top and skipped, so key-nav/click don't double-move the cursor. All scroll sources funnel through the one watch point. Pilot `hex` scenario: put the cursor mid-viewport, scroll 30 rows, assert its screen row is unchanged (+ clicks still land on the exact byte). 10/10 pass.
-rw-r--r--idatui/app.py35
-rw-r--r--tests/test_scenarios.py29
2 files changed, 38 insertions, 26 deletions
diff --git a/idatui/app.py b/idatui/app.py
index 4b073af..446c364 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -1617,6 +1617,7 @@ class HexView(ScrollView, can_focus=True):
super().__init__(id="hex")
self.model = None
self.total = 0
+ self._internal_top: int | None = None # scroll target we set ourselves
# -- public API -------------------------------------------------------- #
def load(self, model, va: int | None = None) -> None: # type: ignore[no-untyped-def]
@@ -1658,12 +1659,16 @@ class HexView(ScrollView, can_focus=True):
def _apply_scroll(self, y: int) -> None:
y = max(0, y)
- self.scroll_to(y=y, animate=False)
+ if round(self.scroll_offset.y) != y:
+ self._internal_top = y # cursor-driven scroll incoming; don't follow it
def _fix(yy: int = y) -> None:
+ if round(self.scroll_offset.y) != yy:
+ self._internal_top = yy
self.scroll_to(y=yy, animate=False)
self.refresh(layout=True)
+ self.scroll_to(y=y, animate=False)
self.call_after_refresh(_fix)
def _scroll_to_cursor(self, center: bool = False) -> None:
@@ -1682,21 +1687,23 @@ class HexView(ScrollView, can_focus=True):
def watch_scroll_y(self, old_value: float, new_value: float) -> None:
super().watch_scroll_y(old_value, new_value)
- # Follow the scroll: a wheel-scroll / scrollbar drag drags the byte cursor
- # along so it stays visible (pinned to the nearest edge row, same column).
- if round(old_value) != round(new_value):
- self._follow_scroll(round(new_value))
+ if round(old_value) == round(new_value):
+ return
+ nt = round(new_value)
+ if self._internal_top is not None and nt == self._internal_top:
+ self._internal_top = None # our cursor-driven scroll: cursor already placed
+ return
+ # A user scroll (wheel / scrollbar): drag the cursor by the same row delta
+ # so its screen position stays frozen (it points at a new byte in place).
+ self._internal_top = None
+ self._shift_cursor(nt - round(old_value))
- def _follow_scroll(self, top: int) -> None:
- if self.model is None or self.model.size == 0:
+ def _shift_cursor(self, rows: int) -> None:
+ if not rows or self.model is None or self.model.size == 0:
return
- height = self._visible_height()
- row, col = self.cursor // 16, self.cursor % 16
- new_row = min(max(row, top), top + max(height - 1, 0))
- new_row = max(0, min(new_row, max(self.total - 1, 0)))
- new_cursor = max(0, min(new_row * 16 + col, self.model.size - 1))
- if new_cursor != self.cursor:
- self.cursor = new_cursor
+ new = max(0, min(self.cursor + rows * 16, self.model.size - 1))
+ if new != self.cursor:
+ self.cursor = new
self.refresh() # cursor is reactive(repaint=False)
self.post_message(HexView.Moved(self.cursor_va()))
diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py
index 4731a16..1a07fb4 100644
--- a/tests/test_scenarios.py
+++ b/tests/test_scenarios.py
@@ -506,29 +506,34 @@ async def s_hex(c: Ctx):
await c.wait(lambda: hx.cursor_va() == target_va, 15)
c.check("'g' in the hex view jumps the cursor to an address",
hx.cursor_va() == target_va, f"va={hx.cursor_va():#x} want={target_va:#x}")
- # -- scroll the viewport, then click to move the byte cursor into it ----
+ # -- a user scroll freezes the cursor's screen row (points at a new byte) --
await c.press("g")
await c.type(hex(rng[0]))
await c.press("enter")
await c.wait(lambda: hx.cursor_va() == rng[0], 10)
- hx.scroll_to(y=40, animate=False) # wheel-style scroll
+ for _ in range(8): # cursor to viewport row 8 (top still 0)
+ await c.press("j")
+ await c.pause(0.1)
+ top0 = round(hx.scroll_offset.y)
+ screen_row = hx.cursor // 16 - top0
+ hx.scroll_to(y=top0 + 30, animate=False) # user scroll down 30 rows
await c.pause(0.15)
- top = round(hx.scroll_offset.y)
- height = hx._visible_height()
- c.check("hex cursor follows the scroll (stays visible)",
- top >= 20 and top <= (hx.cursor // 16) < top + height,
- f"top={top} cursor_row={hx.cursor // 16} height={height}")
+ top1 = round(hx.scroll_offset.y)
+ c.check("hex viewport scrolled", top1 >= top0 + 20, f"top0={top0} top1={top1}")
+ c.check("hex cursor's screen row stays frozen on scroll",
+ hx.cursor // 16 - top1 == screen_row,
+ f"screen_row={screen_row} now={hx.cursor // 16 - top1} top1={top1}")
PAD = 1 # HexView { padding: 0 1 } -> content is inset one col
await c.pilot.click(HexView, offset=(PAD + 19 + 3 * 3, 5)) # hex byte 3, row 5
await c.pause(0.1)
- c.check("clicking the hex pane moves the cursor into the scrolled region",
- hx.cursor == (top + 5) * 16 + 3,
- f"cursor={hx.cursor} want={(top + 5) * 16 + 3} top={top}")
+ c.check("clicking the hex pane moves the cursor to the clicked byte",
+ hx.cursor == (top1 + 5) * 16 + 3,
+ f"cursor={hx.cursor} want={(top1 + 5) * 16 + 3} top1={top1}")
await c.pilot.click(HexView, offset=(PAD + 70 + 10, 7)) # ascii byte 10, row 7
await c.pause(0.1)
c.check("clicking the ascii pane maps to the right byte",
- hx.cursor == (top + 7) * 16 + 10,
- f"cursor={hx.cursor} want={(top + 7) * 16 + 10}")
+ hx.cursor == (top1 + 7) * 16 + 10,
+ f"cursor={hx.cursor} want={(top1 + 7) * 16 + 10}")
await c.press("backslash")
await c.wait(lambda: app._active != "hex", 10)
c.check("backslash returns from hex to the code view",