mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'main' into call-later
This commit is contained in:
14
CHANGELOG.md
14
CHANGELOG.md
@@ -7,14 +7,22 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
## [0.5.0] - Unreleased
|
||||
|
||||
|
||||
### Added
|
||||
|
||||
- Add easing parameter to Widget.scroll_* methods https://github.com/Textualize/textual/pull/1144
|
||||
- Added Widget.call_later which invokes a callback on idle.
|
||||
- `DOMNode.ancestors` no longer includes `self`.
|
||||
- Added `DOMNode.ancestors_with_self`, which retains the old behaviour of
|
||||
`DOMNode.ancestors`.
|
||||
- Improved the speed of `DOMQuery.remove`.
|
||||
|
||||
|
||||
### Changed
|
||||
|
||||
- Watchers are now called immediately when setting the attribute if they are synchronous. https://github.com/Textualize/textual/pull/1145
|
||||
- Widget.call_later has been renamed to Widget.call_after_refresh.
|
||||
|
||||
### Added
|
||||
|
||||
- Added Widget.call_later which invokes a callback on idle.
|
||||
|
||||
## [0.4.0] - 2022-11-08
|
||||
|
||||
|
||||
@@ -128,3 +128,4 @@ EASING = {
|
||||
}
|
||||
|
||||
DEFAULT_EASING = "in_out_cubic"
|
||||
DEFAULT_SCROLL_EASING = "out_cubic"
|
||||
|
||||
@@ -355,7 +355,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
|
||||
@property
|
||||
def return_value(self) -> ReturnType | None:
|
||||
"""Get the return type."""
|
||||
"""ReturnType | None: The return type of the app."""
|
||||
return self._return_value
|
||||
|
||||
def animate(
|
||||
@@ -396,32 +396,17 @@ class App(Generic[ReturnType], DOMNode):
|
||||
|
||||
@property
|
||||
def debug(self) -> bool:
|
||||
"""Check if debug mode is enabled.
|
||||
|
||||
Returns:
|
||||
bool: True if debug mode is enabled.
|
||||
|
||||
"""
|
||||
"""bool: Is debug mode is enabled?"""
|
||||
return "debug" in self.features
|
||||
|
||||
@property
|
||||
def is_headless(self) -> bool:
|
||||
"""Check if the app is running in 'headless' mode.
|
||||
|
||||
Returns:
|
||||
bool: True if the app is in headless mode.
|
||||
|
||||
"""
|
||||
"""bool: Is the app running in 'headless' mode?"""
|
||||
return False if self._driver is None else self._driver.is_headless
|
||||
|
||||
@property
|
||||
def screen_stack(self) -> list[Screen]:
|
||||
"""Get a *copy* of the screen stack.
|
||||
|
||||
Returns:
|
||||
list[Screen]: List of screens.
|
||||
|
||||
"""
|
||||
"""list[Screen]: A *copy* of the screen stack."""
|
||||
return self._screen_stack.copy()
|
||||
|
||||
def exit(self, result: ReturnType | None = None) -> None:
|
||||
@@ -435,7 +420,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
|
||||
@property
|
||||
def focused(self) -> Widget | None:
|
||||
"""Get the widget that is focused on the currently active screen."""
|
||||
"""Widget | None: the widget that is focused on the currently active screen."""
|
||||
return self.screen.focused
|
||||
|
||||
@property
|
||||
@@ -514,13 +499,10 @@ class App(Generic[ReturnType], DOMNode):
|
||||
|
||||
@property
|
||||
def screen(self) -> Screen:
|
||||
"""Get the current screen.
|
||||
"""Screen: The current screen.
|
||||
|
||||
Raises:
|
||||
ScreenStackError: If there are no screens on the stack.
|
||||
|
||||
Returns:
|
||||
Screen: The currently active screen.
|
||||
"""
|
||||
try:
|
||||
return self._screen_stack[-1]
|
||||
@@ -529,11 +511,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
|
||||
@property
|
||||
def size(self) -> Size:
|
||||
"""Get the size of the terminal.
|
||||
|
||||
Returns:
|
||||
Size: Size of the terminal
|
||||
"""
|
||||
"""Size: The size of the terminal."""
|
||||
if self._driver is not None and self._driver._size is not None:
|
||||
width, height = self._driver._size
|
||||
else:
|
||||
@@ -542,6 +520,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
|
||||
@property
|
||||
def log(self) -> Logger:
|
||||
"""Logger: The logger object."""
|
||||
return self._logger
|
||||
|
||||
def _log(
|
||||
@@ -1660,7 +1639,9 @@ class App(Generic[ReturnType], DOMNode):
|
||||
(self, self._bindings),
|
||||
]
|
||||
else:
|
||||
namespace_bindings = [(node, node._bindings) for node in focused.ancestors]
|
||||
namespace_bindings = [
|
||||
(node, node._bindings) for node in focused.ancestors_with_self
|
||||
]
|
||||
return namespace_bindings
|
||||
|
||||
async def check_bindings(self, key: str, universal: bool = False) -> bool:
|
||||
@@ -1830,23 +1811,64 @@ class App(Generic[ReturnType], DOMNode):
|
||||
await self.screen.post_message(event)
|
||||
|
||||
async def _on_remove(self, event: events.Remove) -> None:
|
||||
widget = event.widget
|
||||
parent = widget.parent
|
||||
"""Handle a remove event.
|
||||
|
||||
remove_widgets = widget.walk_children(
|
||||
Widget, with_self=True, method="depth", reverse=True
|
||||
)
|
||||
Args:
|
||||
event (events.Remove): The remove event.
|
||||
"""
|
||||
|
||||
if self.screen.focused in remove_widgets:
|
||||
self.screen._reset_focus(
|
||||
self.screen.focused,
|
||||
[to_remove for to_remove in remove_widgets if to_remove.can_focus],
|
||||
# We've been given a list of widgets to remove, but removing those
|
||||
# will also result in other (descendent) widgets being removed. So
|
||||
# to start with let's get a list of everything that's not going to
|
||||
# be in the DOM by the time we've finished. Note that, at this
|
||||
# point, it's entirely possible that there will be duplicates.
|
||||
everything_to_remove: list[Widget] = []
|
||||
for widget in event.widgets:
|
||||
everything_to_remove.extend(
|
||||
widget.walk_children(
|
||||
Widget, with_self=True, method="depth", reverse=True
|
||||
)
|
||||
)
|
||||
|
||||
await self._prune_node(widget)
|
||||
# Next up, let's quickly create a deduped collection of things to
|
||||
# remove and ensure that, if one of them is the focused widget,
|
||||
# focus gets moved to somewhere else.
|
||||
dedupe_to_remove = set(everything_to_remove)
|
||||
if self.screen.focused in dedupe_to_remove:
|
||||
self.screen._reset_focus(
|
||||
self.screen.focused,
|
||||
[to_remove for to_remove in dedupe_to_remove if to_remove.can_focus],
|
||||
)
|
||||
|
||||
if parent is not None:
|
||||
parent.refresh(layout=True)
|
||||
# Next, we go through the set of widgets we've been asked to remove
|
||||
# and try and find the minimal collection of widgets that will
|
||||
# result in everything else that should be removed, being removed.
|
||||
# In other words: find the smallest set of ancestors in the DOM that
|
||||
# will remove the widgets requested for removal, and also ensure
|
||||
# that all knock-on effects happen too.
|
||||
request_remove = set(event.widgets)
|
||||
pruned_remove = [
|
||||
widget
|
||||
for widget in event.widgets
|
||||
if request_remove.isdisjoint(widget.ancestors)
|
||||
]
|
||||
|
||||
# Now that we know that minimal set of widgets, we go through them
|
||||
# and get their parents to forget about them. This has the effect of
|
||||
# snipping each affected branch from the DOM.
|
||||
for widget in pruned_remove:
|
||||
if widget.parent is not None:
|
||||
widget.parent.children._remove(widget)
|
||||
|
||||
# Having done that, it's now safe for us to start the process of
|
||||
# winding down all of the affected widgets. We do that by pruning
|
||||
# just the roots of each affected branch, and letting the normal
|
||||
# prune process take care of all the offspring.
|
||||
for widget in pruned_remove:
|
||||
await self._prune_node(widget)
|
||||
|
||||
# And finally, redraw all the things!
|
||||
self.refresh(layout=True)
|
||||
|
||||
def _walk_children(self, root: Widget) -> Iterable[list[Widget]]:
|
||||
"""Walk children depth first, generating widgets and a list of their siblings.
|
||||
|
||||
@@ -20,6 +20,8 @@ from typing import cast, Generic, TYPE_CHECKING, Iterator, TypeVar, overload
|
||||
|
||||
import rich.repr
|
||||
|
||||
from .. import events
|
||||
from .._context import active_app
|
||||
from .errors import DeclarationError, TokenError
|
||||
from .match import match
|
||||
from .model import SelectorSet
|
||||
@@ -348,8 +350,8 @@ class DOMQuery(Generic[QueryType]):
|
||||
|
||||
def remove(self) -> DOMQuery[QueryType]:
|
||||
"""Remove matched nodes from the DOM"""
|
||||
for node in self:
|
||||
node.remove()
|
||||
app = active_app.get()
|
||||
app.post_message_no_wait(events.Remove(app, widgets=list(self)))
|
||||
return self
|
||||
|
||||
def set_styles(
|
||||
|
||||
@@ -179,11 +179,7 @@ class DOMNode(MessagePump):
|
||||
|
||||
@property
|
||||
def _node_bases(self) -> Iterator[Type[DOMNode]]:
|
||||
"""Get the DOMNode bases classes (including self.__class__)
|
||||
|
||||
Returns:
|
||||
Iterator[Type[DOMNode]]: An iterable of DOMNode classes.
|
||||
"""
|
||||
"""Iterator[Type[DOMNode]]: The DOMNode bases classes (including self.__class__)"""
|
||||
# Node bases are in reversed order so that the base class is lower priority
|
||||
return self._css_bases(self.__class__)
|
||||
|
||||
@@ -248,17 +244,16 @@ class DOMNode(MessagePump):
|
||||
|
||||
@property
|
||||
def parent(self) -> DOMNode | None:
|
||||
"""Get the parent node.
|
||||
|
||||
Returns:
|
||||
DOMNode | None: The node which is the direct parent of this node.
|
||||
"""
|
||||
|
||||
"""DOMNode | None: The parent node."""
|
||||
return cast("DOMNode | None", self._parent)
|
||||
|
||||
@property
|
||||
def screen(self) -> "Screen":
|
||||
"""Get the screen that this node is contained within. Note that this may not be the currently active screen within the app."""
|
||||
"""Screen: The screen that this node is contained within.
|
||||
|
||||
Note:
|
||||
This may not be the currently active screen within the app.
|
||||
"""
|
||||
# Get the node by looking up a chain of parents
|
||||
# Note that self.screen may not be the same as self.app.screen
|
||||
from .screen import Screen
|
||||
@@ -272,11 +267,7 @@ class DOMNode(MessagePump):
|
||||
|
||||
@property
|
||||
def id(self) -> str | None:
|
||||
"""The ID of this node, or None if the node has no ID.
|
||||
|
||||
Returns:
|
||||
(str | None): A Node ID or None.
|
||||
"""
|
||||
"""str | None: The ID of this node, or None if the node has no ID."""
|
||||
return self._id
|
||||
|
||||
@id.setter
|
||||
@@ -301,11 +292,12 @@ class DOMNode(MessagePump):
|
||||
|
||||
@property
|
||||
def name(self) -> str | None:
|
||||
"""str | None: The name of the node."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def css_identifier(self) -> str:
|
||||
"""A CSS selector that identifies this DOM node."""
|
||||
"""str: A CSS selector that identifies this DOM node."""
|
||||
tokens = [self.__class__.__name__]
|
||||
if self.id is not None:
|
||||
tokens.append(f"#{self.id}")
|
||||
@@ -313,7 +305,7 @@ class DOMNode(MessagePump):
|
||||
|
||||
@property
|
||||
def css_identifier_styled(self) -> Text:
|
||||
"""A stylized CSS identifier."""
|
||||
"""Text: A stylized CSS identifier."""
|
||||
tokens = Text.styled(self.__class__.__name__)
|
||||
if self.id is not None:
|
||||
tokens.append(f"#{self.id}", style="bold")
|
||||
@@ -326,27 +318,18 @@ class DOMNode(MessagePump):
|
||||
|
||||
@property
|
||||
def classes(self) -> frozenset[str]:
|
||||
"""A frozenset of the current classes set on the widget.
|
||||
|
||||
Returns:
|
||||
frozenset[str]: Set of class names.
|
||||
|
||||
"""
|
||||
"""frozenset[str]: A frozenset of the current classes set on the widget."""
|
||||
return frozenset(self._classes)
|
||||
|
||||
@property
|
||||
def pseudo_classes(self) -> frozenset[str]:
|
||||
"""Get a set of all pseudo classes"""
|
||||
"""frozenset[str]: A set of all pseudo classes"""
|
||||
pseudo_classes = frozenset({*self.get_pseudo_classes()})
|
||||
return pseudo_classes
|
||||
|
||||
@property
|
||||
def css_path_nodes(self) -> list[DOMNode]:
|
||||
"""A list of nodes from the root to this node, forming a "path".
|
||||
|
||||
Returns:
|
||||
list[DOMNode]: List of Nodes, starting with the root and ending with this node.
|
||||
"""
|
||||
"""list[DOMNode] A list of nodes from the root to this node, forming a "path"."""
|
||||
result: list[DOMNode] = [self]
|
||||
append = result.append
|
||||
|
||||
@@ -488,7 +471,7 @@ class DOMNode(MessagePump):
|
||||
Style: Rich Style object.
|
||||
"""
|
||||
return Style.combine(
|
||||
node.styles.text_style for node in reversed(self.ancestors)
|
||||
node.styles.text_style for node in reversed(self.ancestors_with_self)
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -497,7 +480,7 @@ class DOMNode(MessagePump):
|
||||
background = WHITE
|
||||
color = BLACK
|
||||
style = Style()
|
||||
for node in reversed(self.ancestors):
|
||||
for node in reversed(self.ancestors_with_self):
|
||||
styles = node.styles
|
||||
if styles.has_rule("background"):
|
||||
background += styles.background
|
||||
@@ -520,7 +503,7 @@ class DOMNode(MessagePump):
|
||||
|
||||
"""
|
||||
base_background = background = BLACK
|
||||
for node in reversed(self.ancestors):
|
||||
for node in reversed(self.ancestors_with_self):
|
||||
styles = node.styles
|
||||
if styles.has_rule("background"):
|
||||
base_background = background
|
||||
@@ -536,7 +519,7 @@ class DOMNode(MessagePump):
|
||||
"""
|
||||
base_background = background = WHITE
|
||||
base_color = color = BLACK
|
||||
for node in reversed(self.ancestors):
|
||||
for node in reversed(self.ancestors_with_self):
|
||||
styles = node.styles
|
||||
if styles.has_rule("background"):
|
||||
base_background = background
|
||||
@@ -551,8 +534,11 @@ class DOMNode(MessagePump):
|
||||
return (base_background, base_color, background, color)
|
||||
|
||||
@property
|
||||
def ancestors(self) -> list[DOMNode]:
|
||||
"""Get a list of Nodes by tracing ancestors all the way back to App."""
|
||||
def ancestors_with_self(self) -> list[DOMNode]:
|
||||
"""list[DOMNode]: A list of Nodes by tracing a path all the way back to App.
|
||||
|
||||
Note: This is inclusive of ``self``.
|
||||
"""
|
||||
nodes: list[MessagePump | None] = []
|
||||
add_node = nodes.append
|
||||
node: MessagePump | None = self
|
||||
@@ -561,6 +547,11 @@ class DOMNode(MessagePump):
|
||||
node = node._parent
|
||||
return cast("list[DOMNode]", nodes)
|
||||
|
||||
@property
|
||||
def ancestors(self) -> list[DOMNode]:
|
||||
"""list[DOMNode]: A list of ancestor nodes Nodes by tracing ancestors all the way back to App."""
|
||||
return self.ancestors_with_self[1:]
|
||||
|
||||
@property
|
||||
def displayed_children(self) -> list[Widget]:
|
||||
"""The children which don't have display: none set.
|
||||
|
||||
@@ -127,10 +127,10 @@ class Unmount(Mount, bubble=False, verbose=False):
|
||||
|
||||
|
||||
class Remove(Event, bubble=False):
|
||||
"""Sent to a widget to ask it to remove itself from the DOM."""
|
||||
"""Sent to the app to ask it to remove one or more widgets from the DOM."""
|
||||
|
||||
def __init__(self, sender: MessageTarget, widget: Widget) -> None:
|
||||
self.widget = widget
|
||||
def __init__(self, sender: MessageTarget, widgets: list[Widget]) -> None:
|
||||
self.widgets = widgets
|
||||
super().__init__(sender)
|
||||
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ from . import errors, events, messages
|
||||
from ._animator import BoundAnimator, DEFAULT_EASING, Animatable, EasingFunction
|
||||
from ._arrange import DockArrangeResult, arrange
|
||||
from ._context import active_app
|
||||
from ._easing import DEFAULT_SCROLL_EASING
|
||||
from ._layout import Layout
|
||||
from ._segment_tools import align_lines
|
||||
from ._styles_cache import StylesCache
|
||||
@@ -431,7 +432,7 @@ class Widget(DOMNode):
|
||||
# children. We should be able to go looking for the widget's
|
||||
# location amongst its parent's children.
|
||||
try:
|
||||
return spot.parent, spot.parent.children.index(spot)
|
||||
return cast("Widget", spot.parent), spot.parent.children.index(spot)
|
||||
except ValueError:
|
||||
raise MountError(f"{spot!r} is not a child of {self!r}") from None
|
||||
|
||||
@@ -1054,7 +1055,7 @@ class Widget(DOMNode):
|
||||
Returns:
|
||||
tuple[str, ...]: Tuple of layer names.
|
||||
"""
|
||||
for node in self.ancestors:
|
||||
for node in self.ancestors_with_self:
|
||||
if not isinstance(node, Widget):
|
||||
break
|
||||
if node.styles.has_rule("layers"):
|
||||
@@ -1136,6 +1137,7 @@ class Widget(DOMNode):
|
||||
animate: bool = True,
|
||||
speed: float | None = None,
|
||||
duration: float | None = None,
|
||||
easing: EasingFunction | str | None = None,
|
||||
) -> bool:
|
||||
"""Scroll to a given (absolute) coordinate, optionally animating.
|
||||
|
||||
@@ -1145,6 +1147,8 @@ class Widget(DOMNode):
|
||||
animate (bool, optional): Animate to new scroll position. Defaults to True.
|
||||
speed (float | None, optional): Speed of scroll if animate is True. Or None to use duration.
|
||||
duration (float | None, optional): Duration of animation, if animate is True and speed is None.
|
||||
easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
|
||||
which will result in Textual choosing the default scrolling easing function.
|
||||
|
||||
Returns:
|
||||
bool: True if the scroll position changed, otherwise False.
|
||||
@@ -1154,6 +1158,10 @@ class Widget(DOMNode):
|
||||
# TODO: configure animation speed
|
||||
if duration is None and speed is None:
|
||||
speed = 50
|
||||
|
||||
if easing is None:
|
||||
easing = DEFAULT_SCROLL_EASING
|
||||
|
||||
if x is not None:
|
||||
self.scroll_target_x = x
|
||||
if x != self.scroll_x:
|
||||
@@ -1162,7 +1170,7 @@ class Widget(DOMNode):
|
||||
self.scroll_target_x,
|
||||
speed=speed,
|
||||
duration=duration,
|
||||
easing="out_cubic",
|
||||
easing=easing,
|
||||
)
|
||||
scrolled_x = True
|
||||
if y is not None:
|
||||
@@ -1173,7 +1181,7 @@ class Widget(DOMNode):
|
||||
self.scroll_target_y,
|
||||
speed=speed,
|
||||
duration=duration,
|
||||
easing="out_cubic",
|
||||
easing=easing,
|
||||
)
|
||||
scrolled_y = True
|
||||
|
||||
@@ -1197,6 +1205,7 @@ class Widget(DOMNode):
|
||||
animate: bool = True,
|
||||
speed: float | None = None,
|
||||
duration: float | None = None,
|
||||
easing: EasingFunction | str | None = None,
|
||||
) -> bool:
|
||||
"""Scroll relative to current position.
|
||||
|
||||
@@ -1206,6 +1215,8 @@ class Widget(DOMNode):
|
||||
animate (bool, optional): Animate to new scroll position. Defaults to False.
|
||||
speed (float | None, optional): Speed of scroll if animate is True. Or None to use duration.
|
||||
duration (float | None, optional): Duration of animation, if animate is True and speed is None.
|
||||
easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
|
||||
which will result in Textual choosing the configured default scrolling easing function.
|
||||
|
||||
Returns:
|
||||
bool: True if the scroll position changed, otherwise False.
|
||||
@@ -1216,6 +1227,7 @@ class Widget(DOMNode):
|
||||
animate=animate,
|
||||
speed=speed,
|
||||
duration=duration,
|
||||
easing=easing,
|
||||
)
|
||||
|
||||
def scroll_home(
|
||||
@@ -1224,6 +1236,7 @@ class Widget(DOMNode):
|
||||
animate: bool = True,
|
||||
speed: float | None = None,
|
||||
duration: float | None = None,
|
||||
easing: EasingFunction | str | None = None,
|
||||
) -> bool:
|
||||
"""Scroll to home position.
|
||||
|
||||
@@ -1231,13 +1244,17 @@ class Widget(DOMNode):
|
||||
animate (bool, optional): Animate scroll. Defaults to True.
|
||||
speed (float | None, optional): Speed of scroll if animate is True. Or None to use duration.
|
||||
duration (float | None, optional): Duration of animation, if animate is True and speed is None.
|
||||
easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
|
||||
which will result in Textual choosing the configured default scrolling easing function.
|
||||
|
||||
Returns:
|
||||
bool: True if any scrolling was done.
|
||||
"""
|
||||
if speed is None and duration is None:
|
||||
duration = 1.0
|
||||
return self.scroll_to(0, 0, animate=animate, speed=speed, duration=duration)
|
||||
return self.scroll_to(
|
||||
0, 0, animate=animate, speed=speed, duration=duration, easing=easing
|
||||
)
|
||||
|
||||
def scroll_end(
|
||||
self,
|
||||
@@ -1245,6 +1262,7 @@ class Widget(DOMNode):
|
||||
animate: bool = True,
|
||||
speed: float | None = None,
|
||||
duration: float | None = None,
|
||||
easing: EasingFunction | str | None = None,
|
||||
) -> bool:
|
||||
"""Scroll to the end of the container.
|
||||
|
||||
@@ -1252,6 +1270,8 @@ class Widget(DOMNode):
|
||||
animate (bool, optional): Animate scroll. Defaults to True.
|
||||
speed (float | None, optional): Speed of scroll if animate is True. Or None to use duration.
|
||||
duration (float | None, optional): Duration of animation, if animate is True and speed is None.
|
||||
easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
|
||||
which will result in Textual choosing the configured default scrolling easing function.
|
||||
|
||||
Returns:
|
||||
bool: True if any scrolling was done.
|
||||
@@ -1260,7 +1280,12 @@ class Widget(DOMNode):
|
||||
if speed is None and duration is None:
|
||||
duration = 1.0
|
||||
return self.scroll_to(
|
||||
0, self.max_scroll_y, animate=animate, speed=speed, duration=duration
|
||||
0,
|
||||
self.max_scroll_y,
|
||||
animate=animate,
|
||||
speed=speed,
|
||||
duration=duration,
|
||||
easing=easing,
|
||||
)
|
||||
|
||||
def scroll_left(
|
||||
@@ -1269,6 +1294,7 @@ class Widget(DOMNode):
|
||||
animate: bool = True,
|
||||
speed: float | None = None,
|
||||
duration: float | None = None,
|
||||
easing: EasingFunction | str | None = None,
|
||||
) -> bool:
|
||||
"""Scroll one cell left.
|
||||
|
||||
@@ -1276,13 +1302,19 @@ class Widget(DOMNode):
|
||||
animate (bool, optional): Animate scroll. Defaults to True.
|
||||
speed (float | None, optional): Speed of scroll if animate is True. Or None to use duration.
|
||||
duration (float | None, optional): Duration of animation, if animate is True and speed is None.
|
||||
easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
|
||||
which will result in Textual choosing the configured default scrolling easing function.
|
||||
|
||||
Returns:
|
||||
bool: True if any scrolling was done.
|
||||
|
||||
"""
|
||||
return self.scroll_to(
|
||||
x=self.scroll_target_x - 1, animate=animate, speed=speed, duration=duration
|
||||
x=self.scroll_target_x - 1,
|
||||
animate=animate,
|
||||
speed=speed,
|
||||
duration=duration,
|
||||
easing=easing,
|
||||
)
|
||||
|
||||
def scroll_right(
|
||||
@@ -1291,6 +1323,7 @@ class Widget(DOMNode):
|
||||
animate: bool = True,
|
||||
speed: float | None = None,
|
||||
duration: float | None = None,
|
||||
easing: EasingFunction | str | None = None,
|
||||
) -> bool:
|
||||
"""Scroll on cell right.
|
||||
|
||||
@@ -1298,13 +1331,19 @@ class Widget(DOMNode):
|
||||
animate (bool, optional): Animate scroll. Defaults to True.
|
||||
speed (float | None, optional): Speed of scroll if animate is True. Or None to use duration.
|
||||
duration (float | None, optional): Duration of animation, if animate is True and speed is None.
|
||||
easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
|
||||
which will result in Textual choosing the configured default scrolling easing function.
|
||||
|
||||
Returns:
|
||||
bool: True if any scrolling was done.
|
||||
|
||||
"""
|
||||
return self.scroll_to(
|
||||
x=self.scroll_target_x + 1, animate=animate, speed=speed, duration=duration
|
||||
x=self.scroll_target_x + 1,
|
||||
animate=animate,
|
||||
speed=speed,
|
||||
duration=duration,
|
||||
easing=easing,
|
||||
)
|
||||
|
||||
def scroll_down(
|
||||
@@ -1313,6 +1352,7 @@ class Widget(DOMNode):
|
||||
animate: bool = True,
|
||||
speed: float | None = None,
|
||||
duration: float | None = None,
|
||||
easing: EasingFunction | str | None = None,
|
||||
) -> bool:
|
||||
"""Scroll one line down.
|
||||
|
||||
@@ -1320,13 +1360,19 @@ class Widget(DOMNode):
|
||||
animate (bool, optional): Animate scroll. Defaults to True.
|
||||
speed (float | None, optional): Speed of scroll if animate is True. Or None to use duration.
|
||||
duration (float | None, optional): Duration of animation, if animate is True and speed is None.
|
||||
easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
|
||||
which will result in Textual choosing the configured default scrolling easing function.
|
||||
|
||||
Returns:
|
||||
bool: True if any scrolling was done.
|
||||
|
||||
"""
|
||||
return self.scroll_to(
|
||||
y=self.scroll_target_y + 1, animate=animate, speed=speed, duration=duration
|
||||
y=self.scroll_target_y + 1,
|
||||
animate=animate,
|
||||
speed=speed,
|
||||
duration=duration,
|
||||
easing=easing,
|
||||
)
|
||||
|
||||
def scroll_up(
|
||||
@@ -1335,6 +1381,7 @@ class Widget(DOMNode):
|
||||
animate: bool = True,
|
||||
speed: float | None = None,
|
||||
duration: float | None = None,
|
||||
easing: EasingFunction | str | None = None,
|
||||
) -> bool:
|
||||
"""Scroll one line up.
|
||||
|
||||
@@ -1342,13 +1389,19 @@ class Widget(DOMNode):
|
||||
animate (bool, optional): Animate scroll. Defaults to True.
|
||||
speed (float | None, optional): Speed of scroll if animate is True. Or None to use duration.
|
||||
duration (float | None, optional): Duration of animation, if animate is True and speed is None.
|
||||
easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
|
||||
which will result in Textual choosing the configured default scrolling easing function.
|
||||
|
||||
Returns:
|
||||
bool: True if any scrolling was done.
|
||||
|
||||
"""
|
||||
return self.scroll_to(
|
||||
y=self.scroll_target_y - 1, animate=animate, speed=speed, duration=duration
|
||||
y=self.scroll_target_y - 1,
|
||||
animate=animate,
|
||||
speed=speed,
|
||||
duration=duration,
|
||||
easing=easing,
|
||||
)
|
||||
|
||||
def scroll_page_up(
|
||||
@@ -1357,6 +1410,7 @@ class Widget(DOMNode):
|
||||
animate: bool = True,
|
||||
speed: float | None = None,
|
||||
duration: float | None = None,
|
||||
easing: EasingFunction | str | None = None,
|
||||
) -> bool:
|
||||
"""Scroll one page up.
|
||||
|
||||
@@ -1364,6 +1418,8 @@ class Widget(DOMNode):
|
||||
animate (bool, optional): Animate scroll. Defaults to True.
|
||||
speed (float | None, optional): Speed of scroll if animate is True. Or None to use duration.
|
||||
duration (float | None, optional): Duration of animation, if animate is True and speed is None.
|
||||
easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
|
||||
which will result in Textual choosing the configured default scrolling easing function.
|
||||
|
||||
Returns:
|
||||
bool: True if any scrolling was done.
|
||||
@@ -1374,6 +1430,7 @@ class Widget(DOMNode):
|
||||
animate=animate,
|
||||
speed=speed,
|
||||
duration=duration,
|
||||
easing=easing,
|
||||
)
|
||||
|
||||
def scroll_page_down(
|
||||
@@ -1382,6 +1439,7 @@ class Widget(DOMNode):
|
||||
animate: bool = True,
|
||||
speed: float | None = None,
|
||||
duration: float | None = None,
|
||||
easing: EasingFunction | str | None = None,
|
||||
) -> bool:
|
||||
"""Scroll one page down.
|
||||
|
||||
@@ -1389,6 +1447,8 @@ class Widget(DOMNode):
|
||||
animate (bool, optional): Animate scroll. Defaults to True.
|
||||
speed (float | None, optional): Speed of scroll if animate is True. Or None to use duration.
|
||||
duration (float | None, optional): Duration of animation, if animate is True and speed is None.
|
||||
easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
|
||||
which will result in Textual choosing the configured default scrolling easing function.
|
||||
|
||||
Returns:
|
||||
bool: True if any scrolling was done.
|
||||
@@ -1399,6 +1459,7 @@ class Widget(DOMNode):
|
||||
animate=animate,
|
||||
speed=speed,
|
||||
duration=duration,
|
||||
easing=easing,
|
||||
)
|
||||
|
||||
def scroll_page_left(
|
||||
@@ -1407,6 +1468,7 @@ class Widget(DOMNode):
|
||||
animate: bool = True,
|
||||
speed: float | None = None,
|
||||
duration: float | None = None,
|
||||
easing: EasingFunction | str | None = None,
|
||||
) -> bool:
|
||||
"""Scroll one page left.
|
||||
|
||||
@@ -1414,6 +1476,8 @@ class Widget(DOMNode):
|
||||
animate (bool, optional): Animate scroll. Defaults to True.
|
||||
speed (float | None, optional): Speed of scroll if animate is True. Or None to use duration.
|
||||
duration (float | None, optional): Duration of animation, if animate is True and speed is None.
|
||||
easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
|
||||
which will result in Textual choosing the configured default scrolling easing function.
|
||||
|
||||
Returns:
|
||||
bool: True if any scrolling was done.
|
||||
@@ -1426,6 +1490,7 @@ class Widget(DOMNode):
|
||||
animate=animate,
|
||||
speed=speed,
|
||||
duration=duration,
|
||||
easing=easing,
|
||||
)
|
||||
|
||||
def scroll_page_right(
|
||||
@@ -1434,6 +1499,7 @@ class Widget(DOMNode):
|
||||
animate: bool = True,
|
||||
speed: float | None = None,
|
||||
duration: float | None = None,
|
||||
easing: EasingFunction | str | None = None,
|
||||
) -> bool:
|
||||
"""Scroll one page right.
|
||||
|
||||
@@ -1441,6 +1507,8 @@ class Widget(DOMNode):
|
||||
animate (bool, optional): Animate scroll. Defaults to True.
|
||||
speed (float | None, optional): Speed of scroll if animate is True. Or None to use duration.
|
||||
duration (float | None, optional): Duration of animation, if animate is True and speed is None.
|
||||
easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
|
||||
which will result in Textual choosing the configured default scrolling easing function.
|
||||
|
||||
Returns:
|
||||
bool: True if any scrolling was done.
|
||||
@@ -1453,6 +1521,7 @@ class Widget(DOMNode):
|
||||
animate=animate,
|
||||
speed=speed,
|
||||
duration=duration,
|
||||
easing=easing,
|
||||
)
|
||||
|
||||
def scroll_to_widget(
|
||||
@@ -1462,6 +1531,7 @@ class Widget(DOMNode):
|
||||
animate: bool = True,
|
||||
speed: float | None = None,
|
||||
duration: float | None = None,
|
||||
easing: EasingFunction | str | None = None,
|
||||
top: bool = False,
|
||||
) -> bool:
|
||||
"""Scroll scrolling to bring a widget in to view.
|
||||
@@ -1471,6 +1541,9 @@ class Widget(DOMNode):
|
||||
animate (bool, optional): True to animate, or False to jump. Defaults to True.
|
||||
speed (float | None, optional): Speed of scroll if animate is True. Or None to use duration.
|
||||
duration (float | None, optional): Duration of animation, if animate is True and speed is None.
|
||||
easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
|
||||
which will result in Textual choosing the configured default scrolling easing function.
|
||||
top (bool, optional): Scroll widget to top of container. Defaults to False.
|
||||
|
||||
Returns:
|
||||
bool: True if any scrolling has occurred in any descendant, otherwise False.
|
||||
@@ -1489,6 +1562,7 @@ class Widget(DOMNode):
|
||||
speed=speed,
|
||||
duration=duration,
|
||||
top=top,
|
||||
easing=easing,
|
||||
)
|
||||
if scroll_offset:
|
||||
scrolled = True
|
||||
@@ -1515,6 +1589,7 @@ class Widget(DOMNode):
|
||||
animate: bool = True,
|
||||
speed: float | None = None,
|
||||
duration: float | None = None,
|
||||
easing: EasingFunction | str | None = None,
|
||||
top: bool = False,
|
||||
) -> Offset:
|
||||
"""Scrolls a given region in to view, if required.
|
||||
@@ -1528,6 +1603,8 @@ class Widget(DOMNode):
|
||||
animate (bool, optional): True to animate, or False to jump. Defaults to True.
|
||||
speed (float | None, optional): Speed of scroll if animate is True. Or None to use duration.
|
||||
duration (float | None, optional): Duration of animation, if animate is True and speed is None.
|
||||
easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
|
||||
which will result in Textual choosing the configured default scrolling easing function.
|
||||
top (bool, optional): Scroll region to top of container. Defaults to False.
|
||||
|
||||
Returns:
|
||||
@@ -1555,6 +1632,7 @@ class Widget(DOMNode):
|
||||
animate=animate if (abs(delta_y) > 1 or delta_x) else False,
|
||||
speed=speed,
|
||||
duration=duration,
|
||||
easing=easing,
|
||||
)
|
||||
return delta
|
||||
|
||||
@@ -1565,6 +1643,7 @@ class Widget(DOMNode):
|
||||
speed: float | None = None,
|
||||
duration: float | None = None,
|
||||
top: bool = False,
|
||||
easing: EasingFunction | str | None = None,
|
||||
) -> None:
|
||||
"""Scroll the container to make this widget visible.
|
||||
|
||||
@@ -1573,6 +1652,8 @@ class Widget(DOMNode):
|
||||
speed (float | None, optional): _description_. Defaults to None.
|
||||
duration (float | None, optional): _description_. Defaults to None.
|
||||
top (bool, optional): Scroll to top of container. Defaults to False.
|
||||
easing (EasingFunction | str | None, optional): An easing method for the scrolling animation. Defaults to "None",
|
||||
which will result in Textual choosing the configured default scrolling easing function.
|
||||
"""
|
||||
parent = self.parent
|
||||
if isinstance(parent, Widget):
|
||||
@@ -1583,6 +1664,7 @@ class Widget(DOMNode):
|
||||
speed=speed,
|
||||
duration=duration,
|
||||
top=top,
|
||||
easing=easing,
|
||||
)
|
||||
|
||||
def __init_subclass__(
|
||||
@@ -1910,7 +1992,7 @@ class Widget(DOMNode):
|
||||
|
||||
def remove(self) -> None:
|
||||
"""Remove the Widget from the DOM (effectively deleting it)"""
|
||||
self.app.post_message_no_wait(events.Remove(self, widget=self))
|
||||
self.app.post_message_no_wait(events.Remove(self, widgets=[self]))
|
||||
|
||||
def render(self) -> RenderableType:
|
||||
"""Get renderable for widget.
|
||||
@@ -2059,14 +2141,14 @@ class Widget(DOMNode):
|
||||
self.mouse_over = True
|
||||
|
||||
def _on_focus(self, event: events.Focus) -> None:
|
||||
for node in self.ancestors:
|
||||
for node in self.ancestors_with_self:
|
||||
if node._has_focus_within:
|
||||
self.app.update_styles(node)
|
||||
self.has_focus = True
|
||||
self.refresh()
|
||||
|
||||
def _on_blur(self, event: events.Blur) -> None:
|
||||
if any(node._has_focus_within for node in self.ancestors):
|
||||
if any(node._has_focus_within for node in self.ancestors_with_self):
|
||||
self.app.update_styles(self)
|
||||
self.has_focus = False
|
||||
self.refresh()
|
||||
|
||||
@@ -10,7 +10,7 @@ async def test_unmount():
|
||||
|
||||
class UnmountWidget(Container):
|
||||
def on_unmount(self, event: events.Unmount):
|
||||
unmount_ids.append(f"{self.__class__.__name__}#{self.id}")
|
||||
unmount_ids.append(f"{self.__class__.__name__}#{self.id}-{self.parent is not None}-{len(self.children)}")
|
||||
|
||||
class MyScreen(Screen):
|
||||
def compose(self) -> ComposeResult:
|
||||
@@ -36,13 +36,13 @@ async def test_unmount():
|
||||
await pilot.exit(None)
|
||||
|
||||
expected = [
|
||||
"UnmountWidget#bar1",
|
||||
"UnmountWidget#bar2",
|
||||
"UnmountWidget#baz1",
|
||||
"UnmountWidget#baz2",
|
||||
"UnmountWidget#bar",
|
||||
"UnmountWidget#baz",
|
||||
"UnmountWidget#top",
|
||||
"UnmountWidget#bar1-True-0",
|
||||
"UnmountWidget#bar2-True-0",
|
||||
"UnmountWidget#baz1-True-0",
|
||||
"UnmountWidget#baz2-True-0",
|
||||
"UnmountWidget#bar-True-0",
|
||||
"UnmountWidget#baz-True-0",
|
||||
"UnmountWidget#top-True-0",
|
||||
"MyScreen#main",
|
||||
]
|
||||
|
||||
|
||||
127
tests/test_widget_removing.py
Normal file
127
tests/test_widget_removing.py
Normal file
@@ -0,0 +1,127 @@
|
||||
import asyncio
|
||||
from textual.app import App
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Static, Button
|
||||
from textual.containers import Container
|
||||
|
||||
async def await_remove_standin():
|
||||
"""Standin function for awaiting removal.
|
||||
|
||||
These tests are being written so that we can go on and make remove
|
||||
awaitable, but it would be good to have some tests in place *before* we
|
||||
make that change, but the tests need to await remove to be useful tests.
|
||||
So to get around that bootstrap issue, we just use this function as a
|
||||
standin until we can swap over.
|
||||
"""
|
||||
await asyncio.sleep(0) # Until we can await remove.
|
||||
|
||||
async def test_remove_single_widget():
|
||||
"""It should be possible to the only widget on a screen."""
|
||||
async with App().run_test() as pilot:
|
||||
await pilot.app.mount(Static())
|
||||
assert len(pilot.app.screen.children) == 1
|
||||
pilot.app.query_one(Static).remove()
|
||||
await await_remove_standin()
|
||||
assert len(pilot.app.screen.children) == 0
|
||||
|
||||
async def test_many_remove_all_widgets():
|
||||
"""It should be possible to remove all widgets on a multi-widget screen."""
|
||||
async with App().run_test() as pilot:
|
||||
await pilot.app.mount(*[Static() for _ in range(1000)])
|
||||
assert len(pilot.app.screen.children) == 1000
|
||||
pilot.app.query(Static).remove()
|
||||
await await_remove_standin()
|
||||
assert len(pilot.app.screen.children) == 0
|
||||
|
||||
async def test_many_remove_some_widgets():
|
||||
"""It should be possible to remove some widgets on a multi-widget screen."""
|
||||
async with App().run_test() as pilot:
|
||||
await pilot.app.mount(*[Static(id=f"is-{n%2}") for n in range(1000)])
|
||||
assert len(pilot.app.screen.children) == 1000
|
||||
pilot.app.query("#is-0").remove()
|
||||
await await_remove_standin()
|
||||
assert len(pilot.app.screen.children) == 500
|
||||
|
||||
async def test_remove_branch():
|
||||
"""It should be possible to remove a whole branch in the DOM."""
|
||||
async with App().run_test() as pilot:
|
||||
await pilot.app.mount(
|
||||
Container(
|
||||
Container(
|
||||
Container(
|
||||
Container(
|
||||
Container(
|
||||
Static()
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
Static(),
|
||||
Container(
|
||||
Container(
|
||||
Container(
|
||||
Container(
|
||||
Container(
|
||||
Static()
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
assert len(pilot.app.screen.walk_children(with_self=False)) == 13
|
||||
pilot.app.screen.children[0].remove()
|
||||
await await_remove_standin()
|
||||
assert len(pilot.app.screen.walk_children(with_self=False)) == 7
|
||||
|
||||
async def test_remove_overlap():
|
||||
"""It should be possible to remove an overlapping collection of widgets."""
|
||||
async with App().run_test() as pilot:
|
||||
await pilot.app.mount(
|
||||
Container(
|
||||
Container(
|
||||
Container(
|
||||
Container(
|
||||
Container(
|
||||
Static()
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
Static(),
|
||||
Container(
|
||||
Container(
|
||||
Container(
|
||||
Container(
|
||||
Container(
|
||||
Static()
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
)
|
||||
assert len(pilot.app.screen.walk_children(with_self=False)) == 13
|
||||
pilot.app.query(Container).remove()
|
||||
await await_remove_standin()
|
||||
assert len(pilot.app.screen.walk_children(with_self=False)) == 1
|
||||
|
||||
async def test_remove_move_focus():
|
||||
"""Removing a focused widget should settle focus elsewhere."""
|
||||
async with App().run_test() as pilot:
|
||||
buttons = [ Button(str(n)) for n in range(10)]
|
||||
await pilot.app.mount(Container(*buttons[:5]), Container(*buttons[5:]))
|
||||
assert len(pilot.app.screen.children) == 2
|
||||
assert len(pilot.app.screen.walk_children(with_self=False)) == 12
|
||||
assert pilot.app.focused is None
|
||||
await pilot.press( "tab" )
|
||||
assert pilot.app.focused is not None
|
||||
assert pilot.app.focused == buttons[0]
|
||||
pilot.app.screen.children[0].remove()
|
||||
await await_remove_standin()
|
||||
assert len(pilot.app.screen.children) == 1
|
||||
assert len(pilot.app.screen.walk_children(with_self=False)) == 6
|
||||
assert pilot.app.focused is not None
|
||||
assert pilot.app.focused == buttons[9]
|
||||
Reference in New Issue
Block a user