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 /idatui | |
| 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).
Diffstat (limited to 'idatui')
| -rw-r--r-- | idatui/app.py | 58 |
1 files changed, 49 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 |
