diff options
| author | blasty <blasty@local> | 2026-07-26 09:37:47 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-07-26 09:37:47 +0200 |
| commit | 8add668ad1f10f77132de36b062d16a9e4103989 (patch) | |
| tree | 883249ed5e1cb4091643c52d459f0c34ba3ea26e | |
| parent | listing: `c` disassembles until something stops it (diff) | |
| download | ida-tui-8add668ad1f10f77132de36b062d16a9e4103989.tar.gz ida-tui-8add668ad1f10f77132de36b062d16a9e4103989.tar.xz ida-tui-8add668ad1f10f77132de36b062d16a9e4103989.zip | |
listing: an edit must not move the view
Pressing `c` jumped the scroll position. Defining an item reloads the view, and
the reload only carried the cursor's row index — the viewport was recomputed
from scratch, so you landed somewhere else and lost your place mid-carve.
Row indices are the wrong thing to remember across this reload anyway: carving
COLLAPSES rows (four undefined byte rows become one instruction row), so the row
that was at the top is a different address afterwards. The anchor has to be the
top visible ADDRESS, resolved back to a row after the model is rebuilt.
on_edit_item_requested captures it before the edit, _do_edit_item resolves it
against the new model, and _open_at grew a scroll_y so the entry can carry it.
Verified: cursor at 0x4800 with the top of the screen at 0x47da, press `c`, and
both are unchanged afterwards.
tests: +3 blob UI (scrolled far enough to have something to lose, top address
unchanged, cursor address unchanged). 25/0 blob, 202/0 scenarios.
| -rw-r--r-- | idatui/app.py | 33 | ||||
| -rw-r--r-- | tests/test_blob_ui.py | 24 |
2 files changed, 50 insertions, 7 deletions
diff --git a/idatui/app.py b/idatui/app.py index 90678d3..5150ee1 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -5394,15 +5394,27 @@ class IdaTui(App): # -- item structure edits (IDA c/p/u) --------------------------------- # def on_edit_item_requested(self, msg: EditItemRequested) -> None: - ea = (msg.view._cursor_ea() - if isinstance(msg.view, (DisasmView, ListingView)) else None) + view = msg.view + ea = (view._cursor_ea() + if isinstance(view, (DisasmView, ListingView)) else None) if ea is None: self._status("no address on this line to (re)define") return - self._do_edit_item(msg.kind, ea) + # Remember the TOP VISIBLE ADDRESS, not the row index: defining code + # collapses rows (four undefined bytes become one instruction), so the + # row that was at the top afterwards is a different place entirely. The + # view should not appear to move just because you carved in it. + top_ea = None + model = getattr(view, "model", None) + if model is not None: + top = round(view.scroll_offset.y) + h = model.cached_line(top) or model.get(top) + top_ea = getattr(h, "ea", None) + self._do_edit_item(msg.kind, ea, top_ea) @work(thread=True, exclusive=True, group="edititem") - def _do_edit_item(self, kind: str, ea: int) -> None: # worker context + def _do_edit_item(self, kind: str, ea: int, + top_ea: int | None = None) -> None: # worker context assert self.program is not None verb = {"code": "defined code", "func": "created function", "undef": "undefined", "string": "made string"}[kind] @@ -5448,14 +5460,18 @@ class IdaTui(App): if fn is not None: model = self.program.disasm(fn.addr, fn.name) idx = 0 if ea == fn.addr else model.index_of_ea(ea) + top = model.index_of_ea(top_ea) if top_ea is not None else -1 self.app.call_from_thread( - self._open_at, fn.addr, fn.name, idx, False, -1, 0, False) + self._open_at, fn.addr, fn.name, idx, False, -1, 0, False, + None, max(top, -1)) else: name = self.program.region_label(ea) lm = self.program.listing(ea) idx = max(lm.ensure_ea(ea), 0) if lm is not None else 0 + top = lm.index_of_ea(top_ea) if (lm is not None and top_ea is not None) else -1 self.app.call_from_thread( - self._open_at, ea, name, idx, False, -1, 0, True) + self._open_at, ea, name, idx, False, -1, 0, True, + None, max(top, -1)) self.app.call_from_thread(self._edit_item_done, verb, ea) def _edit_item_done(self, verb: str, ea: int) -> None: @@ -5657,7 +5673,8 @@ class IdaTui(App): def _open_at(self, ea: int, name: str, cursor: int, push: bool, dec_cursor: int = -1, dec_cursor_x: int = 0, - is_region: bool = False, focus_name: str | None = None) -> None: + is_region: bool = False, focus_name: str | None = None, + scroll_y: int = -1) -> None: if push: self._save_current_pos() self._decomp_return = None # a real navigation abandons the F5 return @@ -5665,6 +5682,8 @@ class IdaTui(App): # the row is loaded, instead of column 0. self._pending_focus_name = focus_name entry = NavEntry(ea=ea, name=name, cursor=cursor, is_region=is_region) + if scroll_y >= 0: + entry.scroll_y = scroll_y if dec_cursor >= 0: entry.dec_cursor = dec_cursor entry.dec_cursor_x = dec_cursor_x diff --git a/tests/test_blob_ui.py b/tests/test_blob_ui.py index 9161f44..ca4dbb3 100644 --- a/tests/test_blob_ui.py +++ b/tests/test_blob_ui.py @@ -163,6 +163,30 @@ async def run() -> int: m.get(m.index_of_ea(target - 1)).size == 1 and m.get(m.index_of_ea(target - 1)).ea == target - 1) + # -- carving must not move the view -------------------------- # + # Defining code collapses rows (four byte rows become one + # instruction), so anything that remembers a row INDEX puts you + # somewhere else afterwards. Scroll down far enough that there is a + # viewport to lose, then check the top ADDRESS is unchanged. + far = 0x4000 + 0x600 + lst.cursor = lst.model.index_of_ea(far) + lst._scroll_cursor_into_view() + await pilot.pause(0.4) + top_before = lst.model.get(round(lst.scroll_offset.y)).ea + cur_before = lst._cursor_ea() + check("scrolled somewhere with rows above us", + round(lst.scroll_offset.y) > 0, f"top={lst.scroll_offset.y}") + await pilot.press("c") + await pilot.pause(2.5) + m2 = lst.model + top_after = m2.get(round(lst.scroll_offset.y)).ea + check("carving leaves the scroll position where it was", + top_after == top_before, + f"{top_before:#x} -> {top_after:#x}") + check("and leaves the cursor on the same address", + lst._cursor_ea() == cur_before, + f"{cur_before:#x} -> {lst._cursor_ea():#x}") + await pilot.press("ctrl+l") opened = await wait(lambda: isinstance(app.screen, ConfirmScreen), pilot, 20) check("Ctrl+L offers to reload with different options", opened, |
