diff --git a/CHANGELOG.md b/CHANGELOG.md index 27c3fa5bb..c26553d34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added Shift+scroll wheel and ctrl+scroll wheel to scroll horizontally - Added `Tree.action_toggle_node` to toggle a node without selecting, and bound it to Space https://github.com/Textualize/textual/issues/1433 - Added `Tree.reset` to fully reset a `Tree` https://github.com/Textualize/textual/issues/1437 +- Added DOMNode.watch and DOMNode.is_attached methods https://github.com/Textualize/textual/pull/1750 ### Changed @@ -43,6 +44,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Removed - Methods `MessagePump.emit` and `MessagePump.emit_no_wait` https://github.com/Textualize/textual/pull/1738 +- Removed `reactive.watch` in favor of DOMNode.watch. ## [0.10.1] - 2023-01-20 diff --git a/src/textual/cli/previews/easing.py b/src/textual/cli/previews/easing.py index b81290302..53b7c4475 100644 --- a/src/textual/cli/previews/easing.py +++ b/src/textual/cli/previews/easing.py @@ -5,7 +5,7 @@ from textual._easing import EASING from textual.app import App, ComposeResult from textual.cli.previews.borders import TEXT from textual.containers import Container, Horizontal, Vertical -from textual.reactive import Reactive +from textual.reactive import reactive, var from textual.scrollbar import ScrollBarRender from textual.widget import Widget from textual.widgets import Button, Footer, Label, Input @@ -23,8 +23,8 @@ class EasingButtons(Widget): class Bar(Widget): - position = Reactive.init(START_POSITION) - animation_running = Reactive(False) + position = reactive(START_POSITION) + animation_running = reactive(False) DEFAULT_CSS = """ @@ -53,8 +53,8 @@ class Bar(Widget): class EasingApp(App): - position = Reactive.init(START_POSITION) - duration = Reactive.var(1.0) + position = reactive(START_POSITION) + duration = var(1.0) def on_load(self): self.bind( diff --git a/src/textual/demo.py b/src/textual/demo.py index 362937c02..a470cf72b 100644 --- a/src/textual/demo.py +++ b/src/textual/demo.py @@ -15,7 +15,7 @@ from rich.text import Text from textual.app import App, ComposeResult from textual.binding import Binding from textual.containers import Container, Horizontal -from textual.reactive import reactive, watch +from textual.reactive import reactive from textual.widgets import ( Button, DataTable, @@ -203,7 +203,7 @@ class DarkSwitch(Horizontal): yield Static("Dark mode toggle", classes="label") def on_mount(self) -> None: - watch(self.app, "dark", self.on_dark_change, init=False) + self.watch(self.app, "dark", self.on_dark_change, init=False) def on_dark_change(self, dark: bool) -> None: self.query_one(Switch).value = self.app.dark diff --git a/src/textual/dom.py b/src/textual/dom.py index b6129daed..07f994958 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -22,6 +22,7 @@ from rich.tree import Tree from ._context import NoActiveAppError from ._node_list import NodeList +from ._types import CallbackType from .binding import Bindings, BindingType from .color import BLACK, WHITE, Color from .css._error_tools import friendly_list @@ -31,7 +32,7 @@ from .css.parse import parse_declarations from .css.styles import RenderStyles, Styles from .css.tokenize import IDENTIFIER from .message_pump import MessagePump -from .reactive import Reactive +from .reactive import Reactive, _watch from .timer import Timer from .walk import walk_breadth_first, walk_depth_first @@ -210,6 +211,10 @@ class DOMNode(MessagePump): styles = self._component_styles[name] return styles + def _post_mount(self): + """Called after the object has been mounted.""" + Reactive._initialize_object(self) + @property def _node_bases(self) -> Iterator[Type[DOMNode]]: """Iterator[Type[DOMNode]]: The DOMNode bases classes (including self.__class__)""" @@ -643,6 +648,23 @@ class DOMNode(MessagePump): """ return [child for child in self.children if child.display] + def watch( + self, + obj: DOMNode, + attribute_name: str, + callback: CallbackType, + init: bool = True, + ) -> None: + """Watches for modifications to reactive attributes on another object. + + Args: + obj: Object containing attribute to watch. + attribute_name: Attribute to watch. + callback: A callback to run when attribute changes. + init: Check watchers on first call. + """ + _watch(self, obj, attribute_name, callback, init=init) + def get_pseudo_classes(self) -> Iterable[str]: """Get any pseudo classes applicable to this Node, e.g. hover, focus. diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 4058139ce..d17bd8249 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -123,6 +123,19 @@ class MessagePump(metaclass=MessagePumpMeta): """ return self.app._logger + @property + def is_attached(self) -> bool: + """Is the node is attached to the app via the DOM.""" + from .app import App + + node = self + + while not isinstance(node, App): + if node._parent is None: + return False + node = node._parent + return True + def _attach(self, parent: MessagePump) -> None: """Set the parent, and therefore attach this node to the tree. @@ -358,7 +371,10 @@ class MessagePump(metaclass=MessagePumpMeta): finally: # This is critical, mount may be waiting self._mounted_event.set() - Reactive._initialize_object(self) + self._post_mount() + + def _post_mount(self): + """Called after the object has been mounted.""" async def _process_messages_loop(self) -> None: """Process messages until the queue is closed.""" diff --git a/src/textual/pilot.py b/src/textual/pilot.py index 2051c3098..b68f9409c 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -53,6 +53,7 @@ class Pilot(Generic[ReturnType]): async def wait_for_scheduled_animations(self) -> None: """Wait for any current and scheduled animations to complete.""" await self._app.animator.wait_until_complete() + await wait_for_idle(0) async def exit(self, result: ReturnType) -> None: """Exit the app with the given result. diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 93139501d..58f9a0837 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -7,36 +7,26 @@ from typing import ( Any, Awaitable, Callable, + ClassVar, Generic, Type, TypeVar, - Union, ) import rich.repr from . import events -from ._callback import count_parameters, invoke -from ._types import MessageTarget +from ._callback import count_parameters +from ._types import MessageTarget, CallbackType if TYPE_CHECKING: - from .app import App - from .widget import Widget + from .dom import DOMNode - Reactable = Union[Widget, App] + Reactable = DOMNode ReactiveType = TypeVar("ReactiveType") -class _NotSet: - pass - - -_NOT_SET = _NotSet() - -T = TypeVar("T") - - @rich.repr.auto class Reactive(Generic[ReactiveType]): """Reactive descriptor. @@ -50,7 +40,7 @@ class Reactive(Generic[ReactiveType]): compute: Run compute methods when attribute is changed. Defaults to True. """ - _reactives: TypeVar[dict[str, object]] = {} + _reactives: ClassVar[dict[str, object]] = {} def __init__( self, @@ -77,37 +67,6 @@ class Reactive(Generic[ReactiveType]): yield "always_update", self._always_update yield "compute", self._run_compute - @classmethod - def init( - cls, - default: ReactiveType | Callable[[], ReactiveType], - *, - layout: bool = False, - repaint: bool = True, - always_update: bool = False, - compute: bool = True, - ) -> Reactive: - """A reactive variable that calls watchers and compute on initialize (post mount). - - Args: - default: A default value or callable that returns a default. - layout: Perform a layout on change. Defaults to False. - repaint: Perform a repaint on change. Defaults to True. - always_update: Call watchers even when the new value equals the old value. Defaults to False. - compute: Run compute methods when attribute is changed. Defaults to True. - - Returns: - A Reactive instance which calls watchers or initialize. - """ - return cls( - default, - layout=layout, - repaint=repaint, - init=True, - always_update=always_update, - compute=compute, - ) - @classmethod def var( cls, @@ -254,7 +213,7 @@ class Reactive(Generic[ReactiveType]): def invoke_watcher( watch_function: Callable, old_value: object, value: object - ) -> bool: + ) -> None: """Invoke a watch function. Args: @@ -262,8 +221,6 @@ class Reactive(Generic[ReactiveType]): old_value: The old value of the attribute. value: The new value of the attribute. - Returns: - True if the watcher was run, or False if it was posted. """ _rich_traceback_omit = True param_count = count_parameters(watch_function) @@ -280,17 +237,23 @@ class Reactive(Generic[ReactiveType]): sender=obj, callback=partial(await_watcher, watch_result) ) ) - return False - else: - return True watch_function = getattr(obj, f"watch_{name}", None) if callable(watch_function): 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) + # Process "global" watchers + watchers: list[tuple[Reactable, Callable]] + watchers = getattr(obj, "__watchers", {}).get(name, []) + # Remove any watchers for reactables that have since closed + if watchers: + watchers[:] = [ + (reactable, callback) + for reactable, callback in watchers + if reactable.is_attached and not reactable._closing + ] + for _, callback in watchers: + invoke_watcher(callback, old_value, value) @classmethod def _compute(cls, obj: Reactable) -> None: @@ -362,10 +325,12 @@ class var(Reactive[ReactiveType]): ) -def watch( +def _watch( + node: DOMNode, obj: Reactable, attribute_name: str, - callback: Callable[[Any], object], + callback: CallbackType, + *, init: bool = True, ) -> None: """Watch a reactive variable on an object. @@ -379,11 +344,11 @@ def watch( if not hasattr(obj, "__watchers"): setattr(obj, "__watchers", {}) - watchers: dict[str, list[Callable]] = getattr(obj, "__watchers") + watchers: dict[str, list[tuple[Reactable, Callable]]] = getattr(obj, "__watchers") watcher_list = watchers.setdefault(attribute_name, []) if callback in watcher_list: return - watcher_list.append(callback) + watcher_list.append((node, callback)) if init: current_value = getattr(obj, attribute_name, None) Reactive._check_watchers(obj, attribute_name, current_value) diff --git a/src/textual/widgets/__init__.pyi b/src/textual/widgets/__init__.pyi index 00d64cf50..7681c32f0 100644 --- a/src/textual/widgets/__init__.pyi +++ b/src/textual/widgets/__init__.pyi @@ -14,5 +14,4 @@ from ._static import Static as Static from ._switch import Switch as Switch from ._text_log import TextLog as TextLog from ._tree import Tree as Tree -from ._tree_node import TreeNode as TreeNode from ._welcome import Welcome as Welcome diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index ade5a1fd8..eaa0b874b 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -11,7 +11,7 @@ from rich.text import Text, TextType from .. import events from ..css._error_tools import friendly_list from ..message import Message -from ..reactive import Reactive +from ..reactive import reactive from ..widgets import Static @@ -151,13 +151,13 @@ class Button(Static, can_focus=True): ACTIVE_EFFECT_DURATION = 0.3 """When buttons are clicked they get the `-active` class for this duration (in seconds)""" - label: Reactive[RenderableType] = Reactive("") + label: reactive[RenderableType] = reactive("") """The text label that appears within the button.""" - variant = Reactive.init("default") + variant = reactive("default") """The variant name for the button.""" - disabled = Reactive(False) + disabled = reactive(False) """The disabled state of the button; `True` if disabled, `False` if not.""" class Pressed(Message, bubble=True): diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index 0b1c9a808..00ccf9e1f 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -8,7 +8,7 @@ from rich.console import RenderableType from rich.text import Text from .. import events -from ..reactive import Reactive, watch +from ..reactive import Reactive from ..widget import Widget @@ -66,7 +66,7 @@ class Footer(Widget): self.refresh() def on_mount(self) -> None: - watch(self.screen, "focused", self._focus_changed) + self.watch(self.screen, "focused", self._focus_changed) def _focus_changed(self, focused: Widget | None) -> None: self._key_text = None diff --git a/src/textual/widgets/_header.py b/src/textual/widgets/_header.py index b2df426cc..d09c4dad8 100644 --- a/src/textual/widgets/_header.py +++ b/src/textual/widgets/_header.py @@ -5,7 +5,7 @@ from datetime import datetime from rich.text import Text from ..widget import Widget -from ..reactive import Reactive, watch +from ..reactive import Reactive class HeaderIcon(Widget): @@ -133,5 +133,5 @@ class Header(Widget): def set_sub_title(sub_title: str) -> None: self.query_one(HeaderTitle).sub_text = sub_title - watch(self.app, "title", set_title) - watch(self.app, "sub_title", set_sub_title) + self.watch(self.app, "title", set_title) + self.watch(self.app, "sub_title", set_sub_title) diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 0064c4c32..a9f31e2aa 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -13964,6 +13964,164 @@ ''' # --- +# name: test_screen_switch + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ModalApp + + + + + + + + + + ModalApp + B + + + + + + + + + + + + + + + + + + + + + +  A  Push screen A  + + + + + ''' +# --- # name: test_switches ''' diff --git a/tests/snapshot_tests/snapshot_apps/screen_switch.py b/tests/snapshot_tests/snapshot_apps/screen_switch.py new file mode 100644 index 000000000..b58866eff --- /dev/null +++ b/tests/snapshot_tests/snapshot_apps/screen_switch.py @@ -0,0 +1,38 @@ +from textual.app import App, ComposeResult +from textual.screen import Screen +from textual.widgets import Static, Header, Footer + + +class ScreenA(Screen): + BINDINGS = [("b", "switch_to_b", "Switch to screen B")] + + def compose(self) -> ComposeResult: + yield Header() + yield Static("A") + yield Footer() + + def action_switch_to_b(self): + self.app.switch_screen(ScreenB()) + + +class ScreenB(Screen): + def compose(self) -> ComposeResult: + yield Header() + yield Static("B") + yield Footer() + + +class ModalApp(App): + BINDINGS = [("a", "push_a", "Push screen A")] + + def compose(self) -> ComposeResult: + yield Header() + yield Footer() + + def action_push_a(self) -> None: + self.push_screen(ScreenA()) + + +if __name__ == "__main__": + app = ModalApp() + app.run() diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index cdc2d0552..c77025d4d 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -214,3 +214,7 @@ def test_auto_width_input(snap_compare): assert snap_compare( SNAPSHOT_APPS_DIR / "auto_width_input.py", press=["tab", *"Hello"] ) + + +def test_screen_switch(snap_compare): + assert snap_compare(SNAPSHOT_APPS_DIR / "screen_switch.py", press=["a", "b"]) diff --git a/tests/test_widget_removing.py b/tests/test_widget_removing.py index e050bb09d..a33860c83 100644 --- a/tests/test_widget_removing.py +++ b/tests/test_widget_removing.py @@ -8,9 +8,13 @@ from textual.containers import Container 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()) + widget = Static() + assert not widget.is_attached + await pilot.app.mount(widget) + assert widget.is_attached assert len(pilot.app.screen.children) == 1 await pilot.app.query_one(Static).remove() + assert not widget.is_attached assert len(pilot.app.screen.children) == 0