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:
darrenburns
2022-08-05 13:47:47 +01:00
committed by GitHub
parent 589bf6a1bc
commit a166d84eef
10 changed files with 66 additions and 19 deletions

View File

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

View File

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

View File

@@ -1,3 +0,0 @@
parser.py:
python -m lark.tools.standalone css.g > parser.py
black parser.py

View File

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

View File

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

View File

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

View File

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

View File

@@ -40,6 +40,8 @@ class TextWidgetBase(Widget):
key = event.key
if key == "escape":
return
elif key == "space":
key = " "
changed = False
if event.is_printable:

View File

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

View File

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