aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-07-26 09:37:47 +0200
committerblasty <blasty@local>2026-07-26 09:37:47 +0200
commit8add668ad1f10f77132de36b062d16a9e4103989 (patch)
tree883249ed5e1cb4091643c52d459f0c34ba3ea26e
parentlisting: `c` disassembles until something stops it (diff)
downloadida-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.py33
-rw-r--r--tests/test_blob_ui.py24
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,