mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Register callbacks at message pump level, invoke them after refresh (#607)
* Register callbacks at message pump level, invoke them after refresh * Fix a typo * Code review feedback actions * call_later callbacks invoked after refresh or on idle * Fix space key in text input * Make Widget.on_idle synchronous * Fix call_later * Rename PostScreenUpdate to InvokeCallbacks, and only fire if callbacks exist * Update type hints for InvokeLater callbacks * Update type signature of call_later callbacks, extract typevar
This commit is contained in:
2
Makefile
2
Makefile
@@ -1,5 +1,7 @@
|
||||
test:
|
||||
pytest --cov-report term-missing --cov=textual tests/ -vv
|
||||
unit-test:
|
||||
pytest --cov-report term-missing --cov=textual tests/ -vv -m "not integration_test"
|
||||
typecheck:
|
||||
mypy src/textual
|
||||
format:
|
||||
|
||||
@@ -4,7 +4,6 @@ from pathlib import Path
|
||||
from typing import Iterable
|
||||
|
||||
from rich.console import RenderableType
|
||||
from rich.style import Style
|
||||
from rich.table import Table
|
||||
from rich.text import Text
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
parser.py:
|
||||
python -m lark.tools.standalone css.g > parser.py
|
||||
black parser.py
|
||||
@@ -45,6 +45,10 @@ class Callback(Event, bubble=False, verbosity=3):
|
||||
yield "callback", self.callback
|
||||
|
||||
|
||||
class InvokeCallbacks(Event, bubble=False):
|
||||
"""Sent after the Screen is updated"""
|
||||
|
||||
|
||||
class ShutdownRequest(Event):
|
||||
pass
|
||||
|
||||
@@ -209,7 +213,7 @@ class Key(InputEvent):
|
||||
Returns:
|
||||
bool: True if the key is printable. False otherwise.
|
||||
"""
|
||||
return self.key not in KEY_VALUES
|
||||
return self.key == Keys.Space or self.key not in KEY_VALUES
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
|
||||
@@ -193,14 +193,20 @@ class MessagePump:
|
||||
return timer
|
||||
|
||||
def call_later(self, callback: Callable, *args, **kwargs) -> None:
|
||||
"""Run a callback after processing all messages and refreshing the screen.
|
||||
"""Schedule a callback to run after all messages are processed and the screen
|
||||
has been refreshed.
|
||||
|
||||
Args:
|
||||
callback (Callable): A callable.
|
||||
"""
|
||||
self.post_message_no_wait(
|
||||
events.Callback(self, partial(callback, *args, **kwargs))
|
||||
)
|
||||
# We send the InvokeLater message to ourselves first, to ensure we've cleared
|
||||
# out anything already pending in our own queue.
|
||||
message = messages.InvokeLater(self, partial(callback, *args, **kwargs))
|
||||
self.post_message_no_wait(message)
|
||||
|
||||
def handle_invoke_later(self, message: messages.InvokeLater) -> None:
|
||||
# Forward InvokeLater message to the Screen
|
||||
self.app.screen.post_message_no_wait(message)
|
||||
|
||||
def close_messages_no_wait(self) -> None:
|
||||
"""Request the message queue to exit."""
|
||||
@@ -392,9 +398,6 @@ class MessagePump:
|
||||
return False
|
||||
return self.post_message_no_wait(message)
|
||||
|
||||
async def on_callback(self, event: events.Callback) -> None:
|
||||
await invoke(event.callback)
|
||||
|
||||
def emit_no_wait(self, message: Message) -> bool:
|
||||
if self._parent:
|
||||
return self._parent.post_message_from_child_no_wait(message)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Callable, Awaitable, Union
|
||||
|
||||
import rich.repr
|
||||
|
||||
@@ -11,6 +11,9 @@ if TYPE_CHECKING:
|
||||
from .widget import Widget
|
||||
|
||||
|
||||
CallbackType = Union[Callable[[], Awaitable[None]], Callable[[], None]]
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
class Update(Message, verbosity=3):
|
||||
def __init__(self, sender: MessagePump, widget: Widget):
|
||||
@@ -37,6 +40,16 @@ class Layout(Message, verbosity=3):
|
||||
return isinstance(message, Layout)
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
class InvokeLater(Message, verbosity=3):
|
||||
def __init__(self, sender: MessagePump, callback: CallbackType) -> None:
|
||||
self.callback = callback
|
||||
super().__init__(sender)
|
||||
|
||||
def __rich_repr__(self) -> rich.repr.Result:
|
||||
yield "callback", self.callback
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
class CursorMove(Message):
|
||||
def __init__(self, sender: MessagePump, line: int) -> None:
|
||||
|
||||
@@ -8,16 +8,18 @@ from rich.style import Style
|
||||
|
||||
|
||||
from . import events, messages, errors
|
||||
from ._callback import invoke
|
||||
|
||||
from .geometry import Offset, Region, Size
|
||||
from ._compositor import Compositor, MapGeometry
|
||||
from .messages import CallbackType
|
||||
from .reactive import Reactive
|
||||
from .renderables.blank import Blank
|
||||
from ._timer import Timer
|
||||
from .widget import Widget
|
||||
|
||||
if sys.version_info >= (3, 8):
|
||||
from typing import Final
|
||||
from typing import Final, Callable, Awaitable
|
||||
else:
|
||||
from typing_extensions import Final
|
||||
|
||||
@@ -31,7 +33,7 @@ class Screen(Widget):
|
||||
|
||||
CSS = """
|
||||
Screen {
|
||||
|
||||
|
||||
layout: vertical;
|
||||
overflow-y: auto;
|
||||
}
|
||||
@@ -44,6 +46,7 @@ class Screen(Widget):
|
||||
self._compositor = Compositor()
|
||||
self._dirty_widgets: set[Widget] = set()
|
||||
self._update_timer: Timer | None = None
|
||||
self._callbacks: list[CallbackType] = []
|
||||
|
||||
@property
|
||||
def is_transparent(self) -> bool:
|
||||
@@ -110,7 +113,7 @@ class Screen(Widget):
|
||||
"""
|
||||
return self._compositor.find_widget(widget)
|
||||
|
||||
def on_idle(self, event: events.Idle) -> None:
|
||||
async def on_idle(self, event: events.Idle) -> None:
|
||||
# Check for any widgets marked as 'dirty' (needs a repaint)
|
||||
event.prevent_default()
|
||||
if self._layout_required:
|
||||
@@ -125,6 +128,9 @@ class Screen(Widget):
|
||||
if self._dirty_widgets:
|
||||
self.update_timer.resume()
|
||||
|
||||
# The Screen is idle - a good opportunity to invoke the scheduled callbacks
|
||||
await self._invoke_and_clear_callbacks()
|
||||
|
||||
def _on_update(self) -> None:
|
||||
"""Called by the _update_timer."""
|
||||
# Render widgets together
|
||||
@@ -132,7 +138,27 @@ class Screen(Widget):
|
||||
self._compositor.update_widgets(self._dirty_widgets)
|
||||
self.app._display(self._compositor.render())
|
||||
self._dirty_widgets.clear()
|
||||
|
||||
self.update_timer.pause()
|
||||
if self._callbacks:
|
||||
self.post_message_no_wait(events.InvokeCallbacks(self))
|
||||
|
||||
async def on_invoke_callbacks(self, event: events.InvokeCallbacks) -> None:
|
||||
"""Handle PostScreenUpdate events, which are sent after the screen is updated"""
|
||||
await self._invoke_and_clear_callbacks()
|
||||
|
||||
async def _invoke_and_clear_callbacks(self) -> None:
|
||||
"""If there are scheduled callbacks to run, call them and clear
|
||||
the callback queue."""
|
||||
if self._callbacks:
|
||||
callbacks = self._callbacks[:]
|
||||
self._callbacks.clear()
|
||||
for callback in callbacks:
|
||||
await invoke(callback)
|
||||
|
||||
def handle_invoke_later(self, message: messages.InvokeLater) -> None:
|
||||
# Enqueue the callback function to be called later
|
||||
self._callbacks.append(message.callback)
|
||||
|
||||
def _refresh_layout(self, size: Size | None = None, full: bool = False) -> None:
|
||||
"""Refresh the layout (can change size and positions of widgets)."""
|
||||
|
||||
@@ -40,6 +40,8 @@ class TextWidgetBase(Widget):
|
||||
key = event.key
|
||||
if key == "escape":
|
||||
return
|
||||
elif key == "space":
|
||||
key = " "
|
||||
|
||||
changed = False
|
||||
if event.is_printable:
|
||||
|
||||
@@ -12,6 +12,8 @@ from textual.geometry import Size
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Placeholder
|
||||
|
||||
pytestmark = pytest.mark.integration_test
|
||||
|
||||
# Let's allow ourselves some abbreviated names for those tests,
|
||||
# in order to make the test cases a bit easier to read :-)
|
||||
SCREEN_W = 100 # width of our Screens
|
||||
@@ -26,7 +28,6 @@ SCROLL_V_SIZE = 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration_test # this is a slow test, we may want to skip them in some contexts
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"placeholders_count",
|
||||
@@ -164,7 +165,6 @@ async def test_composition_of_vertical_container_with_children(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration_test
|
||||
@pytest.mark.parametrize(
|
||||
"edge_type,expected_box_inner_size,expected_box_size,expected_top_left_edge_color,expects_visible_char_at_top_left_edge",
|
||||
(
|
||||
|
||||
@@ -9,12 +9,13 @@ from textual.geometry import Size
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Placeholder
|
||||
|
||||
pytestmark = pytest.mark.integration_test
|
||||
|
||||
SCREEN_SIZE = Size(100, 30)
|
||||
|
||||
|
||||
@pytest.mark.skip("Needs a rethink")
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration_test # this is a slow test, we may want to skip them in some contexts
|
||||
@pytest.mark.parametrize(
|
||||
(
|
||||
"screen_size",
|
||||
|
||||
Reference in New Issue
Block a user