From 4794b11cd1ea05fb59a14f68a3a15d5114507fe7 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 8 Nov 2022 11:23:49 +0000 Subject: [PATCH 01/83] Remove a type-checking error for the result of _find_mount_point --- src/textual/widget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index aacf7b82e..f7d967294 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -422,7 +422,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 self.MountError(f"{spot!r} is not a child of {self!r}") from None From 2415554a655526654e1fc07ede1cec7b1e7eb35a Mon Sep 17 00:00:00 2001 From: overflowy Date: Tue, 8 Nov 2022 12:55:36 +0100 Subject: [PATCH 02/83] Lift returned value closer to condition, drop else --- src/textual/css/scalar.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/textual/css/scalar.py b/src/textual/css/scalar.py index bb4368514..3f6c4cb92 100644 --- a/src/textual/css/scalar.py +++ b/src/textual/css/scalar.py @@ -259,13 +259,12 @@ class Scalar(NamedTuple): """ if token.lower() == "auto": scalar = cls(1.0, Unit.AUTO, Unit.AUTO) - else: - match = _MATCH_SCALAR(token) - if match is None: - raise ScalarParseError(f"{token!r} is not a valid scalar") - value, unit_name = match.groups() - scalar = cls(float(value), SYMBOL_UNIT[unit_name or ""], percent_unit) - return scalar + return scalar + match = _MATCH_SCALAR(token) + if match is None: + raise ScalarParseError(f"{token!r} is not a valid scalar") + value, unit_name = match.groups() + scalar = cls(float(value), SYMBOL_UNIT[unit_name or ""], percent_unit) @lru_cache(maxsize=4096) def resolve_dimension( From 65ad5953ad893c8fe7e6d081e9da83ade427139d Mon Sep 17 00:00:00 2001 From: overflowy Date: Tue, 8 Nov 2022 12:56:46 +0100 Subject: [PATCH 03/83] Drop unnecessary else --- src/textual/css/styles.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 8efd8c22f..085fd2b9e 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -556,10 +556,9 @@ class Styles(StylesBase): """ if value is None: return self._rules.pop(rule, None) is not None - else: - current = self._rules.get(rule) - self._rules[rule] = value - return current != value + current = self._rules.get(rule) + self._rules[rule] = value + return current != value def get_rule(self, rule: str, default: object = None) -> object: return self._rules.get(rule, default) From 33116aa8c55fb6cfe7786e2547823b6da3f8b771 Mon Sep 17 00:00:00 2001 From: overflowy Date: Tue, 8 Nov 2022 13:00:24 +0100 Subject: [PATCH 04/83] Move repeated code outside conditional statement --- src/textual/css/stylesheet.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/textual/css/stylesheet.py b/src/textual/css/stylesheet.py index b91273eb4..5c7093d00 100644 --- a/src/textual/css/stylesheet.py +++ b/src/textual/css/stylesheet.py @@ -71,17 +71,12 @@ class StylesheetErrors: if token.referenced_by: line_idx, col_idx = token.referenced_by.location - line_no, col_no = line_idx + 1, col_idx + 1 - path_string = ( - f"{path.absolute() if path else filename}:{line_no}:{col_no}" - ) else: line_idx, col_idx = token.location - line_no, col_no = line_idx + 1, col_idx + 1 - path_string = ( - f"{path.absolute() if path else filename}:{line_no}:{col_no}" - ) - + line_no, col_no = line_idx + 1, col_idx + 1 + path_string = ( + f"{path.absolute() if path else filename}:{line_no}:{col_no}" + ) link_style = Style( link=f"file://{path.absolute()}", color="red", From 3148b605218eb59e3886109073a14045e070ad77 Mon Sep 17 00:00:00 2001 From: overflowy Date: Tue, 8 Nov 2022 22:37:21 +0100 Subject: [PATCH 05/83] Revert "Lift returned value closer to condition, drop else" This reverts commit 2415554a655526654e1fc07ede1cec7b1e7eb35a. --- src/textual/css/scalar.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/textual/css/scalar.py b/src/textual/css/scalar.py index 3f6c4cb92..bb4368514 100644 --- a/src/textual/css/scalar.py +++ b/src/textual/css/scalar.py @@ -259,12 +259,13 @@ class Scalar(NamedTuple): """ if token.lower() == "auto": scalar = cls(1.0, Unit.AUTO, Unit.AUTO) - return scalar - match = _MATCH_SCALAR(token) - if match is None: - raise ScalarParseError(f"{token!r} is not a valid scalar") - value, unit_name = match.groups() - scalar = cls(float(value), SYMBOL_UNIT[unit_name or ""], percent_unit) + else: + match = _MATCH_SCALAR(token) + if match is None: + raise ScalarParseError(f"{token!r} is not a valid scalar") + value, unit_name = match.groups() + scalar = cls(float(value), SYMBOL_UNIT[unit_name or ""], percent_unit) + return scalar @lru_cache(maxsize=4096) def resolve_dimension( From 394bdec362d5054ffcabfd0d3d5d5492eead9ded Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 9 Nov 2022 11:10:42 +0000 Subject: [PATCH 06/83] Change the Remove event so that it takes a list of widgets to remove This commit sets things up so that a list of widgets can be used, but also then goes on to ignore the fact that there is a list, only removing the first one in the list. That's a feature, not a but -- here I'm just testing that we can make this move and nothing breaks. Next up is to allow removing all of the widgets while holding off on any layout refresh until all removals have been performed. --- src/textual/app.py | 2 +- src/textual/events.py | 6 +++--- src/textual/widget.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index dba40a935..9d53a4aac 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1822,7 +1822,7 @@ class App(Generic[ReturnType], DOMNode): await self.screen.post_message(event) async def _on_remove(self, event: events.Remove) -> None: - widget = event.widget + widget = event.widgets[0] parent = widget.parent remove_widgets = widget.walk_children( diff --git a/src/textual/events.py b/src/textual/events.py index 524673cd4..d34b206ad 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -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) diff --git a/src/textual/widget.py b/src/textual/widget.py index b4723487c..e581ea3e5 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1910,7 +1910,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. From aa6364112e61f5c1e05e4ec54203cadd2a60415c Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 9 Nov 2022 11:14:43 +0000 Subject: [PATCH 07/83] Add easing parameter to Widget.scroll_* methods --- src/textual/widget.py | 78 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 70 insertions(+), 8 deletions(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index b4723487c..b5fa4f28d 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1136,6 +1136,7 @@ class Widget(DOMNode): animate: bool = True, speed: float | None = None, duration: float | None = None, + easing: EasingFunction | str = "out_cubic", ) -> bool: """Scroll to a given (absolute) coordinate, optionally animating. @@ -1145,6 +1146,7 @@ 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, optional): An easing method for the scrolling animation. Defaults to "out_cubic". Returns: bool: True if the scroll position changed, otherwise False. @@ -1162,7 +1164,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 +1175,7 @@ class Widget(DOMNode): self.scroll_target_y, speed=speed, duration=duration, - easing="out_cubic", + easing=easing, ) scrolled_y = True @@ -1197,6 +1199,7 @@ class Widget(DOMNode): animate: bool = True, speed: float | None = None, duration: float | None = None, + easing: EasingFunction | str = "out_cubic", ) -> bool: """Scroll relative to current position. @@ -1206,6 +1209,7 @@ 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, optional): An easing method for the scrolling animation. Defaults to "out_cubic". Returns: bool: True if the scroll position changed, otherwise False. @@ -1216,6 +1220,7 @@ class Widget(DOMNode): animate=animate, speed=speed, duration=duration, + easing=easing, ) def scroll_home( @@ -1224,6 +1229,7 @@ class Widget(DOMNode): animate: bool = True, speed: float | None = None, duration: float | None = None, + easing: EasingFunction | str = "out_cubic", ) -> bool: """Scroll to home position. @@ -1231,13 +1237,16 @@ 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, optional): An easing method for the scrolling animation. Defaults to "out_cubic". 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 +1254,7 @@ class Widget(DOMNode): animate: bool = True, speed: float | None = None, duration: float | None = None, + easing: EasingFunction | str = "out_cubic", ) -> bool: """Scroll to the end of the container. @@ -1252,6 +1262,7 @@ 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, optional): An easing method for the scrolling animation. Defaults to "out_cubic". Returns: bool: True if any scrolling was done. @@ -1260,7 +1271,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 +1285,7 @@ class Widget(DOMNode): animate: bool = True, speed: float | None = None, duration: float | None = None, + easing: EasingFunction | str = "out_cubic", ) -> bool: """Scroll one cell left. @@ -1276,13 +1293,18 @@ 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, optional): An easing method for the scrolling animation. Defaults to "out_cubic". 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 +1313,7 @@ class Widget(DOMNode): animate: bool = True, speed: float | None = None, duration: float | None = None, + easing: EasingFunction | str = "out_cubic", ) -> bool: """Scroll on cell right. @@ -1298,13 +1321,18 @@ 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, optional): An easing method for the scrolling animation. Defaults to "out_cubic". 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 +1341,7 @@ class Widget(DOMNode): animate: bool = True, speed: float | None = None, duration: float | None = None, + easing: EasingFunction | str = "out_cubic", ) -> bool: """Scroll one line down. @@ -1320,13 +1349,18 @@ 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, optional): An easing method for the scrolling animation. Defaults to "out_cubic". 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 +1369,7 @@ class Widget(DOMNode): animate: bool = True, speed: float | None = None, duration: float | None = None, + easing: EasingFunction | str = "out_cubic", ) -> bool: """Scroll one line up. @@ -1342,13 +1377,18 @@ 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, optional): An easing method for the scrolling animation. Defaults to "out_cubic". 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 +1397,7 @@ class Widget(DOMNode): animate: bool = True, speed: float | None = None, duration: float | None = None, + easing: EasingFunction | str = "out_cubic", ) -> bool: """Scroll one page up. @@ -1364,6 +1405,7 @@ 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, optional): An easing method for the scrolling animation. Defaults to "out_cubic". Returns: bool: True if any scrolling was done. @@ -1374,6 +1416,7 @@ class Widget(DOMNode): animate=animate, speed=speed, duration=duration, + easing=easing, ) def scroll_page_down( @@ -1382,6 +1425,7 @@ class Widget(DOMNode): animate: bool = True, speed: float | None = None, duration: float | None = None, + easing: EasingFunction | str = "out_cubic", ) -> bool: """Scroll one page down. @@ -1389,6 +1433,7 @@ 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, optional): An easing method for the scrolling animation. Defaults to "out_cubic". Returns: bool: True if any scrolling was done. @@ -1399,6 +1444,7 @@ class Widget(DOMNode): animate=animate, speed=speed, duration=duration, + easing=easing, ) def scroll_page_left( @@ -1407,6 +1453,7 @@ class Widget(DOMNode): animate: bool = True, speed: float | None = None, duration: float | None = None, + easing: EasingFunction | str = "out_cubic", ) -> bool: """Scroll one page left. @@ -1414,6 +1461,7 @@ 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, optional): An easing method for the scrolling animation. Defaults to "out_cubic". Returns: bool: True if any scrolling was done. @@ -1426,6 +1474,7 @@ class Widget(DOMNode): animate=animate, speed=speed, duration=duration, + easing=easing, ) def scroll_page_right( @@ -1434,6 +1483,7 @@ class Widget(DOMNode): animate: bool = True, speed: float | None = None, duration: float | None = None, + easing: EasingFunction | str = "out_cubic", ) -> bool: """Scroll one page right. @@ -1441,6 +1491,7 @@ 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, optional): An easing method for the scrolling animation. Defaults to "out_cubic". Returns: bool: True if any scrolling was done. @@ -1453,6 +1504,7 @@ class Widget(DOMNode): animate=animate, speed=speed, duration=duration, + easing=easing, ) def scroll_to_widget( @@ -1462,6 +1514,7 @@ class Widget(DOMNode): animate: bool = True, speed: float | None = None, duration: float | None = None, + easing: EasingFunction | str = "out_cubic", top: bool = False, ) -> bool: """Scroll scrolling to bring a widget in to view. @@ -1471,6 +1524,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, optional): An easing method for the scrolling animation. Defaults to "out_cubic". + 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 +1544,7 @@ class Widget(DOMNode): speed=speed, duration=duration, top=top, + easing=easing, ) if scroll_offset: scrolled = True @@ -1515,6 +1571,7 @@ class Widget(DOMNode): animate: bool = True, speed: float | None = None, duration: float | None = None, + easing: EasingFunction | str = "out_cubic", top: bool = False, ) -> Offset: """Scrolls a given region in to view, if required. @@ -1528,6 +1585,7 @@ 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, optional): An easing method for the scrolling animation. Defaults to "out_cubic". top (bool, optional): Scroll region to top of container. Defaults to False. Returns: @@ -1555,6 +1613,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 +1624,7 @@ class Widget(DOMNode): speed: float | None = None, duration: float | None = None, top: bool = False, + easing: EasingFunction | str = "out_cubic", ) -> None: """Scroll the container to make this widget visible. @@ -1573,6 +1633,7 @@ 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, optional): An easing method for the scrolling animation. Defaults to "out_cubic". """ parent = self.parent if isinstance(parent, Widget): @@ -1583,6 +1644,7 @@ class Widget(DOMNode): speed=speed, duration=duration, top=top, + easing=easing, ) def __init_subclass__( From bad2c0add872611cddcc5e28ce5d96c8b9efbb52 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 9 Nov 2022 11:18:43 +0000 Subject: [PATCH 08/83] Update changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2978f79a7..104139512 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) 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 + ## [0.4.0] - 2022-11-08 https://textual.textualize.io/blog/2022/11/08/version-040/#version-040 From dd5c0e612a419b57b41f5971fceef838c71a3fc8 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 9 Nov 2022 12:06:14 +0000 Subject: [PATCH 09/83] make watchers instant --- src/textual/reactive.py | 75 +++++++++++++++++++++++------------------ tests/test_reactive.py | 26 ++++++++++++++ 2 files changed, 68 insertions(+), 33 deletions(-) create mode 100644 tests/test_reactive.py diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 2ee3ba5df..6e281e2df 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -2,7 +2,16 @@ from __future__ import annotations from functools import partial from inspect import isawaitable -from typing import TYPE_CHECKING, Any, Callable, Generic, Type, TypeVar, Union +from typing import ( + TYPE_CHECKING, + Any, + Awaitable, + Callable, + Generic, + Type, + TypeVar, + Union, +) from . import events from ._callback import count_parameters, invoke @@ -146,6 +155,7 @@ class Reactive(Generic[ReactiveType]): setattr(owner, f"_default_{name}", default) def __get__(self, obj: Reactable, obj_type: type[object]) -> ReactiveType: + _rich_traceback_omit = True value: _NotSet | ReactiveType = getattr(obj, self.internal_name, _NOT_SET) if isinstance(value, _NotSet): # No value present, we need to set the default @@ -160,6 +170,7 @@ class Reactive(Generic[ReactiveType]): return value def __set__(self, obj: Reactable, value: ReactiveType) -> None: + _rich_traceback_omit = True name = self.name current_value = getattr(obj, name) # Check for validate function @@ -193,55 +204,50 @@ class Reactive(Generic[ReactiveType]): old_value (Any): The old (previous) value of the attribute. first_set (bool, optional): True if this is the first time setting the value. Defaults to False. """ + _rich_traceback_omit = True # Get the current value. internal_name = f"_reactive_{name}" value = getattr(obj, internal_name) - async def update_watcher( - obj: Reactable, watch_function: Callable, old_value: Any, value: Any + async def await_watcher(awaitable: Awaitable) -> None: + """Coroutine to await an awaitable returned from a watcher""" + _rich_traceback_omit = True + await awaitable + # Watcher may have changed the state, so run compute again + obj.post_message_no_wait( + events.Callback(sender=obj, callback=partial(Reactive._compute, obj)) + ) + + def invoke_watcher( + watch_function: Callable, old_value: object, value: object ) -> None: - """Call watch function, and run compute. + """Invoke a watch function. Args: - obj (Reactable): Reactable object. - watch_function (Callable): Watch method. - old_value (Any): Old value. - value (Any): new value. + watch_function (Callable): A watch function, which may be sync or async. + old_value (object): The old value of the attribute. + value (object): The new value of the attribute. """ - _rich_traceback_guard = True - # Call watch with one or two parameters + _rich_traceback_omit = True if count_parameters(watch_function) == 2: watch_result = watch_function(old_value, value) else: watch_result = watch_function(value) - # Optionally await result if isawaitable(watch_result): - await watch_result - # Run computes - await Reactive._compute(obj) + # Result is awaitable, so we need to await it within an async context + obj.post_message_no_wait( + events.Callback( + sender=obj, callback=partial(await_watcher, watch_result) + ) + ) - # Check for watch method watch_function = getattr(obj, f"watch_{name}", None) if callable(watch_function): - # Post a callback message, so we can call the watch method in an orderly async manner - obj.post_message_no_wait( - events.Callback( - sender=obj, - callback=partial( - update_watcher, obj, watch_function, old_value, value - ), - ) - ) + invoke_watcher(watch_function, old_value, value) - # Check for watchers set via `watch` watchers: list[Callable] = getattr(obj, "__watchers", {}).get(name, []) for watcher in watchers: - obj.post_message_no_wait( - events.Callback( - sender=obj, - callback=partial(update_watcher, obj, watcher, old_value, value), - ) - ) + invoke_watcher(watcher, old_value, value) # Run computes obj.post_message_no_wait( @@ -301,10 +307,13 @@ class var(Reactive[ReactiveType]): Args: default (ReactiveType | Callable[[], ReactiveType]): A default value or callable that returns a default. + init (bool, optional): Call watchers on initialize (post mount). Defaults to True. """ - def __init__(self, default: ReactiveType | Callable[[], ReactiveType]) -> None: - super().__init__(default, layout=False, repaint=False, init=True) + def __init__( + self, default: ReactiveType | Callable[[], ReactiveType], init: bool = True + ) -> None: + super().__init__(default, layout=False, repaint=False, init=init) def watch( diff --git a/tests/test_reactive.py b/tests/test_reactive.py new file mode 100644 index 000000000..7158b46f2 --- /dev/null +++ b/tests/test_reactive.py @@ -0,0 +1,26 @@ +from textual.app import App, ComposeResult +from textual.reactive import reactive + + +class WatchApp(App): + + count = reactive(0, init=False) + + test_count = 0 + + def watch_count(self, value: int) -> None: + self.test_count = value + + +async def test_watch(): + """Test that changes to a watched reactive attribute happen immediately.""" + app = WatchApp() + async with app.run_test(): + app.count += 1 + assert app.test_count == 1 + app.count += 1 + assert app.test_count == 2 + app.count -= 1 + assert app.test_count == 1 + app.count -= 1 + assert app.test_count == 0 From f9b2434fb2a5e92725a11c1fa43d1586bfc41c86 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 9 Nov 2022 12:08:56 +0000 Subject: [PATCH 10/83] changelog --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2978f79a7..047026758 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [0.5.0] - Unreleased + +### Changed + +- Watchers are now called immediately when setting the attribute if they are synchronous. https://github.com/Textualize/textual/pull/1145 + ## [0.4.0] - 2022-11-08 https://textual.textualize.io/blog/2022/11/08/version-040/#version-040 From 395616f3bc9c6a096224bfa76516fa8e60efb924 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 9 Nov 2022 12:15:58 +0000 Subject: [PATCH 11/83] only compute when watcher runs --- src/textual/reactive.py | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 6e281e2df..9dac3bf80 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -220,13 +220,16 @@ class Reactive(Generic[ReactiveType]): def invoke_watcher( watch_function: Callable, old_value: object, value: object - ) -> None: + ) -> bool: """Invoke a watch function. Args: watch_function (Callable): A watch function, which may be sync or async. old_value (object): The old value of the attribute. value (object): The new value of the attribute. + + Returns: + bool: True if the watcher was run, or False if it was posted. """ _rich_traceback_omit = True if count_parameters(watch_function) == 2: @@ -240,19 +243,29 @@ class Reactive(Generic[ReactiveType]): sender=obj, callback=partial(await_watcher, watch_result) ) ) + return False + else: + return True + # Compute is only required if a watcher runs immediately, not if they were posted. + require_compute = False watch_function = getattr(obj, f"watch_{name}", None) if callable(watch_function): - invoke_watcher(watch_function, old_value, value) + require_compute = require_compute or invoke_watcher( + watch_function, old_value, value + ) watchers: list[Callable] = getattr(obj, "__watchers", {}).get(name, []) for watcher in watchers: - invoke_watcher(watcher, old_value, value) + require_compute = require_compute or invoke_watcher( + watcher, old_value, value + ) - # Run computes - obj.post_message_no_wait( - events.Callback(sender=obj, callback=partial(Reactive._compute, obj)) - ) + if require_compute: + # Run computes + obj.post_message_no_wait( + events.Callback(sender=obj, callback=partial(Reactive._compute, obj)) + ) @classmethod async def _compute(cls, obj: Reactable) -> None: From ec8bc40e315bb2bfefd23efc5272e7e339b97cb0 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 9 Nov 2022 12:19:13 +0000 Subject: [PATCH 12/83] remove flag --- src/textual/reactive.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 9dac3bf80..7cd88d630 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -165,7 +165,7 @@ class Reactive(Generic[ReactiveType]): # Set and return the value setattr(obj, self.internal_name, default_value) if self._init: - self._check_watchers(obj, self.name, default_value, first_set=True) + self._check_watchers(obj, self.name, default_value) return default_value return value @@ -187,15 +187,13 @@ class Reactive(Generic[ReactiveType]): # Store the internal value setattr(obj, self.internal_name, value) # Check all watchers - self._check_watchers(obj, name, current_value, first_set=first_set) + self._check_watchers(obj, name, current_value) # Refresh according to descriptor flags if self._layout or self._repaint: obj.refresh(repaint=self._repaint, layout=self._layout) @classmethod - def _check_watchers( - cls, obj: Reactable, name: str, old_value: Any, first_set: bool = False - ) -> None: + def _check_watchers(cls, obj: Reactable, name: str, old_value: Any): """Check watchers, and call watch methods / computes Args: From 5d6daf2a00a3da036d6c6094654d6d77c17365f1 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 9 Nov 2022 14:59:13 +0000 Subject: [PATCH 13/83] Extend the on_unmount unit tests Because some changes are going to be made to the way that removal of widgets happens, and because this will affect the linkage between parents and children, and because we don't want the current way of working to change... extend to the tests to be sure that when an unmount happens a widget has no children any more, but still knows about its parent. --- tests/test_unmount.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/tests/test_unmount.py b/tests/test_unmount.py index 7a20e83b6..3e6a5ed0f 100644 --- a/tests/test_unmount.py +++ b/tests/test_unmount.py @@ -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", ] From 20c037752be10695620fae1bdbddd95ac9902c87 Mon Sep 17 00:00:00 2001 From: Darren Burns Date: Wed, 9 Nov 2022 15:08:45 +0000 Subject: [PATCH 14/83] Allow scroll easing params to be Noneable for future configurability of defaults --- src/textual/_easing.py | 1 + src/textual/widget.py | 80 ++++++++++++++++++++++++++---------------- 2 files changed, 51 insertions(+), 30 deletions(-) diff --git a/src/textual/_easing.py b/src/textual/_easing.py index 994188ec5..007b643b2 100644 --- a/src/textual/_easing.py +++ b/src/textual/_easing.py @@ -128,3 +128,4 @@ EASING = { } DEFAULT_EASING = "in_out_cubic" +DEFAULT_SCROLL_EASING = "out_cubic" diff --git a/src/textual/widget.py b/src/textual/widget.py index b5fa4f28d..15164cc08 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -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 @@ -1136,7 +1137,7 @@ class Widget(DOMNode): animate: bool = True, speed: float | None = None, duration: float | None = None, - easing: EasingFunction | str = "out_cubic", + easing: EasingFunction | str | None = None, ) -> bool: """Scroll to a given (absolute) coordinate, optionally animating. @@ -1146,7 +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, optional): An easing method for the scrolling animation. Defaults to "out_cubic". + 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. @@ -1156,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: @@ -1199,7 +1205,7 @@ class Widget(DOMNode): animate: bool = True, speed: float | None = None, duration: float | None = None, - easing: EasingFunction | str = "out_cubic", + easing: EasingFunction | str | None = None, ) -> bool: """Scroll relative to current position. @@ -1209,7 +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, optional): An easing method for the scrolling animation. Defaults to "out_cubic". + 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. @@ -1229,7 +1236,7 @@ class Widget(DOMNode): animate: bool = True, speed: float | None = None, duration: float | None = None, - easing: EasingFunction | str = "out_cubic", + easing: EasingFunction | str | None = None, ) -> bool: """Scroll to home position. @@ -1237,7 +1244,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, optional): An easing method for the scrolling animation. Defaults to "out_cubic". + 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. @@ -1254,7 +1262,7 @@ class Widget(DOMNode): animate: bool = True, speed: float | None = None, duration: float | None = None, - easing: EasingFunction | str = "out_cubic", + easing: EasingFunction | str | None = None, ) -> bool: """Scroll to the end of the container. @@ -1262,7 +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, optional): An easing method for the scrolling animation. Defaults to "out_cubic". + 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. @@ -1285,7 +1294,7 @@ class Widget(DOMNode): animate: bool = True, speed: float | None = None, duration: float | None = None, - easing: EasingFunction | str = "out_cubic", + easing: EasingFunction | str | None = None, ) -> bool: """Scroll one cell left. @@ -1293,7 +1302,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, optional): An easing method for the scrolling animation. Defaults to "out_cubic". + 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. @@ -1313,7 +1323,7 @@ class Widget(DOMNode): animate: bool = True, speed: float | None = None, duration: float | None = None, - easing: EasingFunction | str = "out_cubic", + easing: EasingFunction | str | None = None, ) -> bool: """Scroll on cell right. @@ -1321,7 +1331,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, optional): An easing method for the scrolling animation. Defaults to "out_cubic". + 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. @@ -1341,7 +1352,7 @@ class Widget(DOMNode): animate: bool = True, speed: float | None = None, duration: float | None = None, - easing: EasingFunction | str = "out_cubic", + easing: EasingFunction | str | None = None, ) -> bool: """Scroll one line down. @@ -1349,7 +1360,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, optional): An easing method for the scrolling animation. Defaults to "out_cubic". + 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. @@ -1369,7 +1381,7 @@ class Widget(DOMNode): animate: bool = True, speed: float | None = None, duration: float | None = None, - easing: EasingFunction | str = "out_cubic", + easing: EasingFunction | str | None = None, ) -> bool: """Scroll one line up. @@ -1377,7 +1389,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, optional): An easing method for the scrolling animation. Defaults to "out_cubic". + 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. @@ -1397,7 +1410,7 @@ class Widget(DOMNode): animate: bool = True, speed: float | None = None, duration: float | None = None, - easing: EasingFunction | str = "out_cubic", + easing: EasingFunction | str | None = None, ) -> bool: """Scroll one page up. @@ -1405,7 +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, optional): An easing method for the scrolling animation. Defaults to "out_cubic". + 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. @@ -1425,7 +1439,7 @@ class Widget(DOMNode): animate: bool = True, speed: float | None = None, duration: float | None = None, - easing: EasingFunction | str = "out_cubic", + easing: EasingFunction | str | None = None, ) -> bool: """Scroll one page down. @@ -1433,7 +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, optional): An easing method for the scrolling animation. Defaults to "out_cubic". + 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,7 +1468,7 @@ class Widget(DOMNode): animate: bool = True, speed: float | None = None, duration: float | None = None, - easing: EasingFunction | str = "out_cubic", + easing: EasingFunction | str | None = None, ) -> bool: """Scroll one page left. @@ -1461,7 +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, optional): An easing method for the scrolling animation. Defaults to "out_cubic". + 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. @@ -1483,7 +1499,7 @@ class Widget(DOMNode): animate: bool = True, speed: float | None = None, duration: float | None = None, - easing: EasingFunction | str = "out_cubic", + easing: EasingFunction | str | None = None, ) -> bool: """Scroll one page right. @@ -1491,7 +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, optional): An easing method for the scrolling animation. Defaults to "out_cubic". + 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. @@ -1514,7 +1531,7 @@ class Widget(DOMNode): animate: bool = True, speed: float | None = None, duration: float | None = None, - easing: EasingFunction | str = "out_cubic", + easing: EasingFunction | str | None = None, top: bool = False, ) -> bool: """Scroll scrolling to bring a widget in to view. @@ -1524,7 +1541,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, optional): An easing method for the scrolling animation. Defaults to "out_cubic". + 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: @@ -1571,7 +1589,7 @@ class Widget(DOMNode): animate: bool = True, speed: float | None = None, duration: float | None = None, - easing: EasingFunction | str = "out_cubic", + easing: EasingFunction | str | None = None, top: bool = False, ) -> Offset: """Scrolls a given region in to view, if required. @@ -1585,7 +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, optional): An easing method for the scrolling animation. Defaults to "out_cubic". + 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: @@ -1624,7 +1643,7 @@ class Widget(DOMNode): speed: float | None = None, duration: float | None = None, top: bool = False, - easing: EasingFunction | str = "out_cubic", + easing: EasingFunction | str | None = None, ) -> None: """Scroll the container to make this widget visible. @@ -1633,7 +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, optional): An easing method for the scrolling animation. Defaults to "out_cubic". + 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): From e3130f95c69648916f121e779a325b6f6f87e6ba Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 9 Nov 2022 16:14:59 +0000 Subject: [PATCH 15/83] Don't include self in DOMNode.ancestors any more As well as dropping `self` from the list that DOMNode.ancestors provides, this commit also adds DOMNode.ancestors_with_self, which maintains the previous behaviour of DOMNode.ancestors. --- CHANGELOG.md | 8 ++++++++ src/textual/app.py | 4 +++- src/textual/dom.py | 21 +++++++++++++++------ src/textual/widget.py | 6 +++--- 4 files changed, 29 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2978f79a7..892d74454 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## [0.5.0] - Unreleased + +### Changed + +- `DOMNode.ancestors` no longer includes `self`. +- Added `DOMNode.ancestors_with_self`, which retains the old behaviour of + `DOMNode.ancestors`. + ## [0.4.0] - 2022-11-08 https://textual.textualize.io/blog/2022/11/08/version-040/#version-040 diff --git a/src/textual/app.py b/src/textual/app.py index 9d53a4aac..cca15abd4 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1652,7 +1652,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: diff --git a/src/textual/dom.py b/src/textual/dom.py index 3521eab8d..0c9ca5eb0 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -488,7 +488,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 +497,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 +520,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 +536,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 +551,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 +564,12 @@ 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.""" + nodes = self.ancestors_with_self + return nodes[1:] if nodes else nodes + @property def displayed_children(self) -> list[Widget]: """The children which don't have display: none set. diff --git a/src/textual/widget.py b/src/textual/widget.py index e581ea3e5..693baa715 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1054,7 +1054,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"): @@ -2059,14 +2059,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() From 23eb7b907b3d5ee50ba844020057303515ce5a91 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 9 Nov 2022 16:18:05 +0000 Subject: [PATCH 16/83] Handle a Remove event that requests multiple widget removals This finishes off the work to make the loop-free query request to remove objects work as fast as possible. Removing widgets with that query method should be more or less as fast as removing a container for the same number of widgets. See https://github.com/Textualize/textual/discussions/1110 for some context. --- src/textual/app.py | 60 +++++++++++++++++++++++++++++++--------- src/textual/css/query.py | 6 ++-- 2 files changed, 51 insertions(+), 15 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index cca15abd4..b4459a0f2 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1824,23 +1824,57 @@ class App(Generic[ReturnType], DOMNode): await self.screen.post_message(event) async def _on_remove(self, event: events.Remove) -> None: - widget = event.widgets[0] - parent = widget.parent - remove_widgets = widget.walk_children( - Widget, with_self=True, method="depth", reverse=True - ) - - 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 want to go through the full set of everything to remove + # and reduce it to the minimal set of things that need removing. In + # other words, for any given branch that is going to have things + # removed, we need to just keep the topmost node in the DOM that is + # a candidate for removal. Note that we retain the ordering of the + # original walk at the top as it's depth-first and so should be the + # order we want to go in from here on. + pruned_remove: list[Widget] = [ + widget + for widget in everything_to_remove + if not any( + ancestor in everything_to_remove for ancestor in widget.ancestors + ) + ] + + for widget in pruned_remove: + if widget.parent is not None: + widget.parent.children._remove(widget) + + # Now that we have the minimal set of widgets that need to be + # removed from the DOM, to get the effect of removing everything + # affected, let's go prune them. + 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. diff --git a/src/textual/css/query.py b/src/textual/css/query.py index 626d255f8..007daf507 100644 --- a/src/textual/css/query.py +++ b/src/textual/css/query.py @@ -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( From 5dee9ef4dcf2f8d1c60cb3b2670fbbcb17ec4be9 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 9 Nov 2022 16:20:39 +0000 Subject: [PATCH 17/83] Update the CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 892d74454..b43193027 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - `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`. ## [0.4.0] - 2022-11-08 From f34d53ebb30287d01ffc1854377a497a73fc8b80 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 9 Nov 2022 16:31:18 +0000 Subject: [PATCH 18/83] Tidy up some explanations --- src/textual/app.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index b4459a0f2..a16b81212 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1863,13 +1863,17 @@ class App(Generic[ReturnType], DOMNode): ) ] + # 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) - # Now that we have the minimal set of widgets that need to be - # removed from the DOM, to get the effect of removing everything - # affected, let's go prune them. + # Havnig 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) From 1f8aaf56d5c523f35ed0108b9cf207bb87a4c8f3 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 9 Nov 2022 16:32:02 +0000 Subject: [PATCH 19/83] Add a docstring to _on_remove --- src/textual/app.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/textual/app.py b/src/textual/app.py index a16b81212..453f0abd5 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1824,6 +1824,11 @@ class App(Generic[ReturnType], DOMNode): await self.screen.post_message(event) async def _on_remove(self, event: events.Remove) -> None: + """Handle a remove event. + + Args: + event (events.Remove): The remove event. + """ # We've been given a list of widgets to remove, but removing those # will also result in other (descendent) widgets being removed. So From 39a764f49fff7ec3363b8ea25fce3fbf1b67ca58 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 9 Nov 2022 17:23:28 +0000 Subject: [PATCH 20/83] call later --- CHANGELOG.md | 5 ++++ docs/examples/guide/input/binding01.py | 2 +- src/textual/app.py | 33 +++++++++++++-------- src/textual/cli/previews/colors.py | 2 +- src/textual/message_pump.py | 12 +++++++- src/textual/screen.py | 9 +++--- src/textual/widget.py | 4 +-- src/textual/widgets/_directory_tree.py | 2 +- tests/test_call_later.py | 41 ++++++++++++++++++++++++++ 9 files changed, 88 insertions(+), 22 deletions(-) create mode 100644 tests/test_call_later.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 047026758..c5f0f3504 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### 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 diff --git a/docs/examples/guide/input/binding01.py b/docs/examples/guide/input/binding01.py index 12711f637..75458bd19 100644 --- a/docs/examples/guide/input/binding01.py +++ b/docs/examples/guide/input/binding01.py @@ -23,7 +23,7 @@ class BindingApp(App): bar = Bar(color) bar.styles.background = Color.parse(color).with_alpha(0.5) self.mount(bar) - self.call_later(self.screen.scroll_end, animate=False) + self.call_after_refresh(self.screen.scroll_end, animate=False) if __name__ == "__main__": diff --git a/src/textual/app.py b/src/textual/app.py index dba40a935..f85b6c021 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1606,19 +1606,28 @@ class App(Generic[ReturnType], DOMNode): screen (Screen): Screen instance renderable (RenderableType): A Rich renderable. """ - if screen is not self.screen or renderable is None: - return - if self._running and not self._closed and not self.is_headless: - console = self.console - self._begin_update() - try: + + try: + if screen is not self.screen or renderable is None: + return + + if self._running and not self._closed and not self.is_headless: + console = self.console + self._begin_update() try: - console.print(renderable) - except Exception as error: - self._handle_exception(error) - finally: - self._end_update() - console.file.flush() + try: + print(renderable) + console.print(renderable) + except Exception as error: + self._handle_exception(error) + finally: + self._end_update() + console.file.flush() + finally: + self.post_display_hook() + + def post_display_hook(self) -> None: + """Called immediately after a display is done. Used in tests.""" def get_widget_at(self, x: int, y: int) -> tuple[Widget, Region]: """Get the widget under the given coordinates. diff --git a/src/textual/cli/previews/colors.py b/src/textual/cli/previews/colors.py index 9315c47ca..56ac645c6 100644 --- a/src/textual/cli/previews/colors.py +++ b/src/textual/cli/previews/colors.py @@ -71,7 +71,7 @@ class ColorsApp(App): yield Footer() def on_mount(self) -> None: - self.call_later(self.update_view) + self.call_after_refresh(self.update_view) def update_view(self) -> None: content = self.query_one("Content", Content) diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 29f2d07ef..615ec0742 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -251,7 +251,7 @@ class MessagePump(metaclass=MessagePumpMeta): self._timers.add(timer) return timer - def call_later(self, callback: Callable, *args, **kwargs) -> None: + def call_after_refresh(self, callback: Callable, *args, **kwargs) -> None: """Schedule a callback to run after all messages are processed and the screen has been refreshed. Positional and keyword arguments are passed to the callable. @@ -263,6 +263,16 @@ class MessagePump(metaclass=MessagePumpMeta): message = messages.InvokeLater(self, partial(callback, *args, **kwargs)) self.post_message_no_wait(message) + def call_later(self, callback: Callable, *args, **kwargs) -> None: + """Schedule a callback to run after all messages are processed in this object. + Positional and keywords arguments are passed to the callable. + + Args: + callback (Callable): Callable to call next. + """ + message = events.Callback(self, callback=partial(callback, *args, **kwargs)) + self.post_message_no_wait(message) + def _on_invoke_later(self, message: messages.InvokeLater) -> None: # Forward InvokeLater message to the Screen self.app.screen._invoke_later(message.callback) diff --git a/src/textual/screen.py b/src/textual/screen.py index c06c86936..7d9c74609 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -333,11 +333,11 @@ class Screen(Widget): self._compositor.update_widgets(self._dirty_widgets) self.app._display(self, self._compositor.render()) self._dirty_widgets.clear() - - self.update_timer.pause() if self._callbacks: self.post_message_no_wait(events.InvokeCallbacks(self)) + self.update_timer.pause() + async def _on_invoke_callbacks(self, event: events.InvokeCallbacks) -> None: """Handle PostScreenUpdate events, which are sent after the screen is updated""" await self._invoke_and_clear_callbacks() @@ -346,6 +346,8 @@ class Screen(Widget): """If there are scheduled callbacks to run, call them and clear the callback queue.""" if self._callbacks: + display_update = self._compositor.render(full=True) + self.app._display(self, display_update) callbacks = self._callbacks[:] self._callbacks.clear() for callback in callbacks: @@ -402,8 +404,7 @@ class Screen(Widget): self.app._handle_exception(error) return display_update = self._compositor.render(full=full) - if display_update is not None: - self.app._display(self, display_update) + self.app._display(self, display_update) async def _on_update(self, message: messages.Update) -> None: message.stop() diff --git a/src/textual/widget.py b/src/textual/widget.py index b4723487c..1a3537ad3 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1576,7 +1576,7 @@ class Widget(DOMNode): """ parent = self.parent if isinstance(parent, Widget): - self.call_later( + self.call_after_refresh( parent.scroll_to_widget, self, animate=animate, @@ -1989,7 +1989,7 @@ class Widget(DOMNode): except NoScreen: pass - self.app.call_later(set_focus, self) + self.app.call_after_refresh(set_focus, self) def reset_focus(self) -> None: """Reset the focus (move it to the next available widget).""" diff --git a/src/textual/widgets/_directory_tree.py b/src/textual/widgets/_directory_tree.py index 7332a1d16..4961db81d 100644 --- a/src/textual/widgets/_directory_tree.py +++ b/src/textual/widgets/_directory_tree.py @@ -92,7 +92,7 @@ class DirectoryTree(TreeControl[DirEntry]): self.render_tree_label.cache_clear() def on_mount(self) -> None: - self.call_later(self.load_directory, self.root) + self.call_after_refresh(self.load_directory, self.root) async def load_directory(self, node: TreeNode[DirEntry]): path = node.data.path diff --git a/tests/test_call_later.py b/tests/test_call_later.py new file mode 100644 index 000000000..025eb8c12 --- /dev/null +++ b/tests/test_call_later.py @@ -0,0 +1,41 @@ +import asyncio +from textual.app import App + + +class CallLaterApp(App[None]): + def __init__(self) -> None: + self.display_count = 0 + super().__init__() + + def post_display_hook(self) -> None: + self.display_count += 1 + + +async def test_call_later() -> None: + """Check that call later makes a call.""" + app = CallLaterApp() + called_event = asyncio.Event() + + async with app.run_test(): + app.call_later(called_event.set) + await asyncio.wait_for(called_event.wait(), 1) + + +async def test_call_after_refresh() -> None: + """Check that call later makes a call after a refresh.""" + app = CallLaterApp() + + display_count = -1 + + called_event = asyncio.Event() + + def callback() -> None: + nonlocal display_count + called_event.set() + display_count = app.display_count + + async with app.run_test(): + app.call_after_refresh(callback) + await asyncio.wait_for(called_event.wait(), 1) + app_display_count = app.display_count + assert app_display_count > display_count From 9538e60a01c67cbcd73281d9ed35edbea1fedd61 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 9 Nov 2022 17:40:14 +0000 Subject: [PATCH 21/83] remove print debug --- src/textual/app.py | 1 - src/textual/widget.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index f85b6c021..6789393f1 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1616,7 +1616,6 @@ class App(Generic[ReturnType], DOMNode): self._begin_update() try: try: - print(renderable) console.print(renderable) except Exception as error: self._handle_exception(error) diff --git a/src/textual/widget.py b/src/textual/widget.py index 1a3537ad3..48dfefa73 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1989,7 +1989,7 @@ class Widget(DOMNode): except NoScreen: pass - self.app.call_after_refresh(set_focus, self) + self.app.call_later(set_focus, self) def reset_focus(self) -> None: """Reset the focus (move it to the next available widget).""" From 414f0a66bdbf3d71716497c86fc96ff88a9cae33 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 9 Nov 2022 20:58:42 +0000 Subject: [PATCH 22/83] Fix a comment typo --- src/textual/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/app.py b/src/textual/app.py index 453f0abd5..7a11f97f0 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1875,7 +1875,7 @@ class App(Generic[ReturnType], DOMNode): if widget.parent is not None: widget.parent.children._remove(widget) - # Havnig done that, it's now safe for us to start the process of + # 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. From 419ae15435dc6d753334beb89d3692a93f47244e Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 9 Nov 2022 21:00:49 +0000 Subject: [PATCH 23/83] Remove unnecessary caution --- src/textual/dom.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index 0c9ca5eb0..8139eb1d6 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -567,8 +567,7 @@ class DOMNode(MessagePump): @property def ancestors(self) -> list[DOMNode]: """list[DOMNode]: A list of ancestor nodes Nodes by tracing ancestors all the way back to App.""" - nodes = self.ancestors_with_self - return nodes[1:] if nodes else nodes + return self.ancestors_with_self[1:] @property def displayed_children(self) -> list[Widget]: From 3cb00f93d5e2b000f2fa19951b5338ac0ee59180 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 9 Nov 2022 21:17:55 +0000 Subject: [PATCH 24/83] Simplify the building of the pruned remove list See https://github.com/Textualize/textual/pull/1147#discussion_r1018230164 --- src/textual/app.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 7a11f97f0..8006d558c 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1860,12 +1860,10 @@ class App(Generic[ReturnType], DOMNode): # a candidate for removal. Note that we retain the ordering of the # original walk at the top as it's depth-first and so should be the # order we want to go in from here on. - pruned_remove: list[Widget] = [ + pruned_remove = [ widget for widget in everything_to_remove - if not any( - ancestor in everything_to_remove for ancestor in widget.ancestors - ) + if dedupe_to_remove.isdisjoint(widget.ancestors) ] # Now that we know that minimal set of widgets, we go through them From a888e52c30191b17e1d885b64f3a0e2b61faacdf Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 10 Nov 2022 09:03:19 +0000 Subject: [PATCH 25/83] Reduce the work needed to get the topmost widget prune set See https://github.com/Textualize/textual/pull/1147#pullrequestreview-1175289913 --- src/textual/app.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 8006d558c..159d33b0c 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1853,16 +1853,15 @@ class App(Generic[ReturnType], DOMNode): [to_remove for to_remove in dedupe_to_remove if to_remove.can_focus], ) - # Next, we want to go through the full set of everything to remove - # and reduce it to the minimal set of things that need removing. In - # other words, for any given branch that is going to have things - # removed, we need to just keep the topmost node in the DOM that is - # a candidate for removal. Note that we retain the ordering of the - # original walk at the top as it's depth-first and so should be the - # order we want to go in from here on. + # 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. pruned_remove = [ widget - for widget in everything_to_remove + for widget in event.widgets if dedupe_to_remove.isdisjoint(widget.ancestors) ] From efe721fb514e2a55ff488f4ac166568994517430 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 10 Nov 2022 11:03:28 +0000 Subject: [PATCH 26/83] Reduce the amount of work needed to build the pruned remove list --- src/textual/app.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/textual/app.py b/src/textual/app.py index 159d33b0c..45b5a6204 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1859,10 +1859,11 @@ class App(Generic[ReturnType], DOMNode): # 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 dedupe_to_remove.isdisjoint(widget.ancestors) + if request_remove.isdisjoint(widget.ancestors) ] # Now that we know that minimal set of widgets, we go through them From f9e5541d01dc24c7fdb998a235e6b3a1264933bb Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 10 Nov 2022 11:09:17 +0000 Subject: [PATCH 27/83] Tidy some docstrings Moving some property docstrings to a voice that describes what they are, not what they do. --- src/textual/dom.py | 45 ++++++++++++++------------------------------- 1 file changed, 14 insertions(+), 31 deletions(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index 3521eab8d..c1d24f290 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -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 From ed1aeab07160eb1ef04b78c57b05122038641314 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 10 Nov 2022 11:10:04 +0000 Subject: [PATCH 28/83] Tidy up and add some app docstrings --- src/textual/app.py | 37 ++++++++----------------------------- 1 file changed, 8 insertions(+), 29 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index dba40a935..82d1f08ec 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -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( From d73d4e898f5f4f9b01ea06d0392ee0de58225098 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 10 Nov 2022 13:13:15 +0000 Subject: [PATCH 29/83] Add unit tests for App._on_remove These tests are designed to give 100% coverage to the App._on_remove method, which is the heart of the widget removal system. This is done in anticipation to some reworking as part of #1094. Note that, for the moment, there's a bit of a hack in here. These tests need the pilot. The pilot would need that we await changes to the DOM. Removing things from the DOM changes the DOM. Remove can't be awaited (see above change that's coming). So... until such a time as we can await a remove, we go with a simple await of asyncio.sleep to get things started. Once #1094 has been done these tests should all still work fine *and* we should be able to await the removes rather than use the sleeps. --- tests/test_widget_removing.py | 127 ++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 tests/test_widget_removing.py diff --git a/tests/test_widget_removing.py b/tests/test_widget_removing.py new file mode 100644 index 000000000..822695073 --- /dev/null +++ b/tests/test_widget_removing.py @@ -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] From ed779f3e993a3d054e04b3e53bd1bce398ac7026 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 10 Nov 2022 15:22:25 +0000 Subject: [PATCH 30/83] no need for full refresh --- src/textual/screen.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/screen.py b/src/textual/screen.py index 7d9c74609..0069c5a79 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -346,7 +346,7 @@ class Screen(Widget): """If there are scheduled callbacks to run, call them and clear the callback queue.""" if self._callbacks: - display_update = self._compositor.render(full=True) + display_update = self._compositor.render() self.app._display(self, display_update) callbacks = self._callbacks[:] self._callbacks.clear() From b524fa08eecadc83b0b694278db1c79d90feb9d8 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 10 Nov 2022 16:02:30 +0000 Subject: [PATCH 31/83] ffixed table refresh on add row --- src/textual/widgets/_data_table.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index d1ceb90b7..b761673f3 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -261,6 +261,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): content_width = measure(self.app.console, renderable, 1) column.content_width = max(column.content_width, content_width) + self._clear_caches() total_width = sum(column.render_width for column in self.columns) header_height = self.header_height if self.show_header else 0 self.virtual_size = Size( @@ -281,6 +282,21 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): cell_region = Region(x, y, width, height) return cell_region + def clear(self) -> None: + """Clear the table. + + Args: + columns (bool, optional): Also clear the columns. Defaults to False. + """ + self.row_count = 0 + self._clear_caches() + self._y_offsets.clear() + self.data.clear() + self.rows.clear() + self._line_no = 0 + self._require_update_dimensions = True + self.refresh() + def add_columns(self, *labels: TextType) -> None: """Add a number of columns. @@ -355,6 +371,7 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): new_rows = self._new_rows.copy() self._new_rows.clear() self._update_dimensions(new_rows) + self.refresh() def refresh_cell(self, row_index: int, column_index: int) -> None: """Refresh a cell. From 300bd86d45813e70956232197146f40fd7dbb221 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 10 Nov 2022 16:03:26 +0000 Subject: [PATCH 32/83] Changelog --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e55aff25..6a3fd85e4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,13 +16,16 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added `DOMNode.ancestors_with_self`, which retains the old behaviour of `DOMNode.ancestors`. - Improved the speed of `DOMQuery.remove`. - +- Added DataTable.clear ### 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. +### Fixed + +- Fixed DataTable row not updating after add https://github.com/Textualize/textual/issues/1026 ## [0.4.0] - 2022-11-08 From 1b61a95c7025160cbbcb74cf9562a3b057afd3e6 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 10 Nov 2022 16:22:52 +0000 Subject: [PATCH 33/83] table tests --- tests/test_table.py | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 tests/test_table.py diff --git a/tests/test_table.py b/tests/test_table.py new file mode 100644 index 000000000..8369efdef --- /dev/null +++ b/tests/test_table.py @@ -0,0 +1,45 @@ +import asyncio + +from textual.app import App, ComposeResult +from textual.widgets import DataTable + + +class TableApp(App): + def compose(self) -> ComposeResult: + yield DataTable() + + +async def test_table_clear() -> None: + """Check DataTable.clear""" + + app = TableApp() + async with app.run_test() as pilot: + table = app.query_one(DataTable) + table.add_columns("foo", "bar") + assert table.row_count == 0 + table.add_row("Hello", "World!") + assert table.data == {0: ["Hello", "World!"]} + assert table.row_count == 1 + table.clear() + assert table.data == {} + assert table.row_count == 0 + + +async def test_table_add_row() -> None: + + app = TableApp() + async with app.run_test(): + table = app.query_one(DataTable) + table.add_columns("foo", "bar") + + assert table.columns[0].width == 3 + assert table.columns[1].width == 3 + table.add_row("Hello", "World!") + await asyncio.sleep(0) + assert table.columns[0].content_width == 5 + assert table.columns[1].content_width == 6 + + table.add_row("Hello World!!!", "fo") + await asyncio.sleep(0) + assert table.columns[0].content_width == 14 + assert table.columns[1].content_width == 6 From 4dbb1f8e204213ac585664ef20c0c3bad3930ed5 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 10 Nov 2022 16:54:44 +0000 Subject: [PATCH 34/83] AwaitRemove work-in-progress Initial work on #1094 -- this implements the core idea; lots of tidying up to do but saving for the end of the week. --- src/textual/app.py | 48 ++++++++++++++++++++++++----------- src/textual/await_remove.py | 13 ++++++++++ src/textual/css/query.py | 15 ++++++++--- src/textual/events.py | 18 ++++++++++--- src/textual/widget.py | 15 ++++++++--- tests/test_widget_removing.py | 29 +++++---------------- 6 files changed, 90 insertions(+), 48 deletions(-) create mode 100644 src/textual/await_remove.py diff --git a/src/textual/app.py b/src/textual/app.py index 1afe30322..5edaf9e40 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -1802,11 +1802,19 @@ class App(Generic[ReturnType], DOMNode): event.stop() await self.screen.post_message(event) - async def _on_remove(self, event: events.Remove) -> None: - """Handle a remove event. + def _detach_from_dom(self, widgets: list[Widget]) -> list[Widget]: + """Detach a list of widgets from the DOM. Args: - event (events.Remove): The remove event. + widgets (list[Widget]): The list of widgets to detach from the DOM. + + Returns: + list[Widget]: The list of widgets that should be pruned. + + Note: + A side-effect of calling this function is that each parent of + each affected widget will be made to forget about the affected + child. """ # We've been given a list of widgets to remove, but removing those @@ -1815,7 +1823,7 @@ class App(Generic[ReturnType], DOMNode): # 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: + for widget in widgets: everything_to_remove.extend( widget.walk_children( Widget, with_self=True, method="depth", reverse=True @@ -1838,11 +1846,9 @@ class App(Generic[ReturnType], DOMNode): # 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) + request_remove = set(widgets) pruned_remove = [ - widget - for widget in event.widgets - if request_remove.isdisjoint(widget.ancestors) + widget for widget in widgets if request_remove.isdisjoint(widget.ancestors) ] # Now that we know that minimal set of widgets, we go through them @@ -1852,14 +1858,26 @@ class App(Generic[ReturnType], DOMNode): 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) + # Return the list of widgets that should end up being sent off in a + # prune event. + return pruned_remove - # And finally, redraw all the things! + async def _on_prune(self, event: events.Prune) -> None: + """Handle a prune event. + + Args: + event (events.Prune): The prune event. + """ + + try: + # Prune all the widgets. + for widget in event.widgets: + await self._prune_node(widget) + finally: + # Finally, flag that we're done. + event.finished_flag.set() + + # Flag that the layout needs refreshing. self.refresh(layout=True) def _walk_children(self, root: Widget) -> Iterable[list[Widget]]: diff --git a/src/textual/await_remove.py b/src/textual/await_remove.py new file mode 100644 index 000000000..d93c21cf8 --- /dev/null +++ b/src/textual/await_remove.py @@ -0,0 +1,13 @@ +from asyncio import Event +from typing import Generator + + +class AwaitRemove: + def __init__(self, finished_flag: Event) -> None: + self.finished_flag = finished_flag + + def __await__(self) -> Generator[None, None, None]: + async def await_prune() -> None: + await self.finished_flag.wait() + + return await_prune().__await__() diff --git a/src/textual/css/query.py b/src/textual/css/query.py index 007daf507..50119411a 100644 --- a/src/textual/css/query.py +++ b/src/textual/css/query.py @@ -17,11 +17,13 @@ a method which evaluates the query, such as first() and last(). from __future__ import annotations from typing import cast, Generic, TYPE_CHECKING, Iterator, TypeVar, overload +import asyncio import rich.repr from .. import events from .._context import active_app +from ..await_remove import AwaitRemove from .errors import DeclarationError, TokenError from .match import match from .model import SelectorSet @@ -348,11 +350,18 @@ class DOMQuery(Generic[QueryType]): node.toggle_class(*class_names) return self - def remove(self) -> DOMQuery[QueryType]: + def remove(self) -> AwaitRemove: """Remove matched nodes from the DOM""" + prune_finished_event = asyncio.Event() app = active_app.get() - app.post_message_no_wait(events.Remove(app, widgets=list(self))) - return self + app.post_message_no_wait( + events.Prune( + app, + widgets=app._detach_from_dom(list(self)), + finished_flag=prune_finished_event, + ) + ) + return AwaitRemove(prune_finished_event) def set_styles( self, css: str | None = None, **update_styles diff --git a/src/textual/events.py b/src/textual/events.py index d34b206ad..a2cd27190 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -16,6 +16,7 @@ if TYPE_CHECKING: from .timer import Timer as TimerClass from .timer import TimerCallback from .widget import Widget + import asyncio @rich.repr.auto @@ -126,12 +127,21 @@ class Unmount(Mount, bubble=False, verbose=False): """Sent when a widget is unmounted and may not longer receive messages.""" -class Remove(Event, bubble=False): - """Sent to the app to ask it to remove one or more widgets from the DOM.""" +class Prune(Event, bubble=False): + """Sent to the app to ask it to prune one or more widgets from the DOM.""" - def __init__(self, sender: MessageTarget, widgets: list[Widget]) -> None: - self.widgets = widgets + def __init__( + self, sender: MessageTarget, widgets: list[Widget], finished_flag: asyncio.Event + ) -> None: + """Initialise the event. + + Args: + widgets (list[Widgets]): The list of widgets to prune. + finished_flag (asyncio.Event): An asyncio Event to that will be flagged when the prune is done. + """ super().__init__(sender) + self.finished_flag = finished_flag + self.widgets = widgets class Show(Event, bubble=False): diff --git a/src/textual/widget.py b/src/textual/widget.py index b1eb32cb9..214a3d30d 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1,6 +1,6 @@ from __future__ import annotations -from asyncio import Lock, wait, create_task +from asyncio import Lock, wait, create_task, Event as AsyncEvent from fractions import Fraction from itertools import islice from operator import attrgetter @@ -49,6 +49,7 @@ from .message import Message from .messages import CallbackType from .reactive import Reactive from .render import measure +from .await_remove import AwaitRemove if TYPE_CHECKING: from .app import App, ComposeResult @@ -1990,9 +1991,17 @@ class Widget(DOMNode): self.check_idle() - def remove(self) -> None: + def remove(self) -> AwaitRemove: """Remove the Widget from the DOM (effectively deleting it)""" - self.app.post_message_no_wait(events.Remove(self, widgets=[self])) + prune_finished_event = AsyncEvent() + self.app.post_message_no_wait( + events.Prune( + self, + widgets=self.app._detach_from_dom([self]), + finished_flag=prune_finished_event, + ) + ) + return AwaitRemove(prune_finished_event) def render(self) -> RenderableType: """Get renderable for widget. diff --git a/tests/test_widget_removing.py b/tests/test_widget_removing.py index 822695073..5391fe7a0 100644 --- a/tests/test_widget_removing.py +++ b/tests/test_widget_removing.py @@ -4,24 +4,12 @@ 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() + await pilot.app.query_one(Static).remove() assert len(pilot.app.screen.children) == 0 async def test_many_remove_all_widgets(): @@ -29,8 +17,7 @@ async def test_many_remove_all_widgets(): 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() + await pilot.app.query(Static).remove() assert len(pilot.app.screen.children) == 0 async def test_many_remove_some_widgets(): @@ -38,8 +25,7 @@ async def test_many_remove_some_widgets(): 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() + await pilot.app.query("#is-0").remove() assert len(pilot.app.screen.children) == 500 async def test_remove_branch(): @@ -71,8 +57,7 @@ async def test_remove_branch(): ), ) assert len(pilot.app.screen.walk_children(with_self=False)) == 13 - pilot.app.screen.children[0].remove() - await await_remove_standin() + await pilot.app.screen.children[0].remove() assert len(pilot.app.screen.walk_children(with_self=False)) == 7 async def test_remove_overlap(): @@ -104,8 +89,7 @@ async def test_remove_overlap(): ), ) assert len(pilot.app.screen.walk_children(with_self=False)) == 13 - pilot.app.query(Container).remove() - await await_remove_standin() + await pilot.app.query(Container).remove() assert len(pilot.app.screen.walk_children(with_self=False)) == 1 async def test_remove_move_focus(): @@ -119,8 +103,7 @@ async def test_remove_move_focus(): 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() + await pilot.app.screen.children[0].remove() 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 From 1f36ebac97efdda2cb41f87a888545c6693277fb Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 10 Nov 2022 19:47:13 +0000 Subject: [PATCH 35/83] Add some documentation to AwaitRemove --- src/textual/await_remove.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/textual/await_remove.py b/src/textual/await_remove.py index d93c21cf8..efec30b3b 100644 --- a/src/textual/await_remove.py +++ b/src/textual/await_remove.py @@ -1,13 +1,23 @@ +"""Provides the type of an awaitable remove.""" + from asyncio import Event from typing import Generator class AwaitRemove: + """An awaitable returned by App.remove and DOMQuery.remove.""" + def __init__(self, finished_flag: Event) -> None: + """Initialise the instance of ``AwaitRemove``. + + Args: + finished_flag (asyncio.Event): The asyncio event to wait on. + """ self.finished_flag = finished_flag def __await__(self) -> Generator[None, None, None]: async def await_prune() -> None: + """Wait for the prune operation to finish.""" await self.finished_flag.wait() return await_prune().__await__() From 736237be587bd6d84ca69f64c6a491c9782da906 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 10 Nov 2022 19:49:59 +0000 Subject: [PATCH 36/83] Flesh out the docstring for DOMQuery.remove --- src/textual/css/query.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/textual/css/query.py b/src/textual/css/query.py index 50119411a..4abfa3166 100644 --- a/src/textual/css/query.py +++ b/src/textual/css/query.py @@ -351,7 +351,11 @@ class DOMQuery(Generic[QueryType]): return self def remove(self) -> AwaitRemove: - """Remove matched nodes from the DOM""" + """Remove matched nodes from the DOM. + + Returns: + AwaitRemove: An awaitable object that waits for the widget to be removed. + """ prune_finished_event = asyncio.Event() app = active_app.get() app.post_message_no_wait( From de275d701184daa5f09ac375d5f6b7b8b6f678e2 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 10 Nov 2022 19:57:08 +0000 Subject: [PATCH 37/83] Flesh out the Prune event docs --- src/textual/events.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/textual/events.py b/src/textual/events.py index a2cd27190..0eb5abac3 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -128,7 +128,12 @@ class Unmount(Mount, bubble=False, verbose=False): class Prune(Event, bubble=False): - """Sent to the app to ask it to prune one or more widgets from the DOM.""" + """Sent to the app to ask it to prune one or more widgets from the DOM. + + Attributes: + widgets (list[Widgets]): The list of widgets to prune. + finished_flag (asyncio.Event): An asyncio Event to that will be flagged when the prune is done. + """ def __init__( self, sender: MessageTarget, widgets: list[Widget], finished_flag: asyncio.Event From 1fb2cacbe442a64b7d366f2fda8c79b95769a2c3 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 10 Nov 2022 19:58:40 +0000 Subject: [PATCH 38/83] Widgets, not widget --- src/textual/css/query.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/css/query.py b/src/textual/css/query.py index 4abfa3166..ff1657a99 100644 --- a/src/textual/css/query.py +++ b/src/textual/css/query.py @@ -354,7 +354,7 @@ class DOMQuery(Generic[QueryType]): """Remove matched nodes from the DOM. Returns: - AwaitRemove: An awaitable object that waits for the widget to be removed. + AwaitRemove: An awaitable object that waits for the widgets to be removed. """ prune_finished_event = asyncio.Event() app = active_app.get() From 7147df462bc44465d051687f18c0158f36c9c532 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 10 Nov 2022 19:59:18 +0000 Subject: [PATCH 39/83] Flesh out the Widget.remove docs --- src/textual/widget.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index 214a3d30d..00a7fecac 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1992,7 +1992,11 @@ class Widget(DOMNode): self.check_idle() def remove(self) -> AwaitRemove: - """Remove the Widget from the DOM (effectively deleting it)""" + """Remove the Widget from the DOM (effectively deleting it) + + Returns: + AwaitRemove: An awaitable object that waits for the widget to be removed. + """ prune_finished_event = AsyncEvent() self.app.post_message_no_wait( events.Prune( From d378eb429194da85e258dc8ee293d67dff81ca7f Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 10 Nov 2022 20:07:57 +0000 Subject: [PATCH 40/83] Update the CHANGELOG --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e021dbd84..874063cb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added `DOMNode.ancestors_with_self`, which retains the old behaviour of `DOMNode.ancestors`. - Improved the speed of `DOMQuery.remove`. +- It is now possible to `await` a `Widget.remove`. + https://github.com/Textualize/textual/issues/1094 +- It is now possible to `await` a `DOMQuery.remove`. Note that this changes + the return value of `DOMQuery.remove`, which uses to return `self`. + https://github.com/Textualize/textual/issues/1094 ## [0.4.0] - 2022-11-08 From 9748850657337ba31f220387e4a7777a87ec019a Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 10 Nov 2022 20:34:51 +0000 Subject: [PATCH 41/83] Add a unit test for removal ordering via Widget.remove --- tests/test_widget_removing.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/test_widget_removing.py b/tests/test_widget_removing.py index 5391fe7a0..b0da63d2e 100644 --- a/tests/test_widget_removing.py +++ b/tests/test_widget_removing.py @@ -108,3 +108,19 @@ async def test_remove_move_focus(): assert len(pilot.app.screen.walk_children(with_self=False)) == 6 assert pilot.app.focused is not None assert pilot.app.focused == buttons[9] + +async def test_remove_order(): + """The removal of a top-level widget should cause bottom-first removal.""" + + removals: list[str] = [] + + class Removable(Container): + def on_unmount( self, _ ): + removals.append(self.id if self.id is not None else "unknown") + + async with App().run_test() as pilot: + await pilot.app.mount( + Removable(Removable(Removable(id="grandchild"), id="child"), id="parent") + ) + await pilot.app.screen.children[0].remove() + assert removals == ["grandchild", "child", "parent"] From d3e7f5ad994a92ae1734caea8bb66cfb043fcfc4 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 10 Nov 2022 20:36:57 +0000 Subject: [PATCH 42/83] Add a unit test for removal ordering via DOMQuery.remove --- tests/test_widget_removing.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/tests/test_widget_removing.py b/tests/test_widget_removing.py index b0da63d2e..62b9dca32 100644 --- a/tests/test_widget_removing.py +++ b/tests/test_widget_removing.py @@ -109,8 +109,8 @@ async def test_remove_move_focus(): assert pilot.app.focused is not None assert pilot.app.focused == buttons[9] -async def test_remove_order(): - """The removal of a top-level widget should cause bottom-first removal.""" +async def test_widget_remove_order(): + """A Widget.remove of a top-level widget should cause bottom-first removal.""" removals: list[str] = [] @@ -124,3 +124,19 @@ async def test_remove_order(): ) await pilot.app.screen.children[0].remove() assert removals == ["grandchild", "child", "parent"] + +async def test_query_remove_order(): + """A DOMQuery.remove of a top-level widget should cause bottom-first removal.""" + + removals: list[str] = [] + + class Removable(Container): + def on_unmount( self, _ ): + removals.append(self.id if self.id is not None else "unknown") + + async with App().run_test() as pilot: + await pilot.app.mount( + Removable(Removable(Removable(id="grandchild"), id="child"), id="parent") + ) + await pilot.app.query(Removable).remove() + assert removals == ["grandchild", "child", "parent"] From 32e6f71856c786db9e546fd23564941d665449b6 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 10 Nov 2022 20:41:11 +0000 Subject: [PATCH 43/83] Be 100% sure all removals take place --- tests/test_widget_removing.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_widget_removing.py b/tests/test_widget_removing.py index 62b9dca32..341866a9f 100644 --- a/tests/test_widget_removing.py +++ b/tests/test_widget_removing.py @@ -122,7 +122,9 @@ async def test_widget_remove_order(): await pilot.app.mount( Removable(Removable(Removable(id="grandchild"), id="child"), id="parent") ) + assert len(pilot.app.screen.walk_children(with_self=False)) == 3 await pilot.app.screen.children[0].remove() + assert len(pilot.app.screen.walk_children(with_self=False)) == 0 assert removals == ["grandchild", "child", "parent"] async def test_query_remove_order(): @@ -138,5 +140,7 @@ async def test_query_remove_order(): await pilot.app.mount( Removable(Removable(Removable(id="grandchild"), id="child"), id="parent") ) + assert len(pilot.app.screen.walk_children(with_self=False)) == 3 await pilot.app.query(Removable).remove() + assert len(pilot.app.screen.walk_children(with_self=False)) == 0 assert removals == ["grandchild", "child", "parent"] From 60b54562299d884c9a5281dc25f4d41eb745e4b6 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 10 Nov 2022 21:25:14 +0000 Subject: [PATCH 44/83] Add types to all Button.__init__ args --- src/textual/widgets/_button.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index 02db405f1..c2e45cf07 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -171,9 +171,9 @@ class Button(Static, can_focus=True): label (str): The text that appears within the button. disabled (bool): Whether the button is disabled or not. variant (ButtonVariant): The variant of the button. - name: The name of the button. - id: The ID of the button in the DOM. - classes: The CSS classes of the button. + name (str | None, optional): The name of the button. + id (str | None, optional): The ID of the button in the DOM. + classes (str | None, optional): The CSS classes of the button. """ super().__init__(name=name, id=id, classes=classes) From 5d2a2fd340d37c6e7be2f534b700c33dc9fdbdbe Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 10 Nov 2022 21:27:12 +0000 Subject: [PATCH 45/83] Add missing types to arguments --- src/textual/widgets/_button.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index c2e45cf07..8ecebc7ba 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -269,9 +269,9 @@ class Button(Static, can_focus=True): Args: label (str): The text that appears within the button. disabled (bool): Whether the button is disabled or not. - name: The name of the button. - id: The ID of the button in the DOM. - classes: The CSS classes of the button. + name (str | None, optional): The name of the button. + id (str | None, optional): The ID of the button in the DOM. + classes(str | None, optional): The CSS classes of the button. Returns: Button: A Button widget of the 'success' variant. From 7864aa3f8ecfca16b7049a67d17d24e5f7c634a2 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 10 Nov 2022 21:27:52 +0000 Subject: [PATCH 46/83] Add missing types to arguments --- src/textual/widgets/_button.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index 8ecebc7ba..74eeae4a5 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -300,9 +300,9 @@ class Button(Static, can_focus=True): Args: label (str): The text that appears within the button. disabled (bool): Whether the button is disabled or not. - name: The name of the button. - id: The ID of the button in the DOM. - classes: The CSS classes of the button. + name (str | None, optional): The name of the button. + id (str | None, optional): The ID of the button in the DOM. + classes (str | None, optional): The CSS classes of the button. Returns: Button: A Button widget of the 'warning' variant. From 3dae5f2c934541e6550ee0c2d5c5c45a32fae43c Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 10 Nov 2022 21:28:21 +0000 Subject: [PATCH 47/83] Add missing types to arguments --- src/textual/widgets/_button.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index 74eeae4a5..b394762d8 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -331,9 +331,9 @@ class Button(Static, can_focus=True): Args: label (str): The text that appears within the button. disabled (bool): Whether the button is disabled or not. - name: The name of the button. - id: The ID of the button in the DOM. - classes: The CSS classes of the button. + name (str | None, optional): The name of the button. + id (str | None, optional): The ID of the button in the DOM. + classes (str | None, optional): The CSS classes of the button. Returns: Button: A Button widget of the 'error' variant. From bd21e248b1ddfc39b81b0521643665b73cbd0c4c Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 10 Nov 2022 21:32:00 +0000 Subject: [PATCH 48/83] Fix the default value of a checkbox The value is bool, but the default is None. This makes the default False. --- src/textual/widgets/_checkbox.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_checkbox.py b/src/textual/widgets/_checkbox.py index 41336f9d2..d4bd8e49f 100644 --- a/src/textual/widgets/_checkbox.py +++ b/src/textual/widgets/_checkbox.py @@ -66,7 +66,7 @@ class Checkbox(Widget, can_focus=True): def __init__( self, - value: bool = None, + value: bool = False, *, animate: bool = True, name: str | None = None, From 0dae1761ad2d0b6c55d4092c0827506c36ca3e53 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 10 Nov 2022 21:33:24 +0000 Subject: [PATCH 49/83] Tidy up the docstring for the checkbox Google style docstring guidelines say that __init__ args can go either in the class docstring, or the __init__ docstring; so this class was correct. But the guidelines also recommend that once one approach has been picked it should be the same over the whole body of the code. Textual mostly seems to do it in the __init__ so I'm moving things to here and also adding some missing args. --- src/textual/widgets/_checkbox.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/textual/widgets/_checkbox.py b/src/textual/widgets/_checkbox.py index d4bd8e49f..50ca674c7 100644 --- a/src/textual/widgets/_checkbox.py +++ b/src/textual/widgets/_checkbox.py @@ -15,10 +15,6 @@ from ..scrollbar import ScrollBarRender class Checkbox(Widget, can_focus=True): """A checkbox widget. Represents a boolean value. Can be toggled by clicking on it or by pressing the enter key or space bar while it has focus. - - Args: - value (bool, optional): The initial value of the checkbox. Defaults to False. - animate (bool, optional): True if the checkbox should animate when toggled. Defaults to True. """ DEFAULT_CSS = """ @@ -73,6 +69,15 @@ class Checkbox(Widget, can_focus=True): id: str | None = None, classes: str | None = None, ): + """Initialise the checkbox. + + Args: + value (bool, optional): The initial value of the checkbox. Defaults to False. + animate (bool, optional): True if the checkbox should animate when toggled. Defaults to True. + name (str | None, optional): The name of the checkbox. + id (str | None, optional): The ID of the checkbox in the DOM. + classes (str | None, optional): The CSS classes of the checkbox. + """ super().__init__(name=name, id=id, classes=classes) if value: self.slider_pos = 1.0 From c18852a7378908322c1d2910ed0811600ec246fa Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 10 Nov 2022 21:36:05 +0000 Subject: [PATCH 50/83] Add missing property type --- src/textual/widgets/_data_table.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index b761673f3..a2cfe205a 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -56,7 +56,7 @@ class Column: @property def render_width(self) -> int: - """Width in cells, required to render a column.""" + """int: Width in cells, required to render a column.""" # +2 is to account for space padding either side of the cell if self.auto_width: return self.content_width + 2 From 2a80e1761da2ff71d7116cdc2f49fe001b6bc0b2 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 10 Nov 2022 21:38:58 +0000 Subject: [PATCH 51/83] Add some missing return types --- src/textual/widgets/_data_table.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index a2cfe205a..767bf74cf 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -88,22 +88,38 @@ class Coord(NamedTuple): column: int def left(self) -> Coord: - """Get coordinate to the left.""" + """Get coordinate to the left. + + Returns: + Coord: The coordinate. + """ row, column = self return Coord(row, column - 1) def right(self) -> Coord: - """Get coordinate to the right.""" + """Get coordinate to the right. + + Returns: + Coord: The coordinate. + """ row, column = self return Coord(row, column + 1) def up(self) -> Coord: - """Get coordinate above.""" + """Get coordinate above. + + Returns: + Coord: The coordinate. + """ row, column = self return Coord(row - 1, column) def down(self) -> Coord: - """Get coordinate below.""" + """Get coordinate below. + + Returns: + Coord: The coordinate. + """ row, column = self return Coord(row + 1, column) From d83c4aacbe34a107f1426421fa86590fe35e6c0b Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 10 Nov 2022 21:43:14 +0000 Subject: [PATCH 52/83] Make a property docstring more property-a-like --- src/textual/pilot.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/textual/pilot.py b/src/textual/pilot.py index fee51539d..0d6f47b0e 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -21,11 +21,7 @@ class Pilot: @property def app(self) -> App: - """Get a reference to the application. - - Returns: - App: The App instance. - """ + """App: A reference to the application.""" return self._app async def press(self, *keys: str) -> None: From 698ecc62c6ff3ed40aa375133b93980ebdf6a0e4 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 11 Nov 2022 09:29:45 +0000 Subject: [PATCH 53/83] factored out walk --- src/textual/dom.py | 49 +++++++++------------------------------------- 1 file changed, 9 insertions(+), 40 deletions(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index bfee3da5a..da12a8dc1 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -1,7 +1,6 @@ from __future__ import annotations import re -from collections import deque from inspect import getfile from typing import ( TYPE_CHECKING, @@ -34,6 +33,7 @@ from .css.styles import RenderStyles, Styles from .css.tokenize import IDENTIFIER from .message_pump import MessagePump from .timer import Timer +from .walk import walk_breadth_first, walk_depth_first if TYPE_CHECKING: from .app import App @@ -41,11 +41,11 @@ if TYPE_CHECKING: from .screen import Screen from .widget import Widget -from textual._typing import Literal, TypeAlias +from textual._typing import Literal _re_identifier = re.compile(IDENTIFIER) -WalkMethod: TypeAlias = Literal["depth", "breadth"] +WalkMethod = Literal["depth", "breadth"] class BadIdentifier(Exception): @@ -600,7 +600,7 @@ class DOMNode(MessagePump): node._attach(self) _append(node) - WalkType = TypeVar("WalkType") + WalkType = TypeVar("WalkType", bound="DOMNode") @overload def walk_children( @@ -645,43 +645,12 @@ class DOMNode(MessagePump): """ - def walk_depth_first() -> Iterable[DOMNode]: - """Walk the tree depth first (parents first).""" - stack: list[Iterator[DOMNode]] = [iter(self.children)] - pop = stack.pop - push = stack.append - check_type = filter_type or DOMNode - - if with_self and isinstance(self, check_type): - yield self - while stack: - node = next(stack[-1], None) - if node is None: - pop() - else: - if isinstance(node, check_type): - yield node - if node.children: - push(iter(node.children)) - - def walk_breadth_first() -> Iterable[DOMNode]: - """Walk the tree breadth first (children first).""" - queue: deque[DOMNode] = deque() - popleft = queue.popleft - extend = queue.extend - check_type = filter_type or DOMNode - - if with_self and isinstance(self, check_type): - yield self - extend(self.children) - while queue: - node = popleft() - if isinstance(node, check_type): - yield node - extend(node.children) + check_type = filter_type or DOMNode node_generator = ( - walk_depth_first() if method == "depth" else walk_breadth_first() + walk_depth_first(self, check_type, with_root=with_self) + if method == "depth" + else walk_breadth_first(self, check_type, with_root=with_self) ) # We want a snapshot of the DOM at this point So that it doesn't @@ -689,7 +658,7 @@ class DOMNode(MessagePump): nodes = list(node_generator) if reverse: nodes.reverse() - return nodes + return cast("list[WalkType]", nodes) def get_child(self, id: str) -> DOMNode: """Return the first child (immediate descendent) of this node with the given ID. From d93e300d953b41aa8fab3eab8cc8b89c5163315d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 11 Nov 2022 09:33:31 +0000 Subject: [PATCH 54/83] walk.py --- src/textual/walk.py | 124 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 src/textual/walk.py diff --git a/src/textual/walk.py b/src/textual/walk.py new file mode 100644 index 000000000..6065558ae --- /dev/null +++ b/src/textual/walk.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from collections import deque +from typing import Iterable, Iterator, TypeVar, overload, TYPE_CHECKING + +if TYPE_CHECKING: + from .dom import DOMNode + + WalkType = TypeVar("WalkType", bound=DOMNode) + + +@overload +def walk_depth_first( + root: DOMNode, + *, + with_root: bool = True, +) -> Iterable[DOMNode]: + ... + + +@overload +def walk_depth_first( + root: WalkType, + filter_type: type[WalkType], + *, + with_root: bool = True, +) -> Iterable[WalkType]: + ... + + +def walk_depth_first( + root: DOMNode, + filter_type: type[WalkType] | None = None, + *, + with_root: bool = True, +) -> Iterable[DOMNode] | Iterable[WalkType]: + """Walk the tree depth first (parents first). + + !!! note: + + Do not mutate the DOM while iterating. If you certain you need this, consider `Widget.walk_children`. + + Args: + root (DOMNode): The root note (starting point). + filter_type (type[WalkType] | None, optional): Optional DOMNode subclass to filter by, or ``None`` for no filter. + Defaults to None. + with_root (bool, optional): Include the root in the walk. Defaults to True. + + Returns: + Iterable[DOMNode] | Iterable[WalkType]: An iterable of DOMNodes, or the type specified in `filter_type`. + + """ + stack: list[Iterator[DOMNode]] = [iter(root.children)] + pop = stack.pop + push = stack.append + check_type = filter_type or DOMNode + + if with_root and isinstance(root, check_type): + yield root + while stack: + node = next(stack[-1], None) + if node is None: + pop() + else: + if isinstance(node, check_type): + yield node + if node.children: + push(iter(node.children)) + + +@overload +def walk_breadth_first( + root: DOMNode, + *, + with_root: bool = True, +) -> Iterable[DOMNode]: + ... + + +@overload +def walk_breadth_first( + root: WalkType, + filter_type: type[WalkType], + *, + with_root: bool = True, +) -> Iterable[WalkType]: + ... + + +def walk_breadth_first( + root: DOMNode, + filter_type: type[WalkType] | None = None, + *, + with_root: bool = True, +) -> Iterable[DOMNode] | Iterable[WalkType]: + """Walk the tree breadth first (children first). + + !!! note: + + Do not mutate the DOM while iterating. If you certain you need this, consider `Widget.walk_children`. + + Args: + root (DOMNode): The root note (starting point). + filter_type (type[WalkType] | None, optional): Optional DOMNode subclass to filter by, or ``None`` for no filter. + Defaults to None. + with_root (bool, optional): Include the root in the walk. Defaults to True. + + Returns: + Iterable[DOMNode] | Iterable[WalkType]: An iterable of DOMNodes, or the type specified in `filter_type`. + + """ + queue: deque[DOMNode] = deque() + popleft = queue.popleft + extend = queue.extend + check_type = filter_type or DOMNode + + if with_root and isinstance(root, check_type): + yield root + extend(root.children) + while queue: + node = popleft() + if isinstance(node, check_type): + yield node + extend(node.children) From 44e44da784e9301cadc2989c15faaaf96a758608 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 11 Nov 2022 09:38:17 +0000 Subject: [PATCH 55/83] docstrings --- src/textual/walk.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/textual/walk.py b/src/textual/walk.py index 6065558ae..e6146b235 100644 --- a/src/textual/walk.py +++ b/src/textual/walk.py @@ -38,7 +38,8 @@ def walk_depth_first( !!! note: - Do not mutate the DOM while iterating. If you certain you need this, consider `Widget.walk_children`. + Avoid changing the DOM (mounting, removing etc.) while iterating with this function. + Consider [Widget.walk_children] which doesn't have this limitation. Args: root (DOMNode): The root note (starting point). @@ -47,7 +48,7 @@ def walk_depth_first( with_root (bool, optional): Include the root in the walk. Defaults to True. Returns: - Iterable[DOMNode] | Iterable[WalkType]: An iterable of DOMNodes, or the type specified in `filter_type`. + Iterable[DOMNode] | Iterable[WalkType]: An iterable of DOMNodes, or the type specified in ``filter_type``. """ stack: list[Iterator[DOMNode]] = [iter(root.children)] @@ -97,7 +98,8 @@ def walk_breadth_first( !!! note: - Do not mutate the DOM while iterating. If you certain you need this, consider `Widget.walk_children`. + Avoid changing the DOM (mounting, removing etc.) while iterating with this function. + Consider [Widget.walk_children] which doesn't have this limitation. Args: root (DOMNode): The root note (starting point). @@ -106,7 +108,7 @@ def walk_breadth_first( with_root (bool, optional): Include the root in the walk. Defaults to True. Returns: - Iterable[DOMNode] | Iterable[WalkType]: An iterable of DOMNodes, or the type specified in `filter_type`. + Iterable[DOMNode] | Iterable[WalkType]: An iterable of DOMNodes, or the type specified in ``filter_type``. """ queue: deque[DOMNode] = deque() From 41263c32dfdae24767e30e14f6b8e13ff889af05 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 11 Nov 2022 09:41:14 +0000 Subject: [PATCH 56/83] restore type alias --- src/textual/dom.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index da12a8dc1..dbb878e27 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -41,11 +41,12 @@ if TYPE_CHECKING: from .screen import Screen from .widget import Widget -from textual._typing import Literal +from textual._typing import Literal, TypeAlias _re_identifier = re.compile(IDENTIFIER) -WalkMethod = Literal["depth", "breadth"] + +WalkMethod: TypeAlias = Literal["depth", "breadth"] class BadIdentifier(Exception): From b2d9f4703d8f0cdb464f03ccc65fc1a938e7a8e3 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 11 Nov 2022 09:55:56 +0000 Subject: [PATCH 57/83] keep pylance happy --- src/textual/dom.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index dbb878e27..0373f9e04 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -659,7 +659,7 @@ class DOMNode(MessagePump): nodes = list(node_generator) if reverse: nodes.reverse() - return cast("list[WalkType]", nodes) + return cast("list[DOMNode]", nodes) def get_child(self, id: str) -> DOMNode: """Return the first child (immediate descendent) of this node with the given ID. From e83a3a79bd93d3fda976ebfee2ef307f3a6b59ed Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 11 Nov 2022 10:05:12 +0000 Subject: [PATCH 58/83] Added walk to docs --- CHANGELOG.md | 1 + docs/api/walk.md | 1 + mkdocs.yml | 1 + poetry.lock | 220 ++++++++++++++++++++++++-------------------- pyproject.toml | 2 +- src/textual/walk.py | 8 +- 6 files changed, 129 insertions(+), 104 deletions(-) create mode 100644 docs/api/walk.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a3fd85e4..5e288df0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). `DOMNode.ancestors`. - Improved the speed of `DOMQuery.remove`. - Added DataTable.clear +- Added low-level `textual.walk` methods. ### Changed diff --git a/docs/api/walk.md b/docs/api/walk.md new file mode 100644 index 000000000..7781c5514 --- /dev/null +++ b/docs/api/walk.md @@ -0,0 +1 @@ +::: textual.walk diff --git a/mkdocs.yml b/mkdocs.yml index 4677863ad..1245e197b 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -117,6 +117,7 @@ nav: - "api/screen.md" - "api/static.md" - "api/timer.md" + - "api/walk.md" - "api/widget.md" - "Blog": - blog/index.md diff --git a/poetry.lock b/poetry.lock index 8f20ae475..b98b0925f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -22,11 +22,11 @@ speedups = ["Brotli", "aiodns", "cchardet"] [[package]] name = "aiosignal" -version = "1.2.0" +version = "1.3.1" description = "aiosignal: a list of registered asynchronous callbacks" category = "main" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" [package.dependencies] frozenlist = ">=1.1.0" @@ -205,7 +205,7 @@ testing = ["covdefaults (>=2.2)", "coverage (>=6.4.2)", "pytest (>=7.1.2)", "pyt [[package]] name = "frozenlist" -version = "1.3.1" +version = "1.3.3" description = "A list-like structure which implements collections.abc.MutableSequence" category = "main" optional = false @@ -390,7 +390,7 @@ mkdocs = ">=1.1" [[package]] name = "mkdocs-material" -version = "8.5.8" +version = "8.5.9" description = "Documentation that simply works" category = "dev" optional = false @@ -483,7 +483,7 @@ python-versions = ">=3.7" [[package]] name = "mypy" -version = "0.982" +version = "0.990" description = "Optional static typing for Python" category = "dev" optional = false @@ -497,6 +497,7 @@ typing-extensions = ">=3.10" [package.extras] dmypy = ["psutil (>=4.0)"] +install-types = ["pip"] python2 = ["typed-ast (>=1.4.0,<2)"] reports = ["lxml"] @@ -603,7 +604,7 @@ plugins = ["importlib-metadata"] [[package]] name = "pymdown-extensions" -version = "9.7" +version = "9.8" description = "Extension pack for Python Markdown." category = "dev" optional = false @@ -794,7 +795,7 @@ python-versions = ">=3.6" [[package]] name = "syrupy" -version = "3.0.4" +version = "3.0.5" description = "Pytest Snapshot Test Utility" category = "dev" optional = false @@ -928,7 +929,7 @@ dev = ["aiohttp", "click", "msgpack"] [metadata] lock-version = "1.1" python-versions = "^3.7" -content-hash = "9d355751c84f02b15b267922c96b2ca172dfe2e18e0afaf0d2e6794458ef5667" +content-hash = "0c33f462f79d31068c0d74743f47c1d713ecc8ba009eaef1bef832f1272e4b1a" [metadata.files] aiohttp = [ @@ -1021,8 +1022,8 @@ aiohttp = [ {file = "aiohttp-3.8.3.tar.gz", hash = "sha256:3828fb41b7203176b82fe5d699e0d845435f2374750a44b480ea6b930f6be269"}, ] aiosignal = [ - {file = "aiosignal-1.2.0-py3-none-any.whl", hash = "sha256:26e62109036cd181df6e6ad646f91f0dcfd05fe16d0cb924138ff2ab75d64e3a"}, - {file = "aiosignal-1.2.0.tar.gz", hash = "sha256:78ed67db6c7b7ced4f98e495e572106d5c432a93e1ddd1bf475e1dc05f5b7df2"}, + {file = "aiosignal-1.3.1-py3-none-any.whl", hash = "sha256:f8376fb07dd1e86a584e4fcdec80b36b7f81aac666ebc724e2c090300dd83b17"}, + {file = "aiosignal-1.3.1.tar.gz", hash = "sha256:54cd96e15e1649b75d6c87526a6ff0b6c1b0dd3459f43d9ca11d48c339b68cfc"}, ] async-timeout = [ {file = "async-timeout-4.0.2.tar.gz", hash = "sha256:2163e1640ddb52b7a8c80d0a67a08587e5d245cc9c553a74a847056bc2976b15"}, @@ -1155,65 +1156,80 @@ filelock = [ {file = "filelock-3.8.0.tar.gz", hash = "sha256:55447caa666f2198c5b6b13a26d2084d26fa5b115c00d065664b2124680c4edc"}, ] frozenlist = [ - {file = "frozenlist-1.3.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5f271c93f001748fc26ddea409241312a75e13466b06c94798d1a341cf0e6989"}, - {file = "frozenlist-1.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9c6ef8014b842f01f5d2b55315f1af5cbfde284eb184075c189fd657c2fd8204"}, - {file = "frozenlist-1.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:219a9676e2eae91cb5cc695a78b4cb43d8123e4160441d2b6ce8d2c70c60e2f3"}, - {file = "frozenlist-1.3.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b47d64cdd973aede3dd71a9364742c542587db214e63b7529fbb487ed67cddd9"}, - {file = "frozenlist-1.3.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2af6f7a4e93f5d08ee3f9152bce41a6015b5cf87546cb63872cc19b45476e98a"}, - {file = "frozenlist-1.3.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a718b427ff781c4f4e975525edb092ee2cdef6a9e7bc49e15063b088961806f8"}, - {file = "frozenlist-1.3.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c56c299602c70bc1bb5d1e75f7d8c007ca40c9d7aebaf6e4ba52925d88ef826d"}, - {file = "frozenlist-1.3.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:717470bfafbb9d9be624da7780c4296aa7935294bd43a075139c3d55659038ca"}, - {file = "frozenlist-1.3.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:31b44f1feb3630146cffe56344704b730c33e042ffc78d21f2125a6a91168131"}, - {file = "frozenlist-1.3.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c3b31180b82c519b8926e629bf9f19952c743e089c41380ddca5db556817b221"}, - {file = "frozenlist-1.3.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:d82bed73544e91fb081ab93e3725e45dd8515c675c0e9926b4e1f420a93a6ab9"}, - {file = "frozenlist-1.3.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:49459f193324fbd6413e8e03bd65789e5198a9fa3095e03f3620dee2f2dabff2"}, - {file = "frozenlist-1.3.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:94e680aeedc7fd3b892b6fa8395b7b7cc4b344046c065ed4e7a1e390084e8cb5"}, - {file = "frozenlist-1.3.1-cp310-cp310-win32.whl", hash = "sha256:fabb953ab913dadc1ff9dcc3a7a7d3dc6a92efab3a0373989b8063347f8705be"}, - {file = "frozenlist-1.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:eee0c5ecb58296580fc495ac99b003f64f82a74f9576a244d04978a7e97166db"}, - {file = "frozenlist-1.3.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:0bc75692fb3770cf2b5856a6c2c9de967ca744863c5e89595df64e252e4b3944"}, - {file = "frozenlist-1.3.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:086ca1ac0a40e722d6833d4ce74f5bf1aba2c77cbfdc0cd83722ffea6da52a04"}, - {file = "frozenlist-1.3.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b51eb355e7f813bcda00276b0114c4172872dc5fb30e3fea059b9367c18fbcb"}, - {file = "frozenlist-1.3.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:74140933d45271c1a1283f708c35187f94e1256079b3c43f0c2267f9db5845ff"}, - {file = "frozenlist-1.3.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee4c5120ddf7d4dd1eaf079af3af7102b56d919fa13ad55600a4e0ebe532779b"}, - {file = "frozenlist-1.3.1-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97d9e00f3ac7c18e685320601f91468ec06c58acc185d18bb8e511f196c8d4b2"}, - {file = "frozenlist-1.3.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:6e19add867cebfb249b4e7beac382d33215d6d54476bb6be46b01f8cafb4878b"}, - {file = "frozenlist-1.3.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:a027f8f723d07c3f21963caa7d585dcc9b089335565dabe9c814b5f70c52705a"}, - {file = "frozenlist-1.3.1-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:61d7857950a3139bce035ad0b0945f839532987dfb4c06cfe160254f4d19df03"}, - {file = "frozenlist-1.3.1-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:53b2b45052e7149ee8b96067793db8ecc1ae1111f2f96fe1f88ea5ad5fd92d10"}, - {file = "frozenlist-1.3.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:bbb1a71b1784e68870800b1bc9f3313918edc63dbb8f29fbd2e767ce5821696c"}, - {file = "frozenlist-1.3.1-cp37-cp37m-win32.whl", hash = "sha256:ab6fa8c7871877810e1b4e9392c187a60611fbf0226a9e0b11b7b92f5ac72792"}, - {file = "frozenlist-1.3.1-cp37-cp37m-win_amd64.whl", hash = "sha256:f89139662cc4e65a4813f4babb9ca9544e42bddb823d2ec434e18dad582543bc"}, - {file = "frozenlist-1.3.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:4c0c99e31491a1d92cde8648f2e7ccad0e9abb181f6ac3ddb9fc48b63301808e"}, - {file = "frozenlist-1.3.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:61e8cb51fba9f1f33887e22488bad1e28dd8325b72425f04517a4d285a04c519"}, - {file = "frozenlist-1.3.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:cc2f3e368ee5242a2cbe28323a866656006382872c40869b49b265add546703f"}, - {file = "frozenlist-1.3.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:58fb94a01414cddcdc6839807db77ae8057d02ddafc94a42faee6004e46c9ba8"}, - {file = "frozenlist-1.3.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:022178b277cb9277d7d3b3f2762d294f15e85cd2534047e68a118c2bb0058f3e"}, - {file = "frozenlist-1.3.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:572ce381e9fe027ad5e055f143763637dcbac2542cfe27f1d688846baeef5170"}, - {file = "frozenlist-1.3.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:19127f8dcbc157ccb14c30e6f00392f372ddb64a6ffa7106b26ff2196477ee9f"}, - {file = "frozenlist-1.3.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42719a8bd3792744c9b523674b752091a7962d0d2d117f0b417a3eba97d1164b"}, - {file = "frozenlist-1.3.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2743bb63095ef306041c8f8ea22bd6e4d91adabf41887b1ad7886c4c1eb43d5f"}, - {file = "frozenlist-1.3.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:fa47319a10e0a076709644a0efbcaab9e91902c8bd8ef74c6adb19d320f69b83"}, - {file = "frozenlist-1.3.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:52137f0aea43e1993264a5180c467a08a3e372ca9d378244c2d86133f948b26b"}, - {file = "frozenlist-1.3.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:f5abc8b4d0c5b556ed8cd41490b606fe99293175a82b98e652c3f2711b452988"}, - {file = "frozenlist-1.3.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:1e1cf7bc8cbbe6ce3881863671bac258b7d6bfc3706c600008925fb799a256e2"}, - {file = "frozenlist-1.3.1-cp38-cp38-win32.whl", hash = "sha256:0dde791b9b97f189874d654c55c24bf7b6782343e14909c84beebd28b7217845"}, - {file = "frozenlist-1.3.1-cp38-cp38-win_amd64.whl", hash = "sha256:9494122bf39da6422b0972c4579e248867b6b1b50c9b05df7e04a3f30b9a413d"}, - {file = "frozenlist-1.3.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:31bf9539284f39ff9398deabf5561c2b0da5bb475590b4e13dd8b268d7a3c5c1"}, - {file = "frozenlist-1.3.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:e0c8c803f2f8db7217898d11657cb6042b9b0553a997c4a0601f48a691480fab"}, - {file = "frozenlist-1.3.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:da5ba7b59d954f1f214d352308d1d86994d713b13edd4b24a556bcc43d2ddbc3"}, - {file = "frozenlist-1.3.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74e6b2b456f21fc93ce1aff2b9728049f1464428ee2c9752a4b4f61e98c4db96"}, - {file = "frozenlist-1.3.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526d5f20e954d103b1d47232e3839f3453c02077b74203e43407b962ab131e7b"}, - {file = "frozenlist-1.3.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b499c6abe62a7a8d023e2c4b2834fce78a6115856ae95522f2f974139814538c"}, - {file = "frozenlist-1.3.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ab386503f53bbbc64d1ad4b6865bf001414930841a870fc97f1546d4d133f141"}, - {file = "frozenlist-1.3.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5f63c308f82a7954bf8263a6e6de0adc67c48a8b484fab18ff87f349af356efd"}, - {file = "frozenlist-1.3.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:12607804084d2244a7bd4685c9d0dca5df17a6a926d4f1967aa7978b1028f89f"}, - {file = "frozenlist-1.3.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:da1cdfa96425cbe51f8afa43e392366ed0b36ce398f08b60de6b97e3ed4affef"}, - {file = "frozenlist-1.3.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:f810e764617b0748b49a731ffaa525d9bb36ff38332411704c2400125af859a6"}, - {file = "frozenlist-1.3.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:35c3d79b81908579beb1fb4e7fcd802b7b4921f1b66055af2578ff7734711cfa"}, - {file = "frozenlist-1.3.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c92deb5d9acce226a501b77307b3b60b264ca21862bd7d3e0c1f3594022f01bc"}, - {file = "frozenlist-1.3.1-cp39-cp39-win32.whl", hash = "sha256:5e77a8bd41e54b05e4fb2708dc6ce28ee70325f8c6f50f3df86a44ecb1d7a19b"}, - {file = "frozenlist-1.3.1-cp39-cp39-win_amd64.whl", hash = "sha256:625d8472c67f2d96f9a4302a947f92a7adbc1e20bedb6aff8dbc8ff039ca6189"}, - {file = "frozenlist-1.3.1.tar.gz", hash = "sha256:3a735e4211a04ccfa3f4833547acdf5d2f863bfeb01cfd3edaffbc251f15cec8"}, + {file = "frozenlist-1.3.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ff8bf625fe85e119553b5383ba0fb6aa3d0ec2ae980295aaefa552374926b3f4"}, + {file = "frozenlist-1.3.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:dfbac4c2dfcc082fcf8d942d1e49b6aa0766c19d3358bd86e2000bf0fa4a9cf0"}, + {file = "frozenlist-1.3.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b1c63e8d377d039ac769cd0926558bb7068a1f7abb0f003e3717ee003ad85530"}, + {file = "frozenlist-1.3.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7fdfc24dcfce5b48109867c13b4cb15e4660e7bd7661741a391f821f23dfdca7"}, + {file = "frozenlist-1.3.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c926450857408e42f0bbc295e84395722ce74bae69a3b2aa2a65fe22cb14b99"}, + {file = "frozenlist-1.3.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1841e200fdafc3d51f974d9d377c079a0694a8f06de2e67b48150328d66d5483"}, + {file = "frozenlist-1.3.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f470c92737afa7d4c3aacc001e335062d582053d4dbe73cda126f2d7031068dd"}, + {file = "frozenlist-1.3.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:783263a4eaad7c49983fe4b2e7b53fa9770c136c270d2d4bbb6d2192bf4d9caf"}, + {file = "frozenlist-1.3.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:924620eef691990dfb56dc4709f280f40baee568c794b5c1885800c3ecc69816"}, + {file = "frozenlist-1.3.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:ae4dc05c465a08a866b7a1baf360747078b362e6a6dbeb0c57f234db0ef88ae0"}, + {file = "frozenlist-1.3.3-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:bed331fe18f58d844d39ceb398b77d6ac0b010d571cba8267c2e7165806b00ce"}, + {file = "frozenlist-1.3.3-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:02c9ac843e3390826a265e331105efeab489ffaf4dd86384595ee8ce6d35ae7f"}, + {file = "frozenlist-1.3.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:9545a33965d0d377b0bc823dcabf26980e77f1b6a7caa368a365a9497fb09420"}, + {file = "frozenlist-1.3.3-cp310-cp310-win32.whl", hash = "sha256:d5cd3ab21acbdb414bb6c31958d7b06b85eeb40f66463c264a9b343a4e238642"}, + {file = "frozenlist-1.3.3-cp310-cp310-win_amd64.whl", hash = "sha256:b756072364347cb6aa5b60f9bc18e94b2f79632de3b0190253ad770c5df17db1"}, + {file = "frozenlist-1.3.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b4395e2f8d83fbe0c627b2b696acce67868793d7d9750e90e39592b3626691b7"}, + {file = "frozenlist-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:14143ae966a6229350021384870458e4777d1eae4c28d1a7aa47f24d030e6678"}, + {file = "frozenlist-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5d8860749e813a6f65bad8285a0520607c9500caa23fea6ee407e63debcdbef6"}, + {file = "frozenlist-1.3.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23d16d9f477bb55b6154654e0e74557040575d9d19fe78a161bd33d7d76808e8"}, + {file = "frozenlist-1.3.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eb82dbba47a8318e75f679690190c10a5e1f447fbf9df41cbc4c3afd726d88cb"}, + {file = "frozenlist-1.3.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9309869032abb23d196cb4e4db574232abe8b8be1339026f489eeb34a4acfd91"}, + {file = "frozenlist-1.3.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a97b4fe50b5890d36300820abd305694cb865ddb7885049587a5678215782a6b"}, + {file = "frozenlist-1.3.3-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c188512b43542b1e91cadc3c6c915a82a5eb95929134faf7fd109f14f9892ce4"}, + {file = "frozenlist-1.3.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:303e04d422e9b911a09ad499b0368dc551e8c3cd15293c99160c7f1f07b59a48"}, + {file = "frozenlist-1.3.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:0771aed7f596c7d73444c847a1c16288937ef988dc04fb9f7be4b2aa91db609d"}, + {file = "frozenlist-1.3.3-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:66080ec69883597e4d026f2f71a231a1ee9887835902dbe6b6467d5a89216cf6"}, + {file = "frozenlist-1.3.3-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:41fe21dc74ad3a779c3d73a2786bdf622ea81234bdd4faf90b8b03cad0c2c0b4"}, + {file = "frozenlist-1.3.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f20380df709d91525e4bee04746ba612a4df0972c1b8f8e1e8af997e678c7b81"}, + {file = "frozenlist-1.3.3-cp311-cp311-win32.whl", hash = "sha256:f30f1928162e189091cf4d9da2eac617bfe78ef907a761614ff577ef4edfb3c8"}, + {file = "frozenlist-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:a6394d7dadd3cfe3f4b3b186e54d5d8504d44f2d58dcc89d693698e8b7132b32"}, + {file = "frozenlist-1.3.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8df3de3a9ab8325f94f646609a66cbeeede263910c5c0de0101079ad541af332"}, + {file = "frozenlist-1.3.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0693c609e9742c66ba4870bcee1ad5ff35462d5ffec18710b4ac89337ff16e27"}, + {file = "frozenlist-1.3.3-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd4210baef299717db0a600d7a3cac81d46ef0e007f88c9335db79f8979c0d3d"}, + {file = "frozenlist-1.3.3-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:394c9c242113bfb4b9aa36e2b80a05ffa163a30691c7b5a29eba82e937895d5e"}, + {file = "frozenlist-1.3.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6327eb8e419f7d9c38f333cde41b9ae348bec26d840927332f17e887a8dcb70d"}, + {file = "frozenlist-1.3.3-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2e24900aa13212e75e5b366cb9065e78bbf3893d4baab6052d1aca10d46d944c"}, + {file = "frozenlist-1.3.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:3843f84a6c465a36559161e6c59dce2f2ac10943040c2fd021cfb70d58c4ad56"}, + {file = "frozenlist-1.3.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:84610c1502b2461255b4c9b7d5e9c48052601a8957cd0aea6ec7a7a1e1fb9420"}, + {file = "frozenlist-1.3.3-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:c21b9aa40e08e4f63a2f92ff3748e6b6c84d717d033c7b3438dd3123ee18f70e"}, + {file = "frozenlist-1.3.3-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:efce6ae830831ab6a22b9b4091d411698145cb9b8fc869e1397ccf4b4b6455cb"}, + {file = "frozenlist-1.3.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:40de71985e9042ca00b7953c4f41eabc3dc514a2d1ff534027f091bc74416401"}, + {file = "frozenlist-1.3.3-cp37-cp37m-win32.whl", hash = "sha256:180c00c66bde6146a860cbb81b54ee0df350d2daf13ca85b275123bbf85de18a"}, + {file = "frozenlist-1.3.3-cp37-cp37m-win_amd64.whl", hash = "sha256:9bbbcedd75acdfecf2159663b87f1bb5cfc80e7cd99f7ddd9d66eb98b14a8411"}, + {file = "frozenlist-1.3.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:034a5c08d36649591be1cbb10e09da9f531034acfe29275fc5454a3b101ce41a"}, + {file = "frozenlist-1.3.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ba64dc2b3b7b158c6660d49cdb1d872d1d0bf4e42043ad8d5006099479a194e5"}, + {file = "frozenlist-1.3.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:47df36a9fe24054b950bbc2db630d508cca3aa27ed0566c0baf661225e52c18e"}, + {file = "frozenlist-1.3.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:008a054b75d77c995ea26629ab3a0c0d7281341f2fa7e1e85fa6153ae29ae99c"}, + {file = "frozenlist-1.3.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:841ea19b43d438a80b4de62ac6ab21cfe6827bb8a9dc62b896acc88eaf9cecba"}, + {file = "frozenlist-1.3.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e235688f42b36be2b6b06fc37ac2126a73b75fb8d6bc66dd632aa35286238703"}, + {file = "frozenlist-1.3.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca713d4af15bae6e5d79b15c10c8522859a9a89d3b361a50b817c98c2fb402a2"}, + {file = "frozenlist-1.3.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ac5995f2b408017b0be26d4a1d7c61bce106ff3d9e3324374d66b5964325448"}, + {file = "frozenlist-1.3.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:a4ae8135b11652b08a8baf07631d3ebfe65a4c87909dbef5fa0cdde440444ee4"}, + {file = "frozenlist-1.3.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:4ea42116ceb6bb16dbb7d526e242cb6747b08b7710d9782aa3d6732bd8d27649"}, + {file = "frozenlist-1.3.3-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:810860bb4bdce7557bc0febb84bbd88198b9dbc2022d8eebe5b3590b2ad6c842"}, + {file = "frozenlist-1.3.3-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:ee78feb9d293c323b59a6f2dd441b63339a30edf35abcb51187d2fc26e696d13"}, + {file = "frozenlist-1.3.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:0af2e7c87d35b38732e810befb9d797a99279cbb85374d42ea61c1e9d23094b3"}, + {file = "frozenlist-1.3.3-cp38-cp38-win32.whl", hash = "sha256:899c5e1928eec13fd6f6d8dc51be23f0d09c5281e40d9cf4273d188d9feeaf9b"}, + {file = "frozenlist-1.3.3-cp38-cp38-win_amd64.whl", hash = "sha256:7f44e24fa70f6fbc74aeec3e971f60a14dde85da364aa87f15d1be94ae75aeef"}, + {file = "frozenlist-1.3.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:2b07ae0c1edaa0a36339ec6cce700f51b14a3fc6545fdd32930d2c83917332cf"}, + {file = "frozenlist-1.3.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ebb86518203e12e96af765ee89034a1dbb0c3c65052d1b0c19bbbd6af8a145e1"}, + {file = "frozenlist-1.3.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5cf820485f1b4c91e0417ea0afd41ce5cf5965011b3c22c400f6d144296ccbc0"}, + {file = "frozenlist-1.3.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5c11e43016b9024240212d2a65043b70ed8dfd3b52678a1271972702d990ac6d"}, + {file = "frozenlist-1.3.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8fa3c6e3305aa1146b59a09b32b2e04074945ffcfb2f0931836d103a2c38f936"}, + {file = "frozenlist-1.3.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:352bd4c8c72d508778cf05ab491f6ef36149f4d0cb3c56b1b4302852255d05d5"}, + {file = "frozenlist-1.3.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:65a5e4d3aa679610ac6e3569e865425b23b372277f89b5ef06cf2cdaf1ebf22b"}, + {file = "frozenlist-1.3.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1e2c1185858d7e10ff045c496bbf90ae752c28b365fef2c09cf0fa309291669"}, + {file = "frozenlist-1.3.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f163d2fd041c630fed01bc48d28c3ed4a3b003c00acd396900e11ee5316b56bb"}, + {file = "frozenlist-1.3.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:05cdb16d09a0832eedf770cb7bd1fe57d8cf4eaf5aced29c4e41e3f20b30a784"}, + {file = "frozenlist-1.3.3-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:8bae29d60768bfa8fb92244b74502b18fae55a80eac13c88eb0b496d4268fd2d"}, + {file = "frozenlist-1.3.3-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:eedab4c310c0299961ac285591acd53dc6723a1ebd90a57207c71f6e0c2153ab"}, + {file = "frozenlist-1.3.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3bbdf44855ed8f0fbcd102ef05ec3012d6a4fd7c7562403f76ce6a52aeffb2b1"}, + {file = "frozenlist-1.3.3-cp39-cp39-win32.whl", hash = "sha256:efa568b885bca461f7c7b9e032655c0c143d305bf01c30caf6db2854a4532b38"}, + {file = "frozenlist-1.3.3-cp39-cp39-win_amd64.whl", hash = "sha256:cfe33efc9cb900a4c46f91a5ceba26d6df370ffddd9ca386eb1d4f0ad97b9ea9"}, + {file = "frozenlist-1.3.3.tar.gz", hash = "sha256:58bcc55721e8a90b88332d6cd441261ebb22342e238296bb330968952fbb3a6a"}, ] ghp-import = [ {file = "ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343"}, @@ -1310,8 +1326,8 @@ mkdocs-autorefs = [ {file = "mkdocs_autorefs-0.4.1-py3-none-any.whl", hash = "sha256:a2248a9501b29dc0cc8ba4c09f4f47ff121945f6ce33d760f145d6f89d313f5b"}, ] mkdocs-material = [ - {file = "mkdocs_material-8.5.8-py3-none-any.whl", hash = "sha256:7ff092299e3a63cef99cd87e4a6cc7e7d9ec31fd190d766fd147c35572e6d593"}, - {file = "mkdocs_material-8.5.8.tar.gz", hash = "sha256:61396251819cf7f547f70a09ce6a7edb2ff5d32e47b9199769020b2d20a83d44"}, + {file = "mkdocs_material-8.5.9-py3-none-any.whl", hash = "sha256:143ea55843b3747b640e1110824d91e8a4c670352380e166e64959f9abe98862"}, + {file = "mkdocs_material-8.5.9.tar.gz", hash = "sha256:45eeabb23d2caba8fa3b85c91d9ec8e8b22add716e9bba8faf16d56af8aa5622"}, ] mkdocs-material-extensions = [ {file = "mkdocs_material_extensions-1.1-py3-none-any.whl", hash = "sha256:bcc2e5fc70c0ec50e59703ee6e639d87c7e664c0c441c014ea84461a90f1e902"}, @@ -1445,30 +1461,36 @@ multidict = [ {file = "multidict-6.0.2.tar.gz", hash = "sha256:5ff3bd75f38e4c43f1f470f2df7a4d430b821c4ce22be384e1459cb57d6bb013"}, ] mypy = [ - {file = "mypy-0.982-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5085e6f442003fa915aeb0a46d4da58128da69325d8213b4b35cc7054090aed5"}, - {file = "mypy-0.982-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:41fd1cf9bc0e1c19b9af13a6580ccb66c381a5ee2cf63ee5ebab747a4badeba3"}, - {file = "mypy-0.982-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f793e3dd95e166b66d50e7b63e69e58e88643d80a3dcc3bcd81368e0478b089c"}, - {file = "mypy-0.982-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86ebe67adf4d021b28c3f547da6aa2cce660b57f0432617af2cca932d4d378a6"}, - {file = "mypy-0.982-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:175f292f649a3af7082fe36620369ffc4661a71005aa9f8297ea473df5772046"}, - {file = "mypy-0.982-cp310-cp310-win_amd64.whl", hash = "sha256:8ee8c2472e96beb1045e9081de8e92f295b89ac10c4109afdf3a23ad6e644f3e"}, - {file = "mypy-0.982-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:58f27ebafe726a8e5ccb58d896451dd9a662a511a3188ff6a8a6a919142ecc20"}, - {file = "mypy-0.982-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d6af646bd46f10d53834a8e8983e130e47d8ab2d4b7a97363e35b24e1d588947"}, - {file = "mypy-0.982-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e7aeaa763c7ab86d5b66ff27f68493d672e44c8099af636d433a7f3fa5596d40"}, - {file = "mypy-0.982-cp37-cp37m-win_amd64.whl", hash = "sha256:724d36be56444f569c20a629d1d4ee0cb0ad666078d59bb84f8f887952511ca1"}, - {file = "mypy-0.982-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:14d53cdd4cf93765aa747a7399f0961a365bcddf7855d9cef6306fa41de01c24"}, - {file = "mypy-0.982-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:26ae64555d480ad4b32a267d10cab7aec92ff44de35a7cd95b2b7cb8e64ebe3e"}, - {file = "mypy-0.982-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:6389af3e204975d6658de4fb8ac16f58c14e1bacc6142fee86d1b5b26aa52bda"}, - {file = "mypy-0.982-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b35ce03a289480d6544aac85fa3674f493f323d80ea7226410ed065cd46f206"}, - {file = "mypy-0.982-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:c6e564f035d25c99fd2b863e13049744d96bd1947e3d3d2f16f5828864506763"}, - {file = "mypy-0.982-cp38-cp38-win_amd64.whl", hash = "sha256:cebca7fd333f90b61b3ef7f217ff75ce2e287482206ef4a8b18f32b49927b1a2"}, - {file = "mypy-0.982-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a705a93670c8b74769496280d2fe6cd59961506c64f329bb179970ff1d24f9f8"}, - {file = "mypy-0.982-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:75838c649290d83a2b83a88288c1eb60fe7a05b36d46cbea9d22efc790002146"}, - {file = "mypy-0.982-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:91781eff1f3f2607519c8b0e8518aad8498af1419e8442d5d0afb108059881fc"}, - {file = "mypy-0.982-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eaa97b9ddd1dd9901a22a879491dbb951b5dec75c3b90032e2baa7336777363b"}, - {file = "mypy-0.982-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a692a8e7d07abe5f4b2dd32d731812a0175626a90a223d4b58f10f458747dd8a"}, - {file = "mypy-0.982-cp39-cp39-win_amd64.whl", hash = "sha256:eb7a068e503be3543c4bd329c994103874fa543c1727ba5288393c21d912d795"}, - {file = "mypy-0.982-py3-none-any.whl", hash = "sha256:1021c241e8b6e1ca5a47e4d52601274ac078a89845cfde66c6d5f769819ffa1d"}, - {file = "mypy-0.982.tar.gz", hash = "sha256:85f7a343542dc8b1ed0a888cdd34dca56462654ef23aa673907305b260b3d746"}, + {file = "mypy-0.990-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:aaf1be63e0207d7d17be942dcf9a6b641745581fe6c64df9a38deb562a7dbafa"}, + {file = "mypy-0.990-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d555aa7f44cecb7ea3c0ac69d58b1a5afb92caa017285a8e9c4efbf0518b61b4"}, + {file = "mypy-0.990-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f694d6d09a460b117dccb6857dda269188e3437c880d7b60fa0014fa872d1e9"}, + {file = "mypy-0.990-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:269f0dfb6463b8780333310ff4b5134425157ef0d2b1d614015adaf6d6a7eabd"}, + {file = "mypy-0.990-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8798c8ed83aa809f053abff08664bdca056038f5a02af3660de00b7290b64c47"}, + {file = "mypy-0.990-cp310-cp310-win_amd64.whl", hash = "sha256:47a9955214615108c3480a500cfda8513a0b1cd3c09a1ed42764ca0dd7b931dd"}, + {file = "mypy-0.990-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4a8a6c10f4c63fbf6ad6c03eba22c9331b3946a4cec97f008e9ffb4d3b31e8e2"}, + {file = "mypy-0.990-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cd2dd3730ba894ec2a2082cc703fbf3e95a08479f7be84912e3131fc68809d46"}, + {file = "mypy-0.990-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7da0005e47975287a92b43276e460ac1831af3d23032c34e67d003388a0ce8d0"}, + {file = "mypy-0.990-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:262c543ef24deb10470a3c1c254bb986714e2b6b1a67d66daf836a548a9f316c"}, + {file = "mypy-0.990-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3ff201a0c6d3ea029d73b1648943387d75aa052491365b101f6edd5570d018ea"}, + {file = "mypy-0.990-cp311-cp311-win_amd64.whl", hash = "sha256:1767830da2d1afa4e62b684647af0ff79b401f004d7fa08bc5b0ce2d45bcd5ec"}, + {file = "mypy-0.990-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:6826d9c4d85bbf6d68cb279b561de6a4d8d778ca8e9ab2d00ee768ab501a9852"}, + {file = "mypy-0.990-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46897755f944176fbc504178422a5a2875bbf3f7436727374724842c0987b5af"}, + {file = "mypy-0.990-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:0680389c34284287fe00e82fc8bccdea9aff318f7e7d55b90d967a13a9606013"}, + {file = "mypy-0.990-cp37-cp37m-win_amd64.whl", hash = "sha256:b08541a06eed35b543ae1a6b301590eb61826a1eb099417676ddc5a42aa151c5"}, + {file = "mypy-0.990-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:be88d665e76b452c26fb2bdc3d54555c01226fba062b004ede780b190a50f9db"}, + {file = "mypy-0.990-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9b8f4a8213b1fd4b751e26b59ae0e0c12896568d7e805861035c7a15ed6dc9eb"}, + {file = "mypy-0.990-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:2b6f85c2ad378e3224e017904a051b26660087b3b76490d533b7344f1546d3ff"}, + {file = "mypy-0.990-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ee5f99817ee70254e7eb5cf97c1b11dda29c6893d846c8b07bce449184e9466"}, + {file = "mypy-0.990-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49082382f571c3186ce9ea0bd627cb1345d4da8d44a8377870f4442401f0a706"}, + {file = "mypy-0.990-cp38-cp38-win_amd64.whl", hash = "sha256:aba38e3dd66bdbafbbfe9c6e79637841928ea4c79b32e334099463c17b0d90ef"}, + {file = "mypy-0.990-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:9d851c09b981a65d9d283a8ccb5b1d0b698e580493416a10942ef1a04b19fd37"}, + {file = "mypy-0.990-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d847dd23540e2912d9667602271e5ebf25e5788e7da46da5ffd98e7872616e8e"}, + {file = "mypy-0.990-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cc6019808580565040cd2a561b593d7c3c646badd7e580e07d875eb1bf35c695"}, + {file = "mypy-0.990-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2a3150d409609a775c8cb65dbe305c4edd7fe576c22ea79d77d1454acd9aeda8"}, + {file = "mypy-0.990-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3227f14fe943524f5794679156488f18bf8d34bfecd4623cf76bc55958d229c5"}, + {file = "mypy-0.990-cp39-cp39-win_amd64.whl", hash = "sha256:c76c769c46a1e6062a84837badcb2a7b0cdb153d68601a61f60739c37d41cc74"}, + {file = "mypy-0.990-py3-none-any.whl", hash = "sha256:8f1940325a8ed460ba03d19ab83742260fa9534804c317224e5d4e5aa588e2d6"}, + {file = "mypy-0.990.tar.gz", hash = "sha256:72382cb609142dba3f04140d016c94b4092bc7b4d98ca718740dc989e5271b8d"}, ] mypy-extensions = [ {file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"}, @@ -1507,8 +1529,8 @@ pygments = [ {file = "Pygments-2.13.0.tar.gz", hash = "sha256:56a8508ae95f98e2b9bdf93a6be5ae3f7d8af858b43e02c5a2ff083726be40c1"}, ] pymdown-extensions = [ - {file = "pymdown_extensions-9.7-py3-none-any.whl", hash = "sha256:767d07d9dead0f52f5135545c01f4ed627f9a7918ee86c646d893e24c59db87d"}, - {file = "pymdown_extensions-9.7.tar.gz", hash = "sha256:651b0107bc9ee790aedea3673cb88832c0af27d2569cf45c2de06f1d65292e96"}, + {file = "pymdown_extensions-9.8-py3-none-any.whl", hash = "sha256:8e62688a8b1128acd42fa823f3d429d22f4284b5e6dd4d3cd56721559a5a211b"}, + {file = "pymdown_extensions-9.8.tar.gz", hash = "sha256:1bd4a173095ef8c433b831af1f3cb13c10883be0c100ae613560668e594651f7"}, ] pyparsing = [ {file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"}, @@ -1605,8 +1627,8 @@ smmap = [ {file = "smmap-5.0.0.tar.gz", hash = "sha256:c840e62059cd3be204b0c9c9f74be2c09d5648eddd4580d9314c3ecde0b30936"}, ] syrupy = [ - {file = "syrupy-3.0.4-py3-none-any.whl", hash = "sha256:85c4f5c51618eefab02e60e0172664a22987f20ea17efa815c4832cd64822fc6"}, - {file = "syrupy-3.0.4.tar.gz", hash = "sha256:cbb1e28149340e31a01d3644a234d3195a4b6806c7b8c18e4930ca9add5c6af1"}, + {file = "syrupy-3.0.5-py3-none-any.whl", hash = "sha256:6dc0472cc690782b517d509a2854b5ca552a9851acf43b8a847cfa1aed6f6b01"}, + {file = "syrupy-3.0.5.tar.gz", hash = "sha256:928962a6c14abb2695be9a49b42a016a4e4abdb017a69104cde958f2faf01f98"}, ] time-machine = [ {file = "time-machine-2.8.2.tar.gz", hash = "sha256:2ff3cd145c381ac87b1c35400475a8f019b15dc2267861aad0466f55b49e7813"}, diff --git a/pyproject.toml b/pyproject.toml index 71b9335a2..937fd3590 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,7 +47,7 @@ dev = ["aiohttp", "click", "msgpack"] [tool.poetry.dev-dependencies] pytest = "^7.1.3" black = "^22.3.0" -mypy = "^0.982" +mypy = "^0.990" pytest-cov = "^2.12.1" mkdocs = "^1.3.0" mkdocstrings = {extras = ["python"], version = "^0.19.0"} diff --git a/src/textual/walk.py b/src/textual/walk.py index e6146b235..1126d9d30 100644 --- a/src/textual/walk.py +++ b/src/textual/walk.py @@ -36,10 +36,10 @@ def walk_depth_first( ) -> Iterable[DOMNode] | Iterable[WalkType]: """Walk the tree depth first (parents first). - !!! note: + !!! note Avoid changing the DOM (mounting, removing etc.) while iterating with this function. - Consider [Widget.walk_children] which doesn't have this limitation. + Consider [walk_children][textual.dom.DOMNode.walk_children] which doesn't have this limitation. Args: root (DOMNode): The root note (starting point). @@ -96,10 +96,10 @@ def walk_breadth_first( ) -> Iterable[DOMNode] | Iterable[WalkType]: """Walk the tree breadth first (children first). - !!! note: + !!! note Avoid changing the DOM (mounting, removing etc.) while iterating with this function. - Consider [Widget.walk_children] which doesn't have this limitation. + Consider [walk_children][textual.dom.DOMNode.walk_children] which doesn't have this limitation. Args: root (DOMNode): The root note (starting point). From bc851d5466679723e4725194fe7987648f65b753 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Fri, 11 Nov 2022 17:21:32 +0000 Subject: [PATCH 59/83] Correct the description of the default screenshot action location --- src/textual/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/app.py b/src/textual/app.py index c37128f0a..91c9d1854 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -587,7 +587,7 @@ class App(Generic[ReturnType], DOMNode): Args: filename (str | None, optional): Filename of screenshot, or None to auto-generate. Defaults to None. - path (str, optional): Path to directory. Defaults to "~/". + path (str, optional): Path to directory. Defaults to current working directory. """ self.save_screenshot(filename, path) From 90c14815a0087a2c3322bd0589a475aed91ad657 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Fri, 11 Nov 2022 17:59:33 +0000 Subject: [PATCH 60/83] Add missing close quote to height Python example --- docs/styles/height.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/styles/height.md b/docs/styles/height.md index 589806815..b4488ac07 100644 --- a/docs/styles/height.md +++ b/docs/styles/height.md @@ -46,6 +46,6 @@ width: auto ```python self.styles.height = 10 -self.styles.height = "50% +self.styles.height = "50%" self.styles.height = "auto" ``` From 1549c81ff1ac08fbd500ebde801bfa8ce65bccce Mon Sep 17 00:00:00 2001 From: Aaron Stephens Date: Fri, 11 Nov 2022 12:06:26 -0800 Subject: [PATCH 61/83] feat: initial datatable reactive values --- src/textual/widgets/_data_table.py | 36 +++++++++++++++++++++--------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index d1ceb90b7..e209735b2 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -159,14 +159,39 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): "datatable--cursor", } + show_header = Reactive(True) + fixed_rows = Reactive(0) + fixed_columns = Reactive(0) + zebra_stripes = Reactive(False) + header_height = Reactive(1) + show_cursor = Reactive(True) + cursor_type = Reactive(CELL) + + cursor_cell: Reactive[Coord] = Reactive(Coord(0, 0), repaint=False) + hover_cell: Reactive[Coord] = Reactive(Coord(0, 0), repaint=False) + def __init__( self, *, + show_header: bool = True, + fixed_rows: int = 0, + fixed_columns: int = 0, + zebra_stripes: bool = False, + header_height: int = 1, + show_cursor: bool = True, name: str | None = None, id: str | None = None, classes: str | None = None, ) -> None: super().__init__(name=name, id=id, classes=classes) + + self.show_header = show_header + self.fixed_rows = fixed_rows + self.fixed_columns = fixed_columns + self.zebra_stripes = zebra_stripes + self.header_height = header_height + self.show_cursor = show_cursor + self.columns: list[Column] = [] self.rows: dict[int, Row] = {} self.data: dict[int, list[CellType]] = {} @@ -187,17 +212,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self._require_update_dimensions: bool = False self._new_rows: set[int] = set() - show_header = Reactive(True) - fixed_rows = Reactive(0) - fixed_columns = Reactive(0) - zebra_stripes = Reactive(False) - header_height = Reactive(1) - show_cursor = Reactive(True) - cursor_type = Reactive(CELL) - - cursor_cell: Reactive[Coord] = Reactive(Coord(0, 0), repaint=False) - hover_cell: Reactive[Coord] = Reactive(Coord(0, 0), repaint=False) - @property def hover_row(self) -> int: return self.hover_cell.row From 4a1d1565f34ce89c017b491aecd6ec0f8a613783 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 12 Nov 2022 11:12:49 +0000 Subject: [PATCH 62/83] Fix animation on styles --- CHANGELOG.md | 1 + src/textual/_animator.py | 22 ++++++++++- src/textual/_resolve.py | 2 +- src/textual/box_model.py | 12 +++--- src/textual/css/scalar.py | 6 +-- src/textual/css/scalar_animation.py | 21 +++++----- src/textual/css/styles.py | 60 +++++++++++++++++------------ 7 files changed, 78 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 99a7e0ca0..43ca426f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Fixed - Fixed DataTable row not updating after add https://github.com/Textualize/textual/issues/1026 +- Fixed issues with animation. Now objects of different types may be animated. ## [0.4.0] - 2022-11-08 diff --git a/src/textual/_animator.py b/src/textual/_animator.py index 0b8ebb1ab..29c2e3545 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -11,6 +11,7 @@ from . import _clock from ._callback import invoke from ._easing import DEFAULT_EASING, EASING from ._types import CallbackType +from .css.scalar import Scalar from .timer import Timer if sys.version_info >= (3, 8): @@ -26,6 +27,10 @@ EasingFunction = Callable[[float], float] T = TypeVar("T") +class AnimationError(Exception): + pass + + @runtime_checkable class Animatable(Protocol): def blend(self: T, destination: T, factor: float) -> T: # pragma: no cover @@ -118,7 +123,7 @@ class BoundAnimator: def __call__( self, attribute: str, - value: float | Animatable, + value: str | float | Animatable, *, final_value: object = ..., duration: float | None = None, @@ -140,6 +145,9 @@ class BoundAnimator: on_complete (CallbackType | None, optional): A callable to invoke when the animation is finished. Defaults to None. """ + start_value = getattr(self._obj, attribute) + if isinstance(value, str) and hasattr(start_value, "parse"): + value = start_value.parse(value) easing_function = EASING[easing] if isinstance(easing, str) else easing return self._animator.animate( self._obj, @@ -270,9 +278,11 @@ class Animator: easing_function = EASING[easing] if isinstance(easing, str) else easing animation: Animation | None = None + if hasattr(obj, "__textual_animation__"): animation = getattr(obj, "__textual_animation__")( attribute, + getattr(obj, attribute), value, start_time, duration=duration, @@ -280,7 +290,17 @@ class Animator: easing=easing_function, on_complete=on_complete, ) + if animation is None: + + if not isinstance(value, (int, float)) and not isinstance( + value, Animatable + ): + raise AnimationError( + f"Don't know how to animate {value!r}; " + "Can only animate , , or objects with a blend method" + ) + start_value = getattr(obj, attribute) if start_value == value: diff --git a/src/textual/_resolve.py b/src/textual/_resolve.py index 3ace9759d..f3b9d3b85 100644 --- a/src/textual/_resolve.py +++ b/src/textual/_resolve.py @@ -43,7 +43,7 @@ def resolve( ( (scalar, None) if scalar.is_fraction - else (scalar, scalar.resolve_dimension(size, viewport)) + else (scalar, scalar.resolve(size, viewport)) ) for scalar in dimensions ] diff --git a/src/textual/box_model.py b/src/textual/box_model.py index 2bd93aa00..265f19cdc 100644 --- a/src/textual/box_model.py +++ b/src/textual/box_model.py @@ -65,7 +65,7 @@ def get_box_model( else: # An explicit width styles_width = styles.width - content_width = styles_width.resolve_dimension( + content_width = styles_width.resolve( sizing_container - styles.margin.totals, viewport, width_fraction ) if is_border_box and styles_width.excludes_border: @@ -73,14 +73,14 @@ def get_box_model( if styles.min_width is not None: # Restrict to minimum width, if set - min_width = styles.min_width.resolve_dimension( + min_width = styles.min_width.resolve( content_container, viewport, width_fraction ) content_width = max(content_width, min_width) if styles.max_width is not None: # Restrict to maximum width, if set - max_width = styles.max_width.resolve_dimension( + max_width = styles.max_width.resolve( content_container, viewport, width_fraction ) if is_border_box: @@ -100,7 +100,7 @@ def get_box_model( else: styles_height = styles.height # Explicit height set - content_height = styles_height.resolve_dimension( + content_height = styles_height.resolve( sizing_container - styles.margin.totals, viewport, height_fraction ) if is_border_box and styles_height.excludes_border: @@ -108,14 +108,14 @@ def get_box_model( if styles.min_height is not None: # Restrict to minimum height, if set - min_height = styles.min_height.resolve_dimension( + min_height = styles.min_height.resolve( content_container, viewport, height_fraction ) content_height = max(content_height, min_height) if styles.max_height is not None: # Restrict maximum height, if set - max_height = styles.max_height.resolve_dimension( + max_height = styles.max_height.resolve( content_container, viewport, height_fraction ) content_height = min(content_height, max_height) diff --git a/src/textual/css/scalar.py b/src/textual/css/scalar.py index bb4368514..8c5fa39b9 100644 --- a/src/textual/css/scalar.py +++ b/src/textual/css/scalar.py @@ -268,7 +268,7 @@ class Scalar(NamedTuple): return scalar @lru_cache(maxsize=4096) - def resolve_dimension( + def resolve( self, size: Size, viewport: Size, fraction_unit: Fraction | None = None ) -> Fraction: """Resolve scalar with units in to a dimensions. @@ -363,8 +363,8 @@ class ScalarOffset(NamedTuple): """ x, y = self return Offset( - round(x.resolve_dimension(size, viewport)), - round(y.resolve_dimension(size, viewport)), + round(x.resolve(size, viewport)), + round(y.resolve(size, viewport)), ) diff --git a/src/textual/css/scalar_animation.py b/src/textual/css/scalar_animation.py index 697f94405..21afd51f8 100644 --- a/src/textual/css/scalar_animation.py +++ b/src/textual/css/scalar_animation.py @@ -2,25 +2,26 @@ from __future__ import annotations from typing import TYPE_CHECKING -from .scalar import ScalarOffset +from .scalar import ScalarOffset, Scalar from .._animator import Animation from .._animator import EasingFunction from .._types import CallbackType -from ..geometry import Offset + if TYPE_CHECKING: - from ..widget import Widget - from .styles import Styles + from ..dom import DOMNode + + from .styles import StylesBase class ScalarAnimation(Animation): def __init__( self, - widget: Widget, - styles: Styles, + widget: DOMNode, + styles: StylesBase, start_time: float, attribute: str, - value: ScalarOffset, + value: ScalarOffset | Scalar, duration: float | None, speed: float | None, easing: EasingFunction, @@ -40,8 +41,8 @@ class ScalarAnimation(Animation): size = widget.outer_size viewport = widget.app.size - self.start: Offset = getattr(styles, attribute).resolve(size, viewport) - self.destination: Offset = value.resolve(size, viewport) + self.start = getattr(styles, attribute).resolve(size, viewport) + self.destination = value.resolve(size, viewport) if speed is not None: distance = self.start.get_distance_to(self.destination) @@ -62,7 +63,7 @@ class ScalarAnimation(Animation): value = self.start.blend(self.destination, eased_factor) else: value = self.start + (self.destination - self.start) * eased_factor - current = self.styles._rules.get(self.attribute) + current = self.styles.get_rule(self.attribute) if current != value: setattr(self.styles, f"{self.attribute}", value) diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 085fd2b9e..3b17b9e0c 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -300,6 +300,40 @@ class StylesBase(ABC): link_hover_background = ColorProperty("transparent") link_hover_style = StyleFlagsProperty() + def __textual_animation__( + self, + attribute: str, + start_value: object, + value: object, + start_time: float, + duration: float | None, + speed: float | None, + easing: EasingFunction, + on_complete: CallbackType | None = None, + ) -> ScalarAnimation | None: + if self.node is None: + return None + + if isinstance(start_value, (Scalar, ScalarOffset)): + + if isinstance(value, (int, float)): + value = Scalar(value, Unit.CELLS, Unit.CELLS) + + if not isinstance(value, (Scalar, ScalarOffset)): + return None + return ScalarAnimation( + self.node, + self, + start_time, + attribute, + value, + duration=duration, + speed=speed, + easing=easing, + on_complete=on_complete, + ) + return None + def __eq__(self, styles: object) -> bool: """Check that Styles contains the same rules.""" if not isinstance(styles, StylesBase): @@ -627,30 +661,6 @@ class Styles(StylesBase): if self.important: yield "important", self.important - def __textual_animation__( - self, - attribute: str, - value: Any, - start_time: float, - duration: float | None, - speed: float | None, - easing: EasingFunction, - on_complete: CallbackType | None = None, - ) -> ScalarAnimation | None: - if isinstance(value, ScalarOffset): - return ScalarAnimation( - self.node, - self, - start_time, - attribute, - value, - duration=duration, - speed=speed, - easing=easing, - on_complete=on_complete, - ) - return None - def _get_border_css_lines( self, rules: RulesMap, name: str ) -> Iterable[tuple[str, str]]: @@ -935,7 +945,7 @@ class RenderStyles(StylesBase): def animate( self, attribute: str, - value: float | Animatable, + value: str | float | Animatable, *, final_value: object = ..., duration: float | None = None, From 61379cd435d8192e4c318dcd2f08e38daa00e8ed Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 12 Nov 2022 11:17:25 +0000 Subject: [PATCH 63/83] comments --- src/textual/css/styles.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 3b17b9e0c..17e5963a8 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -314,13 +314,17 @@ class StylesBase(ABC): if self.node is None: return None + # Check we are animating a Scalar or Scalar offset if isinstance(start_value, (Scalar, ScalarOffset)): + # If destination is a number, we can convert that to a scalar if isinstance(value, (int, float)): value = Scalar(value, Unit.CELLS, Unit.CELLS) + # We can only animate to Scalar if not isinstance(value, (Scalar, ScalarOffset)): return None + return ScalarAnimation( self.node, self, From 698e6f56e47b7fa9b0a0fd546a869f2a5c4d96f1 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 12 Nov 2022 11:23:20 +0000 Subject: [PATCH 64/83] docs --- src/textual/_animator.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/textual/_animator.py b/src/textual/_animator.py index 29c2e3545..8270456ac 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -5,13 +5,12 @@ import sys from abc import ABC, abstractmethod from dataclasses import dataclass from functools import partial -from typing import Any, Callable, TypeVar, TYPE_CHECKING +from typing import TYPE_CHECKING, Any, Callable, TypeVar from . import _clock from ._callback import invoke from ._easing import DEFAULT_EASING, EASING from ._types import CallbackType -from .css.scalar import Scalar from .timer import Timer if sys.version_info >= (3, 8): @@ -24,11 +23,12 @@ if TYPE_CHECKING: EasingFunction = Callable[[float], float] -T = TypeVar("T") - class AnimationError(Exception): - pass + """An issue prevented animation from starting.""" + + +T = TypeVar("T") @runtime_checkable @@ -147,6 +147,9 @@ class BoundAnimator: """ start_value = getattr(self._obj, attribute) if isinstance(value, str) and hasattr(start_value, "parse"): + # Color and Scalar have a parse method + # I'm exploiting a coincidence here, but I think this should be a first-class concept + # TODO: add a `Parsable` protocol value = start_value.parse(value) easing_function = EASING[easing] if isinstance(easing, str) else easing return self._animator.animate( From a0ab6c99f1da3ea5a4731cf907306dd194ebca4a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 12 Nov 2022 11:47:14 +0000 Subject: [PATCH 65/83] test for animation --- CHANGELOG.md | 1 + src/textual/pilot.py | 4 ++++ tests/test_animation.py | 33 +++++++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+) create mode 100644 tests/test_animation.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 43ca426f1..05165a611 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - It is now possible to `await` a `DOMQuery.remove`. Note that this changes the return value of `DOMQuery.remove`, which uses to return `self`. https://github.com/Textualize/textual/issues/1094 +- Added Pilot.wait_for_animation ### Changed diff --git a/src/textual/pilot.py b/src/textual/pilot.py index 0d6f47b0e..b6b64359d 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -42,6 +42,10 @@ class Pilot: """ await asyncio.sleep(delay) + async def wait_for_animation(self) -> None: + """Wait for any animation to complete.""" + await self._app.animator.wait_for_idle() + async def exit(self, result: object) -> None: """Exit the app with the given result. diff --git a/tests/test_animation.py b/tests/test_animation.py new file mode 100644 index 000000000..5cfee395b --- /dev/null +++ b/tests/test_animation.py @@ -0,0 +1,33 @@ +from time import time +from textual.app import App, ComposeResult +from textual.widgets import Static + + +class AnimApp(App): + CSS = """ + #foo { + height: 1; + } + + """ + + def compose(self) -> ComposeResult: + yield Static("foo", id="foo") + + +async def test_animate_height() -> None: + """Test animating styles.height works.""" + + # Styles.height is a scalar, which makes it more complicated to animate + + app = AnimApp() + + async with app.run_test() as pilot: + static = app.query_one(Static) + assert static.size.height == 1 + static.styles.animate("height", 100, duration=0.5) + start = time() + await pilot.wait_for_animation() + elapsed = time() - start + assert elapsed > 0.5 + assert static.styles.height.value == 100 From b752ab5a04bc01b7041f0daedc76c0662cfe9e68 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 12 Nov 2022 12:00:49 +0000 Subject: [PATCH 66/83] more tests --- src/textual/pilot.py | 1 + tests/test_animation.py | 14 +++++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/textual/pilot.py b/src/textual/pilot.py index b6b64359d..84247669d 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -41,6 +41,7 @@ class Pilot: delay (float, optional): Seconds to pause. Defaults to 50ms. """ await asyncio.sleep(delay) + await asyncio.sleep(0) async def wait_for_animation(self) -> None: """Wait for any animation to complete.""" diff --git a/tests/test_animation.py b/tests/test_animation.py index 5cfee395b..4ca897f11 100644 --- a/tests/test_animation.py +++ b/tests/test_animation.py @@ -1,4 +1,6 @@ +import asyncio from time import time + from textual.app import App, ComposeResult from textual.widgets import Static @@ -25,9 +27,19 @@ async def test_animate_height() -> None: async with app.run_test() as pilot: static = app.query_one(Static) assert static.size.height == 1 - static.styles.animate("height", 100, duration=0.5) + static.styles.animate("height", 100, duration=0.5, easing="linear") start = time() + # Wait for half the animation + await pilot.pause(0.25) + # Check we reached the half way point + assert static.styles.height.value >= 50 + elapsed = time() - start + # Check at least that much time has elapsed + assert 0.5 > elapsed > 0.25 + # Wait for the animation to finished await pilot.wait_for_animation() elapsed = time() - start + # Check that the full time has elapsed assert elapsed > 0.5 + # Check the height reached the maximum assert static.styles.height.value == 100 From 949745a08a139c665ca5c3830c2709e80c0560b8 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 12 Nov 2022 12:05:59 +0000 Subject: [PATCH 67/83] add sleep to test --- src/textual/pilot.py | 3 +++ tests/test_animation.py | 1 + 2 files changed, 4 insertions(+) diff --git a/src/textual/pilot.py b/src/textual/pilot.py index 84247669d..85e90d272 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -40,6 +40,9 @@ class Pilot: Args: delay (float, optional): Seconds to pause. Defaults to 50ms. """ + # These sleep zeros, are to force asyncio to give up a time-slice, + # So that any pending coroutines have ran + await asyncio.sleep(0) await asyncio.sleep(delay) await asyncio.sleep(0) diff --git a/tests/test_animation.py b/tests/test_animation.py index 4ca897f11..ec053875e 100644 --- a/tests/test_animation.py +++ b/tests/test_animation.py @@ -29,6 +29,7 @@ async def test_animate_height() -> None: assert static.size.height == 1 static.styles.animate("height", 100, duration=0.5, easing="linear") start = time() + # Wait for half the animation await pilot.pause(0.25) # Check we reached the half way point From 2605785288d5968033b0191f4f0c63fd4528357a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 12 Nov 2022 12:12:23 +0000 Subject: [PATCH 68/83] use per_counter --- tests/test_animation.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/test_animation.py b/tests/test_animation.py index ec053875e..c86381107 100644 --- a/tests/test_animation.py +++ b/tests/test_animation.py @@ -1,5 +1,4 @@ -import asyncio -from time import time +from time import perf_counter from textual.app import App, ComposeResult from textual.widgets import Static @@ -28,18 +27,18 @@ async def test_animate_height() -> None: static = app.query_one(Static) assert static.size.height == 1 static.styles.animate("height", 100, duration=0.5, easing="linear") - start = time() + start = perf_counter() # Wait for half the animation await pilot.pause(0.25) # Check we reached the half way point assert static.styles.height.value >= 50 - elapsed = time() - start + elapsed = perf_counter() - start # Check at least that much time has elapsed assert 0.5 > elapsed > 0.25 # Wait for the animation to finished await pilot.wait_for_animation() - elapsed = time() - start + elapsed = perf_counter() - start # Check that the full time has elapsed assert elapsed > 0.5 # Check the height reached the maximum From 46d12fcfe0b34316081aa892fbe93a4ebd7f365b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 12 Nov 2022 12:17:01 +0000 Subject: [PATCH 69/83] win fix --- tests/test_animation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_animation.py b/tests/test_animation.py index c86381107..9b38137be 100644 --- a/tests/test_animation.py +++ b/tests/test_animation.py @@ -32,7 +32,7 @@ async def test_animate_height() -> None: # Wait for half the animation await pilot.pause(0.25) # Check we reached the half way point - assert static.styles.height.value >= 50 + assert abs(static.styles.height.value - 50) < 5 elapsed = perf_counter() - start # Check at least that much time has elapsed assert 0.5 > elapsed > 0.25 From e9489f194f7471e946c722ee035df3931eff10f4 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 12 Nov 2022 12:22:31 +0000 Subject: [PATCH 70/83] tweak to anim test --- src/textual/pilot.py | 3 --- tests/test_animation.py | 8 +------- 2 files changed, 1 insertion(+), 10 deletions(-) diff --git a/src/textual/pilot.py b/src/textual/pilot.py index 85e90d272..5d0427905 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -41,10 +41,7 @@ class Pilot: delay (float, optional): Seconds to pause. Defaults to 50ms. """ # These sleep zeros, are to force asyncio to give up a time-slice, - # So that any pending coroutines have ran - await asyncio.sleep(0) await asyncio.sleep(delay) - await asyncio.sleep(0) async def wait_for_animation(self) -> None: """Wait for any animation to complete.""" diff --git a/tests/test_animation.py b/tests/test_animation.py index 9b38137be..bf7072b70 100644 --- a/tests/test_animation.py +++ b/tests/test_animation.py @@ -26,16 +26,10 @@ async def test_animate_height() -> None: async with app.run_test() as pilot: static = app.query_one(Static) assert static.size.height == 1 + assert static.styles.height.value == 1 static.styles.animate("height", 100, duration=0.5, easing="linear") start = perf_counter() - # Wait for half the animation - await pilot.pause(0.25) - # Check we reached the half way point - assert abs(static.styles.height.value - 50) < 5 - elapsed = perf_counter() - start - # Check at least that much time has elapsed - assert 0.5 > elapsed > 0.25 # Wait for the animation to finished await pilot.wait_for_animation() elapsed = perf_counter() - start From 0a45ff374ff0504238f8b2b6f22b61aa57c64b49 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 12 Nov 2022 12:24:36 +0000 Subject: [PATCH 71/83] update to greater than or equals --- tests/test_animation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_animation.py b/tests/test_animation.py index bf7072b70..0498e7693 100644 --- a/tests/test_animation.py +++ b/tests/test_animation.py @@ -34,6 +34,6 @@ async def test_animate_height() -> None: await pilot.wait_for_animation() elapsed = perf_counter() - start # Check that the full time has elapsed - assert elapsed > 0.5 + assert elapsed >= 0.5 # Check the height reached the maximum assert static.styles.height.value == 100 From 162759ec815319a1c8ec5e03a58d3a8f18deb9c2 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 12 Nov 2022 17:45:02 +0000 Subject: [PATCH 72/83] social links --- mkdocs.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/mkdocs.yml b/mkdocs.yml index 1245e197b..e7b4e5f9e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -211,3 +211,17 @@ plugins: extra_css: - stylesheets/custom.css + + +extra: + social: + - icon: fontawesome/brands/twitter + link: https://twitter.com/textualizeio + name: textualizeio on Twitter + - icon: fontawesome/brands/github + link: https://github.com/textualize/textual/ + name: Textual on Github + - icon: fontawesome/brands/discord + link: https://discord.gg/Enf6Z3qhVr + name: Textual Discord server +copyright: Copyright © Textualize, Inc From 2675d617ffcca80944a3a70434d2bff4348ca0f1 Mon Sep 17 00:00:00 2001 From: Aaron Stephens Date: Sat, 12 Nov 2022 12:04:34 -0800 Subject: [PATCH 73/83] fix: initialize reactive attributes last --- src/textual/widgets/_data_table.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/textual/widgets/_data_table.py b/src/textual/widgets/_data_table.py index 50df2ac53..6d5c9dd01 100644 --- a/src/textual/widgets/_data_table.py +++ b/src/textual/widgets/_data_table.py @@ -201,13 +201,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): ) -> None: super().__init__(name=name, id=id, classes=classes) - self.show_header = show_header - self.fixed_rows = fixed_rows - self.fixed_columns = fixed_columns - self.zebra_stripes = zebra_stripes - self.header_height = header_height - self.show_cursor = show_cursor - self.columns: list[Column] = [] self.rows: dict[int, Row] = {} self.data: dict[int, list[CellType]] = {} @@ -228,6 +221,13 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True): self._require_update_dimensions: bool = False self._new_rows: set[int] = set() + self.show_header = show_header + self.fixed_rows = fixed_rows + self.fixed_columns = fixed_columns + self.zebra_stripes = zebra_stripes + self.header_height = header_height + self.show_cursor = show_cursor + @property def hover_row(self) -> int: return self.hover_cell.row From 3c7391408616c466424249547d812c032cc3574b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 13 Nov 2022 13:22:56 +0000 Subject: [PATCH 74/83] fix deadlock on tests --- src/textual/_animator.py | 9 ++++++--- src/textual/app.py | 21 ++++++++++++++------- 2 files changed, 20 insertions(+), 10 deletions(-) diff --git a/src/textual/_animator.py b/src/textual/_animator.py index 8270456ac..25c7e6db8 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -189,9 +189,12 @@ class Animator: async def stop(self) -> None: """Stop the animator task.""" try: - await self._timer.stop() - except asyncio.CancelledError: - pass + try: + await self._timer.stop() + except asyncio.CancelledError: + pass + finally: + self._idle_event.set() def bind(self, obj: object) -> BoundAnimator: """Bind the animator to a given objects.""" diff --git a/src/textual/app.py b/src/textual/app.py index 9f1640af4..4328854ba 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -803,9 +803,11 @@ class App(Generic[ReturnType], DOMNode): terminal_size=size, ) finally: - if auto_pilot_task is not None: - await auto_pilot_task - await app._shutdown() + try: + if auto_pilot_task is not None: + await auto_pilot_task + finally: + await app._shutdown() return app.return_value @@ -1291,6 +1293,10 @@ class App(Generic[ReturnType], DOMNode): await self.animator.start() + except Exception: + await self.animator.stop() + raise + finally: await self._ready() await invoke_ready_callback() @@ -1303,10 +1309,11 @@ class App(Generic[ReturnType], DOMNode): pass finally: self._running = False - for timer in list(self._timers): - await timer.stop() - - await self.animator.stop() + try: + await self.animator.stop() + finally: + for timer in list(self._timers): + await timer.stop() self._running = True try: From 48e5f5e0267d1019514c3c46263fd97d0cf6c27d Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 14 Nov 2022 12:22:24 +0000 Subject: [PATCH 75/83] Add Widget.move_child Adds a method to Widget that allows moving a child of that widget within its list of children. Options are to move before or after a specific location, or a sibling widget. Seeks to implement #1121. --- CHANGELOG.md | 1 + src/textual/widget.py | 73 ++++++++++++++++++++++ tests/test_widget_child_moving.py | 100 ++++++++++++++++++++++++++++++ 3 files changed, 174 insertions(+) create mode 100644 tests/test_widget_child_moving.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 05165a611..a628871d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). the return value of `DOMQuery.remove`, which uses to return `self`. https://github.com/Textualize/textual/issues/1094 - Added Pilot.wait_for_animation +- Added `Widget.move_child` https://github.com/Textualize/textual/issues/1121 ### Changed diff --git a/src/textual/widget.py b/src/textual/widget.py index 9029eedcb..0f190dfff 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -480,6 +480,79 @@ class Widget(DOMNode): self.app._register(parent, *widgets, before=before, after=after) ) + 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 (int | Widget): The child widget to move. + before: (int | Widget, optional): Optional location to move before. + after: (int | Widget, optional): Optional location to move after. + + Raises: + WidgetError: If there is a problem with the child or target. + """ + + # 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.") + + # Turn the child to move into a reference to the widget, doing some + # checks as we do so. + if isinstance(child, int): + try: + child = self.children[child] + except IndexError: + raise WidgetError( + f"An index of {child} for the child to move is out of bounds" + ) from None + else: + # We got an actual widget, so let's be sure it really is one of + # our children. + try: + _ = self.children.index(child) + except ValueError: + raise WidgetError(f"{child!r} is not a child of {self!r}") from None + + # Next, no matter if we're moving before or after, we just want to + # be sure that the target makes sense at all. So let's concentrate + # on that for a moment. + target = before if after is None else after + if isinstance(target, int): + try: + target = self.children[target] + except IndexError: + raise WidgetError( + f"An index of {target} for the target to move towards is out of bounds" + ) from None + elif isinstance(target, Widget): + # If we got given a widget from the off, let's be sure it's + # actually one of our children. + try: + _ = self.children.index(target) + except ValueError: + raise WidgetError(f"{target!r} is not a child of {self!r}") from None + + # At this point we should know what we're moving, and it should be a + # child; where we're moving it to, which should be within the child + # list; and how we're supposed to move it. All that's left is doing + # the right thing. + self.children._remove(child) + if before is not None: + self.children._insert(self.children.index(target), child) + else: + self.children._insert(self.children.index(target) + 1, child) + + # Request a refresh. + self.refresh(layout=True) + def compose(self) -> ComposeResult: """Called by Textual to create child widgets. diff --git a/tests/test_widget_child_moving.py b/tests/test_widget_child_moving.py new file mode 100644 index 000000000..43aa849eb --- /dev/null +++ b/tests/test_widget_child_moving.py @@ -0,0 +1,100 @@ +import pytest + +from textual.app import App +from textual.widget import Widget, WidgetError + +async def test_widget_move_child() -> None: + """Test moving a widget in a child list.""" + + # Test calling move_child with no direction. + async with App().run_test() as pilot: + child = Widget(Widget()) + await pilot.app.mount(child) + with pytest.raises(WidgetError): + pilot.app.screen.move_child(child) + + # Test calling move_child with more than one direction. + async with App().run_test() as pilot: + child = Widget(Widget()) + await pilot.app.mount(child) + with pytest.raises(WidgetError): + pilot.app.screen.move_child(child, before=1, after=2) + + # Test attempting to move a child that isn't ours. + async with App().run_test() as pilot: + child = Widget(Widget()) + await pilot.app.mount(child) + with pytest.raises(WidgetError): + pilot.app.screen.move_child(Widget(), before=child) + + # Test attempting to move relative to a widget that isn't a child. + async with App().run_test() as pilot: + child = Widget(Widget()) + await pilot.app.mount(child) + with pytest.raises(WidgetError): + pilot.app.screen.move_child(child, before=Widget()) + + # Make a background set of widgets. + widgets = [Widget(id=f"widget-{n}") for n in range( 10 )] + + # Test attempting to move past the end of the child list. + async with App().run_test() as pilot: + container = Widget(*widgets) + await pilot.app.mount(container) + with pytest.raises(WidgetError): + container.move_child(widgets[0], before=len(widgets)+10) + + # Test attempting to move before the end of the child list. + async with App().run_test() as pilot: + container = Widget(*widgets) + await pilot.app.mount(container) + with pytest.raises(WidgetError): + container.move_child(widgets[0], before=-(len(widgets)+10)) + + # Test the different permutations of moving one widget before another. + perms = ( + ( 1, 0 ), + ( widgets[1], 0 ), + ( 1, widgets[ 0 ] ), + ( widgets[ 1 ], widgets[ 0 ]) + ) + for child, target in perms: + async with App().run_test() as pilot: + container = Widget(*widgets) + await pilot.app.mount(container) + container.move_child(child, before=target) + assert container.children[0].id == "widget-1" + assert container.children[1].id == "widget-0" + assert container.children[2].id == "widget-2" + + # Test the different permutations of moving one widget after another. + perms = ( + ( 0, 1 ), + ( widgets[0], 1 ), + ( 0, widgets[ 1 ] ), + ( widgets[ 0 ], widgets[ 1 ]) + ) + for child, target in perms: + async with App().run_test() as pilot: + container = Widget(*widgets) + await pilot.app.mount(container) + container.move_child(child, after=target) + assert container.children[0].id == "widget-1" + assert container.children[1].id == "widget-0" + assert container.children[2].id == "widget-2" + + # Test moving after a child after the last child. + async with App().run_test() as pilot: + container = Widget(*widgets) + await pilot.app.mount(container) + container.move_child(widgets[0], after=widgets[-1]) + assert container.children[0].id == "widget-1" + assert container.children[-1].id == "widget-0" + + # Test moving after a child after the last child's numeric position. + async with App().run_test() as pilot: + container = Widget(*widgets) + await pilot.app.mount(container) + container.move_child(widgets[0], after=widgets[9]) + assert container.children[0].id == "widget-1" + assert container.children[-1].id == "widget-0" From 58ee841dbd1f3b811342b09c936dca1eee9f0fc6 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 14 Nov 2022 16:41:18 +0000 Subject: [PATCH 76/83] DRY the conversion of a child index to a child Widget --- src/textual/widget.py | 54 ++++++++++++++++--------------------------- 1 file changed, 20 insertions(+), 34 deletions(-) diff --git a/src/textual/widget.py b/src/textual/widget.py index 0f190dfff..f9e6dbad6 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -504,41 +504,27 @@ class Widget(DOMNode): elif before is not None and after is not None: raise WidgetError("Only one of `before`or `after` can be handled.") - # Turn the child to move into a reference to the widget, doing some - # checks as we do so. - if isinstance(child, int): - try: - child = self.children[child] - except IndexError: - raise WidgetError( - f"An index of {child} for the child to move is out of bounds" - ) from None - else: - # We got an actual widget, so let's be sure it really is one of - # our children. - try: - _ = self.children.index(child) - except ValueError: - raise WidgetError(f"{child!r} is not a child of {self!r}") from None + def _to_widget(child: int | Widget, called: str) -> Widget: + """Ensure a given child reference is a Widget.""" + if isinstance(child, int): + try: + child = self.children[child] + except IndexError: + raise WidgetError( + f"An index of {child} for the child to {called} is out of bounds" + ) from None + else: + # We got an actual widget, so let's be sure it really is one of + # our children. + try: + _ = self.children.index(child) + except ValueError: + raise WidgetError(f"{child!r} is not a child of {self!r}") from None + return child - # Next, no matter if we're moving before or after, we just want to - # be sure that the target makes sense at all. So let's concentrate - # on that for a moment. - target = before if after is None else after - if isinstance(target, int): - try: - target = self.children[target] - except IndexError: - raise WidgetError( - f"An index of {target} for the target to move towards is out of bounds" - ) from None - elif isinstance(target, Widget): - # If we got given a widget from the off, let's be sure it's - # actually one of our children. - try: - _ = self.children.index(target) - except ValueError: - raise WidgetError(f"{target!r} is not a child of {self!r}") from None + # Ensure the child and target are widgets. + child = _to_widget(child, "move") + target = _to_widget(before if after is None else after, "move towards") # At this point we should know what we're moving, and it should be a # child; where we're moving it to, which should be within the child From 54bf7a9fd3e6933722bdb9135f884a4b2744804f Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 14 Nov 2022 16:56:37 +0000 Subject: [PATCH 77/83] Add a docstring note about before/after being mutually exclusive --- src/textual/widget.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/textual/widget.py b/src/textual/widget.py index f9e6dbad6..2de261227 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -495,6 +495,10 @@ class Widget(DOMNode): 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 From 912980d61581523761eb380bd0ca5101d9fc6eb5 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 15 Nov 2022 09:56:48 +0000 Subject: [PATCH 78/83] Correct a typo in the StylesCache docstring --- src/textual/_styles_cache.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/_styles_cache.py b/src/textual/_styles_cache.py index 3cc5e7512..8fa39e047 100644 --- a/src/textual/_styles_cache.py +++ b/src/textual/_styles_cache.py @@ -53,7 +53,7 @@ def style_links( class StylesCache: - """Responsible for rendering CSS Styles and keeping a cached of rendered lines. + """Responsible for rendering CSS Styles and keeping a cache of rendered lines. The render method applies border, outline, and padding set in the Styles object to widget content. From af1168b4ee89ed461e2bd510de209f55e3f61ed4 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 15 Nov 2022 11:59:01 +0000 Subject: [PATCH 79/83] Don't take transparent to imply not visible This seeks to address #1175, where a containing widget with a transparent background, but with a border, won't show the border. It seems that at the heart of the Compositor it's taking `is_transparent` (of the widget) as one of the indicators that mean that it won't be visible. This wouldn't normally be the case, in that a widget could be transparent (in the background) but could have visible content. Note that some snapshot tests failed with this change, but no material difference was found in those failing snapshot tests. --- src/textual/_compositor.py | 6 +- .../__snapshots__/test_snapshots.ambr | 514 +++++++++--------- 2 files changed, 258 insertions(+), 262 deletions(-) diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py index 8fead2165..ab9d69eca 100644 --- a/src/textual/_compositor.py +++ b/src/textual/_compositor.py @@ -633,11 +633,7 @@ class Compositor: def is_visible(widget: Widget) -> bool: """Return True if the widget is (literally) visible by examining various properties which affect whether it can be seen or not.""" - return ( - widget.visible - and not widget.is_transparent - and widget.styles.opacity > 0 - ) + return widget.visible and widget.styles.opacity > 0 _Region = Region diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 51ad5531c..b10be936d 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -21,162 +21,162 @@ font-weight: 700; } - .terminal-1900837103-matrix { + .terminal-481343241-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1900837103-title { + .terminal-481343241-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1900837103-r1 { fill: #e1e1e1 } - .terminal-1900837103-r2 { fill: #c5c8c6 } - .terminal-1900837103-r3 { fill: #e1e1e1;font-weight: bold } - .terminal-1900837103-r4 { fill: #454a50 } - .terminal-1900837103-r5 { fill: #292b2e } - .terminal-1900837103-r6 { fill: #24292f;font-weight: bold } - .terminal-1900837103-r7 { fill: #555657;font-weight: bold } - .terminal-1900837103-r8 { fill: #000000 } - .terminal-1900837103-r9 { fill: #161617 } - .terminal-1900837103-r10 { fill: #507bb3 } - .terminal-1900837103-r11 { fill: #283c52 } - .terminal-1900837103-r12 { fill: #dde6ed;font-weight: bold } - .terminal-1900837103-r13 { fill: #4f5a62;font-weight: bold } - .terminal-1900837103-r14 { fill: #001541 } - .terminal-1900837103-r15 { fill: #122032 } - .terminal-1900837103-r16 { fill: #7ae998 } - .terminal-1900837103-r17 { fill: #3d6a4a } - .terminal-1900837103-r18 { fill: #0a180e;font-weight: bold } - .terminal-1900837103-r19 { fill: #1e2f23;font-weight: bold } - .terminal-1900837103-r20 { fill: #008139 } - .terminal-1900837103-r21 { fill: #1b4c2f } - .terminal-1900837103-r22 { fill: #ffcf56 } - .terminal-1900837103-r23 { fill: #775f2f } - .terminal-1900837103-r24 { fill: #211505;font-weight: bold } - .terminal-1900837103-r25 { fill: #392b18;font-weight: bold } - .terminal-1900837103-r26 { fill: #b86b00 } - .terminal-1900837103-r27 { fill: #644316 } - .terminal-1900837103-r28 { fill: #e76580 } - .terminal-1900837103-r29 { fill: #683540 } - .terminal-1900837103-r30 { fill: #f5e5e9;font-weight: bold } - .terminal-1900837103-r31 { fill: #6c595e;font-weight: bold } - .terminal-1900837103-r32 { fill: #780028 } - .terminal-1900837103-r33 { fill: #491928 } + .terminal-481343241-r1 { fill: #e1e1e1 } + .terminal-481343241-r2 { fill: #c5c8c6 } + .terminal-481343241-r3 { fill: #e1e1e1;font-weight: bold } + .terminal-481343241-r4 { fill: #454a50 } + .terminal-481343241-r5 { fill: #292b2e } + .terminal-481343241-r6 { fill: #24292f;font-weight: bold } + .terminal-481343241-r7 { fill: #555657;font-weight: bold } + .terminal-481343241-r8 { fill: #000000 } + .terminal-481343241-r9 { fill: #161617 } + .terminal-481343241-r10 { fill: #507bb3 } + .terminal-481343241-r11 { fill: #283c52 } + .terminal-481343241-r12 { fill: #dde6ed;font-weight: bold } + .terminal-481343241-r13 { fill: #4f5a62;font-weight: bold } + .terminal-481343241-r14 { fill: #001541 } + .terminal-481343241-r15 { fill: #122032 } + .terminal-481343241-r16 { fill: #7ae998 } + .terminal-481343241-r17 { fill: #3d6a4a } + .terminal-481343241-r18 { fill: #0a180e;font-weight: bold } + .terminal-481343241-r19 { fill: #1e2f23;font-weight: bold } + .terminal-481343241-r20 { fill: #008139 } + .terminal-481343241-r21 { fill: #1b4c2f } + .terminal-481343241-r22 { fill: #ffcf56 } + .terminal-481343241-r23 { fill: #775f2f } + .terminal-481343241-r24 { fill: #211505;font-weight: bold } + .terminal-481343241-r25 { fill: #392b18;font-weight: bold } + .terminal-481343241-r26 { fill: #b86b00 } + .terminal-481343241-r27 { fill: #644316 } + .terminal-481343241-r28 { fill: #e76580 } + .terminal-481343241-r29 { fill: #683540 } + .terminal-481343241-r30 { fill: #f5e5e9;font-weight: bold } + .terminal-481343241-r31 { fill: #6c595e;font-weight: bold } + .terminal-481343241-r32 { fill: #780028 } + .terminal-481343241-r33 { fill: #491928 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - ButtonsApp + ButtonsApp - - - - - Standard ButtonsDisabled Buttons - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  Default  Default  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  Primary!  Primary!  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  Success!  Success!  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  Warning!  Warning!  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ -  Error!  Error!  - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - + + + + + Standard ButtonsDisabled Buttons + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +  Default  Default  + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +  Primary!  Primary!  + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +  Success!  Success!  + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +  Warning!  Warning!  + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ +  Error!  Error!  + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + @@ -2727,136 +2727,136 @@ font-weight: 700; } - .terminal-4117108672-matrix { + .terminal-1725099926-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-4117108672-title { + .terminal-1725099926-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-4117108672-r1 { fill: #000000 } - .terminal-4117108672-r2 { fill: #c5c8c6 } - .terminal-4117108672-r3 { fill: #008000 } - .terminal-4117108672-r4 { fill: #e5f0e5 } - .terminal-4117108672-r5 { fill: #036a03 } - .terminal-4117108672-r6 { fill: #14191f } + .terminal-1725099926-r1 { fill: #c5c8c6 } + .terminal-1725099926-r2 { fill: #000000 } + .terminal-1725099926-r3 { fill: #008000 } + .terminal-1725099926-r4 { fill: #e5f0e5 } + .terminal-1725099926-r5 { fill: #036a03 } + .terminal-1725099926-r6 { fill: #14191f } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - OverflowApp + OverflowApp - - - - - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - I must not fear.I must not fear. - Fear is the mind-killer.Fear is the mind-killer. - Fear is the little-death that Fear is the little-death that  - brings total obliteration.brings total obliteration. - I will face my fear.I will face my fear. - I will permit it to pass over meI will permit it to pass over me  - and through me.and through me. - And when it has gone past, I And when it has gone past, I will  - will turn the inner eye to see turn the inner eye to see its  - its path.▁▁path. - Where the fear has gone there Where the fear has gone there will - will be nothing. Only I will be nothing. Only I will remain. - remain.▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁I must not fear. - I must not fear.Fear is the mind-killer. - Fear is the mind-killer.Fear is the little-death that  - Fear is the little-death that brings total obliteration. - brings total obliteration.I will face my fear. - I will face my fear.I will permit it to pass over me  - I will permit it to pass over meand through me. + + + + + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + I must not fear.I must not fear. + Fear is the mind-killer.Fear is the mind-killer. + Fear is the little-death that Fear is the little-death that  + brings total obliteration.brings total obliteration. + I will face my fear.I will face my fear. + I will permit it to pass over meI will permit it to pass over me  + and through me.and through me. + And when it has gone past, I And when it has gone past, I will  + will turn the inner eye to see turn the inner eye to see its  + its path.▁▁path. + Where the fear has gone there Where the fear has gone there will + will be nothing. Only I will be nothing. Only I will remain. + remain.▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔ + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁I must not fear. + I must not fear.Fear is the mind-killer. + Fear is the mind-killer.Fear is the little-death that  + Fear is the little-death that brings total obliteration. + brings total obliteration.I will face my fear. + I will face my fear.I will permit it to pass over me  + I will permit it to pass over meand through me. @@ -3197,132 +3197,132 @@ font-weight: 700; } - .terminal-1467518286-matrix { + .terminal-849091649-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-1467518286-title { + .terminal-849091649-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-1467518286-r1 { fill: #3333ff } - .terminal-1467518286-r2 { fill: #c5c8c6 } - .terminal-1467518286-r3 { fill: #14191f } + .terminal-849091649-r1 { fill: #c5c8c6 } + .terminal-849091649-r2 { fill: #3333ff } + .terminal-849091649-r3 { fill: #14191f } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - ScrollbarApp + ScrollbarApp - + - - - - I must not fear. - Fear is the mind-killer. - Fear is the little-death that brings total obliteration. - I will face my fear. - I will permit it to pass over me and through me. - And when it has gone past, I will turn the inner eye to see its path. - Where the fear has gone there will be nothing. Only I will remain. - I must not fear. - Fear is the mind-killer. - Fear is the little-death that brings total obliteration. - I will face my fear. - I will permit it to pass over me and through me.▂▂▂▂ - And when it has gone past, I will turn the inner eye to see its path. - Where the fear has gone there will be nothing. Only I will remain. - I must not fear. - Fear is the mind-killer. - Fear is the little-death that brings total obliteration. - I will face my fear. - I will permit it to pass over me and through me. - And when it has gone past, I will turn the inner eye to see its path. - Where the fear has gone there will be nothing. Only I will remain. + + + + I must not fear. + Fear is the mind-killer. + Fear is the little-death that brings total obliteration. + I will face my fear. + I will permit it to pass over me and through me. + And when it has gone past, I will turn the inner eye to see its path. + Where the fear has gone there will be nothing. Only I will remain. + I must not fear. + Fear is the mind-killer. + Fear is the little-death that brings total obliteration. + I will face my fear. + I will permit it to pass over me and through me.▂▂▂▂ + And when it has gone past, I will turn the inner eye to see its path. + Where the fear has gone there will be nothing. Only I will remain. + I must not fear. + Fear is the mind-killer. + Fear is the little-death that brings total obliteration. + I will face my fear. + I will permit it to pass over me and through me. + And when it has gone past, I will turn the inner eye to see its path. + Where the fear has gone there will be nothing. Only I will remain. @@ -3353,133 +3353,133 @@ font-weight: 700; } - .terminal-2712574715-matrix { + .terminal-2331479198-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-2712574715-title { + .terminal-2331479198-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-2712574715-r1 { fill: #d2d2d2 } - .terminal-2712574715-r2 { fill: #c5c8c6 } - .terminal-2712574715-r3 { fill: #bbbbbb } - .terminal-2712574715-r4 { fill: #800080 } + .terminal-2331479198-r1 { fill: #c5c8c6 } + .terminal-2331479198-r2 { fill: #d2d2d2 } + .terminal-2331479198-r3 { fill: #bbbbbb } + .terminal-2331479198-r4 { fill: #800080 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - ScrollbarApp + ScrollbarApp - + - - - - I must not fear.I must not fear. - Fear is the mind-killer.Fear is the mind-killer. - Fear is the little-death that Fear is the little-death that  - brings total obliteration.brings total obliteration. - I will face my fear.I will face my fear. - I will permit it to pass over I will permit it to pass over  - me and through me.▇▇me and through me.▇▇ - And when it has gone past, I And when it has gone past, I  - will turn the inner eye to seewill turn the inner eye to see - its path.its path. - Where the fear has gone there Where the fear has gone there  - will be nothing. Only I will will be nothing. Only I will  - remain.remain. - I must not fear.I must not fear. - Fear is the mind-killer.Fear is the mind-killer. - Fear is the little-death that Fear is the little-death that  - brings total obliteration.brings total obliteration. - I will face my fear.I will face my fear. - I will permit it to pass over I will permit it to pass over  - me and through me.me and through me. - And when it has gone past, I And when it has gone past, I  + + + + I must not fear.I must not fear. + Fear is the mind-killer.Fear is the mind-killer. + Fear is the little-death that Fear is the little-death that  + brings total obliteration.brings total obliteration. + I will face my fear.I will face my fear. + I will permit it to pass over I will permit it to pass over  + me and through me.▇▇me and through me.▇▇ + And when it has gone past, I And when it has gone past, I  + will turn the inner eye to seewill turn the inner eye to see + its path.its path. + Where the fear has gone there Where the fear has gone there  + will be nothing. Only I will will be nothing. Only I will  + remain.remain. + I must not fear.I must not fear. + Fear is the mind-killer.Fear is the mind-killer. + Fear is the little-death that Fear is the little-death that  + brings total obliteration.brings total obliteration. + I will face my fear.I will face my fear. + I will permit it to pass over I will permit it to pass over  + me and through me.me and through me. + And when it has gone past, I And when it has gone past, I  From 8e89561e2d6eacabfb6a7eb63adf47ae0fbe2a31 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 15 Nov 2022 13:03:06 +0000 Subject: [PATCH 80/83] Cleanup of _border.py All indications are that Border isn't used within Textual any more. --- src/textual/_border.py | 208 +---------------------------------------- 1 file changed, 1 insertion(+), 207 deletions(-) diff --git a/src/textual/_border.py b/src/textual/_border.py index dfcdf01ae..4307281bc 100644 --- a/src/textual/_border.py +++ b/src/textual/_border.py @@ -3,9 +3,7 @@ from __future__ import annotations from functools import lru_cache from typing import cast, Tuple, Union -from rich.console import Console, ConsoleOptions, RenderResult, RenderableType -import rich.repr -from rich.segment import Segment, SegmentLines +from rich.segment import Segment from rich.style import Style from .color import Color @@ -158,164 +156,6 @@ def render_row( return [Segment(box2.text * width, box2.style)] -@rich.repr.auto -class Border: - """Renders Textual CSS borders. - - This is analogous to Rich's `Box` but more flexible. Different borders may be - applied to each of the four edges, and more advanced borders can be achieved through - various combinations of Widget and parent background colors. - - """ - - def __init__( - self, - renderable: RenderableType, - borders: Borders, - inner_color: Color, - outer_color: Color, - outline: bool = False, - ): - self.renderable = renderable - self.edge_styles = borders - self.outline = outline - - ( - (top, top_color), - (right, right_color), - (bottom, bottom_color), - (left, left_color), - ) = borders - self._sides: tuple[EdgeType, EdgeType, EdgeType, EdgeType] - self._sides = (top, right, bottom, left) - from_color = Style.from_color - - self._styles = ( - from_color(top_color.rich_color), - from_color(right_color.rich_color), - from_color(bottom_color.rich_color), - from_color(left_color.rich_color), - ) - self.inner_style = from_color(bgcolor=inner_color.rich_color) - self.outer_style = from_color(bgcolor=outer_color.rich_color) - - def __rich_repr__(self) -> rich.repr.Result: - yield self.renderable - yield self.edge_styles - - def _crop_renderable(self, lines: list[list[Segment]], width: int) -> None: - """Crops a renderable in place. - - Args: - lines (list[list[Segment]]): Segment lines. - width (int): Desired width. - """ - top, right, bottom, left = self._sides - # the 4 following lines rely on the fact that we normalise "none" and "hidden" to en empty string - has_left = bool(left) - has_right = bool(right) - has_top = bool(top) - has_bottom = bool(bottom) - - if has_top: - lines.pop(0) - if has_bottom and lines: - lines.pop(-1) - - # TODO: Divide is probably quite inefficient here, - # It could be much faster for the specific case of one off the start end end - divide = Segment.divide - if has_left and has_right: - for line in lines: - _, line[:] = divide(line, [1, width - 1]) - elif has_left: - for line in lines: - _, line[:] = divide(line, [1, width]) - elif has_right: - for line in lines: - line[:], _ = divide(line, [width - 1, width]) - - def __rich_console__( - self, console: "Console", options: "ConsoleOptions" - ) -> "RenderResult": - top, right, bottom, left = self._sides - style = console.get_style(self.inner_style) - outer_style = console.get_style(self.outer_style) - top_style, right_style, bottom_style, left_style = self._styles - - # ditto than in `_crop_renderable` ☝ - has_left = bool(left) - has_right = bool(right) - has_top = bool(top) - has_bottom = bool(bottom) - - width = options.max_width - has_left - has_right - - if width <= 2: - lines = console.render_lines(self.renderable, options, new_lines=True) - yield SegmentLines(lines) - return - - if self.outline: - render_options = options - else: - if options.height is None: - render_options = options.update_width(width) - else: - new_height = options.height - has_top - has_bottom - if new_height >= 1: - render_options = options.update_dimensions(width, new_height) - else: - render_options = options.update_width(width) - - lines = console.render_lines(self.renderable, render_options) - if self.outline: - self._crop_renderable(lines, options.max_width) - - _Segment = Segment - new_line = _Segment.line() - if has_top: - box1, box2, box3 = get_box(top, style, outer_style, top_style)[0] - if has_left: - yield box1 if top == left else _Segment(" ", box2.style) - yield _Segment(box2.text * width, box2.style) - if has_right: - yield box3 if top == left else _Segment(" ", box3.style) - yield new_line - - left_segment = get_box(left, style, outer_style, left_style)[1][0] - _right_segment = get_box(right, style, outer_style, right_style)[1][2] - right_segment = _Segment(_right_segment.text + "\n", _right_segment.style) - - if has_left and has_right: - for line in lines: - yield left_segment - yield from line - yield right_segment - elif has_left: - for line in lines: - yield left_segment - yield from line - yield new_line - elif has_right: - for line in lines: - yield from line - yield right_segment - else: - for line in lines: - yield from line - yield new_line - - if has_bottom: - box1, box2, box3 = get_box(bottom, style, outer_style, bottom_style)[2] - if has_left: - yield box1 if bottom == left else _Segment(" ", box1.style) - yield _Segment(box2.text * width, box2.style) - if has_right: - yield box3 if bottom == right else _Segment(" ", box3.style) - yield new_line - - _edge_type_normalization_table: dict[EdgeType, EdgeType] = { # i.e. we normalize "border: none;" to "border: ;". # As a result our layout-related calculations that include borders are simpler (and have better performance) @@ -326,49 +166,3 @@ _edge_type_normalization_table: dict[EdgeType, EdgeType] = { def normalize_border_value(value: BorderValue) -> BorderValue: return _edge_type_normalization_table.get(value[0], value[0]), value[1] - - -if __name__ == "__main__": - from rich import print - from rich.text import Text - from rich.padding import Padding - - from .color import Color - - inner = Color.parse("#303F9F") - outer = Color.parse("#212121") - - lorem = """[#C5CAE9]Lorem ipsum dolor sit amet, consectetur adipiscing elit. In velit libero, volutpat nec hendrerit at, faucibus in odio. Aliquam hendrerit nibh sed quam volutpat maximus. Nullam suscipit convallis lorem quis sodales. In tristique lobortis ante et dictum. Ut at finibus ipsum. In urna dolor, placerat et mi facilisis, congue sollicitudin massa. Phasellus felis turpis, cursus eu lectus et, porttitor malesuada augue. Sed feugiat volutpat velit, sollicitudin fringilla velit bibendum faucibus.""" - text = Text.from_markup(lorem) - border = Border( - Padding(text, 1, style="on #303F9F"), - ( - ("none", Color.parse("#C5CAE9")), - ("none", Color.parse("#C5CAE9")), - ("wide", Color.parse("#C5CAE9")), - ("none", Color.parse("#C5CAE9")), - ), - inner_color=inner, - outer_color=outer, - ) - - print( - Padding(border, (1, 2), style="on #212121"), - ) - print() - - border = Border( - Padding(text, 1, style="on #303F9F"), - ( - ("hkey", Color.parse("#8BC34A")), - ("hkey", Color.parse("#8BC34A")), - ("hkey", Color.parse("#8BC34A")), - ("hkey", Color.parse("#8BC34A")), - ), - inner_color=inner, - outer_color=outer, - ) - - print( - Padding(border, (1, 2), style="on #212121"), - ) From 7213834bd45a874187f766f95c3123539ecc8f76 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 15 Nov 2022 13:07:50 +0000 Subject: [PATCH 81/83] Update the CHANGELOG --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05165a611..f6293162f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fixed DataTable row not updating after add https://github.com/Textualize/textual/issues/1026 - Fixed issues with animation. Now objects of different types may be animated. +- Fixed containers with transparent background not showing borders https://github.com/Textualize/textual/issues/1175 ## [0.4.0] - 2022-11-08 From 650b6549662502876dd87d910a45a065200375ed Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 15 Nov 2022 13:28:18 +0000 Subject: [PATCH 82/83] remove nesting of try --- src/textual/_animator.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/textual/_animator.py b/src/textual/_animator.py index 25c7e6db8..f4d373abc 100644 --- a/src/textual/_animator.py +++ b/src/textual/_animator.py @@ -189,10 +189,9 @@ class Animator: async def stop(self) -> None: """Stop the animator task.""" try: - try: - await self._timer.stop() - except asyncio.CancelledError: - pass + await self._timer.stop() + except asyncio.CancelledError: + pass finally: self._idle_event.set() From 9fd1e7605a8f60635062cd038fca7654bfbe4481 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 15 Nov 2022 14:58:38 +0000 Subject: [PATCH 83/83] Create PULL_REQUEST_TEMPLATE.md --- .github/PULL_REQUEST_TEMPLATE.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..1164581d2 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,6 @@ + +**Please review the following checklist.** + +- [ ] Docstrings on all new or modified functions / classes +- [ ] Updated documentation +- [ ] Updated CHANGELOG.md (where appropriate)