From 154f41637ad853281a96bfc658eb89ba00ccf472 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Thu, 8 Oct 2026 09:54:59 -0400 Subject: [PATCH] Keep scroll intent when layout sources are pending --- src/textual/widget.py | 13 ++--- tests/test_native_scene_size_publication.py | 58 +++++++++++++++++++++ 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index 378b3b7801..dcdbb95e11 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -4903,12 +4903,13 @@ def _check_refresh(self) -> None: self.call_later(self.update_node_styles) if self._scroll_required: self._scroll_required = False - if not self._layout_required: - if self.styles.keyline[0] != "none": - # TODO: Feels like a hack - # Perhaps there should be an explicit mechanism for backgrounds to refresh when scrolled? - self._set_dirty() - screen.post_message(messages.UpdateScroll()) + if self.styles.keyline[0] != "none": + # A keyline's background follows its content scroll. + self._set_dirty() + # Layout sources may be held at the screen. Preserve the + # independent scroll intent; Screen owns whether actionable + # sources require full layout or only visible reflow. + screen.post_message(messages.UpdateScroll()) if self._repaint_required: self._repaint_required = False if self.display: diff --git a/tests/test_native_scene_size_publication.py b/tests/test_native_scene_size_publication.py index 196322ef4c..f656070379 100644 --- a/tests/test_native_scene_size_publication.py +++ b/tests/test_native_scene_size_publication.py @@ -8,6 +8,64 @@ from textual.widgets import Static +async def test_scroll_intent_survives_layout_and_held_sources(): + from textual.screen import Screen + + history = VerticalScroll(Static("\n".join(str(n) for n in range(50)))) + + class IntentScreen(Screen): + held = False + + def __init__(self): + super().__init__() + self.admissions = [] + + def compose(self): + yield history + + def _layout_mutation_roots(self): + return (history,) if self.held else () + + def _prepare_compositor_refresh(self): + return self._layout_mutation_roots() + + def _refresh_layout(self, size=None, scroll=False): + self.admissions.append(( + scroll, self._has_actionable_layout_requests(self._held_layout_requests()) + )) + super()._refresh_layout(size, scroll=scroll) + + class IntentApp(App): + CSS = "VerticalScroll { height: 8; } Static { height: auto; }" + + def get_default_screen(self): + return IntentScreen() + + app = IntentApp() + async with app.run_test(size=(30, 12)) as pilot: + await pilot.pause() + screen = app.screen + original = screen._compositor.find_widget(history.children[0]) + screen.held = True + screen.admissions.clear() + history.refresh(layout=True) + history.scroll_to(y=10, animate=False, immediate=True) + history._check_refresh() + await pilot.pause() + assert (True, False) in screen.admissions + assert screen._compositor.find_widget(history.children[0]) == original + assert screen._layout_widgets + screen.held = False + screen.admissions.clear() + history.refresh(layout=True) + history.scroll_to(y=15, animate=False, immediate=True) + history._check_refresh() + await pilot.pause() + assert (True, True) in screen.admissions + assert not screen._layout_widgets + assert screen._compositor.find_widget(history.children[0]).region.y == -15 + + class SizeReceipts: def on_resize(self, event: events.Resize) -> None: self.receipts.append((event.size, event.virtual_size, event.container_size))