aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/SPLIT_VIEW.md5
-rw-r--r--idatui/app.py42
-rw-r--r--tests/test_scenarios.py13
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: