diff options
| -rw-r--r-- | idatui/app.py | 50 | ||||
| -rw-r--r-- | tests/test_tui.py | 47 |
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 |
