Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/).

## [Unreleased]

### Added

- Allow `Reactive` class variables to be passed to `DOMNode.watch()` method

## [7.3.0] - 2026-01-15

### Fixed
Expand Down
9 changes: 6 additions & 3 deletions src/textual/dom.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@


ReactiveType = TypeVar("ReactiveType")

ReactableType = TypeVar("ReactableType", bound="DOMNode")

QueryOneCacheKey: TypeAlias = "tuple[int, str, Type[Widget] | None]"
"""The key used to cache query_one results."""
Expand Down Expand Up @@ -1252,8 +1252,8 @@ def ancestors(self) -> list[DOMNode]:

def watch(
self,
obj: DOMNode,
attribute_name: str,
obj: ReactableType,
attribute_name: Reactive[Any, ReactableType] | str,
callback: WatchCallbackType,
init: bool = True,
) -> None:
Expand All @@ -1274,6 +1274,9 @@ def on_theme_change(old_value:str, new_value:str) -> None:
callback: A callback to run when attribute changes.
init: Check watchers on first call.
"""
if not isinstance(attribute_name, str):
attribute_name = attribute_name.name

_watch(self, obj, attribute_name, callback, init=init)

def get_pseudo_classes(self) -> set[str]:
Expand Down
31 changes: 18 additions & 13 deletions src/textual/reactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@
ClassVar,
Generic,
Type,
TypeVar,
cast,
overload,
)

import rich.repr
from typing_extensions import TypeVar

from textual import events
from textual._callback import count_parameters
Expand All @@ -38,7 +38,10 @@
Reactable = DOMNode

ReactiveType = TypeVar("ReactiveType")
ReactableType = TypeVar("ReactableType", bound="DOMNode")
ReactableType_contra = TypeVar(
"ReactableType_contra", bound="DOMNode", default="DOMNode", contravariant=True
)
OtherReactableType = TypeVar("OtherReactableType", bound="DOMNode")


class _Mutated:
Expand Down Expand Up @@ -72,10 +75,12 @@ def get_names(self) -> list[str]:

"""

def __init__(self, callback: Callable[[ReactableType], ReactiveType]) -> None:
def __init__(
self, callback: Callable[[ReactableType_contra], ReactiveType]
) -> None:
self.callback = callback

def __call__(self, obj: ReactableType) -> ReactiveType:
def __call__(self, obj: ReactableType_contra) -> ReactiveType:
return self.callback(obj)


Expand Down Expand Up @@ -122,7 +127,7 @@ def invoke_watcher(


@rich.repr.auto
class Reactive(Generic[ReactiveType]):
class Reactive(Generic[ReactiveType, ReactableType_contra]):
"""Reactive descriptor.

Args:
Expand Down Expand Up @@ -278,20 +283,20 @@ def __set_name__(self, owner: Type[MessageTarget], name: str) -> None:
@overload
def __get__(
self: Reactive[ReactiveType],
obj: ReactableType,
obj_type: type[ReactableType],
obj: ReactableType_contra,
obj_type: type[ReactableType_contra],
) -> ReactiveType: ...

@overload
def __get__(
self: Reactive[ReactiveType], obj: None, obj_type: type[ReactableType]
) -> Reactive[ReactiveType]: ...
self: Reactive[ReactiveType], obj: None, obj_type: type[OtherReactableType]
) -> Reactive[ReactiveType, OtherReactableType]: ...

def __get__(
self: Reactive[ReactiveType],
obj: Reactable | None,
obj_type: type[ReactableType],
) -> Reactive[ReactiveType] | ReactiveType:
obj_type: type[ReactableType_contra],
) -> Reactive[ReactiveType, ReactableType_contra] | 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
Expand Down Expand Up @@ -434,7 +439,7 @@ def _compute(cls, obj: Reactable) -> None:
cls._check_watchers(obj, compute, current_value)


class reactive(Reactive[ReactiveType]):
class reactive(Reactive[ReactiveType, ReactableType_contra]):
"""Create a reactive attribute.

Args:
Expand Down Expand Up @@ -472,7 +477,7 @@ def __init__(
)


class var(Reactive[ReactiveType]):
class var(Reactive[ReactiveType, ReactableType_contra]):
"""Create a reactive attribute (with no auto-refresh).

Args:
Expand Down
24 changes: 24 additions & 0 deletions tests/test_reactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,30 @@ def _compute_double(self) -> int:
assert pilot.app.double == 10


async def test_watch_method_reactive_attribute():
called = False

class Holder(Widget):
attr = var(None)

class MyApp(App):
def __init__(self):
super().__init__()
self.holder = Holder()

def on_mount(self):
self.watch(self.holder, Holder.attr, self.callback)

def callback(self):
nonlocal called
called = True

async with MyApp().run_test() as pilot:
pilot.app.holder.attr = "hello world"
await pilot.pause()
assert called


async def test_async_reactive_watch_callbacks_go_on_the_watcher():
"""Regression test for https://github.com/Textualize/textual/issues/3036.

Expand Down
Loading