From f97508010ab496aa7de5e1add1c69ad0db1f1dbc Mon Sep 17 00:00:00 2001 From: Prabin Kh Date: Wed, 5 Aug 2026 12:17:47 +0545 Subject: [PATCH] fixt the Scrolling back to the bottom does not restore anchored scroll --- src/textual/_compositor.py | 45 ++++++++------ src/textual/widget.py | 25 ++++++-- tests/test_anchor_scroll.py | 119 ++++++++++++++++++++++++++++++++++++ 3 files changed, 165 insertions(+), 24 deletions(-) create mode 100644 tests/test_anchor_scroll.py diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 9af38a23c7..db0a76611d 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -606,17 +606,22 @@ def add_widget( # Get the region that will be updated sub_clip = clip.intersection(child_region) - if widget._anchored and not widget._anchor_released: - new_scroll_y = ( - arrange_result.spatial_map.total_region.bottom - - ( - widget.container_size.height - - widget.scrollbar_size_horizontal - ) + new_scroll_y = ( + arrange_result.spatial_map.total_region.bottom + - ( + widget.container_size.height + - widget.scrollbar_size_horizontal ) - widget.set_reactive(Widget.scroll_y, new_scroll_y) - widget.set_reactive(Widget.scroll_target_y, new_scroll_y) - widget.vertical_scrollbar._reactive_position = new_scroll_y + ) + if widget._anchored: + if widget._anchor_released and widget.scroll_y >= max( + 0, new_scroll_y + ): + widget._anchor_released = False + if not widget._anchor_released: + widget.set_reactive(Widget.scroll_y, new_scroll_y) + widget.set_reactive(Widget.scroll_target_y, new_scroll_y) + widget.vertical_scrollbar._reactive_position = new_scroll_y if visible_only: placements = arrange_result.get_visible_placements( @@ -690,14 +695,18 @@ def add_widget( ) layer_order -= 1 else: - if widget._anchored and not widget._anchor_released: - new_scroll_y = widget.virtual_size.height - ( - widget.container_size.height - - widget.scrollbar_size_horizontal - ) - widget.scroll_y = new_scroll_y - widget.scroll_target_y = new_scroll_y - widget.vertical_scrollbar.position = new_scroll_y + new_scroll_y = widget.virtual_size.height - ( + widget.container_size.height - widget.scrollbar_size_horizontal + ) + if widget._anchored: + if widget._anchor_released and widget.scroll_y >= max( + 0, new_scroll_y + ): + widget._anchor_released = False + if not widget._anchor_released: + widget.scroll_y = new_scroll_y + widget.scroll_target_y = new_scroll_y + widget.vertical_scrollbar.position = new_scroll_y if visible: # Add any scrollbars diff --git a/src/textual/widget.py b/src/textual/widget.py index 63ef5da97a..c60d48bdd0 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -820,12 +820,25 @@ def release_anchor(self) -> None: self.scroll_target_y = self.scroll_y self._anchor_released = True + def _handle_scroll_anchor(self, x: float | None, y: float | None) -> None: + """Update anchor state when scrolling programmatically.""" + if y is not None: + target_y = clamp(y, 0, self.max_scroll_y) + if self._anchored: + if target_y >= self.max_scroll_y: + self._anchor_released = False + return + self.release_anchor() + return + if x is not None and clamp(x, 0, self.max_scroll_x) < self.max_scroll_x: + self.release_anchor() + def _check_anchor(self) -> None: - """Check if the scroll position is near enough to the bottom to restore anchor.""" + """Check if the scroll position is at the bottom to restore anchor.""" if ( self._anchored and self._anchor_released - and self.scroll_y >= self.max_scroll_y + and self.is_vertical_scroll_end ): self._anchor_released = False @@ -2741,14 +2754,14 @@ def _scroll_to( force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. level: Minimum level required for the animation to take place (inclusive). - release_anchor: If `True` call `release_anchor`. + release_anchor: If `True`, update the anchor state for this scroll. Returns: `True` if the scroll position changed, otherwise `False`. """ if release_anchor: - self.release_anchor() + self._handle_scroll_anchor(x, y) maybe_scroll_x = x is not None and (self.allow_horizontal_scroll or force) maybe_scroll_y = y is not None and (self.allow_vertical_scroll or force) scrolled_x = scrolled_y = False @@ -2885,13 +2898,13 @@ def scroll_to( level: Minimum level required for the animation to take place (inclusive). immediate: If `False` the scroll will be deferred until after a screen refresh, set to `True` to scroll immediately. - release_anchor: If `True` call `release_anchor`. + release_anchor: If `True`, update the anchor state for this scroll. Note: The call to scroll is made after the next refresh. """ if release_anchor: - self.release_anchor() + self._handle_scroll_anchor(x, y) animator = self.app.animator if x is not None: animator.force_stop_animation(self, "scroll_x") diff --git a/tests/test_anchor_scroll.py b/tests/test_anchor_scroll.py new file mode 100644 index 0000000000..7c7fcf7b73 --- /dev/null +++ b/tests/test_anchor_scroll.py @@ -0,0 +1,119 @@ +"""Tests for anchored scroll behavior.""" + +from textual.app import App, ComposeResult +from textual.containers import VerticalScroll +from textual.widgets import Static + + +async def test_anchor_follows_new_content() -> None: + """An anchored widget stays at the bottom as content grows.""" + + class AnchorApp(App[None]): + def compose(self) -> ComposeResult: + with VerticalScroll(id="v"): + for i in range(30): + yield Static(f"line {i}") + + def on_mount(self) -> None: + self.query_one("#v", VerticalScroll).anchor() + + app = AnchorApp() + async with app.run_test(size=(40, 10)) as pilot: + v = app.query_one("#v", VerticalScroll) + await pilot.pause() + assert v.is_vertical_scroll_end + + v.mount(Static("line 30")) + await pilot.pause() + assert v.is_vertical_scroll_end + assert v.is_anchored + assert not v._anchor_released + + +async def test_anchor_released_when_scrolling_up() -> None: + """Scrolling away from the bottom releases the anchor.""" + + class AnchorApp(App[None]): + def compose(self) -> ComposeResult: + with VerticalScroll(id="v"): + for i in range(30): + yield Static(f"line {i}") + + def on_mount(self) -> None: + self.query_one("#v", VerticalScroll).anchor() + + app = AnchorApp() + async with app.run_test(size=(40, 10)) as pilot: + v = app.query_one("#v", VerticalScroll) + await pilot.pause() + max_before = v.max_scroll_y + + v.scroll_relative(y=-1, animate=False, immediate=True) + await pilot.pause() + assert v._anchor_released + assert v.scroll_y < max_before + + v.mount(Static("line 30")) + await pilot.pause() + assert v.scroll_y < v.max_scroll_y + + +async def test_anchor_restored_when_scrolling_to_bottom() -> None: + """Scrolling back to the bottom re-engages the anchor.""" + + class AnchorApp(App[None]): + def compose(self) -> ComposeResult: + with VerticalScroll(id="v"): + for i in range(30): + yield Static(f"line {i}") + + def on_mount(self) -> None: + self.query_one("#v", VerticalScroll).anchor() + + app = AnchorApp() + async with app.run_test(size=(40, 10)) as pilot: + v = app.query_one("#v", VerticalScroll) + await pilot.pause() + + v.scroll_relative(y=-1, animate=False, immediate=True) + await pilot.pause() + assert v._anchor_released + + v.scroll_end(immediate=True, animate=False) + await pilot.pause() + assert not v._anchor_released + + v.mount(Static("line 30")) + await pilot.pause() + assert v.is_vertical_scroll_end + + +async def test_anchor_restored_while_content_streams() -> None: + """Reaching the bottom while content streams restores anchored scroll.""" + + class AnchorStuck(App[None]): + def compose(self) -> ComposeResult: + with VerticalScroll(id="v"): + for i in range(30): + yield Static(f"line {i}") + + def on_mount(self) -> None: + self.query_one("#v", VerticalScroll).anchor() + + app = AnchorStuck() + async with app.run_test(size=(40, 10)) as pilot: + v = app.query_one("#v", VerticalScroll) + await pilot.pause() + + v.scroll_relative(y=-1, animate=False, immediate=True) + await pilot.pause() + assert v._anchor_released + + v.scroll_end(immediate=True, animate=False) + await pilot.pause() + assert not v._anchor_released + + for i in range(5): + v.mount(Static(f"line {30 + i}")) + await pilot.pause() + assert v.is_vertical_scroll_end, f"Failed to stay at bottom after line {i}"