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 `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

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.
## 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.

View File

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

View File

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

View File

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

View File

@@ -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:

View File

@@ -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,6 +521,7 @@ class MessagePump(metaclass=MessagePumpMeta):
if message.no_dispatch:
return
with self.prevent(*message._prevent):
# Allow apps to treat events and messages separately
if isinstance(message, Event):
await self.on_event(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

View File

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

View File

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

View File

@@ -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"