diff --git a/CHANGELOG.md b/CHANGELOG.md index 042796b7b..8242d5dac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Added horizontal rule to Markdown https://github.com/Textualize/textual/pull/1832 - Added `Widget.disabled` https://github.com/Textualize/textual/pull/1785 - Added `DOMNode.notify_style_update` to replace `messages.StylesUpdated` message https://github.com/Textualize/textual/pull/1861 +- Added `MessagePump.prevent` context manager to temporarily suppress a given message type https://github.com/Textualize/textual/pull/1866 ### Changed diff --git a/docs/examples/events/prevent.py b/docs/examples/events/prevent.py new file mode 100644 index 000000000..39fe437c2 --- /dev/null +++ b/docs/examples/events/prevent.py @@ -0,0 +1,26 @@ +from textual.app import App, ComposeResult +from textual.containers import Horizontal +from textual.widgets import Button, Input + + +class PreventApp(App): + """Demonstrates `prevent` context manager.""" + + def compose(self) -> ComposeResult: + yield Input() + yield Button("Clear", id="clear") + + def on_button_pressed(self) -> None: + """Clear the text input.""" + input = self.query_one(Input) + with input.prevent(Input.Changed): # (1)! + input.value = "" + + def on_input_changed(self) -> None: + """Called as the user types.""" + self.bell() # (2)! + + +if __name__ == "__main__": + app = PreventApp() + app.run() diff --git a/docs/guide/events.md b/docs/guide/events.md index 7c3df74c4..09f9a7043 100644 --- a/docs/guide/events.md +++ b/docs/guide/events.md @@ -108,7 +108,7 @@ The message class is defined within the widget class itself. This is not strictl - It creates a namespace for the handler. So rather than `on_selected`, the handler name becomes `on_color_button_selected`. This makes it less likely that your chosen name will clash with another message. -## Sending events +## Sending messages In the previous example we used [post_message()][textual.message_pump.MessagePump.post_message] to send an event to its parent. We could also have used [post_message_no_wait()][textual.message_pump.MessagePump.post_message_no_wait] for non async code. Sending messages in this way allows you to write custom widgets without needing to know in what context they will be used. @@ -118,6 +118,32 @@ There are other ways of sending (posting) messages, which you may need to use le - [post_message_no_wait][textual.message_pump.MessagePump.post_message_no_wait] The non-async version of `post_message`. +## Preventing messages + +You can *temporarily* disable posting of messages of a particular type by calling [prevent][textual.message_pump.MessagePump.prevent], which returns a context manager (used with Python's `with` keyword). This is typically used when updating data in a child widget and you don't want to receive notifications that something has changed. + +The following example will play the terminal bell as you type. It does this by handling [Input.Changed][textual.widgets.Input.Changed] and calling [bell()][textual.app.App.bell]. There is a Clear button which sets the input's value to an empty string. This would normally also result in a `Input.Changed` event being sent (and the bell playing). Since we don't want the button to make a sound, the assignment to `value` is wrapped within a [prevent][textual.message_pump.MessagePump.prevent] context manager. + +!!! tip + + In reality, playing the terminal bell as you type would be very irritating -- we don't recommend it! + +=== "prevent.py" + + ```python title="prevent.py" + --8<-- "docs/examples/events/prevent.py" + ``` + + 1. Clear the input without sending an Input.Changed event. + 2. Plays the terminal sound when typing. + +=== "Output" + + ```{.textual path="docs/examples/events/prevent.py"} + ``` + + + ## Message handlers Most of the logic in a Textual app will be written in message handlers. Let's explore handlers in more detail. diff --git a/src/textual/_context.py b/src/textual/_context.py index 6a5562476..9dc5d8ca3 100644 --- a/src/textual/_context.py +++ b/src/textual/_context.py @@ -1,8 +1,11 @@ +from __future__ import annotations + from contextvars import ContextVar from typing import TYPE_CHECKING if TYPE_CHECKING: from .app import App + from .message import Message from .message_pump import MessagePump @@ -12,3 +15,6 @@ class NoActiveAppError(RuntimeError): active_app: ContextVar["App"] = ContextVar("active_app") active_message_pump: ContextVar["MessagePump"] = ContextVar("active_message_pump") +prevent_message_types_stack: ContextVar[list[set[type[Message]]]] = ContextVar( + "prevent_message_types_stack" +) diff --git a/src/textual/dom.py b/src/textual/dom.py index 9980c9471..55ff4ffd6 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -39,6 +39,7 @@ from .walk import walk_breadth_first, walk_depth_first if TYPE_CHECKING: from .app import App + from .messages import Message from .css.query import DOMQuery from .screen import Screen from .widget import Widget diff --git a/src/textual/events.py b/src/textual/events.py index 9914c03d5..d4c1625b2 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -29,7 +29,9 @@ class Event(Message): @rich.repr.auto class Callback(Event, bubble=False, verbose=True): def __init__( - self, sender: MessageTarget, callback: Callable[[], Awaitable[None]] + self, + sender: MessageTarget, + callback: Callable[[], Awaitable[None]], ) -> None: self.callback = callback super().__init__(sender) diff --git a/src/textual/message.py b/src/textual/message.py index 7ed72789b..8f6816619 100644 --- a/src/textual/message.py +++ b/src/textual/message.py @@ -31,6 +31,7 @@ class Message: "_no_default_action", "_stop_propagation", "_handler_name", + "_prevent", ] sender: MessageTarget @@ -50,6 +51,7 @@ class Message: self._handler_name = ( f"on_{self.namespace}_{name}" if self.namespace else f"on_{name}" ) + self._prevent: set[type[Message]] = set() super().__init__() def __rich_repr__(self) -> rich.repr.Result: diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index d17bd8249..7a2f6d24e 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -10,15 +10,22 @@ from __future__ import annotations import asyncio import inspect from asyncio import CancelledError, Queue, QueueEmpty, Task +from contextlib import contextmanager from functools import partial -from typing import TYPE_CHECKING, Any, Awaitable, Callable, Iterable +from typing import TYPE_CHECKING, Any, Awaitable, Callable, Generator, Iterable from weakref import WeakSet from . import Logger, events, log, messages from ._asyncio import create_task from ._callback import invoke -from ._context import NoActiveAppError, active_app, active_message_pump +from ._context import ( + NoActiveAppError, + active_app, + active_message_pump, + prevent_message_types_stack, +) from ._time import time +from ._types import CallbackType from .case import camel_to_snake from .errors import DuplicateKeyHandlers from .events import Event @@ -78,6 +85,54 @@ class MessagePump(metaclass=MessagePumpMeta): self._mounted_event = asyncio.Event() self._next_callbacks: list[CallbackType] = [] + @property + def _prevent_message_types_stack(self) -> list[set[type[Message]]]: + """The stack that manages prevented messages.""" + try: + stack = prevent_message_types_stack.get() + except LookupError: + stack = [set()] + prevent_message_types_stack.set(stack) + return stack + + def _get_prevented_messages(self) -> set[type[Message]]: + """A set of all the prevented message types.""" + return self._prevent_message_types_stack[-1] + + def _is_prevented(self, message_type: type[Message]) -> bool: + """Check if a message type has been prevented via the + [prevent][textual.message_pump.MessagePump.prevent] context manager. + + Args: + message_type: A message type. + + Returns: + `True` if the message has been prevented from sending, or `False` if it will be sent as normal. + """ + return message_type in self._prevent_message_types_stack[-1] + + @contextmanager + def prevent(self, *message_types: type[Message]) -> Generator[None, None, None]: + """A context manager to *temporarily* prevent the given message types from being posted. + + Example: + ```python + input = self.query_one(Input) + with self.prevent(Input.Changed): + input.value = "foo" + ``` + + """ + if message_types: + prevent_stack = self._prevent_message_types_stack + prevent_stack.append(prevent_stack[-1].union(message_types)) + try: + yield + finally: + prevent_stack.pop() + else: + yield + @property def task(self) -> Task: assert self._task is not None @@ -149,6 +204,14 @@ class MessagePump(metaclass=MessagePumpMeta): self._parent = None def check_message_enabled(self, message: Message) -> bool: + """Check if a given message is enabled (allowed to be sent). + + Args: + message: A message object. + + Returns: + `True` if the message will be sent, or `False` if it is disabled. + """ return type(message) not in self._disabled_messages def disable_messages(self, *messages: type[Message]) -> None: @@ -458,12 +521,13 @@ class MessagePump(metaclass=MessagePumpMeta): if message.no_dispatch: return - # Allow apps to treat events and messages separately - if isinstance(message, Event): - await self.on_event(message) - else: - await self._on_message(message) - await self._flush_next_callbacks() + with self.prevent(*message._prevent): + # Allow apps to treat events and messages separately + if isinstance(message, Event): + await self.on_event(message) + else: + await self._on_message(message) + await self._flush_next_callbacks() def _get_dispatch_methods( self, method_name: str, message: Message @@ -542,6 +606,9 @@ class MessagePump(metaclass=MessagePumpMeta): return False if not self.check_message_enabled(message): return True + # Add a copy of the prevented message types to the message + # This is so that prevented messages are honoured by the event's handler + message._prevent.update(self._get_prevented_messages()) await self._message_queue.put(message) return True @@ -580,6 +647,9 @@ class MessagePump(metaclass=MessagePumpMeta): return False if not self.check_message_enabled(message): return False + # Add a copy of the prevented message types to the message + # This is so that prevented messages are honoured by the event's handler + message._prevent.update(self._get_prevented_messages()) self._message_queue.put_nowait(message) return True diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 0553d076c..ae5cf9d6d 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -253,8 +253,9 @@ class Reactive(Generic[ReactiveType]): for reactable, callback in watchers if reactable.is_attached and not reactable._closing ] - for _, callback in watchers: - invoke_watcher(callback, old_value, value) + for reactable, callback in watchers: + with reactable.prevent(*obj._prevent_message_types_stack[-1]): + invoke_watcher(callback, old_value, value) @classmethod def _compute(cls, obj: Reactable) -> None: diff --git a/src/textual/widget.py b/src/textual/widget.py index 22faeff55..47e1b431d 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2489,9 +2489,20 @@ class Widget(DOMNode): self.app.capture_mouse(None) def check_message_enabled(self, message: Message) -> bool: + """Check if a given message is enabled (allowed to be sent). + + Args: + message: A message object + + Returns: + `True` if the message will be sent, or `False` if it is disabled. + """ # Do the normal checking and get out if that fails. if not super().check_message_enabled(message): return False + message_type = type(message) + if self._is_prevented(message_type): + return False # Otherwise, if this is a mouse event, the widget receiving the # event must not be disabled at this moment. return ( diff --git a/tests/test_message_pump.py b/tests/test_message_pump.py index ce665f7c9..9667b4cbc 100644 --- a/tests/test_message_pump.py +++ b/tests/test_message_pump.py @@ -1,8 +1,10 @@ import pytest +from textual.app import App, ComposeResult from textual.errors import DuplicateKeyHandlers from textual.events import Key from textual.widget import Widget +from textual.widgets import Input class ValidWidget(Widget): @@ -54,3 +56,34 @@ async def test_dispatch_key_raises_when_conflicting_handler_aliases(): with pytest.raises(DuplicateKeyHandlers): await widget.dispatch_key(Key(widget, key="tab", character="\t")) assert widget.called_by == widget.key_tab + + +class PreventTestApp(App): + def __init__(self) -> None: + self.input_changed_events = [] + super().__init__() + + def compose(self) -> ComposeResult: + yield Input() + + def on_input_changed(self, event: Input.Changed) -> None: + self.input_changed_events.append(event) + + +async def test_prevent() -> None: + app = PreventTestApp() + + async with app.run_test() as pilot: + assert not app.input_changed_events + input = app.query_one(Input) + input.value = "foo" + await pilot.pause() + assert len(app.input_changed_events) == 1 + assert app.input_changed_events[0].value == "foo" + + with input.prevent(Input.Changed): + input.value = "bar" + + await pilot.pause() + assert len(app.input_changed_events) == 1 + assert app.input_changed_events[0].value == "foo"