aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-07-09 14:30:13 +0200
committerblasty <blasty@local>2026-07-09 14:30:13 +0200
commit9aa8d8a381a2afc81ffdcfa095247ea66b019b63 (patch)
treee006fdead8005c395eb9dd0519606e6190644e6f
parentpreserve cursor line+column in the jump history (diff)
downloadida-tui-9aa8d8a381a2afc81ffdcfa095247ea66b019b63.tar.gz
ida-tui-9aa8d8a381a2afc81ffdcfa095247ea66b019b63.tar.xz
ida-tui-9aa8d8a381a2afc81ffdcfa095247ea66b019b63.zip
track pseudocode-view cursor position across jumps
NavEntry now also stores dec_cursor/dec_cursor_x. DecompView posts a CursorMoved (with the line's /*0xEA*/ address) on move/click; the app keeps the top-of-history pseudocode position current and DecompView.show restores it when returning to a function. So Esc after a jump lands on the exact pseudocode line+column too, independent of the disasm position. pilot suite 38/38.
-rw-r--r--idatui/app.py50
-rw-r--r--tests/test_tui.py47
2 files changed, 90 insertions, 7 deletions
diff --git a/idatui/app.py b/idatui/app.py
index 22f8157..8d4ab18 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -58,8 +58,10 @@ _S_CELL = Style(reverse=True) # the block cursor cell
class NavEntry:
ea: int
name: str
- cursor: int = 0 # line index (disasm instruction index)
- cursor_x: int = 0 # column
+ 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
class SearchRequested(Message):
@@ -719,6 +721,14 @@ class DecompView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True
cursor = reactive(0, repaint=False)
cursor_x = reactive(0, repaint=False)
+ class CursorMoved(Message):
+ """Posted when the pseudocode cursor moves; carries the line's ea."""
+
+ def __init__(self, index: int, ea: int | None) -> None:
+ super().__init__()
+ self.index = index
+ self.ea = ea
+
def __init__(self) -> None:
super().__init__(id="decomp")
self.loaded_ea: int | None = None
@@ -739,20 +749,33 @@ 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) -> None:
+ def show(self, ea: int, text: str, cursor: int = 0, cursor_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]
self.loaded_ea = ea
- self.cursor = 0
- self.cursor_x = 0
+ total = len(self._strips)
+ self.cursor = max(0, min(max(total - 1, 0), cursor))
+ self.cursor_x = cursor_x
self._matches = []
self._ranges = {}
maxw = max((s.cell_length for s in self._strips), default=0)
- self.virtual_size = Size(maxw, len(self._strips))
+ self.virtual_size = Size(maxw, total)
self.scroll_to(0, 0, animate=False)
+ self._clamp_x()
+ self._scroll_cursor_into_view()
+ self._hscroll()
self.refresh()
+ def _line_ea(self, idx: int) -> int | None:
+ if not (0 <= idx < len(self._texts)):
+ return None
+ ms = re.findall(r"/\*\s*0x([0-9A-Fa-f]+)\s*\*/", self._texts[idx])
+ return int(ms[-1], 16) if ms else None
+
+ def _after_cursor_move(self) -> None:
+ self.post_message(DecompView.CursorMoved(self.cursor, self._line_ea(self.cursor)))
+
@property
def total(self) -> int:
return len(self._strips)
@@ -802,6 +825,7 @@ class DecompView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True
self.refresh()
else:
_refresh_lines(self, old, self.cursor)
+ self._after_cursor_move()
def action_cursor_down(self) -> None:
self._move(1)
@@ -1400,14 +1424,26 @@ class IdaTui(App):
def _apply_decomp(self, ea: int, name: str, dec) -> None: # type: ignore[no-untyped-def]
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
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 "")
+ view.show(ea, dec.code or "", cursor=c, cursor_x=cx)
self._status(f"{name} @ {ea:#x} [pseudocode {len(dec.code or '')} chars]{note}")
+ def on_decomp_view_cursor_moved(self, msg: DecompView.CursorMoved) -> None:
+ if self._nav:
+ self._nav[-1].dec_cursor = msg.index
+ self._nav[-1].dec_cursor_x = self.query_one(DecompView).cursor_x
+ if self._cur is not None:
+ loc = f" @ {msg.ea:#x}" if msg.ea is not None else ""
+ self._status(f"{self._cur.name}{loc} [pseudocode line {msg.index}]")
+
def on_disasm_view_cursor_moved(self, msg: DisasmView.CursorMoved) -> None:
if self._nav:
# Keep the top-of-history position current (line + column) so that
diff --git a/tests/test_tui.py b/tests/test_tui.py
index 5567eb5..bd110c1 100644
--- a/tests/test_tui.py
+++ b/tests/test_tui.py
@@ -337,6 +337,53 @@ async def run(db):
dis.cursor == mline and dis.word_under_cursor() == msym,
f"cursor={dis.cursor} (want {mline}) word={dis.word_under_cursor()!r}")
+ # Pseudocode-view position is tracked independently across jumps.
+ prog = app.program
+ fi = prog.functions()
+ fi.ensure(500)
+ pick = None
+ for fn in fi.all_loaded()[:500]:
+ d = prog.decompile(fn.addr)
+ if d.failed or not d.code:
+ continue
+ dl = d.code.split("\n")
+ if len(dl) < 12:
+ continue
+ for i, txt in enumerate(dl):
+ if i < 8:
+ continue
+ dm2 = re.search(r"\b(sub_[0-9A-Fa-f]+)", txt)
+ if dm2 and dm2.group(1) != fn.name:
+ pick = (fn, i, dm2.start(1), dm2.group(1))
+ break
+ if pick:
+ break
+ if pick is None:
+ check("found a pseudocode line to test decomp nav", False)
+ else:
+ fn, drow, dcol, dsym = pick
+ await pilot.press("g")
+ await pilot.pause(0.2)
+ for ch in fn.name:
+ await pilot.press(ch)
+ await pilot.press("enter")
+ await wait_until(pilot, lambda: dis.total > 0, timeout=20)
+ await pilot.press("tab")
+ await wait_until(pilot, lambda: dec.loaded_ea == fn.addr, timeout=20)
+ dec.cursor = drow
+ dec.cursor_x = dcol
+ dec._after_cursor_move()
+ await pilot.pause(0.1)
+ depth = len(app._nav)
+ await pilot.press("enter") # follow the sym under the pseudocode cursor
+ await wait_until(pilot, lambda: len(app._nav) > depth, timeout=25)
+ await pilot.press("escape")
+ await wait_until(pilot, lambda: dec.loaded_ea == fn.addr, timeout=25)
+ await pilot.pause(0.2)
+ check("pseudocode-view position restored after jump+back",
+ dec.cursor == drow and dec.word_under_cursor() == dsym,
+ f"cursor={dec.cursor} (want {drow}) word={dec.word_under_cursor()!r}")
+
def main(argv):
db = None