Merge branch 'main' into call-later

This commit is contained in:
Will McGugan
2022-11-10 15:24:31 +00:00
committed by GitHub
9 changed files with 341 additions and 108 deletions

View File

@@ -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

View File

@@ -128,3 +128,4 @@ EASING = {
}
DEFAULT_EASING = "in_out_cubic"
DEFAULT_SCROLL_EASING = "out_cubic"

View File

@@ -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.

View File

@@ -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(

View File

@@ -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.

View File

@@ -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)

View File

@@ -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()

View File

@@ -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",
]

View 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]