aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-07-25 00:20:37 +0200
committerblasty <blasty@local>2026-07-25 00:20:37 +0200
commitb519051ce9ca3f8e0835221643211a8879fd6cd7 (patch)
tree787ac095df6756f6aeedc161a66a9baa1a385b82
parentsplit: click a pane to drive it (not just Tab) + spell it out in the status (diff)
downloadida-tui-b519051ce9ca3f8e0835221643211a8879fd6cd7.tar.gz
ida-tui-b519051ce9ca3f8e0835221643211a8879fd6cd7.tar.xz
ida-tui-b519051ce9ca3f8e0835221643211a8879fd6cd7.zip
split: follow the decomp across functions as the listing cursor crosses bounds
The unified listing spans many functions, but the decomp pane was pinned to the one function it was opened on — scrolling the listing cursor past that function's bounds stopped syncing. Now _sync_split tracks the decompiled function's ea span (_split_range, from decomp_map) and, when the listing cursor leaves it, re-points the decomp pane to the function under the cursor: _resync_decomp (exclusive worker) -> function_of(ea) -> _apply_resync re-decompiles + reloads the region map, or just drops the band over data/undefined (keeping the last function). Pilot split_view: moving the listing cursor into another function re-syncs the decomp (18/18).
-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: