mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge pull request #1866 from Textualize/prevent-event
Add a "prevent" context manager
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
26
docs/examples/events/prevent.py
Normal file
26
docs/examples/events/prevent.py
Normal 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()
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user