Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Programmatic change of overflow doesn't update scrollbar #1616

Closed
rodrigogiraoserrao opened this issue Jan 19, 2023 · 6 comments · Fixed by #1627
Closed

Programmatic change of overflow doesn't update scrollbar #1616

rodrigogiraoserrao opened this issue Jan 19, 2023 · 6 comments · Fixed by #1627
Assignees
Labels
bug Something isn't working Task

Comments

@rodrigogiraoserrao
Copy link
Contributor

If you programmatically change the styles overflow_x or overflow_y, the layout of the app doesn't get updated properly and the scrollbar is likely to stick around (or not show, depending on what setting you had and what you changed it to).

For example, if you run the app below, the horizontal scrollbar starts by being shown because overflow-x: scroll is set in the default CSS. If you click the app anywhere, the handler will set overflow_x = "hidden" for the container and yet the scrollbar will remain there. The scrollbar only disappears after you force a full refresh of the app, for example by resizing the terminal window.

This is very similar to #1607 but there is something extra here because the strategy employed in #1610 to fix #1607 doesn't cut it for overflow_x/overflow_y.
I'm opening this issue to keep track of my investigation and to keep different things separated.

from textual.app import App
from textual.containers import Vertical


class MyApp(App[None]):
    DEFAULT_CSS = """Vertical { overflow-x: scroll; }"""

    def compose(self):
        yield Vertical()

    def on_click(self):
        self.query_one(Vertical).styles.overflow_x = "hidden"


if __name__ == "__main__":
    MyApp().run()
@rodrigogiraoserrao rodrigogiraoserrao added bug Something isn't working Task labels Jan 19, 2023
@rodrigogiraoserrao rodrigogiraoserrao self-assigned this Jan 19, 2023
@Textualize Textualize deleted a comment from github-actions bot Jan 19, 2023
@rodrigogiraoserrao
Copy link
Contributor Author

rodrigogiraoserrao commented Jan 19, 2023

An interesting finding comes from comparing a similar app that sets scrollbar_gutter to "stable" on click (see app below).

The two apps signal that the screen needs to be refreshed, but the gutter app refresh will eventually call Widget._size_updated on the Vertical and the virtual_size passed in as an argument will not match the current Vertical.virtual_size, which is what ultimately triggers the call to _scroll_update that prints the scrollbars.
On the other hand, the overflow app will still call the Widget._size_updated on the Vertical, but the virtual size will be unchanged, which makes me think that the bug might be in how the size of Vertical is computed.

from textual.app import App
from textual.containers import Vertical
from textual.widgets import Label


class MyApp(App[None]):
    def compose(self):
        yield Vertical(Label("bananas!"))

    def on_mount(self):
        self.query_one(Label).styles.width = "100%"
        self.query_one(Label).styles.background = "red"

    def on_click(self):
        self.query_one(Vertical).styles.scrollbar_gutter = "stable"


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

@rodrigogiraoserrao
Copy link
Contributor Author

rodrigogiraoserrao commented Jan 19, 2023

I found a partial fix assuming the overflow_x and overflow_y properties have been initialised in styles.py as in 19780db, forcing a layout (and children) refresh when the property is set.

To do the partial fix, all it takes is to go into widget.py and edit this section:

textual/src/textual/widget.py

Lines 1970 to 1980 in c6909b7

if show_horizontal_scrollbar and show_vertical_scrollbar:
(region, _, _, _) = region.split(
-scrollbar_size_vertical,
-scrollbar_size_horizontal,
)
elif show_vertical_scrollbar:
region, _ = region.split_vertical(-scrollbar_size_vertical)
elif show_horizontal_scrollbar:
region, _ = region.split_horizontal(-scrollbar_size_horizontal)
return region

This must be edited to assume that scrollbars are shown if they were already flagged as to be shown OR if the respective overflow is set to "scroll":

def _get_scrollable_region(self, region: Region) -> Region:
    # ...

    show_horizontal = show_horizontal_scrollbar or (
        self.styles.overflow_x == "scroll"
    )
    show_vertical = show_vertical_scrollbar or (self.styles.overflow_y == "scroll")
    if show_horizontal and show_vertical:
        (region, _, _, _) = region.split(
            -scrollbar_size_vertical,
            -scrollbar_size_horizontal,
        )
    elif show_vertical:
        region, _ = region.split_vertical(-scrollbar_size_vertical)
    elif show_horizontal:
        region, _ = region.split_horizontal(-scrollbar_size_horizontal)
    return region

To verify this works, use the app shown below. Run the app and press s to set the horizontal overflow to "scroll". Notice how the horizontal scrollbar gutter is now shown. Try pressing h to set the horizontal overflow to "hidden" and notice how nothing apparent happens. Try pressing r to force a full layout refresh of the screen and nothing happens. Only by resizing the app will we see the scrollbar gutter disappear.

Demo app
from textual.app import App
from textual.containers import Vertical


class MyApp(App[None]):
    def compose(self):
        yield Vertical()

    def on_key(self, event):
        if event.key == "r":
            self.screen._refresh_layout(full=True)
        elif event.key == "s":
            print("setting")
            self.query_one(Vertical).styles.overflow_x = "scroll"
            print("done")
        elif event.key == "h":
            self.query_one(Vertical).styles.overflow_x = "hidden"


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

@rodrigogiraoserrao
Copy link
Contributor Author

rodrigogiraoserrao commented Jan 19, 2023

It seems that some methods that have to do with scrollbars are being called before the scrollbars are refreshed.

Consider all of these statements that were found to be true:

  • As it stands, adding a call to self._refresh_scrollbars() as the very first statement of _get_scrollable_region fixes our problems.
  • As it stands, neither the value "scroll" nor the value "hidden" trigger a call to _refresh_scrollbars.
  • With the edit from above, setting the overflow to "scroll" triggers a call to _refresh_scrollbars but only AFTER we've been to _get_scrollable_region and faked the value of show_horizontal_scrollbar but then setting back to "hidden" DOES NOT trigger such a call to _refresh_scrollbars.

There must be a fault in the logic that decides when to call _refresh_scrollbars and it is highly unlikely that the fix for this issue includes the edit shown in the previous comment.

As it stands, the only location that calls the method Widget._refresh_scrollbars is the method Widget._scroll_update around line 2130.

This must be called from Widget._size_updated. (Because the only other call is in ScrollView._size_updated and we are not dealing with widgets that inherit from ScrollView.)
On the other hand, Widget._size_updated is being called from Widget._refresh_layout.

@rodrigogiraoserrao
Copy link
Contributor Author

rodrigogiraoserrao commented Jan 20, 2023

I did lots of digging and investigation and I managed to sprinkle some print statements that led me to conclude that the fix is likely to be adding a call to _refresh_scrollbars at the top of _get_scrollable_region.

Sadly, I can't articulate very well what led me to this conclusion, but it is in part due to the findings that follow.

After many prints and following the code flow, I figured I wanted to keep track of the only call to compositor.reflow in the whole codebase, which happens inside Screen._refresh_layout.
I figured this out because later in _refresh_layout we call the function _size_updated, and that call takes the virtual size of the widget as an argument.
Now, for the scrollbars to start/stop displaying when we change the overflow_x/overflow_y, I needed that virtual size to have been updated properly and that depends on _get_scrollable_region, because the virtual size is smaller if we have to have space for scrollbars.

So, tracking down the call to reflow, I went down to the call to Compositor._arrange_root which is were size calculations are actually made.

It is inside _arrange_root that we call _get_scrollable_region to help figure out how much space a given widget has.

Now, the problem is that _get_scrollable_region assumes our scrollbar status is completely updated, but it isn't. That is why the partial fix above works: because we force _get_scrollable_region to take into account whether or not the overflow style has been changed to "scroll" in between last call to _refresh_scrollbars and now.

Similarly, we can get a full fix by also checking if the overflow style has now been changed to "hidden" in between the last call to _refresh_scrollbars and now.
Something like what is shown here, but in a better Python style:

def _get_scrollable_region(self, region: Region) -> Region:
    # ...

    show_horizontal_scrollbar |= self.styles.overflow_x == "scroll"
    show_horizontal_scrollbar &= self.styles.overflow_x != "hidden"
    show_vertical_scrollbar |= self.styles.overflow_y == "scroll"
    show_vertical_scrollbar &= self.styles.overflow_y != "hidden"
    if show_horizontal_scrollbar and show_vertical_scrollbar:
        (region, _, _, _) = region.split(
            -scrollbar_size_vertical,
            -scrollbar_size_horizontal,
        )
    elif show_vertical_scrollbar:
        region, _ = region.split_vertical(-scrollbar_size_vertical)
    elif show_horizontal_scrollbar:
        region, _ = region.split_horizontal(-scrollbar_size_horizontal)
    return region

So, the fix is either adding this extra check in _get_scrollable_region, or forcing a full scrollbar refresh with a call to _refresh_scrollbars.

In the next comment I will share some instructions in case you want to see the final debugging that I made that helped me reach this conclusion.

@rodrigogiraoserrao
Copy link
Contributor Author

rodrigogiraoserrao commented Jan 20, 2023

Check this comment if you want to reproduce my final debugging steps:

Demo app I was using. Save this as myapp_overflow.py.
from textual.app import App
from textual.containers import Vertical


class MyApp(App[None]):
    def compose(self):
        yield Vertical()

    def on_key(self, event):
        if event.key == "r":
            self.screen._refresh_layout(full=True)
        elif event.key == "s":
            self.query_one(Vertical).styles.overflow_x = "scroll"
        elif event.key == "h":
            self.query_one(Vertical).styles.overflow_x = "hidden"


if __name__ == "__main__":
    MyApp().run()
Replace the file src/textual/_compositor.py with this.
"""

The compositor handles combining widgets in to a single screen (i.e. compositing).

It also stores the results of that process, so that Textual knows the widgets on
the screen and their locations. The compositor uses this information to answer
queries regarding the widget under an offset, or the style under an offset.

Additionally, the compositor can render portions of the screen which may have updated,
without having to render the entire screen.

"""
from __future__ import annotations


from operator import itemgetter
from typing import TYPE_CHECKING, Iterable, NamedTuple, cast

import rich.repr
from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
from rich.control import Control
from rich.segment import Segment
from rich.style import Style

from . import errors
from ._cells import cell_len
from ._loop import loop_last
from .strip import Strip
from ._typing import TypeAlias
from .geometry import NULL_OFFSET, Offset, Region, Size


if TYPE_CHECKING:
    from .widget import Widget


class ReflowResult(NamedTuple):
    """The result of a reflow operation. Describes the chances to widgets."""

    hidden: set[Widget]  # Widgets that are hidden
    shown: set[Widget]  # Widgets that are shown
    resized: set[Widget]  # Widgets that have been resized


class MapGeometry(NamedTuple):
    """Defines the absolute location of a Widget."""

    region: Region  # The (screen) region occupied by the widget
    order: tuple[tuple[int, ...], ...]  # A tuple of ints defining the painting order
    clip: Region  # A region to clip the widget by (if a Widget is within a container)
    virtual_size: Size  # The virtual size  (scrollable region) of a widget if it is a container
    container_size: Size  # The container size (area not occupied by scrollbars)
    virtual_region: Region  # The region relative to the container (but not necessarily visible)

    @property
    def visible_region(self) -> Region:
        """The Widget region after clipping."""
        return self.clip.intersection(self.region)


# Maps a widget on to its geometry (information that describes its position in the composition)
CompositorMap: TypeAlias = "dict[Widget, MapGeometry]"


@rich.repr.auto(angular=True)
class LayoutUpdate:
    """A renderable containing the result of a render for a given region."""

    def __init__(self, strips: list[Strip], region: Region) -> None:
        self.strips = strips
        self.region = region

    def __rich_console__(
        self, console: Console, options: ConsoleOptions
    ) -> RenderResult:
        x = self.region.x
        new_line = Segment.line()
        move_to = Control.move_to
        for last, (y, line) in loop_last(enumerate(self.strips, self.region.y)):
            yield move_to(x, y)
            yield from line
            if not last:
                yield new_line

    def __rich_repr__(self) -> rich.repr.Result:
        yield self.region


@rich.repr.auto(angular=True)
class ChopsUpdate:
    """A renderable that applies updated spans to the screen."""

    def __init__(
        self,
        chops: list[dict[int, Strip | None]],
        spans: list[tuple[int, int, int]],
        chop_ends: list[list[int]],
    ) -> None:
        """A renderable which updates chops (fragments of lines).

        Args:
            chops: A mapping of offsets to list of segments, per line.
            crop: Region to restrict update to.
            chop_ends: A list of the end offsets for each line
        """
        self.chops = chops
        self.spans = spans
        self.chop_ends = chop_ends

    def __rich_console__(
        self, console: Console, options: ConsoleOptions
    ) -> RenderResult:
        move_to = Control.move_to
        new_line = Segment.line()
        chops = self.chops
        chop_ends = self.chop_ends
        last_y = self.spans[-1][0]

        _cell_len = cell_len
        for y, x1, x2 in self.spans:
            line = chops[y]
            ends = chop_ends[y]
            for end, (x, strip) in zip(ends, line.items()):
                # TODO: crop to x extents
                if strip is None:
                    continue

                if x > x2 or end <= x1:
                    continue

                if x2 > x >= x1 and end <= x2:
                    yield move_to(x, y)
                    yield from strip
                    continue

                iter_segments = iter(strip)
                if x < x1:
                    for segment in iter_segments:
                        next_x = x + _cell_len(segment.text)
                        if next_x > x1:
                            yield move_to(x, y)
                            yield segment
                            break
                        x = next_x
                else:
                    yield move_to(x, y)
                if end <= x2:
                    yield from iter_segments
                else:
                    for segment in iter_segments:
                        if x >= x2:
                            break
                        yield segment
                        x += _cell_len(segment.text)

            if y != last_y:
                yield new_line

    def __rich_repr__(self) -> rich.repr.Result:
        yield from ()


@rich.repr.auto(angular=True)
class Compositor:
    """Responsible for storing information regarding the relative positions of Widgets and rendering them."""

    def __init__(self) -> None:
        # A mapping of Widget on to its "render location" (absolute position / depth)
        self.map: CompositorMap = {}
        self._layers: list[tuple[Widget, MapGeometry]] | None = None

        # All widgets considered in the arrangement
        # Note this may be a superset of self.map.keys() as some widgets may be invisible for various reasons
        self.widgets: set[Widget] = set()

        # Mapping of visible widgets on to their region, and clip region
        self._visible_widgets: dict[Widget, tuple[Region, Region]] | None = None

        # The top level widget
        self.root: Widget | None = None

        # Dimensions of the arrangement
        self.size = Size(0, 0)

        # The points in each line where the line bisects the left and right edges of the widget
        self._cuts: list[list[int]] | None = None

        # Regions that require an update
        self._dirty_regions: set[Region] = set()

        # Mapping of line numbers on to lists of widget and regions
        self._layers_visible: list[list[tuple[Widget, Region, Region]]] | None = None

    @classmethod
    def _regions_to_spans(
        cls, regions: Iterable[Region]
    ) -> Iterable[tuple[int, int, int]]:
        """Converts the regions to horizontal spans. Spans will be combined if they overlap
        or are contiguous to produce optimal non-overlapping spans.

        Args:
            regions: An iterable of Regions.

        Returns:
            Yields tuples of (Y, X1, X2).
        """
        inline_ranges: dict[int, list[tuple[int, int]]] = {}
        setdefault = inline_ranges.setdefault
        for region_x, region_y, width, height in regions:
            span = (region_x, region_x + width)
            for y in range(region_y, region_y + height):
                setdefault(y, []).append(span)

        slice_remaining = slice(1, None)
        for y, ranges in sorted(inline_ranges.items()):
            if len(ranges) == 1:
                # Special case of 1 span
                yield (y, *ranges[0])
            else:
                ranges.sort()
                x1, x2 = ranges[0]
                for next_x1, next_x2 in ranges[slice_remaining]:
                    if next_x1 <= x2:
                        if next_x2 > x2:
                            x2 = next_x2
                    else:
                        yield (y, x1, x2)
                        x1 = next_x1
                        x2 = next_x2
                yield (y, x1, x2)

    def __rich_repr__(self) -> rich.repr.Result:
        yield "size", self.size
        yield "widgets", self.widgets

    def reflow(self, parent: Widget, size: Size) -> ReflowResult:
        """Reflow (layout) widget and its children.

        Args:
            parent: The root widget.
            size: Size of the area to be filled.

        Returns:
            Hidden shown and resized widgets.
        """
        self._cuts = None
        self._layers = None
        self._layers_visible = None
        self._visible_widgets = None
        self.root = parent
        self.size = size

        # Keep a copy of the old map because we're going to compare it with the update
        old_map = self.map.copy()
        old_widgets = old_map.keys()

        map, widgets = self._arrange_root(parent, size)
        new_widgets = map.keys()

        # Newly visible widgets
        shown_widgets = new_widgets - old_widgets
        # Newly hidden widgets
        hidden_widgets = old_widgets - new_widgets

        # Replace map and widgets
        self.map = map
        self.widgets = widgets

        # Contains widgets + geometry for every widget that changed (added, removed, or updated)
        changes = map.items() ^ old_map.items()

        # Widgets in both new and old
        common_widgets = old_widgets & new_widgets

        # Widgets with changed size
        resized_widgets = {
            widget
            for widget, (region, *_) in changes
            if (widget in common_widgets and old_map[widget].region[2:] != region[2:])
        }

        screen_region = size.region
        if screen_region not in self._dirty_regions:
            regions = {
                region
                for region in (
                    map_geometry.clip.intersection(map_geometry.region)
                    for _, map_geometry in changes
                )
                if region
            }
            self._dirty_regions.update(regions)

        return ReflowResult(
            hidden=hidden_widgets,
            shown=shown_widgets,
            resized=resized_widgets,
        )

    @property
    def visible_widgets(self) -> dict[Widget, tuple[Region, Region]]:
        """Get a mapping of widgets on to region and clip.

        Returns:
            Visible widget mapping.
        """
        if self._visible_widgets is None:
            screen = self.size.region
            in_screen = screen.overlaps
            overlaps = Region.overlaps

            # Widgets and regions in render order
            visible_widgets = [
                (order, widget, region, clip)
                for widget, (region, order, clip, _, _, _) in self.map.items()
                if in_screen(region) and overlaps(clip, region)
            ]
            visible_widgets.sort(key=itemgetter(0), reverse=True)
            self._visible_widgets = {
                widget: (region, clip) for _, widget, region, clip in visible_widgets
            }
        return self._visible_widgets

    def _arrange_root(
        self, root: Widget, size: Size
    ) -> tuple[CompositorMap, set[Widget]]:
        """Arrange a widgets children based on its layout attribute.

        Args:
            root: Top level widget.

        Returns:
            Compositor map and set of widgets.
        """
        print(f"Arranging root {root}")
        ORIGIN = NULL_OFFSET

        map: CompositorMap = {}
        widgets: set[Widget] = set()
        layer_order: int = 0

        def add_widget(
            widget: Widget,
            virtual_region: Region,
            region: Region,
            order: tuple[tuple[int, ...], ...],
            layer_order: int,
            clip: Region,
            visible: bool,
            _MapGeometry=MapGeometry,
        ) -> None:
            """Called recursively to place a widget and its children in the map.

            Args:
                widget: The widget to add.
                region: The region the widget will occupy.
                order: A tuple of ints to define the order.
                clip: The clipping region (i.e. the viewport which contains it).
            """
            visibility = widget.styles.get_rule("visibility")
            if visibility is not None:
                visible = visibility == "visible"

            if visible:
                widgets.add(widget)
            styles_offset = widget.styles.offset
            layout_offset = (
                styles_offset.resolve(region.size, clip.size)
                if styles_offset
                else ORIGIN
            )

            # Container region is minus border
            container_region = region.shrink(widget.styles.gutter).translate(
                layout_offset
            )
            container_size = container_region.size

            # Widgets with scrollbars (containers or scroll view) require additional processing
            if widget.is_scrollable:
                # The region that contains the content (container region minus scrollbars)
                child_region = widget._get_scrollable_region(container_region)

                # Adjust the clip region accordingly
                sub_clip = clip.intersection(child_region)

                # The region covered by children relative to parent widget
                total_region = child_region.reset_offset

                if widget.is_container:
                    # Arrange the layout
                    placements, arranged_widgets, spacing = widget._arrange(
                        child_region.size
                    )
                    widgets.update(arranged_widgets)

                    if placements:
                        # An offset added to all placements
                        placement_offset = container_region.offset
                        placement_scroll_offset = (
                            placement_offset - widget.scroll_offset
                        )

                        _layers = widget.layers
                        layers_to_index = {
                            layer_name: index
                            for index, layer_name in enumerate(_layers)
                        }
                        get_layer_index = layers_to_index.get

                        # Add all the widgets
                        for sub_region, margin, sub_widget, z, fixed in reversed(
                            placements
                        ):
                            # Combine regions with children to calculate the "virtual size"
                            if fixed:
                                widget_region = sub_region + placement_offset
                            else:
                                total_region = total_region.union(
                                    sub_region.grow(spacing + margin)
                                )
                                widget_region = sub_region + placement_scroll_offset

                            widget_order = (
                                *order,
                                get_layer_index(sub_widget.layer, 0),
                                z,
                                layer_order,
                            )

                            add_widget(
                                sub_widget,
                                sub_region,
                                widget_region,
                                widget_order,
                                layer_order,
                                sub_clip,
                                visible,
                            )
                            layer_order -= 1

                if visible:
                    # Add any scrollbars
                    for chrome_widget, chrome_region in widget._arrange_scrollbars(
                        container_region
                    ):
                        map[chrome_widget] = _MapGeometry(
                            chrome_region + layout_offset,
                            order,
                            clip,
                            container_size,
                            container_size,
                            chrome_region,
                        )

                    map[widget] = _MapGeometry(
                        region + layout_offset,
                        order,
                        clip,
                        total_region.size,
                        container_size,
                        virtual_region,
                    )

            elif visible:
                # Add the widget to the map
                map[widget] = _MapGeometry(
                    region + layout_offset,
                    order,
                    clip,
                    region.size,
                    container_size,
                    virtual_region,
                )

        # Add top level (root) widget
        add_widget(
            root,
            size.region,
            size.region,
            ((0,),),
            layer_order,
            size.region,
            True,
        )
        print("arranging root done.")
        return map, widgets

    @property
    def layers(self) -> list[tuple[Widget, MapGeometry]]:
        """Get widgets and geometry in layer order."""
        if self._layers is None:
            self._layers = sorted(
                self.map.items(), key=lambda item: item[1].order, reverse=True
            )
        return self._layers

    @property
    def layers_visible(self) -> list[list[tuple[Widget, Region, Region]]]:
        """Visible widgets and regions in layers order."""

        if self._layers_visible is None:
            layers_visible: list[list[tuple[Widget, Region, Region]]]
            layers_visible = [[] for y in range(self.size.height)]
            layers_visible_appends = [layer.append for layer in layers_visible]
            intersection = Region.intersection
            _range = range
            for widget, (region, clip) in self.visible_widgets.items():
                cropped_region = intersection(region, clip)
                _x, region_y, _width, region_height = cropped_region
                if region_height:
                    widget_location = (widget, cropped_region, region)
                    for y in _range(region_y, region_y + region_height):
                        layers_visible_appends[y](widget_location)
            self._layers_visible = layers_visible
        return self._layers_visible

    def get_offset(self, widget: Widget) -> Offset:
        """Get the offset of a widget."""
        try:
            return self.map[widget].region.offset
        except KeyError:
            raise errors.NoWidget("Widget is not in layout")

    def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]:
        """Get the widget under a given coordinate.

        Args:
            x: X Coordinate.
            y: Y Coordinate.

        Raises:
            errors.NoWidget: If there is not widget underneath (x, y).

        Returns:
            A tuple of the widget and its region.
        """

        contains = Region.contains
        if len(self.layers_visible) > y >= 0:
            for widget, cropped_region, region in self.layers_visible[y]:
                if contains(cropped_region, x, y) and widget.visible:
                    return widget, region
        raise errors.NoWidget(f"No widget under screen coordinate ({x}, {y})")

    def get_widgets_at(self, x: int, y: int) -> Iterable[tuple[Widget, Region]]:
        """Get all widgets under a given coordinate.

        Args:
            x: X coordinate.
            y: Y coordinate.

        Returns:
            Sequence of (WIDGET, REGION) tuples.
        """
        contains = Region.contains
        for widget, cropped_region, region in self.layers_visible[y]:
            if contains(cropped_region, x, y) and widget.visible:
                yield widget, region

    def get_style_at(self, x: int, y: int) -> Style:
        """Get the Style at the given cell or Style.null()

        Args:
            x: X position within the Layout
            y: Y position within the Layout

        Returns:
            The Style at the cell (x, y) within the Layout
        """
        try:
            widget, region = self.get_widget_at(x, y)
        except errors.NoWidget:
            return Style.null()
        if widget not in self.visible_widgets:
            return Style.null()

        x -= region.x
        y -= region.y

        lines = widget.render_lines(Region(0, y, region.width, 1))

        if not lines:
            return Style.null()
        end = 0
        for segment in lines[0]:
            end += segment.cell_length
            if x < end:
                return segment.style or Style.null()
        return Style.null()

    def find_widget(self, widget: Widget) -> MapGeometry:
        """Get information regarding the relative position of a widget in the Compositor.

        Args:
            widget: The Widget in this layout you wish to know the Region of.

        Raises:
            NoWidget: If the Widget is not contained in this Layout.

        Returns:
            Widget's composition information.

        """
        try:
            region = self.map[widget]
        except KeyError:
            raise errors.NoWidget("Widget is not in layout")
        else:
            return region

    @property
    def cuts(self) -> list[list[int]]:
        """Get vertical cuts.

        A cut is every point on a line where a widget starts or ends.

        Returns:
            A list of cuts for every line.
        """
        if self._cuts is not None:
            return self._cuts

        width, height = self.size
        screen_region = self.size.region
        cuts = [[0, width] for _ in range(height)]

        intersection = Region.intersection
        extend = list.extend

        for region, clip in self.visible_widgets.values():
            region = intersection(region, clip)
            if region and (region in screen_region):
                x, y, region_width, region_height = region
                region_cuts = (x, x + region_width)
                for cut in cuts[y : y + region_height]:
                    extend(cut, region_cuts)

        # Sort the cuts for each line
        self._cuts = [sorted(set(line_cuts)) for line_cuts in cuts]

        return self._cuts

    def _get_renders(
        self, crop: Region | None = None
    ) -> Iterable[tuple[Region, Region, list[Strip]]]:
        """Get rendered widgets (lists of segments) in the composition.

        Returns:
            An iterable of <region>, <clip region>, and <strips>
        """
        # If a renderable throws an error while rendering, the user likely doesn't care about the traceback
        # up to this point.
        _rich_traceback_guard = True

        if not self.map:
            return

        _Region = Region

        visible_widgets = self.visible_widgets

        if crop:
            crop_overlaps = crop.overlaps
            widget_regions = [
                (widget, region, clip)
                for widget, (region, clip) in visible_widgets.items()
                if crop_overlaps(clip) and widget.styles.opacity > 0
            ]
        else:
            widget_regions = [
                (widget, region, clip)
                for widget, (region, clip) in visible_widgets.items()
                if widget.styles.opacity > 0
            ]

        intersection = _Region.intersection
        contains_region = _Region.contains_region

        for widget, region, clip in widget_regions:
            if contains_region(clip, region):
                yield region, clip, widget.render_lines(
                    _Region(0, 0, region.width, region.height)
                )
            else:
                clipped_region = intersection(region, clip)
                if not clipped_region:
                    continue
                new_x, new_y, new_width, new_height = clipped_region
                delta_x = new_x - region.x
                delta_y = new_y - region.y
                yield region, clip, widget.render_lines(
                    _Region(delta_x, delta_y, new_width, new_height)
                )

    def render(self, full: bool = False) -> RenderableType | None:
        """Render a layout.

        Returns:
            A renderable
        """

        width, height = self.size
        screen_region = Region(0, 0, width, height)

        if full:
            update_regions: set[Region] = set()
        else:
            update_regions = self._dirty_regions.copy()
            if screen_region in update_regions:
                # If one of the updates is the entire screen, then we only need one update
                full = True
        self._dirty_regions.clear()

        if full:
            crop = screen_region
            spans = []
            is_rendered_line = lambda y: True
        elif update_regions:
            # Create a crop regions that surrounds all updates
            crop = Region.from_union(update_regions).intersection(screen_region)
            spans = list(self._regions_to_spans(update_regions))
            is_rendered_line = {y for y, _, _ in spans}.__contains__
        else:
            return None

        # Maps each cut on to a list of segments
        cuts = self.cuts

        # dict.fromkeys is a callable which takes a list of ints returns a dict which maps ints on to a list of Segments or None.
        fromkeys = cast(
            "Callable[[list[int]], dict[int, list[Segment] | None]]", dict.fromkeys
        )
        # A mapping of cut index to a list of segments for each line
        chops: list[dict[int, Strip | None]]
        chops = [fromkeys(cut_set[:-1]) for cut_set in cuts]

        cut_strips: Iterable[Strip]

        # Go through all the renders in reverse order and fill buckets with no render
        renders = self._get_renders(crop)
        intersection = Region.intersection

        for region, clip, strips in renders:
            render_region = intersection(region, clip)

            for y, strip in zip(render_region.line_range, strips):
                if not is_rendered_line(y):
                    continue

                chops_line = chops[y]

                first_cut, last_cut = render_region.column_span
                cuts_line = cuts[y]
                final_cuts = [
                    cut for cut in cuts_line if (last_cut >= cut >= first_cut)
                ]
                if len(final_cuts) <= 2:
                    # Two cuts, which means the entire line
                    cut_strips = [strip]
                else:
                    render_x = render_region.x
                    relative_cuts = [cut - render_x for cut in final_cuts[1:]]
                    cut_strips = strip.divide(relative_cuts)

                # Since we are painting front to back, the first segments for a cut "wins"
                for cut, strip in zip(final_cuts, cut_strips):
                    if chops_line[cut] is None:
                        chops_line[cut] = strip

        if full:
            render_strips = [Strip.join(chop.values()) for chop in chops]
            return LayoutUpdate(render_strips, screen_region)
        else:
            chop_ends = [cut_set[1:] for cut_set in cuts]
            return ChopsUpdate(chops, spans, chop_ends)

    def __rich_console__(
        self, console: Console, options: ConsoleOptions
    ) -> RenderResult:
        if self._dirty_regions:
            yield self.render() or ""

    def update_widgets(self, widgets: set[Widget]) -> None:
        """Update a given widget in the composition.

        Args:
            console: Console instance.
            widget: Widget to update.

        """
        regions: list[Region] = []
        add_region = regions.append
        get_widget = self.visible_widgets.__getitem__
        for widget in self.visible_widgets.keys() & widgets:
            region, clip = get_widget(widget)
            offset = region.offset
            intersection = clip.intersection
            for dirty_region in widget._exchange_repaint_regions():
                update_region = intersection(dirty_region.translate(offset))
                if update_region:
                    add_region(update_region)

        self._dirty_regions.update(regions)
Replace the file src/textual/screen.py with this.
from __future__ import annotations

from typing import Iterable, Iterator

import rich.repr
from rich.console import RenderableType
from rich.style import Style

from . import errors, events, messages
from ._callback import invoke
from ._compositor import Compositor, MapGeometry
from .css.match import match
from .css.parse import parse_selectors
from .dom import DOMNode
from .timer import Timer
from ._types import CallbackType
from .geometry import Offset, Region, Size
from ._typing import Final
from .reactive import Reactive
from .renderables.blank import Blank
from .widget import Widget


# Screen updates will be batched so that they don't happen more often than 120 times per second:
UPDATE_PERIOD: Final[float] = 1 / 120


@rich.repr.auto
class Screen(Widget):
    """A widget for the root of the app."""

    # The screen is a special case and unless a class that inherits from us
    # says otherwise, all screen-level bindings should be treated as having
    # priority.

    DEFAULT_CSS = """
    Screen {
        layout: vertical;
        overflow-y: auto;
        background: $surface;
    }
    """

    focused: Reactive[Widget | None] = Reactive(None)

    def __init__(
        self,
        name: str | None = None,
        id: str | None = None,
        classes: str | None = None,
    ) -> None:
        super().__init__(name=name, id=id, classes=classes)
        self._compositor = Compositor()
        self._dirty_widgets: set[Widget] = set()
        self._update_timer: Timer | None = None
        self._callbacks: list[CallbackType] = []
        self._max_idle = UPDATE_PERIOD

    @property
    def is_transparent(self) -> bool:
        return False

    @property
    def is_current(self) -> bool:
        """Check if this screen is current (i.e. visible to user)."""
        from .app import ScreenStackError

        try:
            return self.app.screen is self
        except ScreenStackError:
            return False

    @property
    def update_timer(self) -> Timer:
        """Timer used to perform updates."""
        if self._update_timer is None:
            self._update_timer = self.set_interval(
                UPDATE_PERIOD, self._on_timer_update, name="screen_update", pause=True
            )
        return self._update_timer

    @property
    def widgets(self) -> list[Widget]:
        """Get all widgets."""
        return list(self._compositor.map.keys())

    @property
    def visible_widgets(self) -> list[Widget]:
        """Get a list of visible widgets."""
        return list(self._compositor.visible_widgets)

    def render(self) -> RenderableType:
        background = self.styles.background
        if background.is_transparent:
            return self.app.render()
        return Blank(background)

    def get_offset(self, widget: Widget) -> Offset:
        """Get the absolute offset of a given Widget.

        Args:
            widget: A widget

        Returns:
            The widget's offset relative to the top left of the terminal.
        """
        return self._compositor.get_offset(widget)

    def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]:
        """Get the widget at a given coordinate.

        Args:
            x: X Coordinate.
            y: Y Coordinate.

        Returns:
            Widget and screen region.
        """
        return self._compositor.get_widget_at(x, y)

    def get_widgets_at(self, x: int, y: int) -> Iterable[tuple[Widget, Region]]:
        """Get all widgets under a given coordinate.

        Args:
            x: X coordinate.
            y: Y coordinate.

        Returns:
            Sequence of (WIDGET, REGION) tuples.
        """
        return self._compositor.get_widgets_at(x, y)

    def get_style_at(self, x: int, y: int) -> Style:
        """Get the style under a given coordinate.

        Args:
            x: X Coordinate.
            y: Y Coordinate.

        Returns:
            Rich Style object
        """
        return self._compositor.get_style_at(x, y)

    def find_widget(self, widget: Widget) -> MapGeometry:
        """Get the screen region of a Widget.

        Args:
            widget: A Widget within the composition.

        Returns:
            Region relative to screen.

        Raises:
            NoWidget: If the widget could not be found in this screen.
        """
        return self._compositor.find_widget(widget)

    @property
    def focus_chain(self) -> list[Widget]:
        """Get widgets that may receive focus, in focus order.

        Returns:
            List of Widgets in focus order.
        """
        widgets: list[Widget] = []
        add_widget = widgets.append
        stack: list[Iterator[Widget]] = [iter(self.focusable_children)]
        pop = stack.pop
        push = stack.append

        while stack:
            node = next(stack[-1], None)
            if node is None:
                pop()
            else:
                if node.is_container and node.can_focus_children:
                    push(iter(node.focusable_children))
                if node.can_focus:
                    add_widget(node)

        return widgets

    def _move_focus(
        self, direction: int = 0, selector: str | type[DOMNode.ExpectType] = "*"
    ) -> Widget | None:
        """Move the focus in the given direction.

        If no widget is currently focused, this will focus the first focusable widget.
        If no focusable widget matches the given CSS selector, focus is set to `None`.

        Args:
            direction: 1 to move forward, -1 to move backward, or
                0 to keep the current focus.
            selector: CSS selector to filter
                what nodes can be focused.

        Returns:
            Newly focused widget, or None for no focus. If the return
                is not `None`, then it is guaranteed that the widget returned matches
                the CSS selectors given in the argument.
        """
        if not isinstance(selector, str):
            selector = selector.__name__
        selector_set = parse_selectors(selector)
        focus_chain = self.focus_chain
        filtered_focus_chain = (
            node for node in focus_chain if match(selector_set, node)
        )

        if not focus_chain:
            # Nothing focusable, so nothing to do
            return self.focused
        if self.focused is None:
            # Nothing currently focused, so focus the first one.
            to_focus = next(filtered_focus_chain, None)
            self.set_focus(to_focus)
            return self.focused

        # Ensure focus will be in a node that matches the selectors.
        if not direction and not match(selector_set, self.focused):
            direction = 1

        try:
            # Find the index of the currently focused widget
            current_index = focus_chain.index(self.focused)
        except ValueError:
            # Focused widget was removed in the interim, start again
            self.set_focus(next(filtered_focus_chain, None))
        else:
            # Only move the focus if we are currently showing the focus
            if direction:
                to_focus: Widget | None = None
                chain_length = len(focus_chain)
                for step in range(1, len(focus_chain) + 1):
                    node = focus_chain[
                        (current_index + direction * step) % chain_length
                    ]
                    if match(selector_set, node):
                        to_focus = node
                        break
                self.set_focus(to_focus)

        return self.focused

    def focus_next(
        self, selector: str | type[DOMNode.ExpectType] = "*"
    ) -> Widget | None:
        """Focus the next widget, optionally filtered by a CSS selector.

        If no widget is currently focused, this will focus the first focusable widget.
        If no focusable widget matches the given CSS selector, focus is set to `None`.

        Args:
            selector: CSS selector to filter
                what nodes can be focused.

        Returns:
            Newly focused widget, or None for no focus. If the return
                is not `None`, then it is guaranteed that the widget returned matches
                the CSS selectors given in the argument.
        """
        return self._move_focus(1, selector)

    def focus_previous(
        self, selector: str | type[DOMNode.ExpectType] = "*"
    ) -> Widget | None:
        """Focus the previous widget, optionally filtered by a CSS selector.

        If no widget is currently focused, this will focus the first focusable widget.
        If no focusable widget matches the given CSS selector, focus is set to `None`.

        Args:
            selector: CSS selector to filter
                what nodes can be focused.

        Returns:
            Newly focused widget, or None for no focus. If the return
                is not `None`, then it is guaranteed that the widget returned matches
                the CSS selectors given in the argument.
        """
        return self._move_focus(-1, selector)

    def _reset_focus(
        self, widget: Widget, avoiding: list[Widget] | None = None
    ) -> None:
        """Reset the focus when a widget is removed

        Args:
            widget: A widget that is removed.
            avoiding: Optional list of nodes to avoid.
        """

        avoiding = avoiding or []

        # Make this a NOP if we're being asked to deal with a widget that
        # isn't actually the currently-focused widget.
        if self.focused is not widget:
            return

        # Grab the list of widgets that we can set focus to.
        focusable_widgets = self.focus_chain
        if not focusable_widgets:
            # If there's nothing to focus... give up now.
            return

        try:
            # Find the location of the widget we're taking focus from, in
            # the focus chain.
            widget_index = focusable_widgets.index(widget)
        except ValueError:
            # widget is not in focusable widgets
            # It may have been made invisible
            # Move to a sibling if possible
            for sibling in widget.visible_siblings:
                if sibling not in avoiding and sibling.can_focus:
                    self.set_focus(sibling)
                    break
            else:
                self.set_focus(None)
            return

        # Now go looking for something before it, that isn't about to be
        # removed, and which can receive focus, and go focus that.
        chosen: Widget | None = None
        for candidate in reversed(
            focusable_widgets[widget_index + 1 :] + focusable_widgets[:widget_index]
        ):
            if candidate not in avoiding:
                chosen = candidate
                break

        # Go with the what was found.
        self.set_focus(chosen)

    def set_focus(self, widget: Widget | None, scroll_visible: bool = True) -> None:
        """Focus (or un-focus) a widget. A focused widget will receive key events first.

        Args:
            widget: Widget to focus, or None to un-focus.
            scroll_visible: Scroll widget in to view.
        """
        if widget is self.focused:
            # Widget is already focused
            return

        if widget is None:
            # No focus, so blur currently focused widget if it exists
            if self.focused is not None:
                self.focused.post_message_no_wait(events.Blur(self))
                self.focused = None
            self.log.debug("focus was removed")
        elif widget.can_focus:
            if self.focused != widget:
                if self.focused is not None:
                    # Blur currently focused widget
                    self.focused.post_message_no_wait(events.Blur(self))
                # Change focus
                self.focused = widget
                # Send focus event
                if scroll_visible:
                    self.screen.scroll_to_widget(widget)
                widget.post_message_no_wait(events.Focus(self))
                self.log.debug(widget, "was focused")

    async def _on_idle(self, event: events.Idle) -> None:
        # Check for any widgets marked as 'dirty' (needs a repaint)
        event.prevent_default()

        async with self.app._dom_lock:
            if self.is_current:
                if self._layout_required:
                    self._refresh_layout()
                    self._layout_required = False
                    self._dirty_widgets.clear()
                if self._repaint_required:
                    self._dirty_widgets.clear()
                    self._dirty_widgets.add(self)
                    self._repaint_required = False

                if self._dirty_widgets:
                    self.update_timer.resume()

        # The Screen is idle - a good opportunity to invoke the scheduled callbacks
        await self._invoke_and_clear_callbacks()

    def _on_timer_update(self) -> None:
        """Called by the _update_timer."""
        # Render widgets together
        if self._dirty_widgets:
            self._compositor.update_widgets(self._dirty_widgets)
            self.app._display(self, self._compositor.render())
            self._dirty_widgets.clear()
        if self._callbacks:
            self.post_message_no_wait(events.InvokeCallbacks(self))

        self.update_timer.pause()

    async def _on_invoke_callbacks(self, event: events.InvokeCallbacks) -> None:
        """Handle PostScreenUpdate events, which are sent after the screen is updated"""
        await self._invoke_and_clear_callbacks()

    async def _invoke_and_clear_callbacks(self) -> None:
        """If there are scheduled callbacks to run, call them and clear
        the callback queue."""
        if self._callbacks:
            display_update = self._compositor.render()
            self.app._display(self, display_update)
            callbacks = self._callbacks[:]
            self._callbacks.clear()
            for callback in callbacks:
                await invoke(callback)

    def _invoke_later(self, callback: CallbackType) -> None:
        """Enqueue a callback to be invoked after the screen is repainted.

        Args:
            callback: A callback.
        """

        self._callbacks.append(callback)
        self.check_idle()

    def _refresh_layout(self, size: Size | None = None, full: bool = False) -> None:
        """Refresh the layout (can change size and positions of widgets)."""
        print("[green]Refreshing[/]")
        size = self.outer_size if size is None else size
        if not size:
            return

        self._compositor.update_widgets(self._dirty_widgets)
        self.update_timer.pause()
        try:
            hidden, shown, resized = self._compositor.reflow(self, size)
            Hide = events.Hide
            Show = events.Show

            for widget in hidden:
                widget.post_message_no_wait(Hide(self))

            # We want to send a resize event to widgets that were just added or change since last layout
            send_resize = shown | resized
            ResizeEvent = events.Resize

            layers = self._compositor.layers
            for widget, (
                region,
                _order,
                _clip,
                virtual_size,
                container_size,
                _,
            ) in layers:
                widget._size_updated(region.size, virtual_size, container_size)
                if widget in send_resize:
                    widget.post_message_no_wait(
                        ResizeEvent(self, region.size, virtual_size, container_size)
                    )

            for widget in shown:
                widget.post_message_no_wait(Show(self))

        except Exception as error:
            self.app._handle_exception(error)
            return
        display_update = self._compositor.render(full=full)
        self.app._display(self, display_update)
        if not self.app._dom_ready:
            self.app.post_message_no_wait(events.Ready(self))
            self.app._dom_ready = True
        print("[green]Done.[/]")

    async def _on_update(self, message: messages.Update) -> None:
        message.stop()
        message.prevent_default()
        widget = message.widget
        assert isinstance(widget, Widget)
        self._dirty_widgets.add(widget)
        self.check_idle()

    async def _on_layout(self, message: messages.Layout) -> None:
        message.stop()
        message.prevent_default()
        self._layout_required = True
        self.check_idle()

    def _screen_resized(self, size: Size):
        """Called by App when the screen is resized."""
        self._refresh_layout(size, full=True)

    def _on_screen_resume(self) -> None:
        """Called by the App"""
        size = self.app.size
        self._refresh_layout(size, full=True)

    async def _on_resize(self, event: events.Resize) -> None:
        event.stop()
        self._screen_resized(event.size)

    async def _handle_mouse_move(self, event: events.MouseMove) -> None:
        try:
            if self.app.mouse_captured:
                widget = self.app.mouse_captured
                region = self.find_widget(widget).region
            else:
                widget, region = self.get_widget_at(event.x, event.y)
        except errors.NoWidget:
            await self.app._set_mouse_over(None)
        else:
            await self.app._set_mouse_over(widget)
            mouse_event = events.MouseMove(
                self,
                event.x - region.x,
                event.y - region.y,
                event.delta_x,
                event.delta_y,
                event.button,
                event.shift,
                event.meta,
                event.ctrl,
                screen_x=event.screen_x,
                screen_y=event.screen_y,
                style=event.style,
            )
            widget.hover_style = event.style
            mouse_event._set_forwarded()
            await widget._forward_event(mouse_event)

    async def _forward_event(self, event: events.Event) -> None:
        if event.is_forwarded:
            return
        event._set_forwarded()
        if isinstance(event, (events.Enter, events.Leave)):
            await self.post_message(event)

        elif isinstance(event, events.MouseMove):
            event.style = self.get_style_at(event.screen_x, event.screen_y)
            await self._handle_mouse_move(event)

        elif isinstance(event, events.MouseEvent):
            try:
                if self.app.mouse_captured:
                    widget = self.app.mouse_captured
                    region = self.find_widget(widget).region
                else:
                    widget, region = self.get_widget_at(event.x, event.y)
            except errors.NoWidget:
                self.set_focus(None)
            else:
                if isinstance(event, events.MouseUp) and widget.can_focus:
                    if self.focused is not widget:
                        self.set_focus(widget)
                        event.stop()
                        return
                event.style = self.get_style_at(event.screen_x, event.screen_y)
                if widget is self:
                    event._set_forwarded()
                    await self.post_message(event)
                else:
                    await widget._forward_event(
                        event._apply_offset(-region.x, -region.y)
                    )

        elif isinstance(event, (events.MouseScrollDown, events.MouseScrollUp)):
            try:
                widget, _region = self.get_widget_at(event.x, event.y)
            except errors.NoWidget:
                return
            scroll_widget = widget
            if scroll_widget is not None:
                if scroll_widget is self:
                    await self.post_message(event)
                else:
                    await scroll_widget._forward_event(event)
        else:
            await self.post_message(event)
Replace the file src/textual/widget.py with this.
from __future__ import annotations

from asyncio import Event as AsyncEvent
from asyncio import Lock, create_task, wait
from collections import Counter
from fractions import Fraction
from itertools import islice
from operator import attrgetter
from typing import (
    TYPE_CHECKING,
    ClassVar,
    Collection,
    Generator,
    Iterable,
    NamedTuple,
    Sequence,
    TypeVar,
    cast,
    overload,
)

import rich.repr
from rich.console import (
    Console,
    ConsoleOptions,
    ConsoleRenderable,
    JustifyMethod,
    RenderableType,
    RenderResult,
    RichCast,
)
from rich.measure import Measurement
from rich.segment import Segment
from rich.style import Style
from rich.text import Text
from rich.traceback import Traceback

from . import errors, events, messages
from ._animator import DEFAULT_EASING, Animatable, BoundAnimator, EasingFunction
from ._arrange import DockArrangeResult, arrange
from ._context import active_app
from ._easing import DEFAULT_SCROLL_EASING
from ._layout import Layout
from ._segment_tools import align_lines
from ._styles_cache import StylesCache
from .actions import SkipAction
from .await_remove import AwaitRemove
from .binding import Binding
from .box_model import BoxModel, get_box_model
from .css.query import NoMatches, WrongType
from .css.scalar import ScalarOffset
from .dom import DOMNode, NoScreen
from .geometry import Offset, Region, Size, Spacing, clamp
from .layouts.vertical import VerticalLayout
from .message import Message
from .messages import CallbackType
from .reactive import Reactive
from .render import measure
from .strip import Strip
from .walk import walk_depth_first

if TYPE_CHECKING:
    from .app import App, ComposeResult
    from .scrollbar import (
        ScrollBar,
        ScrollBarCorner,
        ScrollDown,
        ScrollLeft,
        ScrollRight,
        ScrollTo,
        ScrollUp,
    )

_JUSTIFY_MAP: dict[str, JustifyMethod] = {
    "start": "left",
    "end": "right",
    "justify": "full",
}


def debug(f):
    def wrapper(*args, **kwargs):
        to_print = args[0].__class__.__name__ == "Vertical"
        if to_print:
            print(f"> {f.__name__}, {args = }, {kwargs = }")
        ret = f(*args, **kwargs)
        if to_print:
            print(f"< {f.__name__} {ret = }")
        return ret

    return wrapper


class AwaitMount:
    """An awaitable returned by mount() and mount_all().

    Example:
        await self.mount(Static("foo"))

    """

    def __init__(self, parent: Widget, widgets: Sequence[Widget]) -> None:
        self._parent = parent
        self._widgets = widgets

    def __await__(self) -> Generator[None, None, None]:
        async def await_mount() -> None:
            if self._widgets:
                aws = [
                    create_task(widget._mounted_event.wait())
                    for widget in self._widgets
                ]
                if aws:
                    await wait(aws)
                    self._parent.refresh(layout=True)

        return await_mount().__await__()


class _Styled:
    """Apply a style to a renderable.

    Args:
        renderable: Any renderable.
        style: A style to apply across the entire renderable.
    """

    def __init__(
        self, renderable: "RenderableType", style: Style, link_style: Style | None
    ) -> None:
        self.renderable = renderable
        self.style = style
        self.link_style = link_style

    def __rich_console__(
        self, console: "Console", options: "ConsoleOptions"
    ) -> "RenderResult":
        style = console.get_style(self.style)
        result_segments = console.render(self.renderable, options)

        _Segment = Segment
        if style:
            apply = style.__add__
            result_segments = (
                _Segment(text, apply(_style), control)
                for text, _style, control in result_segments
            )
        link_style = self.link_style
        if link_style:
            result_segments = (
                _Segment(
                    text,
                    style
                    if style._meta is None
                    else (style + link_style if "@click" in style.meta else style),
                    control,
                )
                for text, style, control in result_segments
            )
        return result_segments

    def __rich_measure__(
        self, console: "Console", options: "ConsoleOptions"
    ) -> Measurement:
        return self.renderable.__rich_measure__(console, options)


class RenderCache(NamedTuple):
    """Stores results of a previous render."""

    size: Size
    lines: list[Strip]


class WidgetError(Exception):
    """Base widget error."""


class MountError(WidgetError):
    """Error raised when there was a problem with the mount request."""


@rich.repr.auto
class Widget(DOMNode):
    """
    A Widget is the base class for Textual widgets.

    See also [static][textual.widgets._static.Static] for starting point for your own widgets.

    """

    BINDINGS = [
        Binding("up", "scroll_up", "Scroll Up", show=False),
        Binding("down", "scroll_down", "Scroll Down", show=False),
        Binding("left", "scroll_left", "Scroll Up", show=False),
        Binding("right", "scroll_right", "Scroll Right", show=False),
        Binding("home", "scroll_home", "Scroll Home", show=False),
        Binding("end", "scroll_end", "Scroll End", show=False),
        Binding("pageup", "page_up", "Page Up", show=False),
        Binding("pagedown", "page_down", "Page Down", show=False),
    ]

    DEFAULT_CSS = """
    Widget{
        scrollbar-background: $panel-darken-1;
        scrollbar-background-hover: $panel-darken-2;
        scrollbar-background-active: $panel-darken-3;
        scrollbar-color: $primary-lighten-1;
        scrollbar-color-active: $warning-darken-1;
        scrollbar-color-hover: $primary-lighten-1;
        scrollbar-corner-color: $panel-darken-1;
        scrollbar-size-vertical: 2;
        scrollbar-size-horizontal: 1;
        link-background:;
        link-color: $text;
        link-style: underline;
        link-hover-background: $accent;
        link-hover-color: $text;
        link-hover-style: bold not underline;
    }
    """
    COMPONENT_CLASSES: ClassVar[set[str]] = set()

    can_focus: bool = False
    """Widget may receive focus."""
    can_focus_children: bool = True
    """Widget's children may receive focus."""
    expand = Reactive(False)
    """Rich renderable may expand."""
    shrink = Reactive(True)
    """Rich renderable may shrink."""
    auto_links = Reactive(True)
    """Widget will highlight links automatically."""

    hover_style: Reactive[Style] = Reactive(Style, repaint=False)
    highlight_link_id: Reactive[str] = Reactive("")

    def __init__(
        self,
        *children: Widget,
        name: str | None = None,
        id: str | None = None,
        classes: str | None = None,
    ) -> None:
        self._size = Size(0, 0)
        self._container_size = Size(0, 0)
        self._layout_required = False
        self._repaint_required = False
        self._default_layout = VerticalLayout()
        self._animate: BoundAnimator | None = None
        self.highlight_style: Style | None = None

        self._vertical_scrollbar: ScrollBar | None = None
        self._horizontal_scrollbar: ScrollBar | None = None
        self._scrollbar_corner: ScrollBarCorner | None = None

        self._render_cache = RenderCache(Size(0, 0), [])
        # Regions which need to be updated (in Widget)
        self._dirty_regions: set[Region] = set()
        # Regions which need to be transferred from cache to screen
        self._repaint_regions: set[Region] = set()

        # Cache the auto content dimensions
        # TODO: add mechanism to explicitly clear this
        self._content_width_cache: tuple[object, int] = (None, 0)
        self._content_height_cache: tuple[object, int] = (None, 0)

        self._arrangement_cache_key: tuple[Size, int] = (Size(), -1)
        self._cached_arrangement: DockArrangeResult | None = None

        self._styles_cache = StylesCache()
        self._rich_style_cache: dict[str, tuple[Style, Style]] = {}
        self._stabilized_scrollbar_size: Size | None = None
        self._lock = Lock()

        super().__init__(
            name=name,
            id=id,
            classes=self.DEFAULT_CLASSES if classes is None else classes,
        )

        if self in children:
            raise WidgetError("A widget can't be its own parent")

        self._add_children(*children)

    virtual_size = Reactive(Size(0, 0), layout=True)
    auto_width = Reactive(True)
    auto_height = Reactive(True)
    has_focus = Reactive(False)
    mouse_over = Reactive(False)
    scroll_x = Reactive(0.0, repaint=False, layout=False)
    scroll_y = Reactive(0.0, repaint=False, layout=False)
    scroll_target_x = Reactive(0.0, repaint=False)
    scroll_target_y = Reactive(0.0, repaint=False)
    show_vertical_scrollbar = Reactive(False, layout=True)
    show_horizontal_scrollbar = Reactive(False, layout=True)

    def watch_virtual_size(self, value, value_):
        if self.__class__.__name__ == "Vertical":
            print(f"[pink]Virtual size from {value} to {value_}[/]")

    @property
    def siblings(self) -> list[Widget]:
        """Get the widget's siblings (self is removed from the return list).

        Returns:
            A list of siblings.
        """
        parent = self.parent
        if parent is not None:
            siblings = list(parent.children)
            siblings.remove(self)
            return siblings
        else:
            return []

    @property
    def visible_siblings(self) -> list[Widget]:
        """A list of siblings which will be shown.

        Returns:
            List of siblings.
        """
        siblings = [
            widget for widget in self.siblings if widget.visible and widget.display
        ]
        return siblings

    @property
    def allow_vertical_scroll(self) -> bool:
        """Check if vertical scroll is permitted.

        May be overridden if you want different logic regarding allowing scrolling.

        Returns:
            True if the widget may scroll _vertically_.
        """
        return self.is_scrollable and self.show_vertical_scrollbar

    @property
    @debug
    def allow_horizontal_scroll(self) -> bool:
        """Check if horizontal scroll is permitted.

        May be overridden if you want different logic regarding allowing scrolling.

        Returns:
            True if the widget may scroll _horizontally_.
        """
        return self.is_scrollable and self.show_horizontal_scrollbar

    @property
    def _allow_scroll(self) -> bool:
        """Check if both axis may be scrolled.

        Returns:
            True if horizontal and vertical scrolling is enabled.
        """
        return self.is_scrollable and (
            self.allow_horizontal_scroll or self.allow_vertical_scroll
        )

    @property
    def offset(self) -> Offset:
        """Widget offset from origin.

        Returns:
            Relative offset.
        """
        return self.styles.offset.resolve(self.size, self.app.size)

    @offset.setter
    def offset(self, offset: Offset) -> None:
        self.styles.offset = ScalarOffset.from_offset(offset)

    ExpectType = TypeVar("ExpectType", bound="Widget")

    @overload
    def get_child_by_id(self, id: str) -> Widget:
        ...

    @overload
    def get_child_by_id(self, id: str, expect_type: type[ExpectType]) -> ExpectType:
        ...

    def get_child_by_id(
        self, id: str, expect_type: type[ExpectType] | None = None
    ) -> ExpectType | Widget:
        """Return the first child (immediate descendent) of this node with the given ID.

        Args:
            id: The ID of the child.
            expect_type: Require the object be of the supplied type, or None for any type.
                Defaults to None.

        Returns:
            The first child of this node with the ID.

        Raises:
            NoMatches: if no children could be found for this ID
            WrongType: if the wrong type was found.
        """
        child = self.children._get_by_id(id)
        if child is None:
            raise NoMatches(f"No child found with id={id!r}")
        if expect_type is None:
            return child
        if not isinstance(child, expect_type):
            raise WrongType(
                f"Child with id={id!r} is wrong type; expected {expect_type}, got"
                f" {type(child)}"
            )
        return child

    @overload
    def get_widget_by_id(self, id: str) -> Widget:
        ...

    @overload
    def get_widget_by_id(self, id: str, expect_type: type[ExpectType]) -> ExpectType:
        ...

    def get_widget_by_id(
        self, id: str, expect_type: type[ExpectType] | None = None
    ) -> ExpectType | Widget:
        """Return the first descendant widget with the given ID.
        Performs a depth-first search rooted at this widget.

        Args:
            id: The ID to search for in the subtree
            expect_type: Require the object be of the supplied type, or None for any type.
                Defaults to None.

        Returns:
            The first descendant encountered with this ID.

        Raises:
            NoMatches: if no children could be found for this ID
            WrongType: if the wrong type was found.
        """
        for child in walk_depth_first(self):
            try:
                return child.get_child_by_id(id, expect_type=expect_type)
            except NoMatches:
                pass
            except WrongType as exc:
                raise WrongType(
                    f"Descendant with id={id!r} is wrong type; expected {expect_type},"
                    f" got {type(child)}"
                ) from exc
        raise NoMatches(f"No descendant found with id={id!r}")

    def get_component_rich_style(self, name: str, *, partial: bool = False) -> Style:
        """Get a *Rich* style for a component.

        Args:
            name: Name of component.
            partial: Return a partial style (not combined with parent).

        Returns:
            A Rich style object.
        """

        if name not in self._rich_style_cache:
            component_styles = self.get_component_styles(name)
            style = component_styles.rich_style
            partial_style = component_styles.partial_rich_style
            self._rich_style_cache[name] = (style, partial_style)

        style, partial_style = self._rich_style_cache[name]

        return partial_style if partial else style

    def _arrange(self, size: Size) -> DockArrangeResult:
        """Arrange children.

        Args:
            size: Size of container.

        Returns:
            Widget locations.
        """
        assert self.is_container

        cache_key = (size, self.children._updates)
        if (
            self._arrangement_cache_key == cache_key
            and self._cached_arrangement is not None
        ):
            return self._cached_arrangement

        self._arrangement_cache_key = cache_key
        arrangement = self._cached_arrangement = arrange(
            self, self.children, size, self.screen.size
        )

        return arrangement

    def _clear_arrangement_cache(self) -> None:
        """Clear arrangement cache, forcing a new arrange operation."""
        self._cached_arrangement = None

    def _get_virtual_dom(self) -> Iterable[Widget]:
        """Get widgets not part of the DOM.

        Returns:
            An iterable of Widgets.

        """
        if self._horizontal_scrollbar is not None:
            yield self._horizontal_scrollbar
        if self._vertical_scrollbar is not None:
            yield self._vertical_scrollbar
        if self._scrollbar_corner is not None:
            yield self._scrollbar_corner

    def _find_mount_point(self, spot: int | str | "Widget") -> tuple["Widget", int]:
        """Attempt to locate the point where the caller wants to mount something.

        Args:
            spot: The spot to find.

        Returns:
            The parent and the location in its child list.

        Raises:
            MountError: If there was an error finding where to mount a widget.

        The rules of this method are:

        - Given an ``int``, parent is ``self`` and location is the integer value.
        - Given a ``Widget``, parent is the widget's parent and location is
          where the widget is found in the parent's ``children``. If it
          can't be found a ``MountError`` will be raised.
        - Given a string, it is used to perform a ``query_one`` and then the
          result is used as if a ``Widget`` had been given.
        """

        # A numeric location means at that point in our child list.
        if isinstance(spot, int):
            return self, spot

        # If we've got a string, that should be treated like a query that
        # can be passed to query_one. So let's use that to get a widget to
        # work on.
        if isinstance(spot, str):
            spot = self.query_one(spot, Widget)

        # At this point we should have a widget, either because we got given
        # one, or because we pulled one out of the query. First off, does it
        # have a parent? There's no way we can use it as a sibling to make
        # mounting decisions if it doesn't have a parent.
        if spot.parent is None:
            raise MountError(
                f"Unable to find relative location of {spot!r} because it has no parent"
            )

        # We've got a widget. It has a parent. It has (zero or more)
        # children. We should be able to go looking for the widget's
        # location amongst its parent's children.
        try:
            return cast("Widget", spot.parent), spot.parent.children.index(spot)
        except ValueError:
            raise MountError(f"{spot!r} is not a child of {self!r}") from None

    def mount(
        self,
        *widgets: Widget,
        before: int | str | Widget | None = None,
        after: int | str | Widget | None = None,
    ) -> AwaitMount:
        """Mount widgets below this widget (making this widget a container).

        Args:
            *widgets: The widget(s) to mount.
            before: Optional location to mount before.
            after: Optional location to mount after.

        Returns:
            An awaitable object that waits for widgets to be mounted.

        Raises:
            MountError: If there is a problem with the mount request.

        Note:
            Only one of ``before`` or ``after`` can be provided. If both are
            provided a ``MountError`` will be raised.
        """

        # Check for duplicate IDs in the incoming widgets
        ids_to_mount = [widget.id for widget in widgets if widget.id is not None]
        unique_ids = set(ids_to_mount)
        num_unique_ids = len(unique_ids)
        num_widgets_with_ids = len(ids_to_mount)
        if num_unique_ids != num_widgets_with_ids:
            counter = Counter(widget.id for widget in widgets)
            for widget_id, count in counter.items():
                if count > 1:
                    raise MountError(
                        f"Tried to insert {count!r} widgets with the same ID {widget_id!r}. "
                        "Widget IDs must be unique."
                    )

        # Saying you want to mount before *and* after something is an error.
        if before is not None and after is not None:
            raise MountError(
                "Only one of `before` or `after` can be handled -- not both"
            )

        # Decide the final resting place depending on what we've been asked
        # to do.
        insert_before: int | None = None
        insert_after: int | None = None
        if before is not None:
            parent, insert_before = self._find_mount_point(before)
        elif after is not None:
            parent, insert_after = self._find_mount_point(after)
        else:
            parent = self

        mounted = self.app._register(
            parent, *widgets, before=insert_before, after=insert_after
        )

        return AwaitMount(self, mounted)

    def move_child(
        self,
        child: int | Widget,
        before: int | Widget | None = None,
        after: int | Widget | None = None,
    ) -> None:
        """Move a child widget within its parent's list of children.

        Args:
            child: The child widget to move.
            before: (int | Widget, optional): Optional location to move before.
            after: (int | Widget, optional): Optional location to move after.

        Raises:
            WidgetError: If there is a problem with the child or target.

        Note:
            Only one of ``before`` or ``after`` can be provided. If neither
            or both are provided a ``WidgetError`` will be raised.
        """

        # One or the other of before or after are required. Can't do
        # neither, can't do both.
        if before is None and after is None:
            raise WidgetError("One of `before` or `after` is required.")
        elif before is not None and after is not None:
            raise WidgetError("Only one of `before` or `after` can be handled.")

        def _to_widget(child: int | Widget, called: str) -> Widget:
            """Ensure a given child reference is a Widget."""
            if isinstance(child, int):
                try:
                    child = self.children[child]
                except IndexError:
                    raise WidgetError(
                        f"An index of {child} for the child to {called} is out of bounds"
                    ) from None
            else:
                # We got an actual widget, so let's be sure it really is one of
                # our children.
                try:
                    _ = self.children.index(child)
                except ValueError:
                    raise WidgetError(f"{child!r} is not a child of {self!r}") from None
            return child

        # Ensure the child and target are widgets.
        child = _to_widget(child, "move")
        target = _to_widget(before if after is None else after, "move towards")

        # At this point we should know what we're moving, and it should be a
        # child; where we're moving it to, which should be within the child
        # list; and how we're supposed to move it. All that's left is doing
        # the right thing.
        self.children._remove(child)
        if before is not None:
            self.children._insert(self.children.index(target), child)
        else:
            self.children._insert(self.children.index(target) + 1, child)

        # Request a refresh.
        self.refresh(layout=True)

    def compose(self) -> ComposeResult:
        """Called by Textual to create child widgets.

        Extend this to build a UI.

        Example:
            ```python
            def compose(self) -> ComposeResult:
                yield Header()
                yield Container(
                    Tree(), Viewer()
                )
                yield Footer()
            ```

        """
        yield from ()

    def _post_register(self, app: App) -> None:
        """Called when the instance is registered.

        Args:
            app: App instance.
        """
        # Parse the Widget's CSS
        for path, css, tie_breaker in self._get_default_css():
            self.app.stylesheet.add_source(
                css, path=path, is_default_css=True, tie_breaker=tie_breaker
            )

    def _get_box_model(
        self,
        container: Size,
        viewport: Size,
        width_fraction: Fraction,
        height_fraction: Fraction,
    ) -> BoxModel:
        """Process the box model for this widget.

        Args:
            container: The size of the container widget (with a layout)
            viewport: The viewport size.
            width_fraction: A fraction used for 1 `fr` unit on the width dimension.
            height_fraction: A fraction used for 1 `fr` unit on the height dimension.

        Returns:
            The size and margin for this widget.
        """
        box_model = get_box_model(
            self.styles,
            container,
            viewport,
            width_fraction,
            height_fraction,
            self.get_content_width,
            self.get_content_height,
        )
        return box_model

    def get_content_width(self, container: Size, viewport: Size) -> int:
        """Called by textual to get the width of the content area. May be overridden in a subclass.

        Args:
            container: Size of the container (immediate parent) widget.
            viewport: Size of the viewport.

        Returns:
            The optimal width of the content.
        """
        if self.is_container:
            assert self._layout is not None
            return self._layout.get_content_width(self, container, viewport)

        cache_key = container.width
        if self._content_width_cache[0] == cache_key:
            return self._content_width_cache[1]

        console = self.app.console
        renderable = self._render()

        width = measure(console, renderable, container.width)
        if self.expand:
            width = max(container.width, width)
        if self.shrink:
            width = min(width, container.width)

        self._content_width_cache = (cache_key, width)
        return width

    def get_content_height(self, container: Size, viewport: Size, width: int) -> int:
        """Called by Textual to get the height of the content area. May be overridden in a subclass.

        Args:
            container: Size of the container (immediate parent) widget.
            viewport: Size of the viewport.
            width: Width of renderable.

        Returns:
            The height of the content.
        """
        if self.is_container:
            assert self._layout is not None
            height = (
                self._layout.get_content_height(
                    self,
                    container,
                    viewport,
                    width,
                )
                + self.scrollbar_size_horizontal
            )
        else:
            cache_key = width

            if self._content_height_cache[0] == cache_key:
                return self._content_height_cache[1]

            renderable = self.render()
            options = self._console.options.update_width(width).update(highlight=False)
            segments = self._console.render(renderable, options)
            # Cheaper than counting the lines returned from render_lines!
            height = sum(text.count("\n") for text, _, _ in segments)
            self._content_height_cache = (cache_key, height)

        return height

    def watch_hover_style(
        self, previous_hover_style: Style, hover_style: Style
    ) -> None:
        if self.auto_links:
            self.highlight_link_id = hover_style.link_id

    def watch_scroll_x(self, old_value: float, new_value: float) -> None:
        self.horizontal_scrollbar.position = round(new_value)
        if round(old_value) != round(new_value):
            self._refresh_scroll()

    def watch_scroll_y(self, old_value: float, new_value: float) -> None:
        self.vertical_scrollbar.position = round(new_value)
        if round(old_value) != round(new_value):
            self._refresh_scroll()

    def validate_scroll_x(self, value: float) -> float:
        return clamp(value, 0, self.max_scroll_x)

    def validate_scroll_target_x(self, value: float) -> float:
        return clamp(value, 0, self.max_scroll_x)

    def validate_scroll_y(self, value: float) -> float:
        return clamp(value, 0, self.max_scroll_y)

    def validate_scroll_target_y(self, value: float) -> float:
        return clamp(value, 0, self.max_scroll_y)

    @property
    def max_scroll_x(self) -> int:
        """The maximum value of `scroll_x`."""
        return max(
            0,
            self.virtual_size.width
            - self.container_size.width
            + self.scrollbar_size_vertical,
        )

    @property
    def max_scroll_y(self) -> int:
        """The maximum value of `scroll_y`."""
        return max(
            0,
            self.virtual_size.height
            - self.container_size.height
            + self.scrollbar_size_horizontal,
        )

    @property
    def scrollbar_corner(self) -> ScrollBarCorner:
        """Return the ScrollBarCorner - the cells that appear between the
        horizontal and vertical scrollbars (only when both are visible).
        """
        from .scrollbar import ScrollBarCorner

        if self._scrollbar_corner is not None:
            return self._scrollbar_corner
        self._scrollbar_corner = ScrollBarCorner()
        self.app._start_widget(self, self._scrollbar_corner)
        return self._scrollbar_corner

    @property
    def vertical_scrollbar(self) -> ScrollBar:
        """Get a vertical scrollbar (create if necessary).

        Returns:
            ScrollBar Widget.
        """
        from .scrollbar import ScrollBar

        if self._vertical_scrollbar is not None:
            return self._vertical_scrollbar
        self._vertical_scrollbar = scroll_bar = ScrollBar(
            vertical=True, name="vertical", thickness=self.scrollbar_size_vertical
        )
        self._vertical_scrollbar.display = False
        self.app._start_widget(self, scroll_bar)
        return scroll_bar

    @property
    def horizontal_scrollbar(self) -> ScrollBar:
        """Get a vertical scrollbar (create if necessary).

        Returns:
            ScrollBar Widget.
        """

        from .scrollbar import ScrollBar

        if self._horizontal_scrollbar is not None:
            return self._horizontal_scrollbar
        self._horizontal_scrollbar = scroll_bar = ScrollBar(
            vertical=False, name="horizontal", thickness=self.scrollbar_size_horizontal
        )
        self._horizontal_scrollbar.display = False
        self.app._start_widget(self, scroll_bar)
        return scroll_bar

    @debug
    def _refresh_scrollbars(self) -> None:
        """Refresh scrollbar visibility."""
        if not self.is_scrollable or not self.container_size:
            return

        styles = self.styles
        overflow_x = styles.overflow_x
        overflow_y = styles.overflow_y
        width, height = self.container_size

        show_horizontal = self.show_horizontal_scrollbar
        if overflow_x == "hidden":
            show_horizontal = False
        elif overflow_x == "scroll":
            show_horizontal = True
        elif overflow_x == "auto":
            show_horizontal = self.virtual_size.width > width

        show_vertical = self.show_vertical_scrollbar
        if overflow_y == "hidden":
            show_vertical = False
        elif overflow_y == "scroll":
            show_vertical = True
        elif overflow_y == "auto":
            show_vertical = self.virtual_size.height > height

        if (
            overflow_x == "auto"
            and show_vertical
            and not show_horizontal
            and self._stabilized_scrollbar_size != self.container_size
        ):
            show_horizontal = (
                self.virtual_size.width + styles.scrollbar_size_vertical > width
            )
            self._stabilized_scrollbar_size = self.container_size

        self.show_horizontal_scrollbar = show_horizontal
        self.show_vertical_scrollbar = show_vertical

        if self._horizontal_scrollbar is not None or show_horizontal:
            self.horizontal_scrollbar.display = show_horizontal
        if self._vertical_scrollbar is not None or show_vertical:
            self.vertical_scrollbar.display = show_vertical

    @property
    def scrollbars_enabled(self) -> tuple[bool, bool]:
        """A tuple of booleans that indicate if scrollbars are enabled.

        Returns:
            A tuple of (<vertical scrollbar enabled>, <horizontal scrollbar enabled>)

        """
        if not self.is_scrollable:
            return False, False

        enabled = self.show_vertical_scrollbar, self.show_horizontal_scrollbar
        return enabled

    @property
    def scrollbar_size_vertical(self) -> int:
        """Get the width used by the *vertical* scrollbar.

        Returns:
            Number of columns in the vertical scrollbar.
        """
        styles = self.styles
        if styles.scrollbar_gutter == "stable" and styles.overflow_y == "auto":
            return styles.scrollbar_size_vertical
        return styles.scrollbar_size_vertical if self.show_vertical_scrollbar else 0

    @property
    def scrollbar_size_horizontal(self) -> int:
        """Get the height used by the *horizontal* scrollbar.

        Returns:
            Number of rows in the horizontal scrollbar.
        """
        styles = self.styles
        return styles.scrollbar_size_horizontal if self.show_horizontal_scrollbar else 0

    @property
    def scrollbar_gutter(self) -> Spacing:
        """Spacing required to fit scrollbar(s).

        Returns:
            Scrollbar gutter spacing.
        """
        return Spacing(
            0, self.scrollbar_size_vertical, self.scrollbar_size_horizontal, 0
        )

    @property
    def gutter(self) -> Spacing:
        """Spacing for padding / border / scrollbars.

        Returns:
            Additional spacing around content area.

        """
        return self.styles.gutter + self.scrollbar_gutter

    @property
    def size(self) -> Size:
        """The size of the content area.

        Returns:
            Content area size.
        """
        return self.content_region.size

    @property
    def outer_size(self) -> Size:
        """The size of the widget (including padding and border).

        Returns:
            Outer size.
        """
        return self._size

    @property
    def container_size(self) -> Size:
        """The size of the container (parent widget).

        Returns:
            Container size.
        """
        return self._container_size

    @property
    def content_region(self) -> Region:
        """Gets an absolute region containing the content (minus padding and border).

        Returns:
            Screen region that contains a widget's content.
        """
        content_region = self.region.shrink(self.styles.gutter)
        return content_region

    @property
    def scrollable_content_region(self) -> Region:
        """Gets an absolute region containing the scrollable content (minus padding, border, and scrollbars).

        Returns:
            Screen region that contains a widget's content.
        """
        content_region = self.region.shrink(self.styles.gutter).shrink(
            self.scrollbar_gutter
        )
        return content_region

    @property
    def content_offset(self) -> Offset:
        """An offset from the Widget origin where the content begins.

        Returns:
            Offset from widget's origin.

        """
        x, y = self.gutter.top_left
        return Offset(x, y)

    @property
    def content_size(self) -> Size:
        """Get the size of the content area."""
        return self.region.shrink(self.styles.gutter).size

    @property
    def region(self) -> Region:
        """The region occupied by this widget, relative to the Screen.

        Raises:
            NoScreen: If there is no screen.
            errors.NoWidget: If the widget is not on the screen.

        Returns:
            Region within screen occupied by widget.
        """
        try:
            return self.screen.find_widget(self).region
        except NoScreen:
            return Region()
        except errors.NoWidget:
            return Region()

    @property
    def container_viewport(self) -> Region:
        """The viewport region (parent window).

        Returns:
            The region that contains this widget.
        """
        if self.parent is None:
            return self.size.region
        assert isinstance(self.parent, Widget)
        return self.parent.region

    @property
    def virtual_region(self) -> Region:
        """The widget region relative to it's container. Which may not be visible,
        depending on scroll offset.
        """
        try:
            return self.screen.find_widget(self).virtual_region
        except NoScreen:
            return Region()
        except errors.NoWidget:
            return Region()

    @property
    def window_region(self) -> Region:
        """The region within the scrollable area that is currently visible.

        Returns:
            New region.
        """
        window_region = self.region.at_offset(self.scroll_offset)
        return window_region

    @property
    def virtual_region_with_margin(self) -> Region:
        """The widget region relative to its container (*including margin*), which may not be visible,
        depending on the scroll offset.

        Returns:
            The virtual region of the Widget, inclusive of its margin.
        """
        return self.virtual_region.grow(self.styles.margin)

    @property
    def focusable_children(self) -> list[Widget]:
        """Get the children which may be focused.

        Returns:
            List of widgets that can receive focus.

        """
        focusable = [
            child for child in self.children if child.display and child.visible
        ]
        return sorted(focusable, key=attrgetter("_focus_sort_key"))

    @property
    def _focus_sort_key(self) -> tuple[int, int]:
        """Key function to sort widgets in to focus order."""
        x, y, _, _ = self.virtual_region
        top, _, _, left = self.styles.margin
        return y - top, x - left

    @property
    def scroll_offset(self) -> Offset:
        """Get the current scroll offset.

        Returns:
            Offset a container has been scrolled by.
        """
        return Offset(round(self.scroll_x), round(self.scroll_y))

    @property
    def is_transparent(self) -> bool:
        """Check if the background styles is not set.

        Returns:
            ``True`` if there is background color, otherwise ``False``.
        """
        return self.is_scrollable and self.styles.background.is_transparent

    @property
    def _console(self) -> Console:
        """Get the current console.

        Returns:
            A Rich console object.

        """
        return active_app.get().console

    def animate(
        self,
        attribute: str,
        value: float | Animatable,
        *,
        final_value: object = ...,
        duration: float | None = None,
        speed: float | None = None,
        delay: float = 0.0,
        easing: EasingFunction | str = DEFAULT_EASING,
        on_complete: CallbackType | None = None,
    ) -> None:
        """Animate an attribute.

        Args:
            attribute: Name of the attribute to animate.
            value: The value to animate to.
            final_value: The final value of the animation. Defaults to `value` if not set.
            duration: The duration of the animate. Defaults to None.
            speed: The speed of the animation. Defaults to None.
            delay: A delay (in seconds) before the animation starts. Defaults to 0.0.
            easing: An easing method. Defaults to "in_out_cubic".
            on_complete: A callable to invoke when the animation is finished. Defaults to None.

        """
        if self._animate is None:
            self._animate = self.app.animator.bind(self)
        assert self._animate is not None
        self._animate(
            attribute,
            value,
            final_value=final_value,
            duration=duration,
            speed=speed,
            delay=delay,
            easing=easing,
            on_complete=on_complete,
        )

    @property
    def _layout(self) -> Layout:
        """Get the layout object if set in styles, or a default layout.

        Returns:
            A layout object.

        """
        return self.styles.layout or self._default_layout

    @property
    def is_container(self) -> bool:
        """Check if this widget is a container (contains other widgets).

        Returns:
            True if this widget is a container.
        """
        return self.styles.layout is not None or bool(self.children)

    @property
    def is_scrollable(self) -> bool:
        """Check if this Widget may be scrolled.

        Returns:
            True if this widget may be scrolled.
        """
        return self.styles.layout is not None or bool(self.children)

    @property
    def layer(self) -> str:
        """Get the name of this widgets layer.

        Returns:
            Name of layer.

        """
        return self.styles.layer or "default"

    @property
    def layers(self) -> tuple[str, ...]:
        """Layers of from parent.

        Returns:
            Tuple of layer names.
        """
        for node in self.ancestors_with_self:
            if not isinstance(node, Widget):
                break
            if node.styles.has_rule("layers"):
                return node.styles.layers
        return ("default",)

    @property
    def link_style(self) -> Style:
        """Style of links."""
        styles = self.styles
        _, background = self.background_colors
        link_background = background + styles.link_background
        link_color = link_background + (
            link_background.get_contrast_text(styles.link_color.a)
            if styles.auto_link_color
            else styles.link_color
        )
        style = styles.link_style + Style.from_color(
            link_color.rich_color,
            link_background.rich_color,
        )
        return style

    @property
    def link_hover_style(self) -> Style:
        """Style of links with mouse hover."""
        styles = self.styles
        _, background = self.background_colors
        hover_background = background + styles.link_hover_background
        hover_color = hover_background + (
            hover_background.get_contrast_text(styles.link_hover_color.a)
            if styles.auto_link_hover_color
            else styles.link_hover_color
        )
        style = styles.link_hover_style + Style.from_color(
            hover_color.rich_color,
            hover_background.rich_color,
        )
        return style

    def _set_dirty(self, *regions: Region) -> None:
        """Set the Widget as 'dirty' (requiring re-paint).

        Regions should be specified as positional args. If no regions are added, then
        the entire widget will be considered dirty.

        Args:
            *regions: Regions which require a repaint.

        """
        if regions:
            content_offset = self.content_offset
            widget_regions = [region.translate(content_offset) for region in regions]
            self._dirty_regions.update(widget_regions)
            self._repaint_regions.update(widget_regions)
            self._styles_cache.set_dirty(*widget_regions)
        else:
            self._dirty_regions.clear()
            self._repaint_regions.clear()
            self._styles_cache.clear()
            self._dirty_regions.add(self.outer_size.region)
            self._repaint_regions.add(self.outer_size.region)

    def _exchange_repaint_regions(self) -> Collection[Region]:
        """Get a copy of the regions which need a repaint, and clear internal cache.

        Returns:
            Regions to repaint.
        """
        regions = self._repaint_regions.copy()
        self._repaint_regions.clear()
        return regions

    def scroll_to(
        self,
        x: float | None = None,
        y: float | None = None,
        *,
        animate: bool = True,
        speed: float | None = None,
        duration: float | None = None,
        easing: EasingFunction | str | None = None,
        force: bool = False,
    ) -> bool:
        """Scroll to a given (absolute) coordinate, optionally animating.

        Args:
            x: X coordinate (column) to scroll to, or None for no change. Defaults to None.
            y: Y coordinate (row) to scroll to, or None for no change. Defaults to None.
            animate: Animate to new scroll position. Defaults to True.
            speed: Speed of scroll if animate is True. Or None to use duration.
            duration: Duration of animation, if animate is True and speed is None.
            easing: An easing method for the scrolling animation. Defaults to "None",
                which will result in Textual choosing the default scrolling easing function.
            force: Force scrolling even when prohibited by overflow styling. Defaults to `False`.

        Returns:
            True if the scroll position changed, otherwise False.
        """
        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
        if animate:
            # TODO: configure animation speed
            if duration is None and speed is None:
                speed = 50

            if easing is None:
                easing = DEFAULT_SCROLL_EASING

            if maybe_scroll_x:
                self.scroll_target_x = x
                if x != self.scroll_x:
                    self.animate(
                        "scroll_x",
                        self.scroll_target_x,
                        speed=speed,
                        duration=duration,
                        easing=easing,
                    )
                    scrolled_x = True
            if maybe_scroll_y:
                self.scroll_target_y = y
                if y != self.scroll_y:
                    self.animate(
                        "scroll_y",
                        self.scroll_target_y,
                        speed=speed,
                        duration=duration,
                        easing=easing,
                    )
                    scrolled_y = True

        else:
            if maybe_scroll_x:
                scroll_x = self.scroll_x
                self.scroll_target_x = self.scroll_x = x
                scrolled_x = scroll_x != self.scroll_x
            if maybe_scroll_y:
                scroll_y = self.scroll_y
                self.scroll_target_y = self.scroll_y = y
                scrolled_y = scroll_y != self.scroll_y

        return scrolled_x or scrolled_y

    def scroll_relative(
        self,
        x: float | None = None,
        y: float | None = None,
        *,
        animate: bool = True,
        speed: float | None = None,
        duration: float | None = None,
        easing: EasingFunction | str | None = None,
        force: bool = False,
    ) -> bool:
        """Scroll relative to current position.

        Args:
            x: X distance (columns) to scroll, or ``None`` for no change. Defaults to None.
            y: Y distance (rows) to scroll, or ``None`` for no change. Defaults to None.
            animate: Animate to new scroll position. Defaults to False.
            speed: Speed of scroll if animate is True. Or None to use duration.
            duration: Duration of animation, if animate is True and speed is None.
            easing: An easing method for the scrolling animation. Defaults to "None",
                which will result in Textual choosing the configured default scrolling easing function.
            force: Force scrolling even when prohibited by overflow styling. Defaults to `False`.

        Returns:
            True if the scroll position changed, otherwise False.
        """
        return self.scroll_to(
            None if x is None else (self.scroll_x + x),
            None if y is None else (self.scroll_y + y),
            animate=animate,
            speed=speed,
            duration=duration,
            easing=easing,
            force=force,
        )

    def scroll_home(
        self,
        *,
        animate: bool = True,
        speed: float | None = None,
        duration: float | None = None,
        easing: EasingFunction | str | None = None,
        force: bool = False,
    ) -> bool:
        """Scroll to home position.

        Args:
            animate: Animate scroll. Defaults to True.
            speed: Speed of scroll if animate is True. Or None to use duration.
            duration: Duration of animation, if animate is True and speed is None.
            easing: An easing method for the scrolling animation. Defaults to "None",
                which will result in Textual choosing the configured default scrolling easing function.
            force: Force scrolling even when prohibited by overflow styling. Defaults to `False`.

        Returns:
            True if any scrolling was done.
        """
        if speed is None and duration is None:
            duration = 1.0
        return self.scroll_to(
            0,
            0,
            animate=animate,
            speed=speed,
            duration=duration,
            easing=easing,
            force=force,
        )

    def scroll_end(
        self,
        *,
        animate: bool = True,
        speed: float | None = None,
        duration: float | None = None,
        easing: EasingFunction | str | None = None,
        force: bool = False,
    ) -> bool:
        """Scroll to the end of the container.

        Args:
            animate: Animate scroll. Defaults to True.
            speed: Speed of scroll if animate is True. Or None to use duration.
            duration: Duration of animation, if animate is True and speed is None.
            easing: An easing method for the scrolling animation. Defaults to "None",
                which will result in Textual choosing the configured default scrolling easing function.
            force: Force scrolling even when prohibited by overflow styling. Defaults to `False`.

        Returns:
            True if any scrolling was done.

        """
        if speed is None and duration is None:
            duration = 1.0
        return self.scroll_to(
            0,
            self.max_scroll_y,
            animate=animate,
            speed=speed,
            duration=duration,
            easing=easing,
            force=force,
        )

    def scroll_left(
        self,
        *,
        animate: bool = True,
        speed: float | None = None,
        duration: float | None = None,
        easing: EasingFunction | str | None = None,
        force: bool = False,
    ) -> bool:
        """Scroll one cell left.

        Args:
            animate: Animate scroll. Defaults to True.
            speed: Speed of scroll if animate is True. Or None to use duration.
            duration: Duration of animation, if animate is True and speed is None.
            easing: An easing method for the scrolling animation. Defaults to "None",
                which will result in Textual choosing the configured default scrolling easing function.
            force: Force scrolling even when prohibited by overflow styling. Defaults to `False`.

        Returns:
            True if any scrolling was done.

        """
        return self.scroll_to(
            x=self.scroll_target_x - 1,
            animate=animate,
            speed=speed,
            duration=duration,
            easing=easing,
            force=force,
        )

    def scroll_right(
        self,
        *,
        animate: bool = True,
        speed: float | None = None,
        duration: float | None = None,
        easing: EasingFunction | str | None = None,
        force: bool = False,
    ) -> bool:
        """Scroll on cell right.

        Args:
            animate: Animate scroll. Defaults to True.
            speed: Speed of scroll if animate is True. Or None to use duration.
            duration: Duration of animation, if animate is True and speed is None.
            easing: An easing method for the scrolling animation. Defaults to "None",
                which will result in Textual choosing the configured default scrolling easing function.
            force: Force scrolling even when prohibited by overflow styling. Defaults to `False`.

        Returns:
            True if any scrolling was done.

        """
        return self.scroll_to(
            x=self.scroll_target_x + 1,
            animate=animate,
            speed=speed,
            duration=duration,
            easing=easing,
            force=force,
        )

    def scroll_down(
        self,
        *,
        animate: bool = True,
        speed: float | None = None,
        duration: float | None = None,
        easing: EasingFunction | str | None = None,
        force: bool = False,
    ) -> bool:
        """Scroll one line down.

        Args:
            animate: Animate scroll. Defaults to True.
            speed: Speed of scroll if animate is True. Or None to use duration.
            duration: Duration of animation, if animate is True and speed is None.
            easing: An easing method for the scrolling animation. Defaults to "None",
                which will result in Textual choosing the configured default scrolling easing function.
            force: Force scrolling even when prohibited by overflow styling. Defaults to `False`.

        Returns:
            True if any scrolling was done.

        """
        return self.scroll_to(
            y=self.scroll_target_y + 1,
            animate=animate,
            speed=speed,
            duration=duration,
            easing=easing,
            force=force,
        )

    def scroll_up(
        self,
        *,
        animate: bool = True,
        speed: float | None = None,
        duration: float | None = None,
        easing: EasingFunction | str | None = None,
        force: bool = False,
    ) -> bool:
        """Scroll one line up.

        Args:
            animate: Animate scroll. Defaults to True.
            speed: Speed of scroll if animate is True. Or None to use duration.
            duration: Duration of animation, if animate is True and speed is None.
            easing: An easing method for the scrolling animation. Defaults to "None",
                which will result in Textual choosing the configured default scrolling easing function.
            force: Force scrolling even when prohibited by overflow styling. Defaults to `False`.

        Returns:
            True if any scrolling was done.

        """
        return self.scroll_to(
            y=self.scroll_target_y - 1,
            animate=animate,
            speed=speed,
            duration=duration,
            easing=easing,
            force=force,
        )

    def scroll_page_up(
        self,
        *,
        animate: bool = True,
        speed: float | None = None,
        duration: float | None = None,
        easing: EasingFunction | str | None = None,
        force: bool = False,
    ) -> bool:
        """Scroll one page up.

        Args:
            animate: Animate scroll. Defaults to True.
            speed: Speed of scroll if animate is True. Or None to use duration.
            duration: Duration of animation, if animate is True and speed is None.
            easing: An easing method for the scrolling animation. Defaults to "None",
                which will result in Textual choosing the configured default scrolling easing function.
            force: Force scrolling even when prohibited by overflow styling. Defaults to `False`.

        Returns:
            True if any scrolling was done.

        """
        return self.scroll_to(
            y=self.scroll_target_y - self.container_size.height,
            animate=animate,
            speed=speed,
            duration=duration,
            easing=easing,
            force=force,
        )

    def scroll_page_down(
        self,
        *,
        animate: bool = True,
        speed: float | None = None,
        duration: float | None = None,
        easing: EasingFunction | str | None = None,
        force: bool = False,
    ) -> bool:
        """Scroll one page down.

        Args:
            animate: Animate scroll. Defaults to True.
            speed: Speed of scroll if animate is True. Or None to use duration.
            duration: Duration of animation, if animate is True and speed is None.
            easing: An easing method for the scrolling animation. Defaults to "None",
                which will result in Textual choosing the configured default scrolling easing function.
            force: Force scrolling even when prohibited by overflow styling. Defaults to `False`.

        Returns:
            True if any scrolling was done.

        """
        return self.scroll_to(
            y=self.scroll_target_y + self.container_size.height,
            animate=animate,
            speed=speed,
            duration=duration,
            easing=easing,
            force=force,
        )

    def scroll_page_left(
        self,
        *,
        animate: bool = True,
        speed: float | None = None,
        duration: float | None = None,
        easing: EasingFunction | str | None = None,
        force: bool = False,
    ) -> bool:
        """Scroll one page left.

        Args:
            animate: Animate scroll. Defaults to True.
            speed: Speed of scroll if animate is True. Or None to use duration.
            duration: Duration of animation, if animate is True and speed is None.
            easing: An easing method for the scrolling animation. Defaults to "None",
                which will result in Textual choosing the configured default scrolling easing function.
            force: Force scrolling even when prohibited by overflow styling. Defaults to `False`.

        Returns:
            True if any scrolling was done.

        """
        if speed is None and duration is None:
            duration = 0.3
        return self.scroll_to(
            x=self.scroll_target_x - self.container_size.width,
            animate=animate,
            speed=speed,
            duration=duration,
            easing=easing,
            force=force,
        )

    def scroll_page_right(
        self,
        *,
        animate: bool = True,
        speed: float | None = None,
        duration: float | None = None,
        easing: EasingFunction | str | None = None,
        force: bool = False,
    ) -> bool:
        """Scroll one page right.

        Args:
            animate: Animate scroll. Defaults to True.
            speed: Speed of scroll if animate is True. Or None to use duration.
            duration: Duration of animation, if animate is True and speed is None.
            easing: An easing method for the scrolling animation. Defaults to "None",
                which will result in Textual choosing the configured default scrolling easing function.
            force: Force scrolling even when prohibited by overflow styling. Defaults to `False`.

        Returns:
            True if any scrolling was done.

        """
        if speed is None and duration is None:
            duration = 0.3
        return self.scroll_to(
            x=self.scroll_target_x + self.container_size.width,
            animate=animate,
            speed=speed,
            duration=duration,
            easing=easing,
            force=force,
        )

    def scroll_to_widget(
        self,
        widget: Widget,
        *,
        animate: bool = True,
        speed: float | None = None,
        duration: float | None = None,
        easing: EasingFunction | str | None = None,
        top: bool = False,
        force: bool = False,
    ) -> bool:
        """Scroll scrolling to bring a widget in to view.

        Args:
            widget: A descendant widget.
            animate: True to animate, or False to jump. Defaults to True.
            speed: Speed of scroll if animate is True. Or None to use duration.
            duration: Duration of animation, if animate is True and speed is None.
            easing: An easing method for the scrolling animation. Defaults to "None",
                which will result in Textual choosing the configured default scrolling easing function.
            top: Scroll widget to top of container. Defaults to False.
            force: Force scrolling even when prohibited by overflow styling. Defaults to `False`.

        Returns:
            True if any scrolling has occurred in any descendant, otherwise False.
        """

        # Grow the region by the margin so to keep the margin in view.
        region = widget.virtual_region_with_margin
        scrolled = False

        while isinstance(widget.parent, Widget) and widget is not self:
            container = widget.parent
            scroll_offset = container.scroll_to_region(
                region,
                spacing=widget.parent.gutter,
                animate=animate,
                speed=speed,
                duration=duration,
                top=top,
                easing=easing,
                force=force,
            )
            if scroll_offset:
                scrolled = True

            # Adjust the region by the amount we just scrolled it, and convert to
            # it's parent's virtual coordinate system.
            region = (
                (
                    region.translate(-scroll_offset)
                    .translate(-widget.scroll_offset)
                    .translate(container.virtual_region.offset)
                )
                .grow(container.styles.margin)
                .intersection(container.virtual_region)
            )
            widget = container
        return scrolled

    def scroll_to_region(
        self,
        region: Region,
        *,
        spacing: Spacing | None = None,
        animate: bool = True,
        speed: float | None = None,
        duration: float | None = None,
        easing: EasingFunction | str | None = None,
        top: bool = False,
        force: bool = False,
    ) -> Offset:
        """Scrolls a given region in to view, if required.

        This method will scroll the least distance required to move `region` fully within
        the scrollable area.

        Args:
            region: A region that should be visible.
            spacing: Optional spacing around the region. Defaults to None.
            animate: True to animate, or False to jump. Defaults to True.
            speed: Speed of scroll if animate is True. Or None to use duration.
            duration: Duration of animation, if animate is True and speed is None.
            easing: An easing method for the scrolling animation. Defaults to "None",
                which will result in Textual choosing the configured default scrolling easing function.
            top: Scroll region to top of container. Defaults to False.
            force: Force scrolling even when prohibited by overflow styling. Defaults to `False`.

        Returns:
            The distance that was scrolled.
        """
        window = self.scrollable_content_region.at_offset(self.scroll_offset)
        if spacing is not None:
            window = window.shrink(spacing)

        if window in region and not top:
            return Offset()

        delta_x, delta_y = Region.get_scroll_to_visible(window, region, top=top)
        scroll_x, scroll_y = self.scroll_offset
        delta = Offset(
            clamp(scroll_x + delta_x, 0, self.max_scroll_x) - scroll_x,
            clamp(scroll_y + delta_y, 0, self.max_scroll_y) - scroll_y,
        )
        if delta:
            if speed is None and duration is None:
                duration = 0.2
            self.scroll_relative(
                delta.x or None,
                delta.y or None,
                animate=animate if (abs(delta_y) > 1 or delta_x) else False,
                speed=speed,
                duration=duration,
                easing=easing,
                force=force,
            )
        return delta

    def scroll_visible(
        self,
        animate: bool = True,
        *,
        speed: float | None = None,
        duration: float | None = None,
        top: bool = False,
        easing: EasingFunction | str | None = None,
        force: bool = False,
    ) -> None:
        """Scroll the container to make this widget visible.

        Args:
            animate: _description_. Defaults to True.
            speed: _description_. Defaults to None.
            duration: _description_. Defaults to None.
            top: Scroll to top of container. Defaults to False.
            easing: An easing method for the scrolling animation. Defaults to "None",
                which will result in Textual choosing the configured default scrolling easing function.
            force: Force scrolling even when prohibited by overflow styling. Defaults to `False`.
        """
        parent = self.parent
        if isinstance(parent, Widget):
            self.call_after_refresh(
                parent.scroll_to_widget,
                self,
                animate=animate,
                speed=speed,
                duration=duration,
                top=top,
                easing=easing,
                force=force,
            )

    def __init_subclass__(
        cls,
        can_focus: bool | None = None,
        can_focus_children: bool | None = None,
        inherit_css: bool = True,
        inherit_bindings: bool = True,
    ) -> None:
        base = cls.__mro__[0]
        super().__init_subclass__(
            inherit_css=inherit_css,
            inherit_bindings=inherit_bindings,
        )
        if issubclass(base, Widget):
            cls.can_focus = base.can_focus if can_focus is None else can_focus
            cls.can_focus_children = (
                base.can_focus_children
                if can_focus_children is None
                else can_focus_children
            )

    def __rich_repr__(self) -> rich.repr.Result:
        yield "id", self.id, None
        if self.name:
            yield "name", self.name
        if self.classes:
            yield "classes", set(self.classes)
        pseudo_classes = self.pseudo_classes
        if pseudo_classes:
            yield "pseudo_classes", set(pseudo_classes)

    @debug
    def _get_scrollable_region(self, region: Region) -> Region:
        """Adjusts the Widget region to accommodate scrollbars.

        Args:
            region: A region for the widget.

        Returns:
            The widget region minus scrollbars.
        """
        self._refresh_scrollbars()
        show_vertical_scrollbar, show_horizontal_scrollbar = self.scrollbars_enabled

        scrollbar_size_horizontal = self.styles.scrollbar_size_horizontal
        scrollbar_size_vertical = self.styles.scrollbar_size_vertical

        if self.styles.scrollbar_gutter == "stable":
            # Let's _always_ reserve some space, whether the scrollbar is actually displayed or not:
            show_vertical_scrollbar = True
            scrollbar_size_vertical = self.styles.scrollbar_size_vertical

        if show_horizontal_scrollbar and show_vertical_scrollbar:
            (region, _, _, _) = region.split(
                -scrollbar_size_vertical,
                -scrollbar_size_horizontal,
            )
        elif show_vertical_scrollbar:
            region, _ = region.split_vertical(-scrollbar_size_vertical)
        elif show_horizontal_scrollbar:
            region, _ = region.split_horizontal(-scrollbar_size_horizontal)
        return region

    @debug
    def _arrange_scrollbars(self, region: Region) -> Iterable[tuple[Widget, Region]]:
        """Arrange the 'chrome' widgets (typically scrollbars) for a layout element.

        Args:
            region: The containing region.

        Returns:
            Tuples of scrollbar Widget and region.

        """
        show_vertical_scrollbar, show_horizontal_scrollbar = self.scrollbars_enabled

        scrollbar_size_horizontal = self.scrollbar_size_horizontal
        scrollbar_size_vertical = self.scrollbar_size_vertical

        if show_horizontal_scrollbar and show_vertical_scrollbar:
            (
                _,
                vertical_scrollbar_region,
                horizontal_scrollbar_region,
                scrollbar_corner_gap,
            ) = region.split(
                -scrollbar_size_vertical,
                -scrollbar_size_horizontal,
            )
            if scrollbar_corner_gap:
                yield self.scrollbar_corner, scrollbar_corner_gap
            if vertical_scrollbar_region:
                yield self.vertical_scrollbar, vertical_scrollbar_region
            if horizontal_scrollbar_region:
                yield self.horizontal_scrollbar, horizontal_scrollbar_region

        elif show_vertical_scrollbar:
            _, scrollbar_region = region.split_vertical(-scrollbar_size_vertical)
            if scrollbar_region:
                yield self.vertical_scrollbar, scrollbar_region
        elif show_horizontal_scrollbar:
            _, scrollbar_region = region.split_horizontal(-scrollbar_size_horizontal)
            if scrollbar_region:
                yield self.horizontal_scrollbar, scrollbar_region

    def get_pseudo_classes(self) -> Iterable[str]:
        """Pseudo classes for a widget.

        Returns:
            Names of the pseudo classes.

        """
        if self.mouse_over:
            yield "hover"
        if self.has_focus:
            yield "focus"
        try:
            focused = self.screen.focused
        except NoScreen:
            pass
        else:
            if focused:
                node = focused
                while node is not None:
                    if node is self:
                        yield "focus-within"
                        break
                    node = node._parent

    def post_render(self, renderable: RenderableType) -> ConsoleRenderable:
        """Applies style attributes to the default renderable.

        Returns:
            A new renderable.
        """
        text_justify: JustifyMethod | None = None
        if self.styles.has_rule("text_align"):
            text_align: JustifyMethod = cast(JustifyMethod, self.styles.text_align)
            text_justify = _JUSTIFY_MAP.get(text_align, text_align)

        if isinstance(renderable, str):
            renderable = Text.from_markup(renderable, justify=text_justify)

        if (
            isinstance(renderable, Text)
            and text_justify is not None
            and renderable.justify is None
        ):
            renderable.justify = text_justify

        renderable = _Styled(
            renderable, self.rich_style, self.link_style if self.auto_links else None
        )

        return renderable

    def watch_mouse_over(self, value: bool) -> None:
        """Update from CSS if mouse over state changes."""
        if self._has_hover_style:
            self.app.update_styles(self)

    def watch_has_focus(self, value: bool) -> None:
        """Update from CSS if has focus state changes."""
        self.app.update_styles(self)

    @debug
    def _size_updated(
        self, size: Size, virtual_size: Size, container_size: Size
    ) -> None:
        """Called when the widget's size is updated.

        Args:
            size: Screen size.
            virtual_size: Virtual (scrollable) size.
            container_size: Container size (size of parent).
        """
        if (
            self._size != size
            or self.virtual_size != virtual_size
            or self._container_size != container_size
        ):
            if self.__class__.__name__ == "Vertical":
                print(
                    "[red]_size_updated",
                    self.virtual_size,
                    virtual_size,
                    "[/]",
                )
                print(
                    self._size != size,
                    self.virtual_size != virtual_size,
                    self._container_size != container_size,
                )
            self._size = size
            self.virtual_size = virtual_size
            self._container_size = container_size
            if self.is_scrollable:
                self._scroll_update(virtual_size)
            self.refresh()

    @debug
    def _scroll_update(self, virtual_size: Size) -> None:
        """Update scrollbars visibility and dimensions.

        Args:
            virtual_size: Virtual size.
        """
        self._refresh_scrollbars()
        width, height = self.container_size

        if self.show_vertical_scrollbar:
            self.vertical_scrollbar.window_virtual_size = virtual_size.height
            self.vertical_scrollbar.window_size = (
                height - self.scrollbar_size_horizontal
            )
        if self.show_horizontal_scrollbar:
            self.horizontal_scrollbar.window_virtual_size = virtual_size.width
            self.horizontal_scrollbar.window_size = width - self.scrollbar_size_vertical

        self.scroll_x = self.validate_scroll_x(self.scroll_x)
        self.scroll_y = self.validate_scroll_y(self.scroll_y)

    def _render_content(self) -> None:
        """Render all lines."""
        width, height = self.size
        renderable = self.render()
        renderable = self.post_render(renderable)
        options = self._console.options.update_dimensions(width, height).update(
            highlight=False
        )

        segments = self._console.render(renderable, options)
        lines = list(
            islice(
                Segment.split_and_crop_lines(
                    segments, width, include_new_lines=False, pad=False
                ),
                None,
                height,
            )
        )

        styles = self.styles
        align_horizontal, align_vertical = styles.content_align
        lines = list(
            align_lines(
                lines,
                Style(),
                self.size,
                align_horizontal,
                align_vertical,
            )
        )
        strips = [Strip(line, width) for line in lines]
        self._render_cache = RenderCache(self.size, strips)
        self._dirty_regions.clear()

    def render_line(self, y: int) -> Strip:
        """Render a line of content.

        Args:
            y: Y Coordinate of line.

        Returns:
            A rendered line.
        """
        if self._dirty_regions:
            self._render_content()
        try:
            line = self._render_cache.lines[y]
        except IndexError:
            line = Strip.blank(self.size.width, self.rich_style)
        return line

    def render_lines(self, crop: Region) -> list[Strip]:
        """Render the widget in to lines.

        Args:
            crop: Region within visible area to render.

        Returns:
            A list of list of segments.
        """
        strips = self._styles_cache.render_widget(self, crop)
        return strips

    def get_style_at(self, x: int, y: int) -> Style:
        """Get the Rich style in a widget at a given relative offset.

        Args:
            x: X coordinate relative to the widget.
            y: Y coordinate relative to the widget.

        Returns:
            A rich Style object.
        """
        offset = Offset(x, y)
        screen_offset = offset + self.region.offset

        widget, _ = self.screen.get_widget_at(*screen_offset)
        if widget is not self:
            return Style()
        return self.screen.get_style_at(*screen_offset)

    async def _forward_event(self, event: events.Event) -> None:
        event._set_forwarded()
        await self.post_message(event)

    def _refresh_scroll(self) -> None:
        """Refreshes the scroll position."""
        self._layout_required = True
        self.check_idle()

    def refresh(
        self,
        *regions: Region,
        repaint: bool = True,
        layout: bool = False,
    ) -> None:
        """Initiate a refresh of the widget.

        This method sets an internal flag to perform a refresh, which will be done on the
        next idle event. Only one refresh will be done even if this method is called multiple times.

        By default this method will cause the content of the widget to refresh, but not change its size. You can also
        set `layout=True` to perform a layout.

        !!! warning

            It is rarely necessary to call this method explicitly. Updating styles or reactive attributes will
            do this automatically.

        Args:
            *regions: Additional screen regions to mark as dirty.
            repaint: Repaint the widget (will call render() again). Defaults to True.
            layout: Also layout widgets in the view. Defaults to False.
        """

        if layout:
            self._layout_required = True
            for ancestor in self.ancestors:
                if not isinstance(ancestor, Widget):
                    break
                ancestor._clear_arrangement_cache()

        if repaint:
            self._set_dirty(*regions)
            self._content_width_cache = (None, 0)
            self._content_height_cache = (None, 0)
            self._rich_style_cache.clear()
            self._repaint_required = True

        self.check_idle()

    def remove(self) -> AwaitRemove:
        """Remove the Widget from the DOM (effectively deleting it)

        Returns:
            An awaitable object that waits for the widget to be removed.
        """

        await_remove = self.app._remove_nodes([self])
        return await_remove

    def render(self) -> RenderableType:
        """Get renderable for widget.

        Returns:
            Any renderable
        """
        render = "" if self.is_container else self.css_identifier_styled
        return render

    def _render(self) -> ConsoleRenderable | RichCast:
        """Get renderable, promoting str to text as required.

        Returns:
            A renderable
        """
        renderable = self.render()
        if isinstance(renderable, str):
            return Text(renderable)
        return renderable

    async def action(self, action: str) -> None:
        """Perform a given action, with this widget as the default namespace.

        Args:
            action: Action encoded as a string.
        """
        await self.app.action(action, self)

    async def post_message(self, message: Message) -> bool:
        """Post a message to this widget.

        Args:
            message: Message to post.

        Returns:
            True if the message was posted, False if this widget was closed / closing.
        """
        if not self.check_message_enabled(message):
            return True
        if not self.is_running:
            self.log.warning(self, f"IS NOT RUNNING, {message!r} not sent")
        return await super().post_message(message)

    async def _on_idle(self, event: events.Idle) -> None:
        """Called when there are no more events on the queue.

        Args:
            event: Idle event.
        """
        if self._parent is not None and not self._closing:
            try:
                screen = self.screen
            except NoScreen:
                pass
            else:
                if self._repaint_required:
                    self._repaint_required = False
                    screen.post_message_no_wait(messages.Update(self, self))
                if self._layout_required:
                    self._layout_required = False
                    screen.post_message_no_wait(messages.Layout(self))

    def focus(self, scroll_visible: bool = True) -> None:
        """Give focus to this widget.

        Args:
            scroll_visible: Scroll parent to make this widget
                visible. Defaults to True.
        """

        def set_focus(widget: Widget):
            """Callback to set the focus."""
            try:
                widget.screen.set_focus(self, scroll_visible=scroll_visible)
            except NoScreen:
                pass

        self.app.call_later(set_focus, self)

    def reset_focus(self) -> None:
        """Reset the focus (move it to the next available widget)."""
        try:
            self.screen._reset_focus(self)
        except NoScreen:
            pass

    def capture_mouse(self, capture: bool = True) -> None:
        """Capture (or release) the mouse.

        When captured, mouse events will go to this widget even when the pointer is not directly over the widget.

        Args:
            capture: True to capture or False to release. Defaults to True.
        """
        self.app.capture_mouse(self if capture else None)

    def release_mouse(self) -> None:
        """Release the mouse.

        Mouse events will only be sent when the mouse is over the widget.
        """
        self.app.capture_mouse(None)

    async def broker_event(self, event_name: str, event: events.Event) -> bool:
        return await self.app._broker_event(event_name, event, default_namespace=self)

    def _on_styles_updated(self) -> None:
        self._rich_style_cache.clear()

    async def _on_mouse_down(self, event: events.MouseDown) -> None:
        await self.broker_event("mouse.down", event)

    async def _on_mouse_up(self, event: events.MouseUp) -> None:
        await self.broker_event("mouse.up", event)

    async def _on_click(self, event: events.Click) -> None:
        await self.broker_event("click", event)

    async def _on_key(self, event: events.Key) -> None:
        await self.handle_key(event)

    async def handle_key(self, event: events.Key) -> bool:
        return await self.dispatch_key(event)

    async def _on_compose(self) -> None:
        try:
            widgets = list(self.compose())
        except TypeError as error:
            raise TypeError(
                f"{self!r} compose() returned an invalid response; {error}"
            ) from error
        except Exception:
            self.app.panic(Traceback())
        else:
            await self.mount(*widgets)

    def _on_mount(self, event: events.Mount) -> None:
        if self.styles.overflow_y == "scroll":
            self.show_vertical_scrollbar = True
        if self.styles.overflow_x == "scroll":
            self.show_horizontal_scrollbar = True

    def _on_leave(self, event: events.Leave) -> None:
        self.mouse_over = False
        self.hover_style = Style()

    def _on_enter(self, event: events.Enter) -> None:
        self.mouse_over = True

    def _on_focus(self, event: events.Focus) -> None:
        self.has_focus = True
        self.refresh()
        self.emit_no_wait(events.DescendantFocus(self))

    def _on_blur(self, event: events.Blur) -> None:
        self.has_focus = False
        self.refresh()
        self.emit_no_wait(events.DescendantBlur(self))

    def _on_descendant_blur(self, event: events.DescendantBlur) -> None:
        if self._has_focus_within:
            self.app.update_styles(self)

    def _on_descendant_focus(self, event: events.DescendantBlur) -> None:
        if self._has_focus_within:
            self.app.update_styles(self)

    def _on_mouse_scroll_down(self, event) -> None:
        if self.allow_vertical_scroll:
            if self.scroll_down(animate=False):
                event.stop()

    def _on_mouse_scroll_up(self, event) -> None:
        if self.allow_vertical_scroll:
            if self.scroll_up(animate=False):
                event.stop()

    def _on_scroll_to(self, message: ScrollTo) -> None:
        if self._allow_scroll:
            self.scroll_to(message.x, message.y, animate=message.animate, duration=0.1)
            message.stop()

    def _on_scroll_up(self, event: ScrollUp) -> None:
        if self.allow_vertical_scroll:
            self.scroll_page_up()
            event.stop()

    def _on_scroll_down(self, event: ScrollDown) -> None:
        if self.allow_vertical_scroll:
            self.scroll_page_down()
            event.stop()

    def _on_scroll_left(self, event: ScrollLeft) -> None:
        if self.allow_horizontal_scroll:
            self.scroll_page_left()
            event.stop()

    def _on_scroll_right(self, event: ScrollRight) -> None:
        if self.allow_horizontal_scroll:
            self.scroll_page_right()
            event.stop()

    def _on_hide(self, event: events.Hide) -> None:
        if self.has_focus:
            self.reset_focus()

    def _on_scroll_to_region(self, message: messages.ScrollToRegion) -> None:
        self.scroll_to_region(message.region, animate=True)

    def action_scroll_home(self) -> None:
        if not self._allow_scroll:
            raise SkipAction()
        self.scroll_home()

    def action_scroll_end(self) -> None:
        if not self._allow_scroll:
            raise SkipAction()
        self.scroll_end()

    def action_scroll_left(self) -> None:
        if not self.allow_horizontal_scroll:
            raise SkipAction()
        self.scroll_left()

    def action_scroll_right(self) -> None:
        if not self.allow_horizontal_scroll:
            raise SkipAction()
        self.scroll_right()

    def action_scroll_up(self) -> None:
        if not self.allow_vertical_scroll:
            raise SkipAction()
        self.scroll_up()

    def action_scroll_down(self) -> None:
        if not self.allow_vertical_scroll:
            raise SkipAction()
        self.scroll_down()

    def action_page_down(self) -> None:
        if not self.allow_vertical_scroll:
            raise SkipAction()
        self.scroll_page_down()

    def action_page_up(self) -> None:
        if not self.allow_vertical_scroll:
            raise SkipAction()
        self.scroll_page_up()

Experiment A:

  • Make sure your local history is at 19780db (otherwise programmatically setting overflow_x/overflow_y won't even trigger a layout refresh.
  • Open a terminal and run textual console -x EVENT in it.
  • Open another terminal and run textual run --dev myapp_overflow.py.
  • Ignore the first set of (colourful) prints.
  • Press r (to force a full layout refresh with a "private" method)
    • Nothing happens visually (and nothing should happen because you didn't change anything).
    • Take note of the debugging prints that happen.
  • Press s to set overflow_x to "scroll" and notice that you get the exact same prints: the same structure and the exact same values.
  • Try resizing the terminal (e.g., one column bigger and then set it to the same size again) and ignore the flood of prints.
  • The scrollbar should be visible now.
  • Press r again and notice the same prints as before, with exactly the same structure but with a tiny difference in the height of some of the Region objects that are printed.
  • Press h to set overflow_x to "hidden" and notice that you get the exact same prints as the ones you did just now.

Experiment B:

Exact same setup, but include the partial fix of checking if the overflow is "scroll" inside Widget._get_scrollable_region (but don't check for "hidden").

  • Ignore the first set of colourful prints from opening the app.
  • Press r to get the baseline set of prints.
  • Press s to set overflow_x to "scroll" and you will get many more prints this time.

You care about the sections enclosed in the green prints that start with "Refreshing" and end with "Done.". The first of those that happened after you pressed s will have a red print from within _size_updated which gets printed because the virtual size of the Vertical was computed correctly thanks to the correct return value of _get_scrollable_region.

If you look up, you can see the call to _get_scrollable_region and its arguments in the line that starts with > _get_scrollable_region and you can see the return value in the line that starts with < _get_scrollable_region. Notice that the Region in and the Region out are almost identical, up the the height value that decreased slightly because the horizontal scrollbar was detected. Because this is the first time this difference happens,

  1. this will trigger a call to _size_updated
  2. that will then trigger a call to _scroll_update
  3. that will then result in Vertical.virtual_size being updated (find the debug print that says "Virtual size from ..."

001

  • Press r to get the standard set of prints when nothing happens.
  • Press h to set overflow_x to "hidden" and nothing happens, the prints are the same as the ones from the previous step, and the Region that comes out of _get_scrollable_region is still shorter than the Region that goes in (in height) because the app hasn't picked up the fact that there are no scrollbars.

@github-actions
Copy link

Don't forget to star the repository!

Follow @textualizeio for Textual updates.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
bug Something isn't working Task
Projects
None yet
Development

Successfully merging a pull request may close this issue.

1 participant