diff options
| author | blasty <blasty@local> | 2026-07-25 11:02:14 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-07-25 11:02:14 +0200 |
| commit | 2837a56d4c3bbd251d8927e2c5668f2f2ffcba7f (patch) | |
| tree | 36782a78d20ba43c3c8c4ecb65a1db778df2081d | |
| parent | split: keep the companion pane level with the driver's cursor (diff) | |
| download | ida-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.py | 58 | ||||
| -rw-r--r-- | tests/test_scenarios.py | 22 |
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", |
