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 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
|
||||||
|
|
||||||
|
|||||||
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.
|
- 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.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user