Merge pull request #1866 from Textualize/prevent-event

Add a "prevent" context manager
This commit is contained in:
Will McGugan
2023-02-24 12:36:46 +00:00
committed by GitHub
11 changed files with 191 additions and 12 deletions

View File

@@ -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 horizontal rule to Markdown https://github.com/Textualize/textual/pull/1832
- Added `Widget.disabled` https://github.com/Textualize/textual/pull/1785 - 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 `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 ### Changed

View File

@@ -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()

View File

@@ -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. - 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. 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`. - [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 ## Message handlers
Most of the logic in a Textual app will be written in message handlers. Let's explore handlers in more detail. Most of the logic in a Textual app will be written in message handlers. Let's explore handlers in more detail.

View File

@@ -1,8 +1,11 @@
from __future__ import annotations
from contextvars import ContextVar from contextvars import ContextVar
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
if TYPE_CHECKING: if TYPE_CHECKING:
from .app import App from .app import App
from .message import Message
from .message_pump import MessagePump from .message_pump import MessagePump
@@ -12,3 +15,6 @@ class NoActiveAppError(RuntimeError):
active_app: ContextVar["App"] = ContextVar("active_app") active_app: ContextVar["App"] = ContextVar("active_app")
active_message_pump: ContextVar["MessagePump"] = ContextVar("active_message_pump") active_message_pump: ContextVar["MessagePump"] = ContextVar("active_message_pump")
prevent_message_types_stack: ContextVar[list[set[type[Message]]]] = ContextVar(
"prevent_message_types_stack"
)

View File

@@ -39,6 +39,7 @@ from .walk import walk_breadth_first, walk_depth_first
if TYPE_CHECKING: if TYPE_CHECKING:
from .app import App from .app import App
from .messages import Message
from .css.query import DOMQuery from .css.query import DOMQuery
from .screen import Screen from .screen import Screen
from .widget import Widget from .widget import Widget

View File

@@ -29,7 +29,9 @@ class Event(Message):
@rich.repr.auto @rich.repr.auto
class Callback(Event, bubble=False, verbose=True): class Callback(Event, bubble=False, verbose=True):
def __init__( def __init__(
self, sender: MessageTarget, callback: Callable[[], Awaitable[None]] self,
sender: MessageTarget,
callback: Callable[[], Awaitable[None]],
) -> None: ) -> None:
self.callback = callback self.callback = callback
super().__init__(sender) super().__init__(sender)

View File

@@ -31,6 +31,7 @@ class Message:
"_no_default_action", "_no_default_action",
"_stop_propagation", "_stop_propagation",
"_handler_name", "_handler_name",
"_prevent",
] ]
sender: MessageTarget sender: MessageTarget
@@ -50,6 +51,7 @@ class Message:
self._handler_name = ( self._handler_name = (
f"on_{self.namespace}_{name}" if self.namespace else f"on_{name}" f"on_{self.namespace}_{name}" if self.namespace else f"on_{name}"
) )
self._prevent: set[type[Message]] = set()
super().__init__() super().__init__()
def __rich_repr__(self) -> rich.repr.Result: def __rich_repr__(self) -> rich.repr.Result:

View File

@@ -10,15 +10,22 @@ from __future__ import annotations
import asyncio import asyncio
import inspect import inspect
from asyncio import CancelledError, Queue, QueueEmpty, Task from asyncio import CancelledError, Queue, QueueEmpty, Task
from contextlib import contextmanager
from functools import partial 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 weakref import WeakSet
from . import Logger, events, log, messages from . import Logger, events, log, messages
from ._asyncio import create_task from ._asyncio import create_task
from ._callback import invoke 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 ._time import time
from ._types import CallbackType
from .case import camel_to_snake from .case import camel_to_snake
from .errors import DuplicateKeyHandlers from .errors import DuplicateKeyHandlers
from .events import Event from .events import Event
@@ -78,6 +85,54 @@ class MessagePump(metaclass=MessagePumpMeta):
self._mounted_event = asyncio.Event() self._mounted_event = asyncio.Event()
self._next_callbacks: list[CallbackType] = [] 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 @property
def task(self) -> Task: def task(self) -> Task:
assert self._task is not None assert self._task is not None
@@ -149,6 +204,14 @@ class MessagePump(metaclass=MessagePumpMeta):
self._parent = None self._parent = None
def check_message_enabled(self, message: Message) -> bool: 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 return type(message) not in self._disabled_messages
def disable_messages(self, *messages: type[Message]) -> None: def disable_messages(self, *messages: type[Message]) -> None:
@@ -458,12 +521,13 @@ class MessagePump(metaclass=MessagePumpMeta):
if message.no_dispatch: if message.no_dispatch:
return return
# Allow apps to treat events and messages separately with self.prevent(*message._prevent):
if isinstance(message, Event): # Allow apps to treat events and messages separately
await self.on_event(message) if isinstance(message, Event):
else: await self.on_event(message)
await self._on_message(message) else:
await self._flush_next_callbacks() await self._on_message(message)
await self._flush_next_callbacks()
def _get_dispatch_methods( def _get_dispatch_methods(
self, method_name: str, message: Message self, method_name: str, message: Message
@@ -542,6 +606,9 @@ class MessagePump(metaclass=MessagePumpMeta):
return False return False
if not self.check_message_enabled(message): if not self.check_message_enabled(message):
return True 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) await self._message_queue.put(message)
return True return True
@@ -580,6 +647,9 @@ class MessagePump(metaclass=MessagePumpMeta):
return False return False
if not self.check_message_enabled(message): if not self.check_message_enabled(message):
return False 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) self._message_queue.put_nowait(message)
return True return True

View File

@@ -253,8 +253,9 @@ class Reactive(Generic[ReactiveType]):
for reactable, callback in watchers for reactable, callback in watchers
if reactable.is_attached and not reactable._closing if reactable.is_attached and not reactable._closing
] ]
for _, callback in watchers: for reactable, callback in watchers:
invoke_watcher(callback, old_value, value) with reactable.prevent(*obj._prevent_message_types_stack[-1]):
invoke_watcher(callback, old_value, value)
@classmethod @classmethod
def _compute(cls, obj: Reactable) -> None: def _compute(cls, obj: Reactable) -> None:

View File

@@ -2489,9 +2489,20 @@ class Widget(DOMNode):
self.app.capture_mouse(None) self.app.capture_mouse(None)
def check_message_enabled(self, message: Message) -> bool: 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. # Do the normal checking and get out if that fails.
if not super().check_message_enabled(message): if not super().check_message_enabled(message):
return False 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 # Otherwise, if this is a mouse event, the widget receiving the
# event must not be disabled at this moment. # event must not be disabled at this moment.
return ( return (

View File

@@ -1,8 +1,10 @@
import pytest import pytest
from textual.app import App, ComposeResult
from textual.errors import DuplicateKeyHandlers from textual.errors import DuplicateKeyHandlers
from textual.events import Key from textual.events import Key
from textual.widget import Widget from textual.widget import Widget
from textual.widgets import Input
class ValidWidget(Widget): class ValidWidget(Widget):
@@ -54,3 +56,34 @@ async def test_dispatch_key_raises_when_conflicting_handler_aliases():
with pytest.raises(DuplicateKeyHandlers): with pytest.raises(DuplicateKeyHandlers):
await widget.dispatch_key(Key(widget, key="tab", character="\t")) await widget.dispatch_key(Key(widget, key="tab", character="\t"))
assert widget.called_by == widget.key_tab 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"