Skip to content

Anchored scroll container paints a sticking negative scroll offset when content is shorter than the viewport #6652

Description

@flowfinity

textual 8.2.8

Anchoring a scrollable container (widget.anchor()) whose content is shorter than
its viewport drives scroll_y negative and keeps it there. Visually: the content
hugs the bottom edge of the container with a void above it, and the state sticks
rather than flashing, because it is re-applied on every arrange.

Minimal repro

from textual.app import App, ComposeResult
from textual.containers import VerticalScroll
from textual.widgets import Static


class AnchorVoid(App):
    def compose(self) -> ComposeResult:
        with VerticalScroll(id="v"):
            for i in range(3):                      # far less than one screen
                yield Static(f"line {i}")

    def on_mount(self) -> None:
        self.query_one("#v", VerticalScroll).anchor()
        self.set_interval(0.5, self.report)

    def report(self) -> None:
        v = self.query_one("#v", VerticalScroll)
        self.log(f"scroll_y={v.scroll_y} max_scroll_y={v.max_scroll_y}")


if __name__ == "__main__":
    AnchorVoid().run()

Run it in a normal-height terminal: the three lines render at the bottom of the
screen with emptiness above, and the log shows a persistent negative scroll_y
(for example scroll_y=-47.0 max_scroll_y=0).

In _compositor.py, the anchored branch of the arrange pass computes:

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)
    )
    widget.set_reactive(Widget.scroll_y, new_scroll_y)
    widget.set_reactive(Widget.scroll_target_y, new_scroll_y)

Content-bottom minus viewport-height is negative whenever the content is shorter
than the viewport, and the write goes through set_reactive, which bypasses the
reactive's validation - so the clamp that protects every other scroll path
(validate_scroll_y flooring at 0) never runs. Because the branch executes on
every arrange, the negative value is re-imposed even if something resets it.

An anchored container whose content fits the viewport should rest at
scroll_y == 0 (there is nothing to stay at the bottom of), and the anchored
arithmetic should be clamped to the same floor the validated scroll paths get -
e.g. max(0, ...) around the computed offset, or routing the write through
validation.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions