""" This module contains the `Widget` class, the base class for all widgets. """ from __future__ import annotations from asyncio import create_task, gather, wait from collections import Counter from contextlib import asynccontextmanager from fractions import Fraction from time import monotonic from types import TracebackType from typing import ( TYPE_CHECKING, AsyncGenerator, Callable, ClassVar, Collection, Generator, Iterable, Mapping, NamedTuple, Sequence, TypeVar, cast, overload, ) import rich.repr from rich.console import ( Console, ConsoleOptions, ConsoleRenderable, JustifyMethod, RenderableType, ) from rich.console import RenderResult as RichRenderResult from rich.measure import Measurement from rich.segment import Segment from rich.style import Style from rich.text import Text from typing_extensions import Self from textual.css.styles import StylesBase if TYPE_CHECKING: from textual.app import RenderResult from textual import constants, errors, events, messages from textual._animator import DEFAULT_EASING, Animatable, BoundAnimator, EasingFunction from textual._arrange import DockArrangeResult, arrange from textual._context import NoActiveAppError from textual._debug import get_caller_file_and_line from textual._dispatch_key import dispatch_key from textual._easing import DEFAULT_SCROLL_EASING from textual._extrema import Extrema from textual._styles_cache import StylesCache from textual._types import AnimationLevel from textual.actions import SkipAction from textual.await_remove import AwaitRemove from textual.box_model import BoxModel from textual.cache import FIFOCache, LRUCache from textual.color import Color from textual.compose import compose from textual.content import Content, ContentType from textual.css.match import match from textual.css.parse import parse_selectors from textual.css.query import NoMatches, WrongType from textual.css.scalar import Scalar, ScalarOffset from textual.dom import DOMNode, NoScreen from textual.geometry import ( NULL_REGION, NULL_SIZE, NULL_SPACING, Offset, Region, Size, Spacing, clamp, ) from textual.layout import Layout, WidgetPlacement from textual.layouts.vertical import VerticalLayout from textual.message import Message from textual.messages import CallbackType, Prune from textual.notifications import SeverityLevel from textual.reactive import Reactive from textual.renderables.blank import Blank from textual.rlock import RLock from textual.selection import Selection from textual.strip import Strip from textual.style import Style as VisualStyle from textual.visual import Visual, VisualType, visualize if TYPE_CHECKING: from textual.app import App, ComposeResult from textual.css.query import QueryType from textual.filter import LineFilter from textual.message_pump import MessagePump from textual.scrollbar import ( ScrollBar, ScrollBarCorner, ScrollDown, ScrollLeft, ScrollRight, ScrollTo, ScrollUp, ) _JUSTIFY_MAP: dict[str, JustifyMethod] = { "start": "left", "end": "right", "justify": "full", } _MOUSE_EVENTS_DISALLOW_IF_DISABLED = (events.MouseEvent, events.Enter, events.Leave) _MOUSE_EVENTS_ALLOW_IF_DISABLED = ( events.MouseScrollDown, events.MouseScrollUp, events.MouseScrollRight, events.MouseScrollLeft, ) @rich.repr.auto class AwaitMount: """An *optional* awaitable returned by [mount][textual.widget.Widget.mount] and [mount_all][textual.widget.Widget.mount_all]. Example: ```python await self.mount(Static("foo")) ``` """ def __init__(self, parent: Widget, widgets: Sequence[Widget]) -> None: self._parent = parent self._widgets = widgets self._caller = get_caller_file_and_line() def __rich_repr__(self) -> rich.repr.Result: yield "parent", self._parent yield "widgets", self._widgets yield "caller", self._caller, None 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) try: self._parent.app._update_mouse_over(self._parent.screen) except NoScreen: pass 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: "ConsoleRenderable", 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" ) -> "RichRenderResult": 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), None) 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 if style is not None ) return result_segments def __rich_measure__( self, console: "Console", options: "ConsoleOptions" ) -> Measurement: return Measurement.get(console, options, self.renderable) class _RenderCache(NamedTuple): """Stores results of a previous render.""" size: Size """The size of the render.""" lines: list[Strip] """Contents of the render.""" class WidgetError(Exception): """Base widget error.""" class MountError(WidgetError): """Error raised when there was a problem with the mount request.""" class PseudoClasses(NamedTuple): """Used for render/render_line based widgets that use caching. This structure can be used as a cache-key.""" enabled: bool """Is 'enabled' applied?""" focus: bool """Is 'focus' applied?""" hover: bool """Is 'hover' applied?""" class _BorderTitle: """Descriptor to set border titles.""" def __set_name__(self, owner: Widget, name: str) -> None: # The private name where we store the real data. self._internal_name = f"_{name}" def __set__(self, obj: Widget, title: Text | ContentType | None) -> None: """Setting a title accepts a str, Text, or None.""" if isinstance(title, Text): title = Content.from_rich_text(title) if title is None: setattr(obj, self._internal_name, None) else: # We store the title as Text new_title = obj.render_str(title).expand_tabs(4) new_title = new_title.split()[0] setattr(obj, self._internal_name, new_title) obj.refresh() def __get__(self, obj: Widget, objtype: type[Widget] | None = None) -> str | None: """Getting a title will return None or a str as console markup.""" title: Text | None = getattr(obj, self._internal_name, None) if title is None: return None # If we have a title, convert from Text to console markup return title.markup class BadWidgetName(Exception): """Raised when widget class names do not satisfy the required restrictions.""" @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. """ DEFAULT_CSS = """ Widget{ scrollbar-background: $scrollbar-background; scrollbar-background-hover: $scrollbar-background-hover; scrollbar-background-active: $scrollbar-background-active; scrollbar-color: $scrollbar; scrollbar-color-active: $scrollbar-active; scrollbar-color-hover: $scrollbar-hover; scrollbar-corner-color: $scrollbar-corner-color; scrollbar-size-vertical: 2; scrollbar-size-horizontal: 1; link-background: $link-background; link-color: $link-color; link-style: $link-style; link-background-hover: $link-background-hover; link-color-hover: $link-color-hover; link-style-hover: $link-style-hover; background: transparent; } """ COMPONENT_CLASSES: ClassVar[set[str]] = set() BORDER_TITLE: ClassVar[str] = "" """Initial value for border_title attribute.""" BORDER_SUBTITLE: ClassVar[str] = "" """Initial value for border_subtitle attribute.""" ALLOW_MAXIMIZE: ClassVar[bool | None] = None """Defines default logic to allow the widget to be maximized. - `None` Use default behavior (Focusable widgets may be maximized) - `False` Do not allow widget to be maximized - `True` Allow widget to be maximized """ ALLOW_SELECT: ClassVar[bool] = True """Does this widget support automatic text selection? May be further refined with [Widget.allow_select][textual.widget.Widget.allow_select].""" FOCUS_ON_CLICK: ClassVar[bool] = True """Should focusable widgets be automatically focused on click? Default return value of [Widget.focus_on_click][textual.widget.Widget.focus_on_click].""" can_focus: bool = False """Widget may receive focus.""" can_focus_children: bool = True """Widget's children may receive focus.""" expand: Reactive[bool] = Reactive(False) """Rich renderable may expand beyond optimal size.""" shrink: Reactive[bool] = Reactive(True) """Rich renderable may shrink below optimal size.""" auto_links: Reactive[bool] = Reactive(True) """Widget will highlight links automatically.""" disabled: Reactive[bool] = Reactive(False) """Is the widget disabled? Disabled widgets can not be interacted with, and are typically styled to look dimmer.""" hover_style: Reactive[Style] = Reactive(Style, repaint=False) """The current hover style (style under the mouse cursor). Read only.""" highlight_link_id: Reactive[str] = Reactive("") """The currently highlighted link id. Read only.""" loading: Reactive[bool] = Reactive(False) """If set to `True` this widget will temporarily be replaced with a loading indicator.""" virtual_size: Reactive[Size] = Reactive(Size(0, 0), layout=True) """The virtual (scrollable) [size][textual.geometry.Size] of the widget.""" has_focus: Reactive[bool] = Reactive(False, repaint=False) """Does this widget have focus? Read only.""" mouse_hover: Reactive[bool] = Reactive(False, repaint=False) """Is the mouse over this widget? Read only.""" scroll_x: Reactive[float] = Reactive(0.0, repaint=False, layout=False) """The scroll position on the X axis.""" scroll_y: Reactive[float] = Reactive(0.0, repaint=False, layout=False) """The scroll position on the Y axis.""" scroll_target_x = Reactive(0.0, repaint=False) """Scroll target destination, X coord.""" scroll_target_y = Reactive(0.0, repaint=False) """Scroll target destination, Y coord.""" show_vertical_scrollbar: Reactive[bool] = Reactive(False, layout=True) """Show a vertical scrollbar?""" show_horizontal_scrollbar: Reactive[bool] = Reactive(False, layout=True) """Show a horizontal scrollbar?""" border_title = _BorderTitle() # type: ignore """A title to show in the top border (if there is one).""" border_subtitle = _BorderTitle() """A title to show in the bottom border (if there is one).""" # Default sort order, incremented by constructor _sort_order: ClassVar[int] = 0 _PSEUDO_CLASSES: ClassVar[dict[str, Callable[[Widget], bool]]] = { "hover": lambda widget: widget.mouse_hover, "focus": lambda widget: widget.has_focus, "blur": lambda widget: not widget.has_focus, "can-focus": lambda widget: widget.allow_focus(), "disabled": lambda widget: widget.is_disabled, "enabled": lambda widget: not widget.is_disabled, "dark": lambda widget: widget.app.current_theme.dark, "light": lambda widget: not widget.app.current_theme.dark, "focus-within": lambda widget: widget.has_focus_within, "inline": lambda widget: widget.app.is_inline, "ansi": lambda widget: widget.app.ansi_color, "nocolor": lambda widget: widget.app.no_color, "first-of-type": lambda widget: widget.first_of_type, "last-of-type": lambda widget: widget.last_of_type, "first-child": lambda widget: widget.first_child, "last-child": lambda widget: widget.last_child, "odd": lambda widget: widget.is_odd, "even": lambda widget: widget.is_even, "empty": lambda widget: widget.is_empty, } # type: ignore[assignment] def __init__( self, *children: Widget, name: str | None = None, id: str | None = None, classes: str | None = None, disabled: bool = False, markup: bool = True, ) -> None: """Initialize a Widget. Args: *children: Child widgets. name: The name of the widget. id: The ID of the widget in the DOM. classes: The CSS classes for the widget. disabled: Whether the widget is disabled or not. markup: Enable content markup? """ self._render_markup = markup _null_size = NULL_SIZE self._size = _null_size self._container_size = _null_size self._layout_required = False self._layout_updates = 0 self._repaint_required = False self._scroll_required = False self._recompose_required = False self._refresh_styles_required = False self._default_layout = VerticalLayout() self._animate: BoundAnimator | None = None Widget._sort_order += 1 self.sort_order = Widget._sort_order 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._border_title: Content | None = None self._border_subtitle: Content | None = None self._layout_cache: dict[str, object] = {} """A dict that is refreshed when the widget is resized / refreshed.""" self._visual_style: VisualStyle | None = None self._render_cache = _RenderCache(_null_size, []) # 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() self._box_model_cache: LRUCache[object, BoxModel] = LRUCache(16) # Cache the auto content dimensions self._content_width_cache: tuple[object, int] = (None, 0) self._content_height_cache: tuple[object, int] = (None, 0) self._arrangement_cache: FIFOCache[ tuple[Size, int, bool], DockArrangeResult ] = FIFOCache(4) self._styles_cache = StylesCache() self._rich_style_cache: dict[tuple[str, ...], tuple[Style, Style]] = {} self._visual_style_cache: dict[tuple[str, ...], VisualStyle] = {} self._tooltip: VisualType | None = None """The tooltip content.""" self.absolute_offset: Offset | None = None """Force an absolute offset for the widget (used by tooltips).""" self._scrollbar_changes: set[tuple[bool, bool]] = set() """Used to stabilize scrollbars.""" 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") for child in children: if not isinstance(child, Widget): raise TypeError( f"Widget positional arguments must be Widget subclasses; not {child!r}" ) self._pending_children = list(children) self.set_reactive(Widget.disabled, disabled) if self.BORDER_TITLE: self.border_title = self.BORDER_TITLE if self.BORDER_SUBTITLE: self.border_subtitle = self.BORDER_SUBTITLE self.lock = RLock() """`asyncio` lock to be used to synchronize the state of the widget. Two different tasks might call methods on a widget at the same time, which might result in a race condition. This can be fixed by adding `async with widget.lock:` around the method calls. """ self._anchored: bool = False """Has this widget been anchored?""" self._anchor_released: bool = False """Has the anchor been released?""" """Flag to enable animation when scrolling anchored widgets.""" self._cover_widget: Widget | None = None """Widget to render over this widget (used by loading indicator).""" self._first_of_type: tuple[int, bool] = (-1, False) """Used to cache :first-of-type pseudoclass state.""" self._last_of_type: tuple[int, bool] = (-1, False) """Used to cache :last-of-type pseudoclass state.""" self._first_child: tuple[int, bool] = (-1, False) """Used to cache :first-child pseudoclass state.""" self._last_child: tuple[int, bool] = (-1, False) """Used to cache :last-child pseudoclass state.""" self._odd: tuple[int, bool] = (-1, False) """Used to cache :odd pseudoclass state.""" self._last_scroll_time = monotonic() """Time of last scroll.""" self._extrema = Extrema() """Optional minimum and maximum values for width and height.""" @property def is_mounted(self) -> bool: """Check if this widget is mounted.""" return self._is_mounted @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._nodes) 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. """ if self._check_disabled(): return False 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. """ if self._check_disabled(): return False 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 allow_maximize(self) -> bool: """Check if the widget may be maximized. Returns: `True` if the widget may be maximized, or `False` if it should not be maximized. """ return self.can_focus if self.ALLOW_MAXIMIZE is None else self.ALLOW_MAXIMIZE @property def offset(self) -> Offset: """Widget offset from origin. Returns: Relative offset. """ return self.styles.offset.resolve(self.size, self.screen.size) @offset.setter def offset(self, offset: tuple[int, int]) -> None: self.styles.offset = ScalarOffset.from_offset(offset) @property def opacity(self) -> float: """Total opacity of widget.""" opacity = 1.0 for node in reversed(self.ancestors_with_self): opacity *= node.styles.opacity if not opacity: break return opacity @property def is_anchored(self) -> bool: """Is this widget anchored? See [anchor()][textual.widget.Widget.anchor] for an explanation of anchoring. """ return self._anchored @property def is_mouse_over(self) -> bool: """Is the mouse currently over this widget? Note this will be `True` if the mouse pointer is within the widget's region, even if the mouse pointer is not directly over the widget (there could be another widget between the mouse pointer and self). """ if not self.screen.is_active: return False for widget, _ in self.screen.get_widgets_at(*self.app.mouse_position): if widget is self: return True return False @property def is_maximized(self) -> bool: """Is this widget maximized?""" try: return self.screen.maximized is self except NoScreen: return False @property def is_in_maximized_view(self) -> bool: """Is this widget, or a parent maximized?""" maximized = self.screen.maximized if not maximized: return False for node in self.ancestors_with_self: if maximized is node: return True return False @property def _render_widget(self) -> Widget: """The widget the compositor should render.""" # Will return the "cover widget" if one is set, otherwise self. return self._cover_widget if self._cover_widget is not None else self @property def text_selection(self) -> Selection | None: """Text selection information, or `None` if no text is selected in this widget.""" return self.screen.selections.get(self, None) def focus_on_click(self) -> bool: """Automatically focus the widget on click? Implement this if you want to change the default click to focus behavior. The default will return the classvar `FOCUS_ON_CLICK`. Returns: `True` if Textual should set focus automatically on a click, or `False` if it shouldn't. """ return self.FOCUS_ON_CLICK def get_line_filters(self) -> Sequence[LineFilter]: """Get the line filters enabled for this widget. Returns: A sequence of [LineFilter][textual.filters.LineFilter] instances. """ return self.app.get_line_filters() def preflight_checks(self) -> None: """Called in debug mode to do preflight checks. This is used by Textual to log some common errors, but you could implement this in custom widgets to perform additional checks. """ if hasattr(self, "CSS"): from textual.screen import Screen if not isinstance(self, Screen): self.log.warning( f"'{self.__class__.__name__}.CSS' will be ignored (use 'DEFAULT_CSS' class variable for widgets)" ) def pre_render(self) -> None: """Called prior to rendering. If you implement this in a subclass, be sure to call the base class method via super. """ self._visual_style = None def _cover(self, widget: Widget) -> None: """Set a widget used to replace the visuals of this widget (used for loading indicator). Args: widget: A newly constructed, but unmounted widget. """ self._uncover() self._cover_widget = widget widget._parent = self widget._start_messages() widget._post_register(self.app) self.app.stylesheet.apply(widget) self.refresh(layout=True) def process_layout( self, placements: list[WidgetPlacement] ) -> list[WidgetPlacement]: """A hook to allow for the manipulation of widget placements before rendering. You could use this as a way to modify the positions / margins of widgets if your requirement is not supported in TCSS. In practice, this method is rarely needed! Args: placements: A list of [`WidgetPlacement`][textual.layout.WidgetPlacement] objects. Returns: A new list of placements. """ return placements def _uncover(self) -> None: """Remove any widget, previously set via [`_cover`][textual.widget.Widget._cover].""" if self._cover_widget is not None: self._cover_widget.remove() self._cover_widget = None self.refresh(layout=True) def anchor(self, anchor: bool = True) -> None: """Anchor a scrollable widget. An anchored widget will stay scrolled the bottom when new content is added, until the user moves the scroll position. Args: anchor: Anchor the widget if `True`, clear the anchor if `False`. """ self._anchored = anchor if anchor: self.scroll_end(immediate=True, animate=False) def release_anchor(self) -> None: """Release the [anchor][textual.widget.Widget]. If a widget is anchored, releasing the anchor will allow the user to scroll as normal. """ self.scroll_target_y = self.scroll_y self._anchor_released = True def _check_anchor(self) -> None: """Check if the scroll position is near enough to the bottom to restore anchor.""" if ( self._anchored and self._anchor_released and self.scroll_y >= self.max_scroll_y ): self._anchor_released = False def _check_disabled(self) -> bool: """Check if the widget is disabled either explicitly by setting `disabled`, or implicitly by setting `loading`. Returns: True if the widget should be disabled. """ return self.disabled or self.loading @property def tooltip(self) -> VisualType | None: """Tooltip for the widget, or `None` for no tooltip.""" return self._tooltip @tooltip.setter def tooltip(self, tooltip: VisualType | None): self._tooltip = tooltip try: self.screen._update_tooltip(self) except NoScreen: pass def with_tooltip(self, tooltip: Visual | RenderableType | None) -> Self: """Chainable method to set a tooltip. Example: ```python def compose(self) -> ComposeResult: yield Label("Hello").with_tooltip("A greeting") ``` Args: tooltip: New tooltip, or `None` to clear the tooltip. Returns: Self. """ self.tooltip = tooltip return self def allow_focus(self) -> bool: """Check if the widget is permitted to focus. The base class returns [`can_focus`][textual.widget.Widget.can_focus]. This method may be overridden if additional logic is required. Returns: `True` if the widget may be focused, or `False` if it may not be focused. """ return self.can_focus def allow_focus_children(self) -> bool: """Check if a widget's children may be focused. The base class returns [`can_focus_children`][textual.widget.Widget.can_focus_children]. This method may be overridden if additional logic is required. Returns: `True` if the widget's children may be focused, or `False` if the widget's children may not be focused. """ return self.can_focus_children def compose_add_child(self, widget: Widget) -> None: """Add a node to children. This is used by the compose process when it adds children. There is no need to use it directly, but you may want to override it in a subclass if you want children to be attached to a different node. Args: widget: A Widget to add. """ _rich_traceback_omit = True self._pending_children.append(widget) @property def is_disabled(self) -> bool: """Is the widget disabled either because `disabled=True` or an ancestor has `disabled=True`.""" node: MessagePump | None = self while isinstance(node, Widget): if node.disabled: return True node = node._parent return False @property def has_focus_within(self) -> bool: """Are any descendants focused?""" try: focused = self.screen.focused except NoScreen: return False node = focused while node is not None: if node is self: return True node = node._parent return False @property def first_of_type(self) -> bool: """Is this the first widget of its type in its siblings?""" parent = self.parent if parent is None: return True # This pseudo classes only changes when the parent's nodes._updates changes if parent._nodes._updates == self._first_of_type[0]: return self._first_of_type[1] widget_type = type(self) for node in parent._nodes.displayed: if isinstance(node, widget_type): self._first_of_type = (parent._nodes._updates, node is self) return self._first_of_type[1] return False @property def last_of_type(self) -> bool: """Is this the last widget of its type in its siblings?""" parent = self.parent if parent is None: return True # This pseudo classes only changes when the parent's nodes._updates changes if parent._nodes._updates == self._last_of_type[0]: return self._last_of_type[1] widget_type = type(self) for node in parent._nodes.displayed_reverse: if isinstance(node, widget_type): self._last_of_type = (parent._nodes._updates, node is self) return self._last_of_type[1] return False @property def first_child(self) -> bool: """Is this the first widget in its siblings?""" parent = self.parent if parent is None: return True # This pseudo class only changes when the parent's nodes._updates changes if parent._nodes._updates == self._first_child[0]: return self._first_child[1] for node in parent._nodes.displayed: self._first_child = (parent._nodes._updates, node is self) return self._first_child[1] return False @property def last_child(self) -> bool: """Is this the last widget in its siblings?""" parent = self.parent if parent is None: return True # This pseudo class only changes when the parent's nodes._updates changes if parent._nodes._updates == self._last_child[0]: return self._last_child[1] for node in parent._nodes.displayed_reverse: self._last_child = (parent._nodes._updates, node is self) return self._last_child[1] return False @property def is_odd(self) -> bool: """Is this widget at an oddly numbered position within its siblings?""" parent = self.parent if parent is None: return True # This pseudo classes only changes when the parent's nodes._updates changes if parent._nodes._updates == self._odd[0]: return self._odd[1] try: is_odd = parent._nodes.displayed_and_visible.index(self) % 2 == 0 self._odd = (parent._nodes._updates, is_odd) return is_odd except ValueError: return False @property def is_even(self) -> bool: """Is this widget at an evenly numbered position within its siblings?""" return not self.is_odd def __enter__(self) -> Self: """Use as context manager when composing.""" self.app._compose_stacks[-1].append(self) return self def __exit__( self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None, ) -> None: """Exit compose context manager.""" compose_stack = self.app._compose_stacks[-1] composed = compose_stack.pop() if compose_stack: compose_stack[-1].compose_add_child(composed) else: self.app._composed[-1].append(composed) def clear_cached_dimensions(self) -> None: """Clear cached results of `get_content_width` and `get_content_height`. Call if the widget's renderable changes size after the widget has been created. !!! note This is not required if you are extending [`Static`][textual.widgets.Static]. """ self._content_width_cache = (None, 0) self._content_height_cache = (None, 0) def get_loading_widget(self) -> Widget: """Get a widget to display a loading indicator. The default implementation will defer to App.get_loading_widget. Returns: A widget in place of this widget to indicate a loading. """ loading_widget = self.screen.get_loading_widget() return loading_widget def set_loading(self, loading: bool) -> None: """Set or reset the loading state of this widget. A widget in a loading state will display a `LoadingIndicator` or a custom widget set through overriding the `get_loading_widget` method. Args: loading: `True` to put the widget into a loading state, or `False` to reset the loading state. """ if loading: loading_indicator = self.get_loading_widget() loading_indicator.add_class("-textual-loading-indicator") self._cover(loading_indicator) else: self._uncover() def _watch_loading(self, loading: bool) -> None: """Called when the 'loading' reactive is changed.""" if not self.is_mounted: self.call_later(self.set_loading, loading) else: self.set_loading(loading) ExpectType = TypeVar("ExpectType", bound="Widget") if TYPE_CHECKING: @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. 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._get_dom_base()._nodes._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 the wrong type; expected type {expect_type.__name__!r}, found {child}" ) return child if TYPE_CHECKING: @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. 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. """ widget = self.query_one(f"#{id}") if expect_type is not None and not isinstance(widget, expect_type): raise WrongType( f"Descendant with id={id!r} is the wrong type; expected type {expect_type.__name__!r}, found {widget}" ) return widget def get_child_by_type(self, expect_type: type[ExpectType]) -> ExpectType: """Get the first immediate child of a given type. Only returns exact matches, and so will not match subclasses of the given type. Args: expect_type: The type of the child to search for. Raises: NoMatches: If no matching child is found. Returns: The first immediate child widget with the expected type. """ for child in self._nodes: # We want the child with the exact type (not subclasses) if type(child) is expect_type: assert isinstance(child, expect_type) return child raise NoMatches(f"No immediate child of type {expect_type}; {self._nodes}") def get_component_rich_style(self, *names: str, partial: bool = False) -> Style: """Get a *Rich* style for a component. Args: names: Names of components. partial: Return a partial style (not combined with parent). Returns: A Rich style object. """ if names not in self._rich_style_cache: component_styles = self.get_component_styles(*names) style = component_styles.rich_style text_opacity = component_styles.text_opacity if text_opacity < 1 and style.bgcolor is not None: style += Style.from_color( ( Color.from_rich_color(style.bgcolor) + component_styles.color.multiply_alpha(text_opacity) ).rich_color ) partial_style = component_styles.partial_rich_style self._rich_style_cache[names] = (style, partial_style) style, partial_style = self._rich_style_cache[names] return partial_style if partial else style def get_visual_style( self, *component_classes: str, partial: bool = False ) -> VisualStyle: """Get the visual style for the widget, including any component styles. Args: component_classes: Optional component styles. partial: Return a partial style (not combined with parent). Returns: A Visual style instance. """ cache_key = (self._pseudo_classes_cache_key, component_classes, partial) if (visual_style := self._visual_style_cache.get(cache_key, None)) is None: background = Color(0, 0, 0, 0) color = Color(255, 255, 255, 0) style = Style() opacity = 1.0 def iter_styles() -> Iterable[StylesBase]: """Iterate over the styles from the DOM and additional components styles.""" if partial: node = self else: for node in reversed(self.ancestors_with_self): yield node.styles for name in component_classes: yield node.get_component_styles(name) for styles in iter_styles(): has_rule = styles.has_rule opacity *= styles.opacity if has_rule("background"): text_background = background + styles.background.tint( styles.background_tint ) if partial: background_tint = styles.background.tint(styles.background_tint) background = background.blend( background_tint, 1 - background_tint.a ).multiply_alpha(opacity) else: background += ( styles.background.tint(styles.background_tint) ).multiply_alpha(opacity) else: text_background = background if has_rule("color"): color = styles.color.multiply_alpha(styles.text_opacity) style += styles.text_style if has_rule("auto_color") and styles.auto_color: color = text_background.get_contrast_text(color.a) visual_style = VisualStyle( background, color, bold=style.bold, dim=style.dim, italic=style.italic, underline=style.underline, strike=style.strike, ) self._visual_style_cache[cache_key] = visual_style return visual_style def _get_style(self, style: VisualStyle | str) -> VisualStyle: """A get_style method for use in Content. Args: style: A style prefixed with a dot. Returns: A visual style if one is fund, otherwise `None`. """ if isinstance(style, VisualStyle): return style visual_style = VisualStyle.null() if style.startswith("."): for node in self.ancestors_with_self: if not isinstance(node, Widget): break try: visual_style = node.get_visual_style(style[1:], partial=True) break except KeyError: continue else: raise KeyError(f"No matching component class found for '{style}'") return visual_style try: visual_style = VisualStyle.parse(style) except Exception: pass return visual_style @overload def render_str(self, text_content: str) -> Content: ... @overload def render_str(self, text_content: Content) -> Content: ... def render_str(self, text_content: str | Content) -> Content: """Convert str into a [Content][textual.content.Content] instance. If you pass in an existing Content instance it will be returned unaltered. Args: text_content: Content or str. Returns: Content object. """ if isinstance(text_content, Content): return text_content return Content.from_markup(text_content) def arrange(self, size: Size, optimal: bool = False) -> DockArrangeResult: """Arrange child widgets. This method is best left alone, unless you have a deep understanding of what it does. Args: size: Size of container. optimal: Whether fr units should expand the widget (`False`) or avoid expanding the widget (`True`). Returns: Widget locations. """ cache_key = (size, self._nodes._updates, optimal) cached_result = self._arrangement_cache.get(cache_key) if cached_result is not None: return cached_result arrangement = self._arrangement_cache[cache_key] = arrange( self, self._nodes, size, self.screen.size, optimal=optimal ) return arrangement def _clear_arrangement_cache(self) -> None: """Clear arrangement cache, forcing a new arrange operation.""" self._arrangement_cache.clear() 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_exactly_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._nodes.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. """ if self._closing or self._pruning: return AwaitMount(self, []) if not self.is_attached: raise MountError(f"Can't mount widget(s) before {self!r} is mounted") # Check for duplicate IDs in the incoming widgets ids_to_mount = [ widget_id for widget in widgets if (widget_id := widget.id) is not None ] if len(set(ids_to_mount)) != len(ids_to_mount): counter = Counter(ids_to_mount) 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 ) def update_styles(children: list[DOMNode]) -> None: """Update order related CSS""" if before is not None or after is not None: # If the new children aren't at the end. # we need to update both odd/even, first-of-type/last-of-type and first-child/last-child for child in children: if child._has_order_style or child._has_odd_or_even: child._update_styles() else: for child in children: if child._has_order_style: child._update_styles() self.call_later(update_styles, self.displayed_children) await_mount = AwaitMount(self, mounted) self.call_next(await_mount) return await_mount def _refresh_styles(self) -> None: """Request refresh of styles on idle.""" self._refresh_styles_required = True self.check_idle() 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. """ if self.app._exit: return AwaitMount(self, []) await_mount = self.mount(*widgets, before=before, after=after) return await_mount def mount_compose( self, compose_result: ComposeResult, *, before: int | str | Widget | None = None, after: int | str | Widget | None = None, ) -> AwaitMount: """Mount widgets from the result of a compose method. Example: ```python def on_key(self, event:events.Key) -> None: def add_key(key:str) -> ComposeResult: '''Compose key information widgets''' with containers.HorizontalGroup(): yield Label("You pressed:") yield Label(key) self.mount_compose(add_key(event.key)) ``` Args: compose_result: The result of a compose method. 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. """ return self.mount_all(compose(self, compose_result), before=before, after=after) if TYPE_CHECKING: @overload def move_child( self, child: int | Widget, *, before: int | Widget, after: None = None, ) -> None: ... @overload def move_child( self, child: int | Widget, *, after: int | Widget, before: None = None, ) -> None: ... 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: Child widget or location index to move before. after: Child widget or location index 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._nodes[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._nodes.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( cast("int | Widget", before if after is None else after), "move towards" ) if child is target: return # Nothing to be done. # 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._nodes._remove(child) if before is not None: self._nodes._insert(self._nodes.index(target), child) else: self._nodes._insert(self._nodes.index(target) + 1, child) # Request a refresh. self.refresh(layout=True) def compose(self) -> ComposeResult: """Called by Textual to create child widgets. This method is called when a widget is mounted or by setting `recompose=True` when calling [`refresh()`][textual.widget.Widget.refresh]. Note that you don't typically need to explicitly call this method. Example: ```python def compose(self) -> ComposeResult: yield Header() yield Label("Press the button below:") yield Button() yield Footer() ``` """ yield from () async def _check_recompose(self) -> None: """Check if a recompose is required.""" if self._recompose_required: self._recompose_required = False await self.recompose() async def recompose(self) -> None: """Recompose the widget. Recomposing will remove children and call `self.compose` again to remount. """ if not self.is_attached or self._pruning: return async with self.batch(): await self.query_children("*").exclude(".-textual-system").remove() if self.is_attached: compose_nodes = compose(self) await self.mount_all(compose_nodes) def _post_register(self, app: App) -> None: """Called when the instance is registered. Args: app: App instance. """ # Parse the Widget's CSS for read_from, css, tie_breaker, scope in self._get_default_css(): self.app.stylesheet.add_source( css, read_from=read_from, is_default_css=True, tie_breaker=tie_breaker, scope=scope, ) if app.debug: app.call_next(self.preflight_checks) def _get_box_model( self, container: Size, viewport: Size, width_fraction: Fraction, height_fraction: Fraction, constrain_width: bool = False, greedy: bool = True, ) -> 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. constrain_width: Restrict the width to the container width. Returns: The size and margin for this widget. """ cache_key = ( container, viewport, width_fraction, height_fraction, constrain_width, greedy, self._layout_updates, self.styles._cache_key, ) if cached_box_model := self._box_model_cache.get(cache_key): return cached_box_model styles = self.styles is_border_box = styles.box_sizing == "border-box" gutter = styles.gutter # Padding plus border margin = styles.margin styles_width = styles.width if not greedy and styles_width is not None and styles_width.is_fraction: styles_width = Scalar.parse("auto") is_auto_width = styles_width and styles_width.is_auto is_auto_height = styles.height and styles.height.is_auto # Container minus padding and border content_container = container - gutter.totals extrema = self._extrema = self._resolve_extrema( container, viewport, width_fraction, height_fraction ) min_width, max_width, min_height, max_height = extrema if styles_width is None: # No width specified, fill available space content_width = Fraction(content_container.width - margin.width) elif is_auto_width: # When width is auto, we want enough space to always fit the content content_width = Fraction( self.get_content_width(content_container - margin.totals, viewport) ) if ( styles.overflow_x == "auto" and styles.scrollbar_gutter == "stable" ) or self.show_vertical_scrollbar: content_width += styles.scrollbar_size_vertical if ( content_width < content_container.width and self._has_relative_children_width ): content_width = Fraction(content_container.width) else: # An explicit width content_width = styles_width.resolve( container - margin.totals, viewport, width_fraction ) if is_border_box: content_width -= gutter.width if min_width is not None: # Restrict to minimum width, if set content_width = max(content_width, min_width, Fraction(0)) if max_width is not None and not ( container.width == 0 and not styles.max_width.is_cells and self._parent is not None and self._parent.styles.is_auto_width ): # Restrict to maximum width, if set content_width = min(content_width, max_width) content_width = max(Fraction(0), content_width) if constrain_width: content_width = min(Fraction(container.width - gutter.width), content_width) if styles.height is None: # No height specified, fill the available space content_height = Fraction(content_container.height - margin.height) elif is_auto_height: # Calculate dimensions based on content content_height = Fraction( self.get_content_height( content_container - margin.totals, viewport, int(content_width), ) ) if ( styles.overflow_y == "auto" and styles.scrollbar_gutter == "stable" ) or self.show_horizontal_scrollbar: content_height += styles.scrollbar_size_horizontal if ( content_height < content_container.height and self._has_relative_children_height ): content_height = Fraction(content_container.height) else: styles_height = styles.height # Explicit height set content_height = styles_height.resolve( container - margin.totals, viewport, height_fraction ) if is_border_box: content_height -= gutter.height if min_height is not None: # Restrict to minimum height, if set content_height = max(content_height, min_height, Fraction(0)) if max_height is not None and not ( container.height == 0 and not styles.max_height.is_cells and self._parent is not None and self._parent.styles.is_auto_height ): content_height = min(content_height, max_height) content_height = max(Fraction(0), content_height) model = BoxModel( content_width + gutter.width, content_height + gutter.height, margin ) self._box_model_cache[cache_key] = model return 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: width = self.layout.get_content_width(self, container, viewport) return width cache_key = container.width if self._content_width_cache[0] == cache_key: return self._content_width_cache[1] visual = self._render() width = visual.get_optimal_width(self.styles, 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 not width: return 0 if self.is_container: assert self.layout is not None height = self.layout.get_content_height( self, container, viewport, width, ) else: cache_key = width if self._content_height_cache[0] == cache_key: return self._content_height_cache[1] visual = self._render() height = visual.get_height(self.styles, width) self._content_height_cache = (cache_key, height) return height def watch_hover_style( self, previous_hover_style: Style, hover_style: Style ) -> None: # TODO: This will cause the widget to refresh, even when there are no links # Can we avoid this? if self.auto_links and not self.app.mouse_captured: self.highlight_link_id = hover_style.link_id def watch_scroll_x(self, old_value: float, new_value: float) -> None: self.horizontal_scrollbar.position = 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 = new_value if self._anchored and self._anchor_released: self._check_anchor() 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 round(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 round(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 is_vertical_scroll_end(self) -> bool: """Is the vertical scroll position at the maximum?""" return self.scroll_offset.y == self.max_scroll_y or not self.size @property def is_horizontal_scroll_end(self) -> bool: """Is the horizontal scroll position at the maximum?""" return self.scroll_offset.x == self.max_scroll_x or not self.size @property def is_vertical_scrollbar_grabbed(self) -> bool: """Is the user dragging the vertical scrollbar?""" return bool(self._vertical_scrollbar and self._vertical_scrollbar.grabbed) @property def is_horizontal_scrollbar_grabbed(self) -> bool: """Is the user dragging the vertical scrollbar?""" return bool(self._horizontal_scrollbar and self._horizontal_scrollbar.grabbed) @property def scrollbar_corner(self) -> ScrollBarCorner: """The scrollbar corner. Note: This will *create* a scrollbar corner if one doesn't exist. Returns: ScrollBarCorner Widget. """ from textual.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: """The vertical scrollbar (create if necessary). Note: This will *create* a scrollbar if one doesn't exist. Returns: ScrollBar Widget. """ from textual.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: """The horizontal scrollbar. Note: This will *create* a scrollbar if one doesn't exist. Returns: ScrollBar Widget. """ from textual.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 = False 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 = False 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 _show_horizontal = show_horizontal _show_vertical = show_vertical if not ( overflow_x == "auto" and overflow_y == "auto" and (show_horizontal, show_vertical) in self._scrollbar_changes ): # When a single scrollbar is shown, the other dimension changes, so we need to recalculate. if overflow_x == "auto" and show_vertical and not show_horizontal: show_horizontal = self.virtual_size.width > ( width - styles.scrollbar_size_vertical ) if overflow_y == "auto" and show_horizontal and not show_vertical: show_vertical = self.virtual_size.height > ( height - styles.scrollbar_size_horizontal ) if ( self.show_horizontal_scrollbar != show_horizontal or self.show_vertical_scrollbar != show_vertical ): self._scrollbar_changes.add((_show_horizontal, _show_vertical)) else: self._scrollbar_changes.clear() 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 (, ) """ if not self.is_scrollable: return False, False return (self.show_vertical_scrollbar, self.show_horizontal_scrollbar) @property def scrollbars_space(self) -> tuple[int, int]: """The number of cells occupied by scrollbars for width and height""" return (self.scrollbar_size_vertical, self.scrollbar_size_horizontal) @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 scrollable_size(self) -> Size: """The size of the scrollable content. Returns: Scrollable content size. """ return self.scrollable_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: """The size of the content area. Returns: Content area size. """ 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, errors.NoWidget): return NULL_REGION @property def dock_gutter(self) -> Spacing: """Space allocated to docks in the parent. Returns: Space to be subtracted from scrollable area. """ try: return self.screen.find_widget(self).dock_gutter except (NoScreen, errors.NoWidget): return NULL_SPACING @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 its container (which may not be visible, depending on scroll offset). Returns: The virtual region. """ 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 _self_or_ancestors_disabled(self) -> bool: """Is this widget or any of its ancestors disabled?""" node: Widget | None = self while isinstance(node, Widget) and not node.is_dom_root: if node.disabled: return True node = node._parent # type:ignore[assignment] return False @property def focusable(self) -> bool: """Can this widget currently be focused?""" return ( not self.loading and self.allow_focus() and self.visible and not self._self_or_ancestors_disabled ) @property def _focus_sort_key(self) -> tuple[int, int]: """Key function to sort widgets into 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 container_scroll_offset(self) -> Offset: """The scroll offset the nearest container ancestor.""" for node in self.ancestors: if isinstance(node, Widget) and node.is_scrollable: return node.scroll_offset return Offset() @property def _console(self) -> Console: """Get the current console. Returns: A Rich console object. """ return self.app.console @property def _has_relative_children_width(self) -> bool: """Do any children (or progeny) have a relative width?""" if not self.is_container: return False for child in self.children: if child.styles.expand == "optimal": continue styles = child.styles if styles.display == "none": continue width = styles.width if width is None: continue if styles.is_relative_width or ( width.is_auto and child._has_relative_children_width ): return True return False @property def _has_relative_children_height(self) -> bool: """Do any children (or progeny) have a relative height?""" if not self.is_container: return False for child in self.children: styles = child.styles if styles.display == "none": continue height = styles.height if height is None: continue if styles.is_relative_height or ( height.is_auto and child._has_relative_children_height ): return True return False @property def is_on_screen(self) -> bool: """Check if the node was displayed in the last screen update.""" try: self.screen.find_widget(self) except (NoScreen, errors.NoWidget): return False return True def _resolve_extrema( self, container: Size, viewport: Size, width_fraction: Fraction, height_fraction: Fraction, ) -> Extrema: """Resolve minimum and maximum values for width and height. Args: container: Size of outer widget. viewport: Viewport size. width_fraction: Size of 1fr width. height_fraction: Size of 1fr height. Returns: Extrema object. """ min_width: Fraction | None = None max_width: Fraction | None = None min_height: Fraction | None = None max_height: Fraction | None = None styles = self.styles container -= styles.margin.totals if styles.box_sizing == "border-box": gutter_width, gutter_height = styles.gutter.totals else: gutter_width = gutter_height = 0 if styles.min_width is not None: min_width = ( styles.min_width.resolve(container, viewport, width_fraction) - gutter_width ) if styles.max_width is not None: max_width = ( styles.max_width.resolve(container, viewport, width_fraction) - gutter_width ) if styles.min_height is not None: min_height = ( styles.min_height.resolve(container, viewport, height_fraction) - gutter_height ) if styles.max_height is not None: max_height = ( styles.max_height.resolve(container, viewport, height_fraction) - gutter_height ) extrema = Extrema(min_width, max_width, min_height, max_height) return extrema 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, level: AnimationLevel = "full", ) -> 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 (in seconds) of the animation. speed: The speed of the animation. delay: A delay (in seconds) before the animation starts. easing: An easing method. on_complete: A callable to invoke when the animation is finished. level: Minimum level required for the animation to take place (inclusive). """ 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, level=level, ) async def stop_animation(self, attribute: str, complete: bool = True) -> None: """Stop an animation on an attribute. Args: attribute: Name of the attribute whose animation should be stopped. complete: Should the animation be set to its final value? Note: If there is no animation scheduled or running, this is a no-op. """ await self.app.animator.stop_animation(self, attribute, 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: """Is this widget a container (contains other widgets)?""" return self.styles.layout is not None or bool(self._nodes) @property def is_scrollable(self) -> bool: """Can this widget be scrolled?""" return self.styles.layout is not None or bool(self._nodes) @property def is_scrolling(self) -> bool: """Is this widget currently scrolling?""" current_time = monotonic() for node in self.ancestors: if not isinstance(node, Widget): break if ( node.scroll_x != node.scroll_target_x or node.scroll_y != node.scroll_target_y ): return True if current_time - node._last_scroll_time < 0.1: # Scroll ended very recently return True return False @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. """ layers: tuple[str, ...] = ("default",) for node in self.ancestors_with_self: if not isinstance(node, Widget): break if node.styles.has_rule("layers"): layers = node.styles.layers return layers @property def link_style(self) -> Style: """Style of links. Returns: Rich style. """ 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 if styles.link_background.a else None, ) return style @property def link_style_hover(self) -> Style: """Style of links underneath the mouse cursor. Returns: Rich Style. """ styles = self.styles _, background = self.background_colors hover_background = background + styles.link_background_hover hover_color = hover_background + ( hover_background.get_contrast_text(styles.link_color_hover.a) if styles.auto_link_color_hover else styles.link_color_hover ) style = styles.link_style_hover + Style.from_color( hover_color.rich_color, hover_background.rich_color, ) return style @property def select_container(self) -> Widget: """The widget's container used when selecting text.. Returns: A widget which contains this widget. """ container: Widget = self for widget in self.ancestors: if isinstance(widget, Widget) and widget.is_scrollable: return widget return container 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._styles_cache.set_dirty(self.size.region) outer_size = self.outer_size self._dirty_regions.add(outer_size.region) if outer_size: self._repaint_regions.add(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, on_complete: CallbackType | None = None, level: AnimationLevel = "basic", release_anchor: bool = True, ) -> bool: """Scroll to a given (absolute) coordinate, optionally animating. Args: x: X coordinate (column) to scroll to, or `None` for no change. y: Y coordinate (row) to scroll to, or `None` for no change. animate: Animate to new scroll position. 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. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. level: Minimum level required for the animation to take place (inclusive). release_anchor: If `True` call `release_anchor`. Returns: `True` if the scroll position changed, otherwise `False`. """ if release_anchor: self.release_anchor() maybe_scroll_x = x is not None and (self.allow_horizontal_scroll or force) maybe_scroll_y = y is not None and (self.allow_vertical_scroll or force) scrolled_x = scrolled_y = False animator = self.app.animator animator.force_stop_animation(self, "scroll_x") animator.force_stop_animation(self, "scroll_y") def _animate_on_complete() -> None: """set last scroll time, and invoke callback.""" self._last_scroll_time = monotonic() if on_complete is not None: self.call_next(on_complete) 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: assert x is not None self.scroll_target_x = x if x != self.scroll_x: self.animate( "scroll_x", self.scroll_target_x, speed=speed, duration=duration, easing=easing, on_complete=_animate_on_complete, level=level, ) scrolled_x = True if maybe_scroll_y: assert y is not None self.scroll_target_y = y if y != self.scroll_y: self.animate( "scroll_y", self.scroll_target_y, speed=speed, duration=duration, easing=easing, on_complete=_animate_on_complete, level=level, ) scrolled_y = True else: if maybe_scroll_x: assert x is not None scroll_x = self.scroll_x self.scroll_target_x = self.scroll_x = x scrolled_x = scroll_x != self.scroll_x if maybe_scroll_y: assert y is not None scroll_y = self.scroll_y self.scroll_target_y = self.scroll_y = y scrolled_y = scroll_y != self.scroll_y self._last_scroll_time = monotonic() if on_complete is not None: self.call_after_refresh(on_complete) return scrolled_x or scrolled_y @property def allow_select(self) -> bool: """Check if this widget permits text selection. Returns: `True` if the widget supports text selection, otherwise `False`. """ return self.ALLOW_SELECT and not self.is_container def pre_layout(self, layout: Layout) -> None: """This method id called prior to a layout operation. Implement this method if you want to make updates that should impact the layout. Args: layout: The [Layout][textual.layout.Layout] instance that will be used to arrange this widget's children. """ def set_scroll(self, x: float | None, y: float | None) -> None: """Set the scroll position without any validation. This is a low-level method for when you want to see the scroll position in the next frame. For a more fully featured method, see [`scroll_to`][textual.widget.Widget.scroll_to]. Args: x: Desired `X` coordinate. y: Desired `Y` coordinate. """ if x is not None: self.set_reactive(Widget.scroll_x, round(x)) if y is not None: self.set_reactive(Widget.scroll_y, round(y)) 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, on_complete: CallbackType | None = None, level: AnimationLevel = "basic", immediate: bool = False, release_anchor: bool = True, ) -> None: """Scroll to a given (absolute) coordinate, optionally animating. Args: x: X coordinate (column) to scroll to, or `None` for no change. y: Y coordinate (row) to scroll to, or `None` for no change. animate: Animate to new scroll position. 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. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. level: Minimum level required for the animation to take place (inclusive). immediate: If `False` the scroll will be deferred until after a screen refresh, set to `True` to scroll immediately. release_anchor: If `True` call `release_anchor`. Note: The call to scroll is made after the next refresh. """ if release_anchor: self.release_anchor() animator = self.app.animator if x is not None: animator.force_stop_animation(self, "scroll_x") if y is not None: animator.force_stop_animation(self, "scroll_y") if immediate: self._scroll_to( x, y, animate=animate, speed=speed, duration=duration, easing=easing, force=force, on_complete=on_complete, level=level, ) else: self.call_after_refresh( self._scroll_to, x, y, animate=animate, speed=speed, duration=duration, easing=easing, force=force, on_complete=on_complete, level=level, ) 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, on_complete: CallbackType | None = None, level: AnimationLevel = "basic", immediate: bool = False, ) -> None: """Scroll relative to current position. Args: x: X distance (columns) to scroll, or ``None`` for no change. y: Y distance (rows) to scroll, or ``None`` for no change. animate: Animate to new scroll position. 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. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. level: Minimum level required for the animation to take place (inclusive). immediate: If `False` the scroll will be deferred until after a screen refresh, set to `True` to scroll immediately. """ 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, on_complete=on_complete, level=level, immediate=immediate, ) def scroll_home( self, *, animate: bool = True, speed: float | None = None, duration: float | None = None, easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, level: AnimationLevel = "basic", immediate: bool = False, x_axis: bool = True, y_axis: bool = True, ) -> None: """Scroll to home position. Args: animate: Animate scroll. 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. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. level: Minimum level required for the animation to take place (inclusive). immediate: If `False` the scroll will be deferred until after a screen refresh, set to `True` to scroll immediately. x_axis: Allow scrolling on X axis? y_axis: Allow scrolling on Y axis? """ if speed is None and duration is None: duration = 1.0 self.scroll_to( 0 if x_axis else None, 0 if y_axis else None, animate=animate, speed=speed, duration=duration, easing=easing, force=force, on_complete=on_complete, level=level, immediate=immediate, ) def scroll_end( self, *, animate: bool = True, speed: float | None = None, duration: float | None = None, easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, level: AnimationLevel = "basic", immediate: bool = False, x_axis: bool = True, y_axis: bool = True, ) -> None: """Scroll to the end of the container. Args: animate: Animate scroll. 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. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. level: Minimum level required for the animation to take place (inclusive). immediate: If `False` the scroll will be deferred until after a screen refresh, set to `True` to scroll immediately. x_axis: Allow scrolling on X axis? y_axis: Allow scrolling on Y axis? """ if speed is None and duration is None: duration = 1.0 async def scroll_end_on_complete() -> None: """It's possible new content was added before we reached the end.""" if on_complete is not None: self.call_next(on_complete) # In most cases we'd call self.scroll_to and let it handle the call # to do things after a refresh, but here we need the refresh to # happen first so that we can get the new self.max_scroll_y (that # is, we need the layout to work out and then figure out how big # things are). Because of this we'll create a closure over the call # here and make our own call to call_after_refresh. def _lazily_scroll_end() -> None: """Scroll to the end of the widget.""" self._scroll_to( 0 if x_axis else None, self.max_scroll_y if y_axis else None, animate=animate, speed=speed, duration=duration, easing=easing, force=force, on_complete=scroll_end_on_complete, level=level, release_anchor=False, ) if self._anchored and self._anchor_released: self._anchor_released = False if immediate: _lazily_scroll_end() else: self.call_after_refresh(_lazily_scroll_end) def scroll_left( self, *, animate: bool = True, speed: float | None = None, duration: float | None = None, easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, level: AnimationLevel = "basic", immediate: bool = False, ) -> None: """Scroll one cell left. Args: animate: Animate scroll. 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. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. level: Minimum level required for the animation to take place (inclusive). immediate: If `False` the scroll will be deferred until after a screen refresh, set to `True` to scroll immediately. """ self.scroll_to( x=self.scroll_target_x - 1, animate=animate, speed=speed, duration=duration, easing=easing, force=force, on_complete=on_complete, level=level, immediate=immediate, ) def _scroll_left_for_pointer( self, *, animate: bool = True, speed: float | None = None, duration: float | None = None, easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, level: AnimationLevel = "basic", ) -> bool: """Scroll left one position, taking scroll sensitivity into account. Args: animate: Animate scroll. 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. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. level: Minimum level required for the animation to take place (inclusive). Returns: `True` if any scrolling was done. Note: How much is scrolled is controlled by [App.scroll_sensitivity_x][textual.app.App.scroll_sensitivity_x]. """ return self._scroll_to( x=self.scroll_target_x - self.app.scroll_sensitivity_x, animate=animate, speed=speed, duration=duration, easing=easing, force=force, on_complete=on_complete, level=level, ) def scroll_right( self, *, animate: bool = True, speed: float | None = None, duration: float | None = None, easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, level: AnimationLevel = "basic", immediate: bool = False, ) -> None: """Scroll one cell right. Args: animate: Animate scroll. 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. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. level: Minimum level required for the animation to take place (inclusive). immediate: If `False` the scroll will be deferred until after a screen refresh, set to `True` to scroll immediately. """ self.scroll_to( x=self.scroll_target_x + 1, animate=animate, speed=speed, duration=duration, easing=easing, force=force, on_complete=on_complete, level=level, immediate=immediate, ) def _scroll_right_for_pointer( self, *, animate: bool = True, speed: float | None = None, duration: float | None = None, easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, level: AnimationLevel = "basic", ) -> bool: """Scroll right one position, taking scroll sensitivity into account. Args: animate: Animate scroll. 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. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. level: Minimum level required for the animation to take place (inclusive). Returns: `True` if any scrolling was done. Note: How much is scrolled is controlled by [App.scroll_sensitivity_x][textual.app.App.scroll_sensitivity_x]. """ return self._scroll_to( x=self.scroll_target_x + self.app.scroll_sensitivity_x, animate=animate, speed=speed, duration=duration, easing=easing, force=force, on_complete=on_complete, level=level, ) def scroll_down( self, *, animate: bool = True, speed: float | None = None, duration: float | None = None, easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, level: AnimationLevel = "basic", immediate: bool = False, ) -> None: """Scroll one line down. Args: animate: Animate scroll. 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. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. level: Minimum level required for the animation to take place (inclusive). immediate: If `False` the scroll will be deferred until after a screen refresh, set to `True` to scroll immediately. """ self.scroll_to( y=self.scroll_target_y + 1, animate=animate, speed=speed, duration=duration, easing=easing, force=force, on_complete=on_complete, level=level, immediate=immediate, ) def _scroll_down_for_pointer( self, *, animate: bool = True, speed: float | None = None, duration: float | None = None, easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, level: AnimationLevel = "basic", ) -> bool: """Scroll down one position, taking scroll sensitivity into account. Args: animate: Animate scroll. 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. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. level: Minimum level required for the animation to take place (inclusive). Returns: `True` if any scrolling was done. Note: How much is scrolled is controlled by [App.scroll_sensitivity_y][textual.app.App.scroll_sensitivity_y]. """ return self._scroll_to( y=self.scroll_target_y + self.app.scroll_sensitivity_y, animate=animate, speed=speed, duration=duration, easing=easing, force=force, on_complete=on_complete, level=level, ) def scroll_up( self, *, animate: bool = True, speed: float | None = None, duration: float | None = None, easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, level: AnimationLevel = "basic", immediate: bool = False, ) -> None: """Scroll one line up. Args: animate: Animate scroll. 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. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. level: Minimum level required for the animation to take place (inclusive). immediate: If `False` the scroll will be deferred until after a screen refresh, set to `True` to scroll immediately. """ self.scroll_to( y=self.scroll_target_y - 1, animate=animate, speed=speed, duration=duration, easing=easing, force=force, on_complete=on_complete, level=level, immediate=immediate, ) def _scroll_up_for_pointer( self, *, animate: bool = True, speed: float | None = None, duration: float | None = None, easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, level: AnimationLevel = "basic", ) -> bool: """Scroll up one position, taking scroll sensitivity into account. Args: animate: Animate scroll. 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. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. level: Minimum level required for the animation to take place (inclusive). Returns: `True` if any scrolling was done. Note: How much is scrolled is controlled by [App.scroll_sensitivity_y][textual.app.App.scroll_sensitivity_y]. """ return self._scroll_to( y=self.scroll_target_y - self.app.scroll_sensitivity_y, animate=animate, speed=speed, duration=duration, easing=easing, force=force, on_complete=on_complete, level=level, ) def scroll_page_up( self, *, animate: bool = True, speed: float | None = None, duration: float | None = None, easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, level: AnimationLevel = "basic", ) -> None: """Scroll one page up. Args: animate: Animate scroll. 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. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. level: Minimum level required for the animation to take place (inclusive). """ self.scroll_to( y=self.scroll_y - self.scrollable_content_region.height, animate=animate, speed=speed, duration=duration, easing=easing, force=force, on_complete=on_complete, level=level, ) def scroll_page_down( self, *, animate: bool = True, speed: float | None = None, duration: float | None = None, easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, level: AnimationLevel = "basic", ) -> None: """Scroll one page down. Args: animate: Animate scroll. 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. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. level: Minimum level required for the animation to take place (inclusive). """ self.scroll_to( y=self.scroll_y + self.scrollable_content_region.height, animate=animate, speed=speed, duration=duration, easing=easing, force=force, on_complete=on_complete, level=level, ) def scroll_page_left( self, *, animate: bool = True, speed: float | None = None, duration: float | None = None, easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, level: AnimationLevel = "basic", ) -> None: """Scroll one page left. Args: animate: Animate scroll. 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. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. level: Minimum level required for the animation to take place (inclusive). """ if speed is None and duration is None: duration = 0.3 self.scroll_to( x=self.scroll_x - self.scrollable_content_region.width, animate=animate, speed=speed, duration=duration, easing=easing, force=force, on_complete=on_complete, level=level, ) def scroll_page_right( self, *, animate: bool = True, speed: float | None = None, duration: float | None = None, easing: EasingFunction | str | None = None, force: bool = False, on_complete: CallbackType | None = None, level: AnimationLevel = "basic", ) -> None: """Scroll one page right. Args: animate: Animate scroll. 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. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. level: Minimum level required for the animation to take place (inclusive). """ if speed is None and duration is None: duration = 0.3 self.scroll_to( x=self.scroll_x + self.scrollable_content_region.width, animate=animate, speed=speed, duration=duration, easing=easing, force=force, on_complete=on_complete, level=level, ) def scroll_to_widget( self, widget: Widget, *, animate: bool = True, speed: float | None = None, duration: float | None = None, easing: EasingFunction | str | None = None, center: bool = False, top: bool = False, origin_visible: bool = True, force: bool = False, on_complete: CallbackType | None = None, level: AnimationLevel = "basic", immediate: bool = False, ) -> bool: """Scroll scrolling to bring a widget into view. Args: widget: A descendant widget. animate: `True` to animate, or `False` to jump. 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. top: Scroll widget to top of container. origin_visible: Ensure that the top left of the widget is within the window. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. level: Minimum level required for the animation to take place (inclusive). immediate: If `False` the scroll will be deferred until after a screen refresh, set to `True` to scroll immediately. 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 if not region.size: if on_complete is not None: self.call_after_refresh(on_complete) return False while isinstance(widget.parent, Widget) and widget is not self: if not region: break container = widget.parent if widget.styles.dock != "none": scroll_offset = Offset(0, 0) else: scroll_offset = container.scroll_to_region( region, spacing=widget.dock_gutter, animate=animate, speed=speed, duration=duration, center=center, top=top, easing=easing, origin_visible=origin_visible, force=force, on_complete=on_complete, level=level, immediate=immediate, ) if scroll_offset: scrolled = True # Adjust the region by the amount we just scrolled it, and convert to # its parent's virtual coordinate system. region = ( ( region.translate(-scroll_offset) .translate(container.styles.margin.top_left) .translate(container.styles.border.spacing.top_left) .translate(container.virtual_region_with_margin.offset) ) .grow(container.styles.margin) .intersection(container.virtual_region_with_margin) ) 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, center: bool = False, top: bool = False, origin_visible: bool = True, force: bool = False, on_complete: CallbackType | None = None, level: AnimationLevel = "basic", x_axis: bool = True, y_axis: bool = True, immediate: bool = False, ) -> Offset: """Scrolls a given region into 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. animate: `True` to animate, or `False` to jump. 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. top: Scroll `region` to top of container. origin_visible: Ensure that the top left of the widget is within the window. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. level: Minimum level required for the animation to take place (inclusive). x_axis: Allow scrolling on X axis? y_axis: Allow scrolling on Y axis? immediate: If `False` the scroll will be deferred until after a screen refresh, set to `True` to scroll immediately. 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 or center): if on_complete is not None: self.call_after_refresh(on_complete) return Offset() def clamp_delta(delta: Offset) -> Offset: """Clamp the delta to avoid scrolling out of range.""" 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, ) return delta if center: region_center_x, region_center_y = region.center window_center_x, window_center_y = window.center delta = clamp_delta( Offset( int(region_center_x - window_center_x + 0.5), int(region_center_y - window_center_y + 0.5), ) ) if origin_visible and (region.offset not in window.translate(delta)): delta = clamp_delta( Region.get_scroll_to_visible(window, region, top=True) ) else: delta = clamp_delta( Region.get_scroll_to_visible(window, region, top=top), ) if not self.allow_horizontal_scroll and not force: delta = Offset(0, delta.y) if not self.allow_vertical_scroll and not force: delta = Offset(delta.x, 0) if delta: delta_x = delta.x if x_axis else 0 delta_y = delta.y if y_axis else 0 if speed is None and duration is None: duration = 0.2 self.scroll_relative( delta_x or None, delta_y or None, animate=animate, speed=speed, duration=duration, easing=easing, force=force, on_complete=on_complete, level=level, immediate=immediate, ) else: if on_complete is not None: self.call_after_refresh(on_complete) 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, on_complete: CallbackType | None = None, level: AnimationLevel = "basic", immediate: bool = False, ) -> None: """Scroll the container to make this widget visible. Args: animate: Animate scroll. 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`. top: Scroll to top of container. easing: An easing method for the scrolling animation. force: Force scrolling even when prohibited by overflow styling. on_complete: A callable to invoke when the animation is finished. level: Minimum level required for the animation to take place (inclusive). immediate: If `False` the scroll will be deferred until after a screen refresh, set to `True` to scroll immediately. """ parent = self.parent if isinstance(parent, Widget): if self._size: self.screen.scroll_to_widget( self, animate=animate, speed=speed, duration=duration, top=top, easing=easing, force=force, on_complete=on_complete, level=level, immediate=immediate, ) else: # self.region is falsy which may indicate the widget hasn't been through a layout operation # We can potentially make it do the right thing by postponing the scroll to after a refresh parent.call_after_refresh( self.screen.scroll_to_widget, self, animate=animate, speed=speed, duration=duration, top=top, easing=easing, force=force, on_complete=on_complete, level=level, immediate=immediate, ) def scroll_to_center( self, widget: Widget, animate: bool = True, *, speed: float | None = None, duration: float | None = None, easing: EasingFunction | str | None = None, force: bool = False, origin_visible: bool = True, on_complete: CallbackType | None = None, level: AnimationLevel = "basic", immediate: bool = False, ) -> None: """Scroll this widget to the center of self. The center of the widget will be scrolled to the center of the container. Args: widget: The widget to scroll to the center of self. animate: Whether to animate the scroll. 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. force: Force scrolling even when prohibited by overflow styling. origin_visible: Ensure that the top left corner of the widget remains visible after the scroll. on_complete: A callable to invoke when the animation is finished. level: Minimum level required for the animation to take place (inclusive). immediate: If `False` the scroll will be deferred until after a screen refresh, set to `True` to scroll immediately. """ self.scroll_to_widget( widget=widget, animate=animate, speed=speed, duration=duration, easing=easing, force=force, center=True, origin_visible=origin_visible, on_complete=on_complete, level=level, immediate=immediate, ) def can_view_entire(self, widget: Widget) -> bool: """Check if a given widget is *fully* within the current view (scrollable area). Note: This doesn't necessarily equate to a widget being visible. There are other reasons why a widget may not be visible. Args: widget: A widget that is a descendant of self. Returns: `True` if the entire widget is in view, `False` if it is partially visible or not in view. """ if widget is self: return True if widget not in self.screen._compositor.visible_widgets: return False region = widget.region node: Widget = widget while isinstance(node.parent, Widget) and node is not self: if region not in node.parent.scrollable_content_region: return False node = node.parent return True def can_view_partial(self, widget: Widget) -> bool: """Check if a given widget at least partially visible within the current view (scrollable area). Args: widget: A widget that is a descendant of self. Returns: `True` if any part of the widget is visible, `False` if it is outside of the viewable area. """ if widget is self: return True if widget not in self.screen._compositor.visible_widgets or not widget.display: return False region = widget.region node: Widget = widget while isinstance(node.parent, Widget) and node is not self: if not region.overlaps(node.parent.scrollable_content_region): return False node = node.parent return True def __init_subclass__( cls, can_focus: bool | None = None, can_focus_children: bool | None = None, inherit_css: bool = True, inherit_bindings: bool = True, ) -> None: name = cls.__name__ if not name[0].isupper() and not name.startswith("_"): raise BadWidgetName( f"Widget subclass {name!r} should be capitalized or start with '_'." ) super().__init_subclass__( inherit_css=inherit_css, inherit_bindings=inherit_bindings, ) base = cls.__mro__[0] 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: try: yield "id", self.id, None if self.name: yield "name", self.name if self.classes: yield "classes", " ".join(self.classes) except AttributeError: pass 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 show_vertical_scrollbar = bool( show_vertical_scrollbar and scrollbar_size_vertical ) show_horizontal_scrollbar = bool( show_horizontal_scrollbar and scrollbar_size_horizontal ) 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 show_vertical_scrollbar = bool( show_vertical_scrollbar and scrollbar_size_vertical ) show_horizontal_scrollbar = bool( show_horizontal_scrollbar and scrollbar_size_horizontal ) if show_horizontal_scrollbar and show_vertical_scrollbar: ( window_region, vertical_scrollbar_region, horizontal_scrollbar_region, scrollbar_corner_gap, ) = region.split( region.width - scrollbar_size_vertical, region.height - scrollbar_size_horizontal, ) if scrollbar_corner_gap: yield self.scrollbar_corner, scrollbar_corner_gap if vertical_scrollbar_region: scrollbar = self.vertical_scrollbar scrollbar.window_virtual_size = self.virtual_size.height scrollbar.window_size = window_region.height yield scrollbar, vertical_scrollbar_region if horizontal_scrollbar_region: scrollbar = self.horizontal_scrollbar scrollbar.window_virtual_size = self.virtual_size.width scrollbar.window_size = window_region.width yield scrollbar, horizontal_scrollbar_region elif show_vertical_scrollbar: window_region, scrollbar_region = region.split_vertical( region.width - scrollbar_size_vertical ) if scrollbar_region: scrollbar = self.vertical_scrollbar scrollbar.window_virtual_size = self.virtual_size.height scrollbar.window_size = window_region.height yield scrollbar, scrollbar_region elif show_horizontal_scrollbar: window_region, scrollbar_region = region.split_horizontal( region.height - scrollbar_size_horizontal ) if scrollbar_region: scrollbar = self.horizontal_scrollbar scrollbar.window_virtual_size = self.virtual_size.width scrollbar.window_size = window_region.width yield scrollbar, scrollbar_region def get_pseudo_class_state(self) -> PseudoClasses: """Get an object describing whether each pseudo class is present on this object or not. Returns: A PseudoClasses object describing the pseudo classes that are present. """ node: MessagePump | None = self disabled = False while isinstance(node, Widget): if node.disabled: disabled = True break node = node._parent pseudo_classes = PseudoClasses( enabled=not disabled, hover=self.mouse_hover, focus=self.has_focus, ) return pseudo_classes @property def _pseudo_classes_cache_key(self) -> tuple[int, ...]: """A cache key that changes when the pseudo-classes change.""" return ( self.mouse_hover, self.has_focus, self.is_disabled, ) def _get_justify_method(self) -> JustifyMethod | None: """Get the justify method that may be passed to a Rich 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) return text_justify def post_render( self, renderable: RenderableType, base_style: Style ) -> ConsoleRenderable: """Applies style attributes to the default renderable. This method is called by Textual itself. It is unlikely you will need to call or implement this method. Returns: A new renderable. """ text_justify = self._get_justify_method() 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 != text_justify ): renderable = renderable.copy() renderable.justify = text_justify renderable = _Styled( cast(ConsoleRenderable, renderable), base_style, self.link_style if self.auto_links else None, ) return renderable def watch_has_focus(self, _has_focus: bool) -> None: """Update from CSS if has focus state changes.""" self._update_styles() def watch_disabled(self, disabled: bool) -> None: """Update the styles of the widget and its children when disabled is toggled.""" from textual.app import ScreenStackError if disabled and self.mouse_hover and self.app.mouse_over is not None: # Ensure widget gets a Leave if it is disabled while hovered self._message_queue.put_nowait(events.Leave(self.app.mouse_over)) try: screen = self.screen if ( disabled and screen.focused is not None and self in screen.focused.ancestors_with_self ): screen.focused.blur() except (ScreenStackError, NoActiveAppError, NoScreen): pass self._update_styles() def _size_updated( self, size: Size, virtual_size: Size, container_size: Size, layout: bool = True ) -> bool: """Called when the widget's size is updated. Args: size: Screen size. virtual_size: Virtual (scrollable) size. container_size: Container size (size of parent). layout: Perform layout if required. Returns: True if a resize event should be sent, otherwise False. """ self._layout_cache.clear() if ( self._size != size or self.virtual_size != virtual_size or self._container_size != container_size ): if self._size != size: self._set_dirty() self._size = size if layout: self.virtual_size = virtual_size else: self.set_reactive(Widget.virtual_size, virtual_size) self._container_size = container_size if self.is_scrollable: self._scroll_update(virtual_size) return True else: return False 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 ) self.vertical_scrollbar.refresh() 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.horizontal_scrollbar.refresh() self.scroll_x = self.validate_scroll_x(self.scroll_x) self.scroll_y = self.validate_scroll_y(self.scroll_y) @property def visual_style(self) -> VisualStyle: if self._visual_style is None: background = Color(0, 0, 0, 0) color = Color(255, 255, 255, 0) style = Style() opacity = 1.0 for node in reversed(self.ancestors_with_self): styles = node.styles has_rule = styles.has_rule opacity *= styles.opacity if has_rule("background"): text_background = background + styles.background.tint( styles.background_tint ) background += ( styles.background.tint(styles.background_tint) ).multiply_alpha(opacity) else: text_background = background if has_rule("color"): color = styles.color style += styles.text_style if has_rule("auto_color") and styles.auto_color: color = text_background.get_contrast_text(color.a) self._visual_style = VisualStyle( background, color, bold=style.bold, dim=style.dim, italic=style.italic, reverse=style.reverse, underline=style.underline, strike=style.strike, ) return self._visual_style def get_selection(self, selection: Selection) -> tuple[str, str] | None: """Get the text under the selection. Args: selection: Selection information. Returns: Tuple of extracted text and ending (typically "\n" or " "), or `None` if no text could be extracted. """ visual = self._render() if isinstance(visual, (Text, Content)): text = str(visual) else: return None return selection.extract(text), "\n" def selection_updated(self, selection: Selection | None) -> None: """Called when the selection is updated. Args: selection: Selection information or `None` if no selection. """ self.refresh() def _render_content(self) -> None: """Render all lines.""" width, height = self.size visual = self._render() strips = Visual.to_strips(self, visual, width, height, self.visual_style) 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.visual_style.rich_style) return line def render_lines(self, crop: Region) -> list[Strip]: """Render the widget into 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) def suppress_click(self) -> None: """Suppress a click event. This will prevent a [Click][textual.events.Click] event being sent, if called after a mouse down event and before the click itself. """ self.app._mouse_down_widget = None def _forward_event(self, event: events.Event) -> None: event._set_forwarded() self.post_message(event) def _refresh_scroll(self) -> None: """Refreshes the scroll position.""" self._scroll_required = True self.check_idle() def refresh( self, *regions: Region, repaint: bool = True, layout: bool = False, recompose: bool = False, ) -> Self: """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). layout: Also layout widgets in the view. recompose: Re-compose the widget (will remove and re-mount children). Returns: The `Widget` instance. """ if layout and not self._layout_required: self._layout_required = True self._layout_updates += 1 if recompose: self._recompose_required = True self.call_next(self._check_recompose) return self if not self._is_mounted: self._repaint_required = True self.check_idle() return self self._layout_cache.clear() if repaint: self._set_dirty(*regions) self.clear_cached_dimensions() self._rich_style_cache.clear() self._repaint_required = True self.check_idle() return self 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._prune(self, parent=self._parent) return await_remove def remove_children( self, selector: str | type[QueryType] | Iterable[Widget] = "*" ) -> AwaitRemove: """Remove the immediate children of this Widget from the DOM. Args: selector: A CSS selector or iterable of widgets to remove. Returns: An awaitable object that waits for the direct children to be removed. """ if callable(selector) and issubclass(selector, Widget): selector = selector.__name__ children_to_remove: Iterable[Widget] if isinstance(selector, str): parsed_selectors = parse_selectors(selector) children_to_remove = [ child for child in self.children if match(parsed_selectors, child) ] else: children_to_remove = selector await_remove = self.app._prune(*children_to_remove, parent=self) return await_remove @asynccontextmanager async def batch(self) -> AsyncGenerator[None, None]: """Async context manager that combines widget locking and update batching. Use this async context manager whenever you want to acquire the widget lock and batch app updates at the same time. Example: ```py async with container.batch(): await container.remove_children(Button) await container.mount(Label("All buttons are gone.")) ``` """ async with self.lock: with self.app.batch_update(): yield def render(self) -> RenderResult: """Get [content](/guide/content) for the widget. Implement this method in a subclass for custom widgets. This method should return [markup](/guide/content#markup), a [Content][textual.content.Content] object, or a [Rich](https://github.com/Textualize/rich) renderable. Example: ```python from textual.app import RenderResult from textual.widget import Widget class CustomWidget(Widget): def render(self) -> RenderResult: return "Welcome to [bold red]Textual[/]!" ``` Returns: A string or object to render as the widget's content. """ if self.is_container: if self.styles.layout and self.styles.keyline[0] != "none": return self.layout.render_keyline(self) else: return Blank(self.background_colors[1]) return self.css_identifier_styled def _render(self) -> Visual: """Get renderable, promoting str to text as required. Returns: A Visual. """ cache_key = "_render.visual" cached_visual = self._layout_cache.get(cache_key, None) if cached_visual is not None: assert isinstance(cached_visual, Visual) return cached_visual visual = visualize(self, self.render(), markup=self._render_markup) self._layout_cache[cache_key] = visual return visual async def run_action( self, action: str, namespaces: Mapping[str, DOMNode] | None = None ) -> None: """Perform a given action, with this widget as the default namespace. Args: action: Action encoded as a string. namespaces: Mapping of namespaces. """ await self.app.run_action(action, self, namespaces) 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. """ _rich_traceback_omit = True # Catch a common error. # This will error anyway, but at least we can offer a helpful message here. if not hasattr(message, "_prevent"): raise RuntimeError( f"{type(message)!r} is missing expected attributes; did you forget to call super().__init__() in the constructor?" ) if constants.DEBUG and not self.is_running and not message.no_dispatch: try: self.log.warning(self, f"IS NOT RUNNING, {message!r} not sent") except NoActiveAppError: pass return super().post_message(message) async def on_prune(self, event: messages.Prune) -> None: """Close message loop when asked to prune.""" await self._close_messages(wait=False) async def _message_loop_exit(self) -> None: """Clean up DOM tree.""" parent = self._parent # Post messages to children, asking them to prune children = [*self.children, *self._get_virtual_dom()] for node in children: node.post_message(Prune()) # Wait for child nodes to exit await gather(*[node._task for node in children if node._task is not None]) # Send unmount event await self._dispatch_message(events.Unmount()) assert isinstance(parent, DOMNode) # Finalize removal from DOM parent._nodes._remove(self) self.app._registry.discard(self) self._detach() self._arrangement_cache.clear() self._nodes._clear() self._render_cache = _RenderCache(NULL_SIZE, []) self._component_styles.clear() self._query_one_cache.clear() async def _on_idle(self, event: events.Idle) -> None: """Called when there are no more events on the queue. Args: event: Idle event. """ self._check_refresh() def _check_refresh(self) -> None: """Check if a refresh was requested.""" if self._parent is not None and not self._closing: try: screen = self.screen except NoScreen: pass else: if self._refresh_styles_required: self._refresh_styles_required = False self.call_later(self._update_styles) if self._scroll_required: self._scroll_required = False if not self._layout_required: if self.styles.keyline[0] != "none": # TODO: Feels like a hack # Perhaps there should be an explicit mechanism for backgrounds to refresh when scrolled? self._set_dirty() screen.post_message(messages.UpdateScroll()) if self._repaint_required: self._repaint_required = False if self.display: screen.post_message(messages.Update(self)) if self._layout_required: self._layout_required = False for ancestor in self.ancestors: if not isinstance(ancestor, Widget): break ancestor._clear_arrangement_cache() ancestor._layout_updates += 1 if not ancestor.styles.auto_dimensions: break screen.post_message(messages.Layout(self)) def focus(self, scroll_visible: bool = True) -> Self: """Give focus to this widget. Args: scroll_visible: Scroll parent to make this widget visible. Returns: The `Widget` instance. """ def set_focus(widget: Widget) -> None: """Callback to set the focus.""" try: widget.screen.set_focus(self, scroll_visible=scroll_visible) except NoScreen: pass self.refresh() self.app.call_later(set_focus, self) return self def blur(self) -> Self: """Blur (un-focus) the widget. Focus will be moved to the next available widget in the focus chain. Returns: The `Widget` instance. """ try: self.screen._reset_focus(self) except NoScreen: pass return self 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. """ 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. """ if self.app.mouse_captured is self: self.app.capture_mouse(None) def text_select_all(self) -> None: """Select the entire widget.""" self.screen._select_all_in_widget(self) def begin_capture_print(self, stdout: bool = True, stderr: bool = True) -> None: """Capture text from print statements (or writes to stdout / stderr). If printing is captured, the widget will be sent an [`events.Print`][textual.events.Print] message. Call [`end_capture_print`][textual.widget.Widget.end_capture_print] to disable print capture. Args: stdout: Whether to capture stdout. stderr: Whether to capture stderr. """ self.app.begin_capture_print(self, stdout=stdout, stderr=stderr) def end_capture_print(self) -> None: """End print capture (set with [`begin_capture_print`][textual.widget.Widget.begin_capture_print]).""" self.app.end_capture_print(self) def check_message_enabled(self, message: Message) -> bool: """Check if a given message is enabled (allowed to be sent). Args: message: A message object Returns: `True` if the message will be sent, or `False` if it is disabled. """ # Do the normal checking and get out if that fails. if not super().check_message_enabled(message) or self._is_prevented( type(message) ): return False # Mouse scroll events should always go through, this allows mouse # wheel scrolling to pass through disabled widgets. if isinstance(message, _MOUSE_EVENTS_ALLOW_IF_DISABLED): return True # Otherwise, if this is any other mouse event, the widget receiving # the event must not be disabled at this moment. return ( not self._self_or_ancestors_disabled if isinstance(message, _MOUSE_EVENTS_DISALLOW_IF_DISABLED) else True ) 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 notify_style_update(self) -> None: self._rich_style_cache.clear() self._visual_style_cache.clear() self._visual_style = None super().notify_style_update() 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: if event.widget is self: if self.allow_select and self.screen.allow_select and self.app.ALLOW_SELECT: if event.chain == 2: self.text_select_all() elif event.chain == 3 and self.parent is not None: self.select_container.text_select_all() 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 dispatch_key(self, event) async def _on_compose(self, event: events.Compose) -> None: _rich_traceback_omit = True event.prevent_default() await self._compose() async def _compose(self) -> None: try: widgets = [*self._pending_children, *compose(self)] self._pending_children.clear() except TypeError as error: raise TypeError( f"{self!r} compose() method returned an invalid result; {error}" ) from error except Exception as error: self.app._handle_exception(error) else: self._extend_compose(widgets) await self.mount_composed_widgets(widgets) async def mount_composed_widgets(self, widgets: list[Widget]) -> None: """Called by Textual to mount widgets after compose. There is generally no need to implement this method in your application. See [Lazy][textual.lazy.Lazy] for a class which uses this method to implement *lazy* mounting. Args: widgets: A list of child widgets. """ if widgets: await self.mount_all(widgets) def _extend_compose(self, widgets: list[Widget]) -> None: """Hook to extend composed widgets. Args: widgets: Widgets to be mounted. """ 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: if event.node is self: self.mouse_hover = False self.hover_style = Style() def _on_enter(self, event: events.Enter) -> None: if event.node is self: self.mouse_hover = True def _on_focus(self, event: events.Focus) -> None: self.has_focus = True self.refresh() if self.parent is not None: self.parent.post_message(events.DescendantFocus(self)) def _on_blur(self, event: events.Blur) -> None: self.has_focus = False self.refresh() if self.parent is not None: self.parent.post_message(events.DescendantBlur(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_for_pointer(animate=False): event.stop() else: if self.allow_vertical_scroll: if self._scroll_down_for_pointer(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_for_pointer(animate=False): event.stop() else: if self.allow_vertical_scroll: if self._scroll_up_for_pointer(animate=False): event.stop() def _on_mouse_scroll_right(self, event: events.MouseScrollRight) -> None: if self.allow_horizontal_scroll: if self._scroll_right_for_pointer(): event.stop() def _on_mouse_scroll_left(self, event: events.MouseScrollLeft) -> None: if self.allow_horizontal_scroll: if self._scroll_left_for_pointer(): 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_show(self, event: events.Show) -> None: if self.show_horizontal_scrollbar: self.horizontal_scrollbar.post_message(event) if self.show_vertical_scrollbar: self.vertical_scrollbar.post_message(event) def _on_hide(self, event: events.Hide) -> None: if self.show_horizontal_scrollbar: self.horizontal_scrollbar.post_message(event) if self.show_vertical_scrollbar: self.vertical_scrollbar.post_message(event) if self.has_focus: self.blur() def _on_scroll_to_region(self, message: messages.ScrollToRegion) -> None: self.scroll_to_region(message.region, animate=True) def _on_unmount(self) -> None: self._uncover() self.workers.cancel_node(self) def action_scroll_home(self) -> None: if not self._allow_scroll: raise SkipAction() self.scroll_home(x_axis=self.scroll_y == 0) def action_scroll_end(self) -> None: if not self._allow_scroll: raise SkipAction() self.scroll_end(x_axis=self.scroll_y == self.is_vertical_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() def action_page_left(self) -> None: if not self.allow_horizontal_scroll: raise SkipAction() self.scroll_page_left() def action_page_right(self) -> None: if not self.allow_horizontal_scroll: raise SkipAction() self.scroll_page_right() def notify( self, message: str, *, title: str = "", severity: SeverityLevel = "information", timeout: float | None = None, markup: bool = True, ) -> None: """Create a notification. !!! tip This method is thread-safe. Args: message: The message for the notification. title: The title for the notification. severity: The severity of the notification. timeout: The timeout (in seconds) for the notification, or `None` for default. markup: Render the message as content markup? See [`App.notify`][textual.app.App.notify] for the full documentation for this method. """ if timeout is None: return self.app.notify( message, title=title, severity=severity, markup=markup, ) else: return self.app.notify( message, title=title, severity=severity, timeout=timeout, markup=markup, ) def action_notify( self, message: str, title: str = "", severity: str = "information", markup: bool = True, ) -> None: self.notify( message, title=title, severity=severity, markup=markup, )