Skip to content

Fix container anchor re-engagement during continuous content streaming - #6694

Open
Jyotish08 wants to merge 2 commits into
Textualize:mainfrom
Jyotish08:main
Open

Jyotish08 wants to merge 2 commits into
Textualize:mainfrom
Jyotish08:main

Conversation

@Jyotish08

@Jyotish08 Jyotish08 commented Aug 9, 2026 •

Copy link
Copy Markdown

closes #6677 Problem
When Widget.anchor() is active on a scrollable container (e.g. VerticalScroll), any call to scroll_to(), scroll_relative(), scroll_by(), or scrollbar interaction sets release_anchor=True, placing the widget into _anchor_released = True.

While content is actively streaming (e.g. mounting child widgets continuously), max_scroll_y grows every frame. Re-engaging the anchor via _check_anchor() required scroll_y >= max_scroll_y exactly. Because max_scroll_y increases continuously while content is added, scroll_y lags behind max_scroll_y by at least 1 unit during scroll events, rendering exact equality unreachable. Consequently, once released, an anchored container would permanently remain unanchored and fail to follow new content even after scrolling back to the bottom.

Solution
Target position anchor restoration: Updated _check_anchor() in widget.py to evaluate self.scroll_target_y >= self.max_scroll_y alongside self.scroll_y >= self.max_scroll_y.
Bottom scroll anchor protection: In scroll_to() and _scroll_to(), when y >= self.max_scroll_y, release_anchor is suppressed and _anchor_released is reset to False (self._anchor_released = False), as scrolling to or beyond the bottom indicates an intent to stay at the bottom edge.
Compositor anchor check: In _compositor.py, widget._check_anchor() is now invoked immediately before evaluating if widget._anchored and not widget._anchor_released:, ensuring _anchor_released is cleared before bottom-gluing calculations occur during layout rendering.
Testing
Added regression test test_anchor_streaming_repro_flow in tests/test_anchor.py verifying that:
Scrolling away (y=-1) releases the anchor and keeps scroll_y frozen while items mount.
Scrolling back to the bottom (v.scroll_to(y=v.max_scroll_y)) restores the anchor and resumes gluing the viewport to new content as items mount.
Verified all core unit tests pass (pytest tests/test_widget.py tests/test_containers.py tests/test_auto_scroll.py tests/test_anchor.py).

Copilot AI lite review requested due to automatic review settings August 9, 2026 09:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adjusts Textual’s anchoring behavior for scrollable containers so that anchors can be re-engaged reliably while content is continuously appended (streaming/mounting), and adds a regression test to cover the reported flow.

Changes:

  • Update Widget._check_anchor() to allow re-engagement based on scroll_target_y as well as scroll_y.
  • Prevent “scroll-to-bottom” operations from releasing the anchor, and ensure anchor state is restored when targeting the bottom.
  • Call _check_anchor() during compositor layout before applying anchored “glue to bottom” behavior; add a streaming regression test.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
tests/test_anchor.py Adds regression coverage for anchor release + re-engagement during incremental mounting.
src/textual/widget.py Updates anchor restoration criteria and modifies scroll methods to preserve anchoring when targeting the bottom.
src/textual/_compositor.py Ensures anchor state is re-checked during layout before applying anchored scroll positioning.
Suppressed comments (1)

src/textual/widget.py:2909

  • Same as _scroll_to: the release_anchor parameter docs here currently imply anchor release happens whenever release_anchor=True, but this method now intentionally preserves anchoring when scrolling to/beyond max_scroll_y. Please update the docstring line so it matches the new semantics.
        if release_anchor:
            if y is not None and y >= self.max_scroll_y:
                release_anchor = False
                if self._anchored:
                    self._anchor_released = False
            else:

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +609 to 612
widget._check_anchor()
if widget._anchored and not widget._anchor_released:
new_scroll_y = (
arrange_result.spatial_map.total_region.bottom
Comment thread src/textual/widget.py
Comment on lines 2753 to +2757
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
Remove �nd not widget.screen._selecting condition in Visual.to_strips so link color and underline styles remain active when widgets re-render during or after text selection.
@CXRunfree

Copy link
Copy Markdown

Thanks for picking this up. I ran this PR against my reproduction from #6677. It fixes the case I reported, but it also changes two behaviours that work today. I've put a smaller alternative patch below and attached the script I used (repro.py), in case either is useful.

Two regressions

_check_anchor() now runs right after the animation is scheduled, while scroll_y is still at the bottom, so an animated scroll upward gets re-anchored and pulled back down. The _check_anchor() calls added to the compositor also restore anchoring on layout alone, so calling release_anchor() and then appending content starts following again. test_anchor_streaming_repro_flow doesn't catch either case, and it passes on the original code too, since every scroll is followed by pilot.pause() and none of them animate. Adding an animated scroll to the test would cover this.

Root cause

scroll_to() releases the anchor and captures y = max_scroll_y at call time. If content is appended before the deferred scroll runs or finishes, that y ends up above the new bottom, _check_anchor() never matches, and the widget stops following.

Alternative solution

The patch below only touches widget.py (+29 lines). It reuses scroll_end(), which already restores the anchor and reads max_scroll_y after the refresh:

  • When an anchored widget gets a vertical scroll_to() targeting the bottom, delegate to scroll_end() instead of releasing the anchor.
  • When the scroll_end() animation completes, snap to the current max_scroll_y in case content was added during it.
  • If the anchor was released again before the deferred scroll_end() runs, skip it so it doesn't pull the view back down.
Patch against 5e5b7ef
diff --git a/src/textual/widget.py b/src/textual/widget.py
index 63ef5da..100303a 100644
--- a/src/textual/widget.py
+++ b/src/textual/widget.py
@@ -2890,6 +2890,28 @@ class Widget(DOMNode):
         Note:
             The call to scroll is made after the next refresh.
         """
+        if (
+            self._anchored
+            and x is None
+            and y is not None
+            and (self.allow_vertical_scroll or force)
+            and y >= self.max_scroll_y
+        ):
+            # The bottom may move before the scroll runs, so follow it instead.
+            if speed is None and duration is None:
+                speed = 50
+            self.scroll_end(
+                animate=animate,
+                speed=speed,
+                duration=duration,
+                easing=easing,
+                force=force,
+                on_complete=on_complete,
+                level=level,
+                immediate=immediate,
+                x_axis=False,
+            )
+            return
         if release_anchor:
             self.release_anchor()
         animator = self.app.animator
@@ -3045,6 +3067,8 @@ class Widget(DOMNode):
 
         async def scroll_end_on_complete() -> None:
             """It's possible new content was added before we reached the end."""
+            if y_axis and self._anchored and not self._anchor_released:
+                self.scroll_target_y = self.scroll_y = self.max_scroll_y
             if on_complete is not None:
                 self.call_next(on_complete)
 
@@ -3056,6 +3080,11 @@ class Widget(DOMNode):
         # here and make our own call to call_after_refresh.
         def _lazily_scroll_end() -> None:
             """Scroll to the end of the widget."""
+            if self._anchored and self._anchor_released:
+                # The anchor was released again before this deferred scroll ran.
+                if on_complete is not None:
+                    self.call_next(on_complete)
+                return
             self._scroll_to(
                 0 if x_axis else None,
                 self.max_scroll_y if y_axis else None,

Results

repro.py with the animation clock frozen:

Check Original This PR Patch above
Return to bottom while streaming FAIL PASS PASS
Animated scroll up from bottom PASS FAIL PASS
Explicit release survives layout PASS FAIL PASS

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Scrolling back to the bottom does not restore anchored scroll

3 participants