diff --git a/CHANGELOG.md b/CHANGELOG.md index 005b76bbfa..32facd86ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,14 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fixed a hang in the Linux driver when connected to a pipe https://github.com/Textualize/textual/issues/4104 - Fixed broken `OptionList` `Option.id` mappings https://github.com/Textualize/textual/issues/4101 +### Added + +- Added DOMQuery.set https://github.com/Textualize/textual/pull/4075 +- Added DOMNode.set_reactive https://github.com/Textualize/textual/pull/4075 +- Added DOMNode.data_bind https://github.com/Textualize/textual/pull/4075 +- Added DOMNode.action_toggle https://github.com/Textualize/textual/pull/4075 +- Added Worker.cancelled_event https://github.com/Textualize/textual/pull/4075 + ### Changed - Breaking change: keyboard navigation in `RadioSet`, `ListView`, `OptionList`, and `SelectionList`, no longer allows highlighting disabled items https://github.com/Textualize/textual/issues/3881 diff --git a/docs/examples/guide/reactivity/set_reactive01.py b/docs/examples/guide/reactivity/set_reactive01.py new file mode 100644 index 0000000000..d9e34f9dcb --- /dev/null +++ b/docs/examples/guide/reactivity/set_reactive01.py @@ -0,0 +1,67 @@ +from textual.app import App, ComposeResult +from textual.containers import Horizontal +from textual.reactive import reactive, var +from textual.widgets import Label + +GREETINGS = [ + "Bonjour", + "Hola", + "こんにちは", + "你好", + "안녕하세요", + "Hello", +] + + +class Greeter(Horizontal): + """Display a greeting and a name.""" + + DEFAULT_CSS = """ + Greeter { + width: auto; + height: 1; + & Label { + margin: 0 1; + } + } + """ + greeting: reactive[str] = reactive("") + who: reactive[str] = reactive("") + + def __init__(self, greeting: str = "Hello", who: str = "World!") -> None: + super().__init__() + self.greeting = greeting # (1)! + self.who = who + + def compose(self) -> ComposeResult: + yield Label(self.greeting, id="greeting") + yield Label(self.who, id="name") + + def watch_greeting(self, greeting: str) -> None: + self.query_one("#greeting", Label).update(greeting) # (2)! + + def watch_who(self, who: str) -> None: + self.query_one("#who", Label).update(who) + + +class NameApp(App): + + CSS = """ + Screen { + align: center middle; + } + """ + greeting_no: var[int] = var(0) + BINDINGS = [("space", "greeting")] + + def compose(self) -> ComposeResult: + yield Greeter(who="Textual") + + def action_greeting(self) -> None: + self.greeting_no = (self.greeting_no + 1) % len(GREETINGS) + self.query_one(Greeter).greeting = GREETINGS[self.greeting_no] + + +if __name__ == "__main__": + app = NameApp() + app.run() diff --git a/docs/examples/guide/reactivity/set_reactive02.py b/docs/examples/guide/reactivity/set_reactive02.py new file mode 100644 index 0000000000..c4e36fc5cd --- /dev/null +++ b/docs/examples/guide/reactivity/set_reactive02.py @@ -0,0 +1,67 @@ +from textual.app import App, ComposeResult +from textual.containers import Horizontal +from textual.reactive import reactive, var +from textual.widgets import Label + +GREETINGS = [ + "Bonjour", + "Hola", + "こんにちは", + "你好", + "안녕하세요", + "Hello", +] + + +class Greeter(Horizontal): + """Display a greeting and a name.""" + + DEFAULT_CSS = """ + Greeter { + width: auto; + height: 1; + & Label { + margin: 0 1; + } + } + """ + greeting: reactive[str] = reactive("") + who: reactive[str] = reactive("") + + def __init__(self, greeting: str = "Hello", who: str = "World!") -> None: + super().__init__() + self.set_reactive(Greeter.greeting, greeting) # (1)! + self.set_reactive(Greeter.who, who) + + def compose(self) -> ComposeResult: + yield Label(self.greeting, id="greeting") + yield Label(self.who, id="name") + + def watch_greeting(self, greeting: str) -> None: + self.query_one("#greeting", Label).update(greeting) + + def watch_who(self, who: str) -> None: + self.query_one("#who", Label).update(who) + + +class NameApp(App): + + CSS = """ + Screen { + align: center middle; + } + """ + greeting_no: var[int] = var(0) + BINDINGS = [("space", "greeting")] + + def compose(self) -> ComposeResult: + yield Greeter(who="Textual") + + def action_greeting(self) -> None: + self.greeting_no = (self.greeting_no + 1) % len(GREETINGS) + self.query_one(Greeter).greeting = GREETINGS[self.greeting_no] + + +if __name__ == "__main__": + app = NameApp() + app.run() diff --git a/docs/examples/guide/reactivity/world_clock01.py b/docs/examples/guide/reactivity/world_clock01.py new file mode 100644 index 0000000000..04830ab8e9 --- /dev/null +++ b/docs/examples/guide/reactivity/world_clock01.py @@ -0,0 +1,52 @@ +from datetime import datetime + +from pytz import timezone + +from textual.app import App, ComposeResult +from textual.reactive import reactive +from textual.widget import Widget +from textual.widgets import Digits, Label + + +class WorldClock(Widget): + + time: reactive[datetime] = reactive(datetime.now) + + def __init__(self, timezone: str) -> None: + self.timezone = timezone + super().__init__() + + def compose(self) -> ComposeResult: + yield Label(self.timezone) + yield Digits() + + def watch_time(self, time: datetime) -> None: + localized_time = time.astimezone(timezone(self.timezone)) + self.query_one(Digits).update(localized_time.strftime("%H:%M:%S")) + + +class WorldClockApp(App): + CSS_PATH = "world_clock01.tcss" + + time: reactive[datetime] = reactive(datetime.now) + + def compose(self) -> ComposeResult: + yield WorldClock("Europe/London") + yield WorldClock("Europe/Paris") + yield WorldClock("Asia/Tokyo") + + def update_time(self) -> None: + self.time = datetime.now() + + def watch_time(self, time: datetime) -> None: + for world_clock in self.query(WorldClock): # (1)! + world_clock.time = time + + def on_mount(self) -> None: + self.update_time() + self.set_interval(1, self.update_time) + + +if __name__ == "__main__": + app = WorldClockApp() + app.run() diff --git a/docs/examples/guide/reactivity/world_clock01.tcss b/docs/examples/guide/reactivity/world_clock01.tcss new file mode 100644 index 0000000000..d0b4f22695 --- /dev/null +++ b/docs/examples/guide/reactivity/world_clock01.tcss @@ -0,0 +1,16 @@ +Screen { + align: center middle; +} + +WorldClock { + width: auto; + height: auto; + padding: 1 2; + background: $panel; + border: wide $background; + + & Digits { + width: auto; + color: $secondary; + } +} diff --git a/docs/examples/guide/reactivity/world_clock02.py b/docs/examples/guide/reactivity/world_clock02.py new file mode 100644 index 0000000000..8988539193 --- /dev/null +++ b/docs/examples/guide/reactivity/world_clock02.py @@ -0,0 +1,47 @@ +from datetime import datetime + +from pytz import timezone + +from textual.app import App, ComposeResult +from textual.reactive import reactive +from textual.widget import Widget +from textual.widgets import Digits, Label + + +class WorldClock(Widget): + + time: reactive[datetime] = reactive(datetime.now) + + def __init__(self, timezone: str) -> None: + self.timezone = timezone + super().__init__() + + def compose(self) -> ComposeResult: + yield Label(self.timezone) + yield Digits() + + def watch_time(self, time: datetime) -> None: + localized_time = time.astimezone(timezone(self.timezone)) + self.query_one(Digits).update(localized_time.strftime("%H:%M:%S")) + + +class WorldClockApp(App): + CSS_PATH = "world_clock01.tcss" + + time: reactive[datetime] = reactive(datetime.now) + + def compose(self) -> ComposeResult: + yield WorldClock("Europe/London").data_bind(WorldClockApp.time) # (1)! + yield WorldClock("Europe/Paris").data_bind(WorldClockApp.time) + yield WorldClock("Asia/Tokyo").data_bind(WorldClockApp.time) + + def update_time(self) -> None: + self.time = datetime.now() + + def on_mount(self) -> None: + self.update_time() + self.set_interval(1, self.update_time) + + +if __name__ == "__main__": + WorldClockApp().run() diff --git a/docs/examples/guide/reactivity/world_clock03.py b/docs/examples/guide/reactivity/world_clock03.py new file mode 100644 index 0000000000..6d5c6dbb07 --- /dev/null +++ b/docs/examples/guide/reactivity/world_clock03.py @@ -0,0 +1,49 @@ +from datetime import datetime + +from pytz import timezone + +from textual.app import App, ComposeResult +from textual.reactive import reactive +from textual.widget import Widget +from textual.widgets import Digits, Label + + +class WorldClock(Widget): + + clock_time: reactive[datetime] = reactive(datetime.now) + + def __init__(self, timezone: str) -> None: + self.timezone = timezone + super().__init__() + + def compose(self) -> ComposeResult: + yield Label(self.timezone) + yield Digits() + + def watch_clock_time(self, time: datetime) -> None: + localized_time = time.astimezone(timezone(self.timezone)) + self.query_one(Digits).update(localized_time.strftime("%H:%M:%S")) + + +class WorldClockApp(App): + CSS_PATH = "world_clock01.tcss" + + time: reactive[datetime] = reactive(datetime.now) + + def compose(self) -> ComposeResult: + yield WorldClock("Europe/London").data_bind( + clock_time=WorldClockApp.time # (1)! + ) + yield WorldClock("Europe/Paris").data_bind(clock_time=WorldClockApp.time) + yield WorldClock("Asia/Tokyo").data_bind(clock_time=WorldClockApp.time) + + def update_time(self) -> None: + self.time = datetime.now() + + def on_mount(self) -> None: + self.update_time() + self.set_interval(1, self.update_time) + + +if __name__ == "__main__": + WorldClockApp().run() diff --git a/docs/guide/queries.md b/docs/guide/queries.md index 0b1e5fc105..d87a78a399 100644 --- a/docs/guide/queries.md +++ b/docs/guide/queries.md @@ -161,10 +161,12 @@ for widget in self.query("Button"): Here are the other loop-free methods on query objects: -- [set_class][textual.css.query.DOMQuery.set_class] Sets a CSS class (or classes) on matched widgets. - [add_class][textual.css.query.DOMQuery.add_class] Adds a CSS class (or classes) to matched widgets. +- [blur][textual.css.query.DOMQuery.focus] Blurs (removes focus) from matching widgets. +- [focus][textual.css.query.DOMQuery.focus] Focuses the first matching widgets. +- [refresh][textual.css.query.DOMQuery.refresh] Refreshes matched widgets. - [remove_class][textual.css.query.DOMQuery.remove_class] Removes a CSS class (or classes) from matched widgets. -- [toggle_class][textual.css.query.DOMQuery.toggle_class] Sets a CSS class (or classes) if it is not set, or removes the class (or classes) if they are set on the matched widgets. - [remove][textual.css.query.DOMQuery.remove] Removes matched widgets from the DOM. -- [refresh][textual.css.query.DOMQuery.refresh] Refreshes matched widgets. - +- [set_class][textual.css.query.DOMQuery.set_class] Sets a CSS class (or classes) on matched widgets. +- [set][textual.css.query.DOMQuery.set] Sets common attributes on a widget. +- [toggle_class][textual.css.query.DOMQuery.toggle_class] Sets a CSS class (or classes) if it is not set, or removes the class (or classes) if they are set on the matched widgets. diff --git a/docs/guide/reactivity.md b/docs/guide/reactivity.md index c84050ad40..40eacb6923 100644 --- a/docs/guide/reactivity.md +++ b/docs/guide/reactivity.md @@ -266,3 +266,140 @@ When the result of `compute_color` changes, Textual will also call `watch_color` !!! note It is best to avoid doing anything slow or CPU-intensive in a compute method. Textual calls compute methods on an object when _any_ reactive attribute changes. + +## Setting reactives without superpowers + +You may find yourself in a situation where you want to set a reactive value, but you *don't* want to invoke watchers or the other super powers. +This is fairly common in constructors which run prior to mounting; any watcher which queries the DOM may break if the widget has not yet been mounted. + +To work around this issue, you can call [set_reactive][textual.dom.DOMNode.set_reactive] as an alternative to setting the attribute. +The `set_reactive` method accepts the reactive attribute (as a class variable) and the new value. + +Let's look at an example. +The following app is intended to cycle through various greeting when you press ++space++, however it contains a bug. + +```python title="set_reactive01.py" +--8<-- "docs/examples/guide/reactivity/set_reactive01.py" +``` + +1. Setting this reactive attribute invokes a watcher. +2. The watcher attempts to update a label before it is mounted. + +If you run this app, you will find Textual raises a `NoMatches` error in `watch_greeting`. +This is because the constructor has assigned the reactive before the widget has fully mounted. + +The following app contains a fix for this issue: + +=== "set_reactive02.py" + + ```python hl_lines="33 34" + --8<-- "docs/examples/guide/reactivity/set_reactive02.py" + ``` + + 1. The attribute is set via `set_reactive`, which avoids calling the watcher. + +=== "Output" + + ```{.textual path="docs/examples/guide/reactivity/set_reactive02.py"} + ``` + +The line `self.set_reactive(Greeter.greeting, greeting)` sets the `greeting` attribute but doesn't immediately invoke the watcher. + +## Data binding + +Reactive attributes from one widget may be *bound* (connected) to another widget, so that changes to a single reactive will automatically update another widget (potentially more than one). + +To bind reactive attributes, call [data_bind][textual.dom.DOMNode.data_bind] on a widget. +This method accepts reactives (as class attributes) in positional arguments or keyword arguments. + +Let's look at an app that could benefit from data binding. +In the following code we have a `WorldClock` widget which displays the time in any given timezone. + + +!!! note + + This example uses the [pytz](https://pypi.org/project/pytz/) library for working with timezones. + You can install pytz with `pip install pytz`. + + +=== "world_clock01.py" + + ```python + --8<-- "docs/examples/guide/reactivity/world_clock01.py" + ``` + + 1. Update the `time` reactive attribute of every `WorldClock`. + +=== "world_clock01.tcss" + + ```css + --8<-- "docs/examples/guide/reactivity/world_clock01.tcss" + ``` + +=== "Output" + + ```{.textual path="docs/examples/guide/reactivity/world_clock01.py"} + ``` + +We've added three world clocks for London, Paris, and Tokyo. +The clocks are kept up-to-date by watching the app's `time` reactive, and updating the clocks in a loop. + +While this approach works fine, it does require we take care to update every `WorldClock` we mount. +Let's see how data binding can simplify this. + +The following app calls `data_bind` on the world clock widgets to connect the app's `time` with the widget's `time` attribute: + +=== "world_clock02.py" + + ```python hl_lines="34-36" + --8<-- "docs/examples/guide/reactivity/world_clock02.py" + ``` + + 1. Bind the `time` attribute, so that changes to `time` will also change the `time` attribute on the `WorldClock` widgets. The `data_bind` method also returns the widget, so we can yield its return value. + +=== "world_clock01.tcss" + + ```css + --8<-- "docs/examples/guide/reactivity/world_clock01.tcss" + ``` + +=== "Output" + + ```{.textual path="docs/examples/guide/reactivity/world_clock02.py"} + ``` + +Note how the addition of the `data_bind` methods negates the need for the watcher in `world_clock01.py`. + + +!!! note + + Data binding works in a single direction. + Setting `time` on the app updates the clocks. + But setting `time` on the clocks will *not* update `time` on the app. + + +In the previous example app, the call to `data_bind(WorldClockApp.time)` worked because both reactive attributes were named `time`. +If you want to bind a reactive attribute which has a different name, you can use keyword arguments. + +In the following app we have changed the attribute name on `WorldClock` from `time` to `clock_time`. +We can make the app continue to work by changing the `data_bind` call to `data_bind(clock_time=WorldClockApp.time)`: + + +=== "world_clock03.py" + + ```python hl_lines="34-38" + --8<-- "docs/examples/guide/reactivity/world_clock03.py" + ``` + + 1. Uses keyword arguments to bind the `time` attribute of `WorldClockApp` to `clock_time` on `WorldClock`. + +=== "world_clock01.tcss" + + ```css + --8<-- "docs/examples/guide/reactivity/world_clock01.tcss" + ``` + +=== "Output" + + ```{.textual path="docs/examples/guide/reactivity/world_clock02.py"} + ``` diff --git a/pyproject.toml b/pyproject.toml index 72502f3bb2..ebf92ceb6f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,9 +73,6 @@ types-tree-sitter = "^0.20.1.4" types-tree-sitter-languages = "^1.7.0.1" griffe = "0.32.3" -[tool.black] -includes = "src" - [tool.pytest.ini_options] asyncio_mode = "auto" testpaths = ["tests"] diff --git a/src/textual/app.py b/src/textual/app.py index 5c54cb8983..68499fec50 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -424,8 +424,9 @@ def __init__( environ = dict(os.environ) no_color = environ.pop("NO_COLOR", None) if no_color is not None: - from .filter import Monochrome + from .filter import ANSIToTruecolor, Monochrome + self._filters.append(ANSIToTruecolor(terminal_theme.DIMMED_MONOKAI)) self._filters.append(Monochrome()) for filter_name in constants.FILTERS.split(","): diff --git a/src/textual/css/query.py b/src/textual/css/query.py index 10a97ed54b..05aa749622 100644 --- a/src/textual/css/query.py +++ b/src/textual/css/query.py @@ -85,6 +85,7 @@ def __init__( Raises: InvalidQueryFormat: If the format of the query is invalid. """ + _rich_traceback_omit = True self._node = node self._nodes: list[QueryType] | None = None self._filters: list[tuple[SelectorSet, ...]] = ( @@ -153,16 +154,19 @@ def __getitem__(self, index: int | slice) -> QueryType | list[QueryType]: return self.nodes[index] def __rich_repr__(self) -> rich.repr.Result: - if self._filters: - yield "query", " AND ".join( - ",".join(selector.css for selector in selectors) - for selectors in self._filters - ) - if self._excludes: - yield "exclude", " OR ".join( - ",".join(selector.css for selector in selectors) - for selectors in self._excludes - ) + try: + if self._filters: + yield "query", " AND ".join( + ",".join(selector.css for selector in selectors) + for selectors in self._filters + ) + if self._excludes: + yield "exclude", " OR ".join( + ",".join(selector.css for selector in selectors) + for selectors in self._excludes + ) + except AttributeError: + pass def filter(self, selector: str) -> DOMQuery[QueryType]: """Filter this set by the given CSS selector. @@ -219,7 +223,7 @@ def first( ) return first else: - raise NoMatches(f"No nodes match {self!r}") + raise NoMatches(f"No nodes match {self!r} on {self.node!r}") @overload def only_one(self) -> QueryType: ... @@ -289,7 +293,7 @@ def last( The matching Widget. """ if not self.nodes: - raise NoMatches(f"No nodes match {self!r}") + raise NoMatches(f"No nodes match {self!r} on dom{self.node!r}") last = self.nodes[-1] if expect_type is not None and not isinstance(last, expect_type): raise WrongType( @@ -444,3 +448,32 @@ def blur(self) -> DOMQuery[QueryType]: if focused in nodes: self._node.screen._reset_focus(focused, avoiding=nodes) return self + + def set( + self, + display: bool | None = None, + visible: bool | None = None, + disabled: bool | None = None, + loading: bool | None = None, + ) -> DOMQuery[QueryType]: + """Sets common attributes on matched nodes. + + Args: + display: Set `display` attribute on nodes, or `None` for no change. + visible: Set `visible` attribute on nodes, or `None` for no change. + disabled: Set `disabled` attribute on nodes, or `None` for no change. + loading: Set `loading` attribute on nodes, or `None` for no change. + + Returns: + Query for chaining. + """ + for node in self: + if display is not None: + node.display = display + if visible is not None: + node.visible = visible + if disabled is not None: + node.disabled = disabled + if loading is not None: + node.loading = loading + return self diff --git a/src/textual/dom.py b/src/textual/dom.py index 293923dd70..bc06272d58 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -28,7 +28,7 @@ from rich.text import Text from rich.tree import Tree -from ._context import NoActiveAppError +from ._context import NoActiveAppError, active_message_pump from ._node_list import NodeList from ._types import WatchCallbackType from ._worker_manager import WorkerManager @@ -41,7 +41,7 @@ from .css.styles import RenderStyles, Styles from .css.tokenize import IDENTIFIER from .message_pump import MessagePump -from .reactive import Reactive, _watch +from .reactive import Reactive, ReactiveError, _watch from .timer import Timer from .walk import walk_breadth_first, walk_depth_first @@ -68,6 +68,9 @@ """Valid walking methods for the [`DOMNode.walk_children` method][textual.dom.DOMNode.walk_children].""" +ReactiveType = TypeVar("ReactiveType") + + class BadIdentifier(Exception): """Exception raised if you supply a `id` attribute or class name in the wrong format.""" @@ -194,9 +197,127 @@ def __init__( ) self._has_hover_style: bool = False self._has_focus_within: bool = False + self._reactive_connect: ( + dict[str, tuple[MessagePump, Reactive | object]] | None + ) = None super().__init__() + def set_reactive( + self, reactive: Reactive[ReactiveType], value: ReactiveType + ) -> None: + """Sets a reactive value *without* invoking validators or watchers. + + Example: + ```python + self.set_reactive(App.dark_mode, True) + ``` + + Args: + name: Name of reactive attribute. + value: New value of reactive. + + Raises: + AttributeError: If the first argument is not a reactive. + """ + if not isinstance(reactive, Reactive): + raise TypeError( + "A Reactive class is required; for example: MyApp.dark_mode" + ) + if reactive.name not in self._reactives: + raise AttributeError( + "No reactive called {name!r}; Have you called super().__init__(...) in the {self.__class__.__name__} constructor?" + ) + setattr(self, f"_reactive_{reactive.name}", value) + + def data_bind( + self, + *reactives: Reactive[Any], + **bind_vars: Reactive[Any] | object, + ) -> Self: + """Bind reactive data so that changes to a reactive automatically change the reactive on another widget. + + Reactives may be given as positional arguments or keyword arguments. + See the [guide on data binding](/guide/reactivity#data-binding). + + Example: + ```python + def compose(self) -> ComposeResult: + yield WorldClock("Europe/London").data_bind(WorldClockApp.time) + yield WorldClock("Europe/Paris").data_bind(WorldClockApp.time) + yield WorldClock("Asia/Tokyo").data_bind(WorldClockApp.time) + ``` + + + Raises: + ReactiveError: If the data wasn't bound. + + Returns: + Self. + """ + _rich_traceback_omit = True + + parent = active_message_pump.get() + + if self._reactive_connect is None: + self._reactive_connect = {} + bind_vars = {**{reactive.name: reactive for reactive in reactives}, **bind_vars} + for name, reactive in bind_vars.items(): + if name not in self._reactives: + raise ReactiveError( + f"Unable to bind non-reactive attribute {name!r} on {self}" + ) + if isinstance(reactive, Reactive) and not isinstance( + parent, reactive.owner + ): + raise ReactiveError( + f"Unable to bind data; {reactive.owner.__name__} is not defined on {parent.__class__.__name__}." + ) + self._reactive_connect[name] = (parent, reactive) + self._initialize_data_bind() + return self + + def _initialize_data_bind(self) -> None: + """initialize a data binding. + + Args: + compose_parent: The node doing the binding. + """ + if not self._reactive_connect: + return + for variable_name, (compose_parent, reactive) in self._reactive_connect.items(): + + def make_setter(variable_name: str) -> Callable[[object], None]: + """Make a setter for the given variable name. + + Args: + variable_name: Name of variable being set. + + Returns: + A callable which takes the value to set. + """ + + def setter(value: object) -> None: + """Set bound data.""" + _rich_traceback_omit = True + Reactive._initialize_object(self) + setattr(self, variable_name, value) + + return setter + + assert isinstance(compose_parent, DOMNode) + setter = make_setter(variable_name) + if isinstance(reactive, Reactive): + self.watch( + compose_parent, + reactive.name, + setter, + init=self._parent is not None, + ) + else: + self.call_later(partial(setter, reactive)) + self._reactive_connect = None + def compose_add_child(self, widget: Widget) -> None: """Add a node to children. @@ -1291,3 +1412,14 @@ def has_pseudo_classes(self, class_names: set[str]) -> bool: def refresh(self, *, repaint: bool = True, layout: bool = False) -> Self: return self + + async def action_toggle(self, attribute_name: str) -> None: + """Toggle an attribute on the node. + + Assumes the attribute is a bool. + + Args: + attribute_name: Name of the attribute. + """ + value = getattr(self, attribute_name) + setattr(self, attribute_name, not value) diff --git a/src/textual/events.py b/src/textual/events.py index a5518407f9..eb53d4a5db 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -475,7 +475,7 @@ class MouseUp(MouseEvent, bubble=True, verbose=True): @rich.repr.auto -class MouseScrollDown(MouseEvent, bubble=True): +class MouseScrollDown(MouseEvent, bubble=True, verbose=True): """Sent when the mouse wheel is scrolled *down*. - [X] Bubbles @@ -484,7 +484,7 @@ class MouseScrollDown(MouseEvent, bubble=True): @rich.repr.auto -class MouseScrollUp(MouseEvent, bubble=True): +class MouseScrollUp(MouseEvent, bubble=True, verbose=True): """Sent when the mouse wheel is scrolled *up*. - [X] Bubbles diff --git a/src/textual/reactive.py b/src/textual/reactive.py index d361bd0049..c23d5a56c4 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -16,6 +16,7 @@ Generic, Type, TypeVar, + overload, ) import rich.repr @@ -30,9 +31,14 @@ Reactable = DOMNode ReactiveType = TypeVar("ReactiveType") +ReactableType = TypeVar("ReactableType", bound="DOMNode") -class TooManyComputesError(Exception): +class ReactiveError(Exception): + """Base class for reactive errors.""" + + +class TooManyComputesError(ReactiveError): """Raised when an attribute has public and private compute methods.""" @@ -67,6 +73,7 @@ def __init__( self._init = init self._always_update = always_update self._run_compute = compute + self._owner: Type[MessageTarget] | None = None def __rich_repr__(self) -> rich.repr.Result: yield self._default @@ -76,6 +83,12 @@ def __rich_repr__(self) -> rich.repr.Result: yield "always_update", self._always_update yield "compute", self._run_compute + @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. @@ -126,6 +139,7 @@ def _reset_object(cls, obj: object) -> None: 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 = ( @@ -148,7 +162,29 @@ def __set_name__(self, owner: Type[MessageTarget], name: str) -> None: default = self._default setattr(owner, f"_default_{name}", default) - def __get__(self, obj: Reactable, obj_type: type[object]) -> ReactiveType: + @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." + ) internal_name = self.internal_name if not hasattr(obj, internal_name): self._initialize_reactive(obj, self.name) @@ -156,7 +192,6 @@ def __get__(self, obj: Reactable, obj_type: type[object]) -> ReactiveType: if hasattr(obj, self.compute_name): value: ReactiveType old_value = getattr(obj, internal_name) - _rich_traceback_omit = True value = getattr(obj, self.compute_name)() setattr(obj, internal_name, value) self._check_watchers(obj, self.name, old_value) @@ -167,6 +202,11 @@ def __get__(self, obj: Reactable, obj_type: type[object]) -> ReactiveType: def __set__(self, obj: Reactable, value: ReactiveType) -> 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." + ) + self._initialize_reactive(obj, self.name) if hasattr(obj, self.compute_name): @@ -261,7 +301,7 @@ def invoke_watcher( watchers[:] = [ (reactable, callback) for reactable, callback in watchers - if reactable.is_attached and not reactable._closing + if not reactable._closing ] for reactable, callback in watchers: with reactable.prevent(*obj._prevent_message_types_stack[-1]): @@ -356,6 +396,7 @@ def _watch( """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. diff --git a/src/textual/renderables/background_screen.py b/src/textual/renderables/background_screen.py index 70ed79f1b1..7dd4bc04d7 100644 --- a/src/textual/renderables/background_screen.py +++ b/src/textual/renderables/background_screen.py @@ -2,11 +2,13 @@ from typing import TYPE_CHECKING, Iterable +from rich import terminal_theme from rich.console import Console, ConsoleOptions, RenderResult from rich.segment import Segment from rich.style import Style from ..color import Color +from ..filter import ANSIToTruecolor if TYPE_CHECKING: from ..screen import Screen @@ -49,12 +51,17 @@ def process_segments( _Segment = Segment NULL_STYLE = Style() + truecolor_style = ANSIToTruecolor(terminal_theme.DIMMED_MONOKAI).truecolor_style for segment in segments: text, style, control = segment if control: yield segment else: - style = NULL_STYLE if style is None else style.clear_meta_and_links() + style = ( + NULL_STYLE + if style is None + else truecolor_style(style.clear_meta_and_links()) + ) yield _Segment( text, ( diff --git a/src/textual/renderables/tint.py b/src/textual/renderables/tint.py index ebdd38105e..afe95f554b 100644 --- a/src/textual/renderables/tint.py +++ b/src/textual/renderables/tint.py @@ -2,11 +2,13 @@ from typing import Iterable +from rich import terminal_theme from rich.console import Console, ConsoleOptions, RenderableType, RenderResult from rich.segment import Segment from rich.style import Style from ..color import Color +from ..filter import ANSIToTruecolor class Tint: @@ -43,13 +45,19 @@ def process_segments( style_from_color = Style.from_color _Segment = Segment + ansi_filter = ANSIToTruecolor(terminal_theme.DIMMED_MONOKAI) + NULL_STYLE = Style() for segment in segments: text, style, control = segment if control: yield segment else: - style = style or NULL_STYLE + style = ( + ansi_filter.truecolor_style(style) + if style is not None + else NULL_STYLE + ) yield _Segment( text, ( diff --git a/src/textual/scroll_view.py b/src/textual/scroll_view.py index a4e3aa03d8..570bd3fafa 100644 --- a/src/textual/scroll_view.py +++ b/src/textual/scroll_view.py @@ -144,6 +144,21 @@ def scroll_to( on_complete=on_complete, ) + def refresh_line(self, y: int) -> None: + """Refresh a single line. + + Args: + y: Coordinate of line. + """ + self.refresh( + Region( + 0, + y - self.scroll_offset.y, + max(self.virtual_size.width, self.size.width), + 1, + ) + ) + def refresh_lines(self, y_start: int, line_count: int = 1) -> None: """Refresh one or more lines. @@ -152,7 +167,10 @@ def refresh_lines(self, y_start: int, line_count: int = 1) -> None: line_count: Total number of lines to refresh. """ - width = self.size.width - scroll_x, scroll_y = self.scroll_offset - refresh_region = Region(scroll_x, y_start - scroll_y, width, line_count) + refresh_region = Region( + 0, + y_start - self.scroll_offset.y, + max(self.virtual_size.width, self.size.width), + line_count, + ) self.refresh(refresh_region) diff --git a/src/textual/widget.py b/src/textual/widget.py index 568e11ad1a..85daa5025e 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2784,9 +2784,12 @@ def __init_subclass__( ) def __rich_repr__(self) -> rich.repr.Result: - yield "id", self.id, None - if self.name: - yield "name", self.name + try: + yield "id", self.id, None + if self.name: + yield "name", self.name + except AttributeError: + pass def _get_scrollable_region(self, region: Region) -> Region: """Adjusts the Widget region to accommodate scrollbars. @@ -3430,6 +3433,7 @@ async def handle_key(self, event: events.Key) -> bool: return await self.dispatch_key(event) async def _on_compose(self, event: events.Compose) -> None: + _rich_traceback_omit = True event.prevent_default() try: widgets = [*self._pending_children, *compose(self)] diff --git a/src/textual/widgets/_log.py b/src/textual/widgets/_log.py index df5b645c4f..12eea8bc10 100644 --- a/src/textual/widgets/_log.py +++ b/src/textual/widgets/_log.py @@ -61,6 +61,7 @@ def __init__( classes: The CSS classes of the text log. disabled: Whether the text log is disabled or not. """ + super().__init__(name=name, id=id, classes=classes, disabled=disabled) self.highlight = highlight """Enable highlighting.""" self.max_lines = max_lines @@ -71,7 +72,6 @@ def __init__( self._render_line_cache: LRUCache[int, Strip] = LRUCache(1024) self.highlighter = ReprHighlighter() """The Rich Highlighter object to use, if `highlight=True`""" - super().__init__(name=name, id=id, classes=classes, disabled=disabled) @property def lines(self) -> Sequence[str]: diff --git a/src/textual/widgets/_switch.py b/src/textual/widgets/_switch.py index a6114ff3a8..2b8d4ccc39 100644 --- a/src/textual/widgets/_switch.py +++ b/src/textual/widgets/_switch.py @@ -74,7 +74,7 @@ class Switch(Widget, can_focus=True): } """ - value = reactive(False, init=False) + value: reactive[bool] = reactive(False, init=False) """The value of the switch; `True` for on and `False` for off.""" slider_pos = reactive(0.0) @@ -124,7 +124,7 @@ def __init__( super().__init__(name=name, id=id, classes=classes, disabled=disabled) if value: self.slider_pos = 1.0 - self._reactive_value = value + self.set_reactive(Switch.value, value) self._should_animate = animate def watch_value(self, value: bool) -> None: diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index 5f05064b16..e7ae3eb25b 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -324,6 +324,9 @@ def __init__( @property def active_pane(self) -> TabPane | None: """The currently active pane, or `None` if no pane is active.""" + active = self.active + if not active: + return None return self.get_pane(self.active) def validate_active(self, active: str) -> str: diff --git a/src/textual/worker.py b/src/textual/worker.py index d858fbe8c5..fa38d196be 100644 --- a/src/textual/worker.py +++ b/src/textual/worker.py @@ -8,6 +8,7 @@ import enum import inspect from contextvars import ContextVar +from threading import Event from time import monotonic from typing import ( TYPE_CHECKING, @@ -162,6 +163,8 @@ def __init__( self.group = group self.description = description self.exit_on_error = exit_on_error + self.cancelled_event: Event = Event() + """A threading event set when the worker is cancelled.""" self._thread_worker = thread self._state = WorkerState.PENDING self.state = self._state @@ -409,6 +412,7 @@ def cancel(self) -> None: self._cancelled = True if self._task is not None: self._task.cancel() + self.cancelled_event.set() async def wait(self) -> ResultType: """Wait for the work to complete. diff --git a/tests/test_data_bind.py b/tests/test_data_bind.py new file mode 100644 index 0000000000..ba29e87d1e --- /dev/null +++ b/tests/test_data_bind.py @@ -0,0 +1,85 @@ +import pytest + +from textual.app import App, ComposeResult +from textual.reactive import ReactiveError, reactive +from textual.widgets import Label + + +class FooLabel(Label): + foo = reactive("Foo") + + def render(self) -> str: + return self.foo + + +class DataBindApp(App): + bar = reactive("Bar") + + def compose(self) -> ComposeResult: + yield FooLabel(id="label1").data_bind(foo=DataBindApp.bar) + yield FooLabel(id="label2") # Not bound + + +async def test_data_binding(): + app = DataBindApp() + async with app.run_test(): + + # Check default + assert app.bar == "Bar" + + label1 = app.query_one("#label1", FooLabel) + label2 = app.query_one("#label2", FooLabel) + + # These are bound, so should have the same value as the App.foo + assert label1.foo == "Bar" + # Not yet bound, so should have its own default + assert label2.foo == "Foo" + + # Changing this reactive, should also change the bound widgets + app.bar = "Baz" + + # Sanity check + assert app.bar == "Baz" + + # Should also have updated bound labels + assert label1.foo == "Baz" + assert label2.foo == "Foo" + + with pytest.raises(ReactiveError): + # This should be an error because FooLabel.foo is not defined on the app + label2.data_bind(foo=FooLabel.foo) + + # Bind data outside of compose + label2.data_bind(foo=DataBindApp.bar) + # Confirm new binding has propagated + assert label2.foo == "Baz" + + # Set reactive and check propagation + app.bar = "Egg" + assert label1.foo == "Egg" + assert label2.foo == "Egg" + + # Test nothing goes awry when removing widget with bound data + await label1.remove() + + # Try one last time + app.bar = "Spam" + + # Confirm remaining widgets still propagate + assert label2.foo == "Spam" + + +async def test_data_binding_missing_reactive(): + + class DataBindErrorApp(App): + foo = reactive("Bar") + + def compose(self) -> ComposeResult: + yield FooLabel(id="label1").data_bind( + nofoo=DataBindErrorApp.foo + ) # Missing reactive + + app = DataBindErrorApp() + with pytest.raises(ReactiveError): + async with app.run_test(): + pass diff --git a/tests/test_reactive.py b/tests/test_reactive.py index 8ee7861a2a..df09b79694 100644 --- a/tests/test_reactive.py +++ b/tests/test_reactive.py @@ -575,3 +575,26 @@ def callback(self): await pilot.pause() assert from_holder assert from_app + + +async def test_set_reactive(): + """Test set_reactive doesn't call watchers.""" + + class MyWidget(Widget): + foo = reactive("") + + def __init__(self, foo: str) -> None: + super().__init__() + self.set_reactive(MyWidget.foo, foo) + + def watch_foo(self) -> None: + # Should never get here + 1 / 0 + + class MyApp(App): + def compose(self) -> ComposeResult: + yield MyWidget("foobar") + + app = MyApp() + async with app.run_test(): + assert app.query_one(MyWidget).foo == "foobar"