diff options
| author | blasty <blasty@local> | 2026-07-09 21:27:24 +0200 |
|---|---|---|
| committer | blasty <blasty@local> | 2026-07-09 21:27:24 +0200 |
| commit | 584af7e8edf6224c0f3affb2bb98230078a9e836 (patch) | |
| tree | 0f6b107020a5e0008666bc08851af4e04f053a8b | |
| parent | decompiler: xref jump within the current function moves the cursor (diff) | |
| download | ida-tui-584af7e8edf6224c0f3affb2bb98230078a9e836.tar.gz ida-tui-584af7e8edf6224c0f3affb2bb98230078a9e836.tar.xz ida-tui-584af7e8edf6224c0f3affb2bb98230078a9e836.zip | |
decompiler: fix stale scroll frame on same-function jump
DecompView.goto derived its scroll with _scroll_cursor_into_view() -> a plain
scroll_to, which updates scroll_offset but doesn't repaint at the new offset
(Textual only repaints on a rounded scroll change, and there's no layout pass
since the pane isn't reloaded). So a same-function xref jump landed the cursor
correctly but painted the old scroll until the next cursor move nudged a repaint.
Route goto's scroll (derived or explicit) through _apply_scroll, which re-applies
after the next refresh with layout=True -- the same pattern show() uses. The
cross-function path already worked because reloading + clearing the loading cover
forces a fresh paint. Document the gotcha in TEXTUAL_NOTES.
full suite 64/64.
| -rw-r--r-- | docs/TEXTUAL_NOTES.md | 12 | ||||
| -rw-r--r-- | idatui/app.py | 32 |
2 files changed, 36 insertions, 8 deletions
diff --git a/docs/TEXTUAL_NOTES.md b/docs/TEXTUAL_NOTES.md index ed2e696..eee40fc 100644 --- a/docs/TEXTUAL_NOTES.md +++ b/docs/TEXTUAL_NOTES.md @@ -16,10 +16,20 @@ Hard-won Textual behaviour and the patterns this app relies on. Pairs with `call_after_refresh` with `refresh(layout=True)`; don't zero `virtual_size` on load (snaps scroll to 0 → visible flash). See `ColumnCursor._apply_scroll`, `DisasmView._on_primed`, `DecompView.show`. +- **Scrolling on already-laid-out content (no reload) leaves a stale frame.** A + plain `scroll_to` moves `scroll_offset` but Textual only repaints on a + *rounded* scroll change, and with no virtual_size/content change there's no + layout pass to force a paint — so the pane shows the old frame until the next + interaction (moving the cursor auto-corrects it). Route these through + `_apply_scroll` too (immediate scroll + deferred `refresh(layout=True)`). Bit + us on same-function decompiler jumps (`DecompView.goto`); the reload path + (`DecompView.show`) hides it because clearing the loading cover repaints. - **Pilot lays out synchronously**, so programmatic scroll always has a valid range in tests — it MASKS the real-terminal clamp/no-repaint bug. Assert the **render** (trace `render_line`'s scroll at paint), not just `scroll_offset.y`. - (`test_tui.py`: "pane is repainted at the restored scroll".) + (`test_tui.py`: "pane is repainted at the restored scroll".) Note even a render + trace can't catch the `goto` stale-frame case (no layout pass to observe) — it + only shows in a real terminal. - **`widget.loading = True`** covers the widget via `_cover_widget` (NOT a normal child — `query()` won't find it; check `widget._cover_widget`) and **blurs focus** (focus → None), so `Tab` falls back to focus-navigation. Fix: make the diff --git a/idatui/app.py b/idatui/app.py index 6356a1b..6b76e63 100644 --- a/idatui/app.py +++ b/idatui/app.py @@ -882,19 +882,37 @@ class DecompView(SearchMixin, NavMixin, ColumnCursor, ScrollView, can_focus=True scroll_x: int = 0) -> None: """Move the cursor/scroll on the already-loaded text (no re-highlight). Used to jump to a target inside the function already displayed, e.g. an - xref/goto that resolves to this same function.""" + xref/goto that resolves to this same function. + + All scrolling routes through ``_apply_scroll`` (which re-applies after the + next refresh with ``layout=True``): unlike the reload path there's no new + layout pass to force a repaint, so a plain ``scroll_to`` would leave a + stale frame until the next interaction. + """ total = len(self._strips) if total == 0: return self.cursor = max(0, min(total - 1, cursor)) self.cursor_x = cursor_x self._clamp_x() - if scroll_y >= 0: - self._apply_scroll(min(scroll_y, max(total - 1, 0)), scroll_x) - else: - self._scroll_cursor_into_view() - self._hscroll() - self.refresh() + if scroll_y < 0: # derive a viewport that shows the (line, column) + height = self._visible_height() + top = round(self.scroll_offset.y) + if self.cursor < top: + scroll_y = self.cursor + elif self.cursor >= top + height: + scroll_y = max(self.cursor - height + 1, 0) + else: + scroll_y = top + width = max(self.size.width - self._gutter, 1) + sx = round(self.scroll_offset.x) + if self.cursor_x < sx: + scroll_x = self.cursor_x + elif self.cursor_x >= sx + width: + scroll_x = self.cursor_x - width + 1 + else: + scroll_x = sx + self._apply_scroll(min(max(scroll_y, 0), max(total - 1, 0)), max(scroll_x, 0)) self._after_cursor_move() def get_loading_widget(self): # type: ignore[override] |
