diff options
| -rw-r--r-- | docs/SPLIT_VIEW.md | 5 | ||||
| -rw-r--r-- | idatui/app.py | 42 | ||||
| -rw-r--r-- | tests/test_scenarios.py | 13 |
3 files changed, 58 insertions, 2 deletions
diff --git a/docs/SPLIT_VIEW.md b/docs/SPLIT_VIEW.md index 1a61efc..de37dc1 100644 --- a/docs/SPLIT_VIEW.md +++ b/docs/SPLIT_VIEW.md @@ -97,6 +97,11 @@ back to the single marker until the map lands. Verified on the pilot's real work split routes through `_open_entry` (listing) → `_show_active` split branch (decomp + `_load_split_map`), so both panes reload for the new function and split stays on. +- Cross-function follow: the unified listing spans many functions, so when the + listing cursor leaves the decompiled function's ea span (`_split_range`), + `_sync_split` re-points the decomp pane to the function under the cursor + (`_resync_decomp` → `function_of` → re-decompile + reload the map). Over + data/undefined it just drops the band and keeps the last function. - Non-issues after review: split is a persistent *mode* orthogonal to nav entries, so back/forward simply navigate within split (no per-entry state needed); search is already per-focused-pane. diff --git a/idatui/app.py b/idatui/app.py index 66d1b44..860c501 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -2609,6 +2609,7 @@ class IdaTui(App): self._split = False # side-by-side listing + pseudocode self._split_eamap: list[list[int]] = [] # split: decomp line -> instr EAs self._split_ea2line: dict[int, int] = {} # split: instr EA -> decomp line + self._split_range: tuple[int, int] | None = None # decomp'd fn ea span self._hex_pending_ea: int | None = None self._cur: NavEntry | None = None self._pending_focus_name: str | None = None # token to land the cursor on @@ -4458,6 +4459,7 @@ class IdaTui(App): dec.set_link(None) self._split_eamap = [] self._split_ea2line = {} + self._split_range = None dec.display = lst.display = hx.display = False if self._active in ("listing", "disasm"): lst.display = True @@ -4605,8 +4607,17 @@ class IdaTui(App): else: # the listing drives lst.set_link(set()) ea = lst._cursor_ea() - line = self._split_ea2line.get(ea) if ea is not None else None - if line is None and ea is not None: # fallback: nearest marker + if ea is None: + dec.set_link(None) + return + rng = self._split_range + if rng is not None and not (rng[0] <= ea <= rng[1]): + # cursor left the decompiled function — follow it to whatever + # function it's now in (the unified view spans many functions). + self._resync_decomp(ea) + return + line = self._split_ea2line.get(ea) + if line is None: # fallback: nearest marker line = dec.line_for_ea(ea) if line is not None: dec.set_link(line) @@ -4614,6 +4625,28 @@ class IdaTui(App): else: dec.set_link(None) + @work(thread=True, group="split-resync", exclusive=True) + def _resync_decomp(self, ea: int) -> None: + # The listing cursor crossed out of the decompiled function; find the + # function it's in now (off the UI thread) and re-point the decomp pane. + assert self.program is not None + fn = self.program.function_of(ea) + self.app.call_from_thread(self._apply_resync, fn) + + def _apply_resync(self, fn) -> None: # type: ignore[no-untyped-def] + if not self._split or self._cur is None: + return + dec = self.query_one(DecompView) + if fn is None: + dec.set_link(None) # over data/undefined: keep the decomp, drop the band + return + self._cur.ea, self._cur.name = fn.addr, fn.name + if dec.loaded_ea == fn.addr: # already decompiled (scrolled back): just relink + self._sync_split("listing") + return + dec.loading = True + self._load_decomp(fn.addr, fn.name) # -> _apply_decomp -> map -> re-sync + def _split_status(self) -> None: """A split-aware status line reflecting the focused pane + the link.""" if self._cur is None: @@ -4660,9 +4693,14 @@ class IdaTui(App): return # left split / navigated away self._split_eamap = m self._split_ea2line = {} + alleas = [] for line, eas in enumerate(m): for e in eas: self._split_ea2line.setdefault(e, line) + alleas.append(e) + # ea span of the decompiled function: when the listing cursor leaves it, + # _sync_split re-points the decomp to the function under the cursor. + self._split_range = (min(alleas), max(alleas)) if alleas else None self._sync_split(self._active) # re-link with the region map def on_decomp_view_cursor_moved(self, msg: DecompView.CursorMoved) -> None: diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index 7d9f3b7..fad54ec 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -422,6 +422,19 @@ async def s_split_view(c: Ctx): app._active == "decomp", f"active={app._active}") await c.press("tab") # restore listing as the driver await c.pause(0.1) + # cross-function follow: the listing cursor leaving the decompiled function + # re-points the decomp pane to whatever function it's now in. + await c.wait(lambda: app._split_range is not None, 10) + other = c.find_func(lambda f: f.addr != app._cur.ea and f.size > 40) + row = lst.model.ensure_ea(other.addr) if other is not None else None + if other is not None and row is not None and row >= 0: + lst.focus() + app._active = "listing" + lst.cursor = row + app._sync_split("listing") # cursor now outside the decompiled fn + followed = await c.wait(lambda: dec.loaded_ea == other.addr, 25) + c.check("listing cursor crossing into another function re-syncs the decomp", + followed, f"dec={dec.loaded_ea} want={other.addr}") # navigation in split keeps BOTH panes on the (new) function nf = c.find_func(lambda f: f.addr != app._cur.ea and f.size > 80) if nf is not None: |
