aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorblasty <blasty@local>2026-07-25 11:02:14 +0200
committerblasty <blasty@local>2026-07-25 11:02:14 +0200
commit2837a56d4c3bbd251d8927e2c5668f2f2ffcba7f (patch)
tree36782a78d20ba43c3c8c4ecb65a1db778df2081d
parentsplit: keep the companion pane level with the driver's cursor (diff)
downloadida-tui-2837a56d4c3bbd251d8927e2c5668f2f2ffcba7f.tar.gz
ida-tui-2837a56d4c3bbd251d8927e2c5668f2f2ffcba7f.tar.xz
ida-tui-2837a56d4c3bbd251d8927e2c5668f2f2ffcba7f.zip
split: propagate pure scrolls (wheel/scrollbar) to the companion pane
The sync only ran off CursorMoved, so a wheel-scroll or scrollbar drag — which moves the viewport but never the cursor — left the other pane behind. * ListingView/DecompView post a new Scrolled message from watch_scroll_y when the rounded scroll changes; the app re-syncs on it (guarded to the active pane, so a companion's align() can't feed back). * _split_anchor(view): the sync now anchors on the driver's cursor while it is visible, else on the top visible row. Cursor moves behave exactly as before (key-nav always scrolls the cursor into view); once a pure scroll takes the cursor off-screen the viewport itself becomes the anchor, so the companion keeps following what you're actually looking at — including re-decompiling as you scroll across function boundaries. Pilot split_view gains a pure-scroll check (viewport moves, cursor doesn't, the companion still moves): 20/20. Full suite 167/2-flake. Three existing checks were setting .cursor without scrolling it into view, which the anchor correctly treats as "cursor not visible"; they now scroll like real key-nav. The multi-region check also had to make the decomp the ACTIVE pane before a decomp-driven sync — otherwise the companion's align() fires Scrolled and re-syncs listing-driven, clobbering the link (impossible in real usage, where the companion is by definition not the active pane).
-rw-r--r--idatui/app.py58
-rw-r--r--tests/test_scenarios.py22
2 files changed, 71 insertions, 9 deletions
diff --git a/idatui/app.py b/idatui/app.py
index 30e9b9b..2576db5 100644
--- a/idatui/app.py
+++ b/idatui/app.py
@@ -988,6 +988,10 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru
self.index = index
self.ea = ea
+ class Scrolled(Message):
+ """Posted when the viewport scrolls (wheel/scrollbar) — the cursor need
+ not have moved, so the split view can still follow along."""
+
def __init__(self) -> None:
super().__init__()
self.model: ListingModel | None = None
@@ -1292,6 +1296,11 @@ class ListingView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=Tru
if top != round(self.scroll_offset.y):
self.scroll_to(y=top, animate=False)
+ def watch_scroll_y(self, old_value: float, new_value: float) -> None:
+ super().watch_scroll_y(old_value, new_value)
+ if round(old_value) != round(new_value):
+ self.post_message(ListingView.Scrolled())
+
# -- navigation -------------------------------------------------------- #
def _visible_height(self) -> int:
return max(self.size.height, 1)
@@ -1439,6 +1448,10 @@ class DecompView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True
self.index = index
self.ea = ea
+ class Scrolled(Message):
+ """Posted when the viewport scrolls (wheel/scrollbar) — the cursor need
+ not have moved, so the split view can still follow along."""
+
def __init__(self) -> None:
super().__init__(id="decomp")
self.loaded_ea: int | None = None
@@ -1612,6 +1625,11 @@ class DecompView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True
if top != round(self.scroll_offset.y):
self.scroll_to(y=top, animate=False)
+ def watch_scroll_y(self, old_value: float, new_value: float) -> None:
+ super().watch_scroll_y(old_value, new_value)
+ if round(old_value) != round(new_value):
+ self.post_message(DecompView.Scrolled())
+
def line_for_ea(self, ea: int) -> int | None:
"""The pseudocode line whose marker ea is the largest <= ``ea`` (the C
line that best covers an instruction address)."""
@@ -4743,10 +4761,11 @@ class IdaTui(App):
dec = self.query_one(DecompView)
if source == "decomp":
dec.set_link(None) # the driver shows its own cursor, no band
- eas = (self._split_eamap[dec.cursor]
- if 0 <= dec.cursor < len(self._split_eamap) else [])
+ line, screen = self._split_anchor(dec)
+ eas = (self._split_eamap[line]
+ if 0 <= line < len(self._split_eamap) else [])
if not eas: # fallback: the single /*ea*/ marker for the line
- one = dec._line_ea(dec.cursor)
+ one = dec._line_ea(line)
eas = [one] if one is not None else []
rows: set[int] = set()
if lst.model is not None:
@@ -4756,19 +4775,21 @@ class IdaTui(App):
rows.add(r)
lst.set_link(rows)
if rows:
- # keep the linked region level with the driver's cursor row so
+ # keep the linked region level with the driver's anchor row so
# the eye tracks straight across the two panes
- lst.align(min(rows), dec.cursor - round(dec.scroll_offset.y))
+ lst.align(min(rows), screen)
else: # the listing drives
lst.set_link(set())
- ea = lst._cursor_ea()
+ row, screen = self._split_anchor(lst)
+ head = lst._head(row)
+ ea = head.ea if head is not None else None
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).
+ # left the decompiled function — follow to whatever function is
+ # under the anchor (the unified view spans many functions).
self._resync_decomp(ea)
return
line = self._split_ea2line.get(ea)
@@ -4776,10 +4797,29 @@ class IdaTui(App):
line = dec.line_for_ea(ea)
if line is not None:
dec.set_link(line)
- dec.align(line, lst.cursor - round(lst.scroll_offset.y))
+ dec.align(line, screen)
else:
dec.set_link(None)
+ @staticmethod
+ def _split_anchor(view) -> tuple[int, int]: # type: ignore[no-untyped-def]
+ """(row, screen_row) the split sync anchors on: the driver's cursor while
+ it's visible, else the top visible row. A wheel-scroll/scrollbar drag
+ never moves the cursor, so anchoring on the viewport keeps the companion
+ following once the cursor scrolls out of sight."""
+ top = round(view.scroll_offset.y)
+ if top <= view.cursor < top + view._visible_height():
+ return view.cursor, view.cursor - top
+ return top, 0
+
+ def on_listing_view_scrolled(self, msg: "ListingView.Scrolled") -> None:
+ if self._split and self._active == "listing":
+ self._sync_split("listing")
+
+ def on_decomp_view_scrolled(self, msg: "DecompView.Scrolled") -> None:
+ if self._split and self._active == "decomp":
+ self._sync_split("decomp")
+
@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
diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py
index d5f710e..acf05f2 100644
--- a/tests/test_scenarios.py
+++ b/tests/test_scenarios.py
@@ -409,11 +409,19 @@ async def s_split_view(c: Ctx):
multi = next((i for i, eas in enumerate(m) if len(eas) > 1), None)
if multi is not None:
app._split_eamap = m
+ dec.focus() # the decomp must BE the driver for a decomp-driven
+ app._active = "decomp" # sync (else its align() re-syncs listing-driven)
dec.cursor = multi
+ dec._scroll_cursor_into_view() # key-nav always does; the anchor needs it
+ await c.pause(0.1)
app._sync_split("decomp")
+ await c.pause(0.1)
c.check("a multi-instruction C line bands a region (>1 listing row)",
len(lst._link_rows) > 1,
f"line={multi} eas={len(m[multi])} rows={sorted(lst._link_rows)[:8]}")
+ lst.focus()
+ app._active = "listing"
+ await c.pause(0.05)
else:
c.check("a multi-instruction C line bands a region (>1 listing row)",
True, "no multi-instruction line in this function (skipped)")
@@ -447,6 +455,16 @@ async def s_split_view(c: Ctx):
c.check("the companion pane sits level with the driver's cursor",
link is not None and top == want,
f"driver_row={drv} link={link} dec_top={top} want={want}")
+ # a PURE scroll (wheel/scrollbar) moves no cursor — it must still drag
+ # the companion along (anchors on the viewport once the cursor is gone)
+ before_cur, before_dec = lst.cursor, round(dec.scroll_offset.y)
+ lst.scroll_to(y=round(lst.scroll_offset.y) + 30, animate=False)
+ await c.pause(0.35)
+ c.check("a pure scroll in the driver drags the companion along",
+ lst.cursor == before_cur
+ and round(dec.scroll_offset.y) != before_dec,
+ f"cursor {before_cur}->{lst.cursor} "
+ f"dec_top {before_dec}->{round(dec.scroll_offset.y)}")
await c.press("tab")
await c.pause(0.1)
c.check("Tab in split focuses the pseudocode pane", app._active == "decomp",
@@ -458,6 +476,8 @@ async def s_split_view(c: Ctx):
"no /*0xEA*/ markers in the pseudocode")
if target is not None:
dec.cursor = target
+ dec._scroll_cursor_into_view()
+ await c.pause(0.1)
app._sync_split("decomp")
await c.pause(0.1)
want = lst.model.ensure_ea(dec._line_eas[target])
@@ -493,6 +513,8 @@ async def s_split_view(c: Ctx):
lst.focus()
app._active = "listing"
lst.cursor = row
+ lst._scroll_cursor_into_view()
+ await c.pause(0.1)
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",