[PATCH b4 1/2] b4 tui: preserve list scroll position across ListView rebuilds
Christian Brauner <[email protected]> Wed, 29 Jul 2026 11:58:19 +0200
| Newsgroups | org.kernel.linux.tools |
|---|---|
| Message-ID | <20260729-work-b4-tui-scroll-preserve-v1-1-42be22445eaf@kernel.org> |
The tracking and bugs TUIs rebuild their list wholesale on every refresh. The old ListView is removed and a freshly constructed one is created so the scroll position dies with the old widget. Restoring the cursor index alone only scrolls the minimum needed to reveal that row and so after every rebuild (updating a series with 'u', a status change, the db-mtime poll) the viewport visibly jumped towards the top while the cursor stayed on the right entry. Add ReplacementListView to b4.tui and use it for both tracker-style lists. It captures the predecessor's scroll offset and seeds the replacement's scroll state on mount, before the first paint, so the swap is invisible: - set_scroll() seeds scroll_y: scroll_to() would clamp against the zero virtual size of the not-yet-laid-out widget. The first reflow re-validates the value, clamping it if the new list is shorter. - scroll_target_y is seeded as well: wheel and page scrolling compute from it, so leaving it at 0 would snap the view back to the top on the first scroll tick. - The scrollbar thumb is positioned manually: nothing syncs it when the first reflow's re-validation does not change scroll_y. There is deliberately no initial_index. ListView's default of 0 schedules a scroll-into-view for row 0 on mount, which would fire after the restore and undo it. The index is assigned explicitly after mounting instead. That schedules a minimal scroll-into-view which is a no-op when the restored offset already shows the row, and otherwise keeps the cursor visible (e.g. after a re-sort moved it). The patchwork and lite lists rebuild without any cursor restore, and the bug-detail comment list jumps to the top or end by design, so those keep constructing plain ListViews. Signed-off-by: Christian Brauner (Amutable) <[email protected]> --- src/b4/bugs/_tui.py | 11 ++++++-- src/b4/review_tui/_common.py | 3 +++ src/b4/review_tui/_tracking_app.py | 9 +++++-- src/b4/tui/__init__.py | 3 +++ src/b4/tui/_common.py | 55 +++++++++++++++++++++++++++++++++++++- 5 files changed, 76 insertions(+), 5 deletions(-) diff --git a/src/b4/bugs/_tui.py b/src/b4/bugs/_tui.py index c383e94..c301af7 100644 --- a/src/b4/bugs/_tui.py +++ b/src/b4/bugs/_tui.py @@ -42,6 +42,7 @@ from b4.tui import ( ConfirmScreen, JKListNavMixin, LimitScreen, + ReplacementListView, SeparatedFooter, _quiet_worker, _wait_for_enter, @@ -2038,7 +2039,11 @@ class BugListApp(JKListNavMixin, App[None]): seen = self._seen_counts.get(bug.id, count) unseen = max(0, count - seen) items.append(BugListItem(bug, unseen=unseen)) - lv = ListView(*items, id='bug-list') + lv = ReplacementListView( + *items, + id='bug-list', + scroll_y=ReplacementListView.capture_scroll(self, '#bug-list'), + ) with self.app.batch_update(): old_lv = self.query_one('#bug-list', ListView) @@ -2046,12 +2051,14 @@ class BugListApp(JKListNavMixin, App[None]): await self.mount(lv, before=self.query_one('#details-panel', Vertical)) # Restore cursor to previously focused bug + new_index = 0 if self._focus_bug_id: for idx, item in enumerate(items): if item.bug.id == self._focus_bug_id: - lv.index = idx + new_index = idx self._focus_bug_id = None break + lv.index = new_index lv.focus() async def on_worker_state_changed(self, event: Worker.StateChanged) -> None: diff --git a/src/b4/review_tui/_common.py b/src/b4/review_tui/_common.py index 873681b..935f553 100644 --- a/src/b4/review_tui/_common.py +++ b/src/b4/review_tui/_common.py @@ -46,6 +46,9 @@ from b4.tui._common import ( from b4.tui._common import ( JKListNavMixin as JKListNavMixin, ) +from b4.tui._common import ( + ReplacementListView as ReplacementListView, +) from b4.tui._common import ( SeparatedFooter as SeparatedFooter, ) diff --git a/src/b4/review_tui/_tracking_app.py b/src/b4/review_tui/_tracking_app.py index 2b29dc1..59c52e9 100644 --- a/src/b4/review_tui/_tracking_app.py +++ b/src/b4/review_tui/_tracking_app.py @@ -43,6 +43,7 @@ from b4.review_tui._common import ( QUIT_BINDINGS, CheckRunnerMixin, LoreNodeShutdownMixin, + ReplacementListView, SeparatedFooter, _fix_ansi_theme, _quiet_worker, @@ -1276,6 +1277,8 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]): title_text += f' (limit: {self._limit_pattern})' left.update(title_text) + scroll_y = ReplacementListView.capture_scroll(self, '#tracking-list') + # Suppress rendering while we swap old widgets for new ones. # Without this, the remove-then-mount sequence can produce a # single intermediate frame showing only the title bar before @@ -1299,19 +1302,21 @@ class TrackingApp(LoreNodeShutdownMixin, CheckRunnerMixin, App[Optional[str]]): header = Static(header_text, id='tracking-header') list_items: List[ListItem] = [TrackedSeriesItem(s) for s in display_series] - lv = ListView(*list_items, id='tracking-list') + lv = ReplacementListView(*list_items, id='tracking-list', scroll_y=scroll_y) await self.mount(header, before=self.query_one(Footer)) await self.mount(lv, before=self.query_one(Footer)) + new_index = 0 if self._focus_change_id: for idx, item in enumerate(list_items): if ( isinstance(item, TrackedSeriesItem) and item.series.get('change_id') == self._focus_change_id ): - lv.index = idx + new_index = idx break self._focus_change_id = None + lv.index = new_index lv.focus() # Populate the details panel for the highlighted item now that diff --git a/src/b4/tui/__init__.py b/src/b4/tui/__init__.py index 82ae122..ab9186e 100644 --- a/src/b4/tui/__init__.py +++ b/src/b4/tui/__init__.py @@ -15,6 +15,7 @@ if TYPE_CHECKING: from b4.tui._common import ( QUIT_BINDINGS, JKListNavMixin, + ReplacementListView, SeparatedFooter, _addrs_to_lines, _fix_ansi_theme, @@ -48,6 +49,7 @@ __all__ = [ 'ConfirmScreen', 'JKListNavMixin', 'LimitScreen', + 'ReplacementListView', 'SeparatedFooter', 'ToCcScreen', '_addrs_to_lines', @@ -71,6 +73,7 @@ __all__ = [ _LAZY_ATTRS: dict[str, str] = { 'QUIT_BINDINGS': '_common', 'JKListNavMixin': '_common', + 'ReplacementListView': '_common', 'SeparatedFooter': '_common', '_addrs_to_lines': '_common', '_fix_ansi_theme': '_common', diff --git a/src/b4/tui/_common.py b/src/b4/tui/_common.py index 5b68ee8..48052d5 100644 --- a/src/b4/tui/_common.py +++ b/src/b4/tui/_common.py @@ -14,7 +14,9 @@ from typing import Any, Dict, List, Optional, Protocol from textual.app import App, ComposeResult from textual.binding import Binding -from textual.widgets import Footer, ListView +from textual.css.query import NoMatches +from textual.dom import DOMNode +from textual.widgets import Footer, ListItem, ListView from textual.widgets._footer import FooterKey from textual.worker import NoActiveWorker, get_current_worker @@ -267,6 +269,57 @@ def _validate_addrs(text: str) -> Optional[str]: return None +class ReplacementListView(ListView): + """A ListView that replaces a predecessor without moving the viewport. + + The tracker-style screens rebuild their ListView wholesale on every + refresh, so the scroll position dies with the old widget. Restoring + the cursor index alone only scrolls the minimum needed to reveal + that row, which made the viewport visibly jump towards the top on + every refresh. Capture the predecessor's offset with + :meth:`capture_scroll` before removing it, pass it as *scroll_y*, + and the replacement seeds its scroll state on mount, before the + first paint. + + There is deliberately no ``initial_index``: ListView's default of 0 + schedules a scroll-into-view for row 0 on mount, which would fire + after the restore and undo it. Assign ``index`` explicitly after + mounting instead — that schedules a minimal scroll-into-view which + is a no-op when the restored offset already shows the row, and + otherwise keeps the cursor visible (e.g. after a re-sort moved it). + """ + + def __init__( + self, *children: ListItem, scroll_y: float = 0.0, id: Optional[str] = None + ) -> None: + super().__init__(*children, initial_index=None, id=id) + self._replaced_scroll_y = scroll_y + + @staticmethod + def capture_scroll(node: DOMNode, selector: str) -> float: + """Return the scroll offset of *selector*'s ListView, or 0.0.""" + try: + return node.query_one(selector, ListView).scroll_y + except NoMatches: + return 0.0 + + def on_mount(self) -> None: + if not self._replaced_scroll_y: + return + # scroll_to() cannot seed the position here: layout hasn't run + # yet, so it would clamp against a zero virtual size. The first + # reflow re-validates these values against the real size, which + # clamps them if the new list is shorter. scroll_target_y feeds + # wheel/page scrolling, and nothing syncs the scrollbar thumb + # when that re-validation is a no-op, so seed both as well. + self.set_scroll(None, self._replaced_scroll_y) + self.set_reactive( + ListView.scroll_target_y, # pyright: ignore[reportArgumentType] # cannot pick Reactive's class-access __get__ overload + float(round(self._replaced_scroll_y)), + ) + self.vertical_scrollbar.position = round(self._replaced_scroll_y) + + class _ListViewHost(Protocol): _list_id: str -- 2.53.0