diff options
| author | blasty <blasty@local> | 2026-07-09 15:14:27 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-07-09 15:14:27 +0200 |
| commit | cf14ede2310a693cb3163198ab9f4284b7f9ffd3 (patch) | |
| tree | 2da4f45fe110e2bad96e03add76cbb8b23dc6c45 | |
| parent | fix: decompiler follow/xrefs of a name not listed in refs (diff) | |
| download | ida-tui-cf14ede2310a693cb3163198ab9f4284b7f9ffd3.tar.gz ida-tui-cf14ede2310a693cb3163198ab9f4284b7f9ffd3.tar.xz ida-tui-cf14ede2310a693cb3163198ab9f4284b7f9ffd3.zip | |
preserve scroll offset in jump history (both views)
Restoring only the cursor re-derived the viewport via scroll-into-view, so back
landed on the right line but scrolled differently. NavEntry now stores scroll_y
(disasm) and dec_scroll_y/dec_scroll_x (pseudocode); a single _save_current_pos()
snapshots cursor+scroll of both views right before navigating away, and load/show
restore the exact scroll. Disasm defers scroll_to via call_after_refresh (its
virtual_size is only set in _on_primed, so an immediate scroll_to clamps to 0).
Also routes rename's reload through the same save/restore path.
pilot suite 46/46.
| -rw-r--r-- | TODO | 4 | ||||
| -rw-r--r-- | idatui/app.py | 79 | ||||
| -rw-r--r-- | tests/test_tui.py | 31 |
3 files changed, 91 insertions, 23 deletions
@@ -0,0 +1,4 @@ +[ ] RPC endpoint for robot-spectator-ida +[ ] page up/down cursor x/y preserve +[ ] hlsearch should jump cursor +[x] makes names pane sortable (addr/name columns) diff --git a/idatui/app.py b/idatui/app.py index b030e69..d68e827 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -58,10 +58,13 @@ _S_CELL = Style(reverse=True) # the block cursor cell class NavEntry: ea: int name: str - cursor: int = 0 # disasm line (instruction index) - cursor_x: int = 0 # disasm column - dec_cursor: int = 0 # pseudocode line - dec_cursor_x: int = 0 # pseudocode column + cursor: int = 0 # disasm line (instruction index) + cursor_x: int = 0 # disasm column + scroll_y: int = -1 # disasm viewport top (-1 = derive from cursor) + dec_cursor: int = 0 # pseudocode line + dec_cursor_x: int = 0 # pseudocode column + dec_scroll_y: int = -1 # pseudocode viewport top (-1 = derive) + dec_scroll_x: int = 0 # pseudocode horizontal scroll class SearchRequested(Message): @@ -499,6 +502,7 @@ class DisasmView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True self.model: DisasmModel | None = None self.total = 0 self._name = "" + self._pending_scroll_y: int | None = None self._term = "" self._matches: list[int] = [] self._ranges: dict[int, list[tuple[int, int]]] = {} @@ -517,12 +521,13 @@ class DisasmView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True # -- public API -------------------------------------------------------- # def load(self, model: DisasmModel, name: str, cursor: int = 0, - cursor_x: int = 0) -> None: + cursor_x: int = 0, scroll_y: int | None = None) -> None: self.model = model self._name = name self.total = 0 self.cursor = cursor self.cursor_x = cursor_x + self._pending_scroll_y = scroll_y self.virtual_size = Size(0, 0) self._matches = [] self._ranges = {} @@ -584,7 +589,14 @@ class DisasmView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True self.total = total self.virtual_size = Size(0, total) self._clamp_x() # cursor line is now cached; keep the column in range - self._scroll_cursor_into_view() + if self._pending_scroll_y is not None and self._pending_scroll_y >= 0: + target = min(self._pending_scroll_y, max(total - 1, 0)) + # Defer until after layout recomputes max_scroll_y, else scroll_to + # clamps to 0 (virtual_size was only just set). + self.call_after_refresh(lambda t=target: self.scroll_to(y=t, animate=False)) + else: + self._scroll_cursor_into_view() + self._pending_scroll_y = None self.refresh() # -- rendering --------------------------------------------------------- # @@ -761,7 +773,8 @@ class DecompView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True elif self.cursor_x >= sx + width: self.scroll_to(x=self.cursor_x - width + 1, animate=False) - def show(self, ea: int, text: str, cursor: int = 0, cursor_x: int = 0) -> None: + def show(self, ea: int, text: str, cursor: int = 0, cursor_x: int = 0, + scroll_y: int = -1, scroll_x: int = 0) -> None: seglists = highlight_c(text) self._strips = [Strip(segs) for segs in seglists] self._texts = ["".join(seg.text for seg in segs) for segs in seglists] @@ -773,10 +786,13 @@ class DecompView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True self._ranges = {} maxw = max((s.cell_length for s in self._strips), default=0) self.virtual_size = Size(maxw, total) - self.scroll_to(0, 0, animate=False) self._clamp_x() - self._scroll_cursor_into_view() - self._hscroll() + if scroll_y >= 0: + self.scroll_to(x=scroll_x, y=min(scroll_y, max(total - 1, 0)), animate=False) + else: + self.scroll_to(0, 0, animate=False) + self._scroll_cursor_into_view() + self._hscroll() self.refresh() def _line_ea(self, idx: int) -> int | None: @@ -1437,15 +1453,9 @@ class IdaTui(App): except Exception: # noqa: BLE001 pass self.program.invalidate(cur.ea) - dis = self.query_one(DisasmView) - dec = self.query_one(DecompView) - cur.cursor, cur.cursor_x = dis.cursor, dis.cursor_x - if dec.loaded_ea == cur.ea: - cur.dec_cursor, cur.dec_cursor_x = dec.cursor, dec.cursor_x - dec.loaded_ea = None # force pseudocode reload with new names - model = self.program.disasm(cur.ea, cur.name) - dis.load(model, cur.name, cursor=cur.cursor, cursor_x=cur.cursor_x) - self._show_active() + self._save_current_pos() # capture cursor + scroll before reloading + self.query_one(DecompView).loaded_ea = None # force pseudocode reload + self._open_entry(cur, push=False) @work(thread=True, exclusive=True, group="save") def _save(self) -> None: @@ -1480,6 +1490,8 @@ class IdaTui(App): self.app.call_from_thread(self._open_at, fn.addr, fn.name, idx, push) def _open_at(self, ea: int, name: str, cursor: int, push: bool) -> None: + if push: + self._save_current_pos() entry = NavEntry(ea=ea, name=name, cursor=cursor) if push: self._nav.append(entry) @@ -1582,7 +1594,24 @@ class IdaTui(App): name = f.name if f else hex(ea) self._open_function(ea, name) + def _save_current_pos(self) -> None: + """Snapshot the active views' cursor + scroll into the top history entry + (called just before we navigate away).""" + if not self._nav: + return + e = self._nav[-1] + dis = self.query_one(DisasmView) + e.cursor, e.cursor_x = dis.cursor, dis.cursor_x + e.scroll_y = round(dis.scroll_offset.y) + dec = self.query_one(DecompView) + if dec.loaded_ea == e.ea: + e.dec_cursor, e.dec_cursor_x = dec.cursor, dec.cursor_x + e.dec_scroll_y = round(dec.scroll_offset.y) + e.dec_scroll_x = round(dec.scroll_offset.x) + def _open_function(self, ea: int, name: str, push: bool = True) -> None: + if push: + self._save_current_pos() entry = NavEntry(ea=ea, name=name, cursor=0) if push: self._nav.append(entry) @@ -1593,8 +1622,9 @@ class IdaTui(App): return self._cur = entry model = self.program.disasm(entry.ea, entry.name) + sy = entry.scroll_y if entry.scroll_y >= 0 else None self.query_one(DisasmView).load( - model, entry.name, cursor=entry.cursor, cursor_x=entry.cursor_x) + model, entry.name, cursor=entry.cursor, cursor_x=entry.cursor_x, scroll_y=sy) self._show_active() def _show_active(self) -> None: @@ -1629,14 +1659,17 @@ class IdaTui(App): view = self.query_one(DecompView) # Restore the saved pseudocode position when returning to this function. cur = self._cur - c = cur.dec_cursor if (cur is not None and cur.ea == ea) else 0 - cx = cur.dec_cursor_x if (cur is not None and cur.ea == ea) else 0 + same = cur is not None and cur.ea == ea + c = cur.dec_cursor if same else 0 + cx = cur.dec_cursor_x if same else 0 + sy = cur.dec_scroll_y if (same and cur.dec_scroll_y >= 0) else -1 + sx = cur.dec_scroll_x if same else 0 if dec.failed: view.show(ea, f"/* decompilation failed at {ea:#x}: {dec.error} */\n") self._status(f"{name} — decompile failed; Tab for disasm") else: note = " (truncated)" if dec.truncated else "" - view.show(ea, dec.code or "", cursor=c, cursor_x=cx) + view.show(ea, dec.code or "", cursor=c, cursor_x=cx, scroll_y=sy, scroll_x=sx) self._status(f"{name} @ {ea:#x} [pseudocode {len(dec.code or '')} chars]{note}") def on_decomp_view_cursor_moved(self, msg: DecompView.CursorMoved) -> None: diff --git a/tests/test_tui.py b/tests/test_tui.py index eee33e5..9813c1f 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -467,6 +467,37 @@ async def run(db): check("decompiler follows a name not in refs (resolve fallback)", moved and app._cur.ea == fn.addr, f"moved={moved}") + # Scroll position (not just the cursor) is restored on back, both views. + async def goto_name(name): + await pilot.press("g") + await pilot.pause(0.2) + for ch in name: + await pilot.press(ch) + await pilot.press("enter") + + # pick two distinct, decently-sized functions + big = sorted(fi.all_loaded(), key=lambda f: f.size, reverse=True) + fa = next((f for f in big if f.size > 0x400), big[0]) + fb = next((f for f in big if f.addr != fa.addr), big[-1]) + await goto_name(fa.name) + await wait_until(pilot, lambda: dis.total > 40 and app._cur.ea == fa.addr, timeout=20) + if app._active != "disasm": + await pilot.press("tab") + await pilot.pause(0.2) + for _ in range(4): + await pilot.press("ctrl+d") + await pilot.pause(0.2) + want_sy, want_cur = round(dis.scroll_offset.y), dis.cursor + await goto_name(fb.name) + await wait_until(pilot, lambda: app._cur.ea == fb.addr, timeout=20) + await pilot.press("escape") + await wait_until(pilot, lambda: app._cur.ea == fa.addr, timeout=20) + await wait_until(pilot, lambda: dis.total > 40, timeout=20) + await pilot.pause(0.4) + check("disasm scroll + cursor restored on back", + round(dis.scroll_offset.y) == want_sy and dis.cursor == want_cur, + f"scroll={round(dis.scroll_offset.y)} (want {want_sy}) cursor={dis.cursor}") + def main(argv): db = None |
