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
+ '''
+
+
+ '''
+# ---
# name: test_switches
'''