Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 27 additions & 18 deletions src/textual/_compositor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down
25 changes: 19 additions & 6 deletions src/textual/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down
119 changes: 119 additions & 0 deletions tests/test_anchor_scroll.py
Original file line number Diff line number Diff line change
@@ -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}"