""" This module contains the `Reactive` class which implements [reactivity](/guide/reactivity/). """ from __future__ import annotations from functools import partial from inspect import isawaitable from typing import ( TYPE_CHECKING, Any, Awaitable, Callable, ClassVar, Generic, Type, TypeVar, cast, overload, ) import rich.repr from textual import events from textual._callback import count_parameters from textual._types import ( MessageTarget, WatchCallbackBothValuesType, WatchCallbackNewValueType, WatchCallbackNoArgsType, WatchCallbackType, ) if TYPE_CHECKING: from textual.dom import DOMNode Reactable = DOMNode ReactiveType = TypeVar("ReactiveType") ReactableType = TypeVar("ReactableType", bound="DOMNode") class _Mutated: """A wrapper to indicate a value was mutated.""" def __init__(self, value: Any) -> None: self.value = value class ReactiveError(Exception): """Base class for reactive errors.""" class TooManyComputesError(ReactiveError): """Raised when an attribute has public and private compute methods.""" class Initialize(Generic[ReactiveType]): """Initialize a reactive by calling a method parent object. Example: ```python class InitializeApp(App): def get_names(self) -> list[str]: return ["foo", "bar", "baz"] # The `names` property will call `get_names` to get its default when first referenced. names = reactive(Initialize(get_names)) ``` """ def __init__(self, callback: Callable[[ReactableType], ReactiveType]) -> None: self.callback = callback def __call__(self, obj: ReactableType) -> ReactiveType: return self.callback(obj) async def await_watcher(obj: Reactable, awaitable: Awaitable[object]) -> 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(events.Callback(callback=partial(Reactive._compute, obj))) def invoke_watcher( watcher_object: Reactable, watch_function: WatchCallbackType, old_value: object, value: object, ) -> None: """Invoke a watch function. Args: watcher_object: The object watching for the changes. watch_function: A watch function, which may be sync or async. old_value: The old value of the attribute. value: The new value of the attribute. """ _rich_traceback_omit = True param_count = count_parameters(watch_function) with watcher_object._context(): if param_count == 2: watch_result = cast(WatchCallbackBothValuesType, watch_function)( old_value, value ) elif param_count == 1: watch_result = cast(WatchCallbackNewValueType, watch_function)(value) else: watch_result = cast(WatchCallbackNoArgsType, watch_function)() if isawaitable(watch_result): # Result is awaitable, so we need to await it within an async context watcher_object.call_next( partial(await_watcher, watcher_object, watch_result) ) @rich.repr.auto class Reactive(Generic[ReactiveType]): """Reactive descriptor. Args: default: A default value or callable that returns a default. layout: Perform a layout on change. repaint: Perform a repaint on change. init: Call watchers on initialize (post mount). always_update: Call watchers even when the new value equals the old value. compute: Run compute methods when attribute is changed. recompose: Compose the widget again when the attribute changes. bindings: Refresh bindings when the reactive changes. toggle_class: An optional TCSS classname(s) to toggle based on the truthiness of the value. """ _reactives: ClassVar[dict[str, object]] = {} def __init__( self, default: ReactiveType | Callable[[], ReactiveType] | Initialize[ReactiveType], *, layout: bool = False, repaint: bool = True, init: bool = False, always_update: bool = False, compute: bool = True, recompose: bool = False, bindings: bool = False, toggle_class: str | None = None, ) -> None: self._default = default self._layout = layout self._repaint = repaint self._init = init self._always_update = always_update self._run_compute = compute self._recompose = recompose self._bindings = bindings self._toggle_class = toggle_class self._owner: Type[MessageTarget] | None = None self.name: str def __rich_repr__(self) -> rich.repr.Result: yield None, self._default yield "layout", self._layout, False yield "repaint", self._repaint, True yield "init", self._init, False yield "always_update", self._always_update, False yield "compute", self._run_compute, True yield "recompose", self._recompose, False yield "bindings", self._bindings, False yield "name", getattr(self, "name", None), None @classmethod def _clear_watchers(cls, obj: Reactable) -> None: """Clear any watchers on a given object. Args: obj: A reactive object. """ try: getattr(obj, "__watchers").clear() except AttributeError: pass @property def owner(self) -> Type[MessageTarget]: """The owner (class) where the reactive was declared.""" assert self._owner is not None return self._owner def _initialize_reactive(self, obj: Reactable, name: str) -> None: """Initialized a reactive attribute on an object. Args: obj: An object with reactive attributes. name: Name of attribute. """ _rich_traceback_omit = True internal_name = f"_reactive_{name}" if hasattr(obj, internal_name): # Attribute already has a value return compute_method = getattr(obj, self.compute_name, None) if compute_method is not None and self._init: default = compute_method() else: default_or_callable = self._default default = ( ( default_or_callable(obj) if isinstance(default_or_callable, Initialize) else default_or_callable() ) if callable(default_or_callable) else default_or_callable ) setattr(obj, internal_name, default) if (toggle_class := self._toggle_class) is not None: obj.set_class(bool(default), *toggle_class.split()) if self._init: self._check_watchers(obj, name, default) @classmethod def _initialize_object(cls, obj: Reactable) -> None: """Set defaults and call any watchers / computes for the first time. Args: obj: An object with Reactive descriptors """ _rich_traceback_omit = True for name, reactive in obj._reactives.items(): reactive._initialize_reactive(obj, name) @classmethod def _reset_object(cls, obj: object) -> None: """Reset reactive structures on object (to avoid reference cycles). Args: obj: A reactive object. """ getattr(obj, "__watchers", {}).clear() getattr(obj, "__computes", []).clear() def __set_name__(self, owner: Type[MessageTarget], name: str) -> None: # Check for compute method self._owner = owner public_compute = f"compute_{name}" private_compute = f"_compute_{name}" compute_name = ( private_compute if hasattr(owner, private_compute) else public_compute ) if hasattr(owner, compute_name): # Compute methods are stored in a list called `__computes` try: computes = getattr(owner, "__computes") except AttributeError: computes = [] setattr(owner, "__computes", computes) computes.append(name) # The name of the attribute self.name = name # The internal name where the attribute's value is stored self.internal_name = f"_reactive_{name}" self.compute_name = compute_name default = self._default setattr(owner, f"_default_{name}", default) if TYPE_CHECKING: @overload def __get__( self: Reactive[ReactiveType], obj: ReactableType, obj_type: type[ReactableType], ) -> ReactiveType: ... @overload def __get__( self: Reactive[ReactiveType], obj: None, obj_type: type[ReactableType] ) -> Reactive[ReactiveType]: ... def __get__( self: Reactive[ReactiveType], obj: Reactable | None, obj_type: type[ReactableType], ) -> Reactive[ReactiveType] | ReactiveType: _rich_traceback_omit = True if obj is None: # obj is None means we are invoking the descriptor via the class, and not the instance return self if not hasattr(obj, "id"): raise ReactiveError( f"Node is missing data; Check you are calling super().__init__(...) in the {obj.__class__.__name__}() constructor, before getting reactives." ) if not hasattr(obj, internal_name := self.internal_name): self._initialize_reactive(obj, self.name) if hasattr(obj, self.compute_name): value: ReactiveType old_value = getattr(obj, internal_name) value = getattr(obj, self.compute_name)() setattr(obj, internal_name, value) self._check_watchers(obj, self.name, old_value) return value else: return getattr(obj, internal_name) def _set(self, obj: Reactable, value: ReactiveType, always: bool = False) -> None: _rich_traceback_omit = True if not hasattr(obj, "_id"): raise ReactiveError( f"Node is missing data; Check you are calling super().__init__(...) in the {obj.__class__.__name__}() constructor, before setting reactives." ) if isinstance(value, _Mutated): value = value.value always = True self._initialize_reactive(obj, self.name) if hasattr(obj, self.compute_name): raise AttributeError( f"Can't set {obj}.{self.name!r}; reactive attributes with a compute method are read-only" ) name = self.name current_value = getattr(obj, name) # Check for private and public validate functions. private_validate_function = getattr(obj, f"_validate_{name}", None) if callable(private_validate_function): value = private_validate_function(value) public_validate_function = getattr(obj, f"validate_{name}", None) if callable(public_validate_function): value = public_validate_function(value) # Toggle the classes using the value's truthiness if (toggle_class := self._toggle_class) is not None: obj.set_class(bool(value), *toggle_class.split()) # If the value has changed, or this is the first time setting the value if always or self._always_update or current_value != value: # Store the internal value setattr(obj, self.internal_name, value) # Check all watchers self._check_watchers(obj, name, current_value) if self._run_compute: self._compute(obj) if self._bindings: obj.refresh_bindings() # Refresh according to descriptor flags if self._layout or self._repaint or self._recompose: obj.refresh( repaint=self._repaint, layout=self._layout, recompose=self._recompose, ) def __set__(self, obj: Reactable, value: ReactiveType) -> None: _rich_traceback_omit = True self._set(obj, value) @classmethod def _check_watchers(cls, obj: Reactable, name: str, old_value: Any) -> None: """Check watchers, and call watch methods / computes Args: obj: The reactable object. name: Attribute name. old_value: The old (previous) value of the attribute. """ _rich_traceback_omit = True # Get the current value. internal_name = f"_reactive_{name}" value = getattr(obj, internal_name) private_watch_function = getattr(obj, f"_watch_{name}", None) if callable(private_watch_function): invoke_watcher(obj, private_watch_function, old_value, value) public_watch_function = getattr(obj, f"watch_{name}", None) if callable(public_watch_function): invoke_watcher(obj, public_watch_function, old_value, value) # Process "global" watchers watchers: list[tuple[Reactable, WatchCallbackType]] 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 not reactable._closing ] for reactable, callback in watchers: with reactable.prevent(*obj._prevent_message_types_stack[-1]): invoke_watcher(reactable, callback, old_value, value) @classmethod def _compute(cls, obj: Reactable) -> None: """Invoke all computes. Args: obj: Reactable object. """ _rich_traceback_guard = True for compute in obj._reactives.keys() & obj._computes: try: compute_method = getattr(obj, f"compute_{compute}") except AttributeError: try: compute_method = getattr(obj, f"_compute_{compute}") except AttributeError: continue current_value = getattr( obj, f"_reactive_{compute}", getattr(obj, f"_default_{compute}", None) ) value = compute_method() setattr(obj, f"_reactive_{compute}", value) if value != current_value: cls._check_watchers(obj, compute, current_value) class reactive(Reactive[ReactiveType]): """Create a reactive attribute. Args: default: A default value or callable that returns a default. layout: Perform a layout on change. repaint: Perform a repaint on change. init: Call watchers on initialize (post mount). always_update: Call watchers even when the new value equals the old value. recompose: Compose the widget again when the attribute changes. bindings: Refresh bindings when the reactive changes. toggle_class: An optional TCSS classname(s) to toggle based on the truthiness of the value. """ def __init__( self, default: ReactiveType | Callable[[], ReactiveType] | Initialize[ReactiveType], *, layout: bool = False, repaint: bool = True, init: bool = True, always_update: bool = False, recompose: bool = False, bindings: bool = False, toggle_class: str | None = None, ) -> None: super().__init__( default, layout=layout, repaint=repaint, init=init, always_update=always_update, recompose=recompose, bindings=bindings, toggle_class=toggle_class, ) class var(Reactive[ReactiveType]): """Create a reactive attribute (with no auto-refresh). Args: default: A default value or callable that returns a default. init: Call watchers on initialize (post mount). always_update: Call watchers even when the new value equals the old value. bindings: Refresh bindings when the reactive changes. toggle_class: An optional TCSS classname(s) to toggle based on the truthiness of the value. """ def __init__( self, default: ReactiveType | Callable[[], ReactiveType] | Initialize[ReactiveType], init: bool = True, always_update: bool = False, bindings: bool = False, toggle_class: str | None = None, ) -> None: super().__init__( default, layout=False, repaint=False, init=init, always_update=always_update, bindings=bindings, toggle_class=toggle_class, ) def _watch( node: DOMNode, obj: Reactable, attribute_name: str, callback: WatchCallbackType, *, init: bool = True, ) -> None: """Watch a reactive variable on an object. Args: node: The node that created the watcher. obj: The parent object. attribute_name: The attribute to watch. callback: A callable to call when the attribute changes. init: True to call watcher initialization. """ if not hasattr(obj, "__watchers"): setattr(obj, "__watchers", {}) watchers: dict[str, list[tuple[Reactable, WatchCallbackType]]] watchers = getattr(obj, "__watchers") watcher_list = watchers.setdefault(attribute_name, []) if any(callback == callback_from_list for _, callback_from_list in watcher_list): return if init: current_value = getattr(obj, attribute_name, None) invoke_watcher(obj, callback, current_value, current_value) watcher_list.append((node, callback))