Skip to content

Commit a61f41e

Browse files
committed
Fix container anchor re-engagement during continuous content streaming
1 parent 06dbeef commit a61f41e

3 files changed

Lines changed: 68 additions & 3 deletions

File tree

src/textual/_compositor.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -606,6 +606,7 @@ def add_widget(
606606
# Get the region that will be updated
607607
sub_clip = clip.intersection(child_region)
608608

609+
widget._check_anchor()
609610
if widget._anchored and not widget._anchor_released:
610611
new_scroll_y = (
611612
arrange_result.spatial_map.total_region.bottom
@@ -690,6 +691,7 @@ def add_widget(
690691
)
691692
layer_order -= 1
692693
else:
694+
widget._check_anchor()
693695
if widget._anchored and not widget._anchor_released:
694696
new_scroll_y = widget.virtual_size.height - (
695697
widget.container_size.height

src/textual/widget.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -825,7 +825,10 @@ def _check_anchor(self) -> None:
825825
if (
826826
self._anchored
827827
and self._anchor_released
828-
and self.scroll_y >= self.max_scroll_y
828+
and (
829+
self.scroll_y >= self.max_scroll_y
830+
or self.scroll_target_y >= self.max_scroll_y
831+
)
829832
):
830833
self._anchor_released = False
831834

@@ -2748,7 +2751,12 @@ def _scroll_to(
27482751
"""
27492752

27502753
if release_anchor:
2751-
self.release_anchor()
2754+
if y is not None and y >= self.max_scroll_y:
2755+
release_anchor = False
2756+
if self._anchored:
2757+
self._anchor_released = False
2758+
else:
2759+
self.release_anchor()
27522760
maybe_scroll_x = x is not None and (self.allow_horizontal_scroll or force)
27532761
maybe_scroll_y = y is not None and (self.allow_vertical_scroll or force)
27542762
scrolled_x = scrolled_y = False
@@ -2819,6 +2827,9 @@ def _animate_on_complete() -> None:
28192827
if on_complete is not None:
28202828
self.call_after_refresh(on_complete)
28212829

2830+
if self._anchored and self._anchor_released:
2831+
self._check_anchor()
2832+
28222833
return scrolled_x or scrolled_y
28232834

28242835
@property
@@ -2891,7 +2902,12 @@ def scroll_to(
28912902
The call to scroll is made after the next refresh.
28922903
"""
28932904
if release_anchor:
2894-
self.release_anchor()
2905+
if y is not None and y >= self.max_scroll_y:
2906+
release_anchor = False
2907+
if self._anchored:
2908+
self._anchor_released = False
2909+
else:
2910+
self.release_anchor()
28952911
animator = self.app.animator
28962912
if x is not None:
28972913
animator.force_stop_animation(self, "scroll_x")
@@ -2908,6 +2924,7 @@ def scroll_to(
29082924
force=force,
29092925
on_complete=on_complete,
29102926
level=level,
2927+
release_anchor=release_anchor,
29112928
)
29122929
else:
29132930
self.call_after_refresh(
@@ -2921,6 +2938,7 @@ def scroll_to(
29212938
force=force,
29222939
on_complete=on_complete,
29232940
level=level,
2941+
release_anchor=release_anchor,
29242942
)
29252943

29262944
def scroll_relative(

tests/test_anchor.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from textual.app import App, ComposeResult
2+
from textual.containers import VerticalScroll
3+
from textual.widgets import Static
4+
5+
6+
async def test_anchor_streaming_repro_flow() -> None:
7+
class AnchorStuck(App):
8+
def compose(self) -> ComposeResult:
9+
with VerticalScroll(id="v"):
10+
for i in range(30):
11+
yield Static(f"line {i}")
12+
13+
app = AnchorStuck()
14+
async with app.run_test() as pilot:
15+
v = app.query_one("#v", VerticalScroll)
16+
v.anchor()
17+
await pilot.pause()
18+
19+
assert v.is_anchored
20+
assert not v._anchor_released
21+
assert v.scroll_y == v.max_scroll_y
22+
23+
# User scrolls up by 1 line while streaming
24+
v.scroll_relative(y=-1, animate=False)
25+
await pilot.pause()
26+
assert v._anchor_released is True
27+
frozen_scroll_y = v.scroll_y
28+
29+
# Mounting items while released keeps scroll position frozen
30+
v.mount(Static("new line 0"))
31+
await pilot.pause()
32+
assert v._anchor_released is True
33+
assert v.scroll_y == frozen_scroll_y
34+
35+
# User scrolls back down to bottom edge
36+
v.scroll_to(y=v.max_scroll_y, animate=False)
37+
await pilot.pause()
38+
assert v._anchor_released is False
39+
assert v.scroll_y == v.max_scroll_y
40+
41+
# Mounting new items after re-engaging keeps viewport glued to bottom
42+
v.mount(Static("new line 1"))
43+
await pilot.pause()
44+
assert v._anchor_released is False
45+
assert v.scroll_y == v.max_scroll_y

0 commit comments

Comments
 (0)