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
2 changes: 2 additions & 0 deletions src/textual/_compositor.py
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,7 @@ def add_widget(
# Get the region that will be updated
sub_clip = clip.intersection(child_region)

widget._check_anchor()
if widget._anchored and not widget._anchor_released:
new_scroll_y = (
arrange_result.spatial_map.total_region.bottom
Comment on lines +609 to 612
Expand Down Expand Up @@ -690,6 +691,7 @@ def add_widget(
)
layer_order -= 1
else:
widget._check_anchor()
if widget._anchored and not widget._anchor_released:
new_scroll_y = widget.virtual_size.height - (
widget.container_size.height
Expand Down
6 changes: 1 addition & 5 deletions src/textual/visual.py
Original file line number Diff line number Diff line change
Expand Up @@ -235,11 +235,7 @@ def to_strips(
selection_style,
),
)
if (
widget.auto_links
and not widget.is_container
and not widget.screen._selecting
):
if widget.auto_links and not widget.is_container:
link_style = widget.link_style
strips = [strip._apply_link_style(link_style) for strip in strips]

Expand Down
24 changes: 21 additions & 3 deletions src/textual/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -825,7 +825,10 @@ def _check_anchor(self) -> None:
if (
self._anchored
and self._anchor_released
and self.scroll_y >= self.max_scroll_y
and (
self.scroll_y >= self.max_scroll_y
or self.scroll_target_y >= self.max_scroll_y
)
):
self._anchor_released = False

Expand Down Expand Up @@ -2748,7 +2751,12 @@ def _scroll_to(
"""

if release_anchor:
self.release_anchor()
if y is not None and y >= self.max_scroll_y:
release_anchor = False
if self._anchored:
self._anchor_released = False
Comment on lines 2753 to +2757
else:
self.release_anchor()
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 @@ -2819,6 +2827,9 @@ def _animate_on_complete() -> None:
if on_complete is not None:
self.call_after_refresh(on_complete)

if self._anchored and self._anchor_released:
self._check_anchor()

return scrolled_x or scrolled_y

@property
Expand Down Expand Up @@ -2891,7 +2902,12 @@ def scroll_to(
The call to scroll is made after the next refresh.
"""
if release_anchor:
self.release_anchor()
if y is not None and y >= self.max_scroll_y:
release_anchor = False
if self._anchored:
self._anchor_released = False
else:
self.release_anchor()
animator = self.app.animator
if x is not None:
animator.force_stop_animation(self, "scroll_x")
Expand All @@ -2908,6 +2924,7 @@ def scroll_to(
force=force,
on_complete=on_complete,
level=level,
release_anchor=release_anchor,
)
else:
self.call_after_refresh(
Expand All @@ -2921,6 +2938,7 @@ def scroll_to(
force=force,
on_complete=on_complete,
level=level,
release_anchor=release_anchor,
)

def scroll_relative(
Expand Down
45 changes: 45 additions & 0 deletions tests/test_anchor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from textual.app import App, ComposeResult
from textual.containers import VerticalScroll
from textual.widgets import Static


async def test_anchor_streaming_repro_flow() -> None:
class AnchorStuck(App):
def compose(self) -> ComposeResult:
with VerticalScroll(id="v"):
for i in range(30):
yield Static(f"line {i}")

app = AnchorStuck()
async with app.run_test() as pilot:
v = app.query_one("#v", VerticalScroll)
v.anchor()
await pilot.pause()

assert v.is_anchored
assert not v._anchor_released
assert v.scroll_y == v.max_scroll_y

# User scrolls up by 1 line while streaming
v.scroll_relative(y=-1, animate=False)
await pilot.pause()
assert v._anchor_released is True
frozen_scroll_y = v.scroll_y

# Mounting items while released keeps scroll position frozen
v.mount(Static("new line 0"))
await pilot.pause()
assert v._anchor_released is True
assert v.scroll_y == frozen_scroll_y

# User scrolls back down to bottom edge
v.scroll_to(y=v.max_scroll_y, animate=False)
await pilot.pause()
assert v._anchor_released is False
assert v.scroll_y == v.max_scroll_y

# Mounting new items after re-engaging keeps viewport glued to bottom
v.mount(Static("new line 1"))
await pilot.pause()
assert v._anchor_released is False
assert v.scroll_y == v.max_scroll_y
44 changes: 44 additions & 0 deletions tests/test_selection.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,3 +225,47 @@ async def test_select_out_of_scrollable_container_on_gap():
assert (
f"item-{i:02d}" in selected_text
), f"item-{i:02d} missing from {selected_text!r}"


async def test_link_style_preserved_during_selection():
"""Test that link style is preserved on widgets re-rendered while screen._selecting is active."""

class LinkApp(App[None]):
CSS = "Static { link-color: cyan; link-style: underline; }"

def compose(self) -> ComposeResult:
yield Static("See the [@click='app.bell()']docs[/] link.")

app = LinkApp()
async with app.run_test() as pilot:
await pilot.pause()
static_widget = app.query_one(Static)

# Before selection
strip_before = static_widget.render_line(0)
docs_seg_before = [seg for seg in strip_before if "docs" in seg.text][0]
assert docs_seg_before.style.underline is True
assert docs_seg_before.style.color.name == "#00ffff"

# Mouse down (triggers selecting state)
assert await pilot.mouse_down(offset=(0, 0))
await pilot.pause()

assert app.screen._selecting is True
static_widget.refresh()
strip_selecting = static_widget.render_line(0)
docs_seg_selecting = [seg for seg in strip_selecting if "docs" in seg.text][0]
assert docs_seg_selecting.style.underline is True
assert docs_seg_selecting.style.color.name == "#00ffff"

# Mouse up (selection ends at index 10, splitting 'docs' into 'doc' and 's')
await pilot.mouse_up(offset=(10, 0))
await pilot.pause()

strip_after = static_widget.render_line(0)
docs_segs = [seg for seg in strip_after if any(char in seg.text for char in "docs") and seg.text not in ("See the ", " link.")]
assert len(docs_segs) > 0
for seg in docs_segs:
assert seg.style.underline is True
assert seg.style.color.name == "#00ffff"