Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

wait for screen #2584

Merged
merged 7 commits into from
May 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- App `title` and `sub_title` attributes can be set to any type https://github.com/Textualize/textual/issues/2521
- Using `Widget.move_child` where the target and the child being moved are the same is now a no-op https://github.com/Textualize/textual/issues/1743
- Calling `dismiss` on a screen that is not at the top of the stack now raises an exception https://github.com/Textualize/textual/issues/2575
- `MessagePump.call_after_refresh` and `MessagePump.call_later` will not return `False` if the callback could not be scheduled. https://github.com/Textualize/textual/pull/2584

### Fixed

Expand Down
18 changes: 14 additions & 4 deletions src/textual/message_pump.py
Original file line number Diff line number Diff line change
Expand Up @@ -349,30 +349,40 @@ def set_interval(
self._timers.add(timer)
return timer

def call_after_refresh(self, callback: Callable, *args: Any, **kwargs: Any) -> None:
def call_after_refresh(self, callback: Callable, *args: Any, **kwargs: Any) -> bool:
"""Schedule a callback to run after all messages are processed and the screen
has been refreshed. Positional and keyword arguments are passed to the callable.

Args:
callback: A callable.

Returns:
`True` if the callback was scheduled, or `False` if the callback could not be
scheduled (may occur if the message pump was closed or closing).

"""
# We send the InvokeLater message to ourselves first, to ensure we've cleared
# out anything already pending in our own queue.

message = messages.InvokeLater(partial(callback, *args, **kwargs))
self.post_message(message)
return self.post_message(message)

def call_later(self, callback: Callable, *args: Any, **kwargs: Any) -> None:
def call_later(self, callback: Callable, *args: Any, **kwargs: Any) -> bool:
"""Schedule a callback to run after all messages are processed in this object.
Positional and keywords arguments are passed to the callable.

Args:
callback: Callable to call next.
*args: Positional arguments to pass to the callable.
**kwargs: Keyword arguments to pass to the callable.

Returns:
`True` if the callback was scheduled, or `False` if the callback could not be
scheduled (may occur if the message pump was closed or closing).

"""
message = events.Callback(callback=partial(callback, *args, **kwargs))
self.post_message(message)
return self.post_message(message)

def call_next(self, callback: Callable, *args: Any, **kwargs: Any) -> None:
"""Schedule a callback to run immediately after processing the current message.
Expand Down
42 changes: 41 additions & 1 deletion src/textual/pilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ async def press(self, *keys: str) -> None:
"""
if keys:
await self._app._press_keys(keys)
await self._wait_for_screen()

async def click(
self,
Expand Down Expand Up @@ -132,13 +133,49 @@ async def hover(
app.post_message(MouseMove(**message_arguments))
await self.pause()

async def _wait_for_screen(self, timeout: float = 30.0) -> bool:
"""Wait for the current screen to have processed all pending events.

Args:
timeout: A timeout in seconds to wait.

Returns:
`True` if all events were processed, or `False` if the wait timed out.
"""
children = [self.app, *self.app.screen.walk_children(with_self=True)]
count = 0
count_zero_event = asyncio.Event()

def decrement_counter() -> None:
"""Decrement internal counter, and set an event if it reaches zero."""
nonlocal count
count -= 1
if count == 0:
# When count is zero, all messages queued at the start of the method have been processed
count_zero_event.set()

# Increase the count for every successful call_later
for child in children:
if child.call_later(decrement_counter):
count += 1

if count:
# Wait for the count to return to zero, or a timeout
try:
await asyncio.wait_for(count_zero_event.wait(), timeout=timeout)
except asyncio.TimeoutError:
return False

return True

async def pause(self, delay: float | None = None) -> None:
"""Insert a pause.

Args:
delay: Seconds to pause, or None to wait for cpu idle.
"""
# These sleep zeros, are to force asyncio to give up a time-slice,
# These sleep zeros, are to force asyncio to give up a time-slice.
await self._wait_for_screen()
if delay is None:
await wait_for_idle(0)
else:
Expand All @@ -152,7 +189,9 @@ async def wait_for_animation(self) -> None:

async def wait_for_scheduled_animations(self) -> None:
"""Wait for any current and scheduled animations to complete."""
await self._wait_for_screen()
await self._app.animator.wait_until_complete()
await self._wait_for_screen()
await wait_for_idle()
self.app.screen._on_timer_update()

Expand All @@ -162,5 +201,6 @@ async def exit(self, result: ReturnType) -> None:
Args:
result: The app result returned by `run` or `run_async`.
"""
await self._wait_for_screen()
await wait_for_idle()
self.app.exit(result)