mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
2574 lines
85 KiB
Python
2574 lines
85 KiB
Python
from __future__ import annotations
|
|
|
|
from asyncio import Lock, 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 ._asyncio import create_task
|
|
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",
|
|
}
|
|
|
|
|
|
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
|
|
|
|
async def __call__(self) -> None:
|
|
"""Allows awaiting via a call operation."""
|
|
await self
|
|
|
|
def __await__(self) -> Generator[None, None, None]:
|
|
async def await_mount() -> None:
|
|
if self._widgets:
|
|
aws = [
|
|
create_task(widget._mounted_event.wait(), name="await mount")
|
|
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)
|
|
|
|
@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
|
|
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. An `int` is the index
|
|
of the child to mount before, a `str` is a `query_one` query to
|
|
find the widget to mount before.
|
|
after: Optional location to mount after. An `int` is the index
|
|
of the child to mount after, a `str` is a `query_one` query to
|
|
find the widget 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
|
|
)
|
|
|
|
await_mount = AwaitMount(self, mounted)
|
|
self.call_next(await_mount)
|
|
return await_mount
|
|
|
|
def mount_all(
|
|
self,
|
|
widgets: Iterable[Widget],
|
|
*,
|
|
before: int | str | Widget | None = None,
|
|
after: int | str | Widget | None = None,
|
|
) -> AwaitMount:
|
|
"""Mount widgets from an iterable.
|
|
|
|
Args:
|
|
widgets: An iterable of widgets.
|
|
before: Optional location to mount before. An `int` is the index
|
|
of the child to mount before, a `str` is a `query_one` query to
|
|
find the widget to mount before.
|
|
after: Optional location to mount after. An `int` is the index
|
|
of the child to mount after, a `str` is a `query_one` query to
|
|
find the widget 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.
|
|
"""
|
|
await_mount = self.mount(*widgets, before=before, after=after)
|
|
return await_mount
|
|
|
|
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: Optional location to move before. An `int` is the index
|
|
of the child to move before, a `str` is a `query_one` query to
|
|
find the widget to move before.
|
|
after: Optional location to move after. An `int` is the index
|
|
of the child to move after, a `str` is a `query_one` query to
|
|
find the widget 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, container_width=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
|
|
|
|
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 - self.app.scroll_sensitivity_x,
|
|
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 + self.app.scroll_sensitivity_x,
|
|
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 + self.app.scroll_sensitivity_y,
|
|
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 - +self.app.scroll_sensitivity_y,
|
|
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)
|
|
|
|
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.
|
|
"""
|
|
show_vertical_scrollbar, show_horizontal_scrollbar = self.scrollbars_enabled
|
|
|
|
styles = self.styles
|
|
scrollbar_size_horizontal = styles.scrollbar_size_horizontal
|
|
scrollbar_size_vertical = styles.scrollbar_size_vertical
|
|
|
|
if 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 = 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
|
|
|
|
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)
|
|
|
|
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
|
|
):
|
|
self._size = size
|
|
self.virtual_size = virtual_size
|
|
self._container_size = container_size
|
|
if self.is_scrollable:
|
|
self._scroll_update(virtual_size)
|
|
self.refresh()
|
|
|
|
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.post_message_no_wait(events.DescendantFocus(self))
|
|
|
|
def _on_blur(self, event: events.Blur) -> None:
|
|
self.has_focus = False
|
|
self.refresh()
|
|
self.post_message_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: events.MouseScrollDown) -> None:
|
|
if event.ctrl or event.shift:
|
|
if self.allow_horizontal_scroll:
|
|
if self.scroll_right(animate=False):
|
|
event.stop()
|
|
else:
|
|
if self.allow_vertical_scroll:
|
|
if self.scroll_down(animate=False):
|
|
event.stop()
|
|
|
|
def _on_mouse_scroll_up(self, event: events.MouseScrollUp) -> None:
|
|
if event.ctrl or event.shift:
|
|
if self.allow_horizontal_scroll:
|
|
if self.scroll_left(animate=False):
|
|
event.stop()
|
|
else:
|
|
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()
|