diff --git a/src/textual/_wait.py b/src/textual/_wait.py deleted file mode 100644 index 88f73b982f..0000000000 --- a/src/textual/_wait.py +++ /dev/null @@ -1,40 +0,0 @@ -from asyncio import sleep -from time import monotonic, thread_time - -SLEEP_GRANULARITY: float = 1 / 50 -SLEEP_IDLE: float = SLEEP_GRANULARITY / 20.0 - - -async def wait_for_idle( - min_sleep: float = SLEEP_GRANULARITY, max_sleep: float = 1 -) -> None: - """Wait until the calling event-loop thread isn't working very hard. - - Compare wall time with this thread's CPU time. Work on background threads - cannot determine whether this event loop has drained its UI work. - - Thread idleness suggests input has been processed, but does not establish - completion of a worker or domain operation; tests must await those receipts. - - Args: - min_sleep: Minimum time to wait. - max_sleep: Maximum time to wait. - """ - start_time = monotonic() - - while True: - cpu_time = thread_time() - # Sleep for a predetermined amount of time - await sleep(SLEEP_GRANULARITY) - # Measure the owner whose idleness this test helper is waiting for. - cpu_elapsed = thread_time() - cpu_time - elapsed_time = monotonic() - start_time - - # If we have slept the maximum, we can break - if elapsed_time >= max_sleep: - break - - # If we have slept at least the minimum and the cpu elapsed is significantly less - # than wall clock, then we can assume the process has finished working for now - if elapsed_time > min_sleep and cpu_elapsed < SLEEP_IDLE: - break diff --git a/src/textual/app.py b/src/textual/app.py index 47f2df6be2..2054abb37b 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -88,7 +88,6 @@ _make_path_object_relative, ) from textual._types import AnimationLevel -from textual._wait import wait_for_idle from textual.actions import ActionParseResult, SkipAction from textual.ansi_palette import AnsiPalette, MappedAnsiPalette from textual.await_complete import AwaitComplete @@ -243,6 +242,13 @@ class InvalidThemeError(Exception): """Raised when an invalid theme is set.""" +class WaitForScreenTimeout(Exception): + """Exception raised if messages aren't being processed quickly enough. + + If this occurs, the most likely explanation is some kind of deadlock in the app code. + """ + + ReturnType = TypeVar("ReturnType") CallThreadReturnType = TypeVar("CallThreadReturnType") @@ -2137,13 +2143,110 @@ def get_key_display(self, binding: Binding) -> str: key_tokens = modifiers + [key] return "+".join(key_tokens) + SETTLE_UPDATES: ClassVar[int] = 2 + """Screen updates one settle runs. A layout's own consequences (the Resize it sends + re-measuring content) land in the second; a widget that asks for a layout on every + layout never stops asking, so the settle stops there.""" + + async def _settle(self, timeout: float = 30.0) -> bool: + """Handle what is posted to the app and its screen, and whatever that handling posts, + until no pump there owes work. + + Two kinds of work are owed. Messages: each round waits behind the work every owing + pump has queued, and work it posts makes its target owe again. The update those + messages asked for: layout and painting run on the screen's update timer, not in a + queue, so once the queues drain the settle runs that update itself, then looks at + the queues again. A key's whole cascade (the app forwarding it, the focused widget + handling it, the layout and paint it asks for) is done when an update leaves + nothing owed. A running animation is not owed: it may run for a minute, and callers + that need it finished ask the animator (Pilot.wait_for_animation). + + Args: + timeout: Seconds one round may take. + + Returns: + `True` once nothing is owed; `False` if an exception stopped the app first. + + Raises: + WaitForScreenTimeout: If one round did not finish within the timeout. + """ + updates = 0 + while True: + # Input a driver delivered is posted from a loop callback (call_soon_threadsafe): + # one turn of the loop puts everything already handed to it on its queues. + await asyncio.sleep(0) + try: + screen = self.screen + except Exception: + return False + pumps = (self, *screen.walk_children(with_self=True)) + owed = await self._wait_behind( + [pump.call_after_pending for pump in pumps], timeout + ) + if self._exception_event.is_set() or self._exit: + return False + if owed: + continue + if self._resize_event is not None: + # A terminal resize waits on a debounce timer; deliver it to the screens. + self._check_resize() + continue + if updates == self.SETTLE_UPDATES: + return True + await screen._update_now() + updates += 1 + + async def _wait_behind( + self, schedulers: list[Callable[[Callable[[], object]], bool]], timeout: float + ) -> int: + """Schedule one callback with each scheduler and wait until every scheduled one ran, + or an exception stopped the app. + + Returns: + How many callbacks were scheduled; a scheduler answering False owed no work. + + Raises: + WaitForScreenTimeout: If they did not all run within the timeout. + """ + count = 0 + handled_all = asyncio.Event() + + def handled() -> None: + nonlocal count + count -= 1 + if count == 0: + handled_all.set() + + scheduled = sum(schedule(handled) for schedule in schedulers) + count += scheduled + if not scheduled: + return 0 + waits = [ + create_task(handled_all.wait()), + create_task(self._exception_event.wait()), + ] + # The app's own loop ending (an exit, a crash) means nothing owed will ever run. + loop_ended = [] if self._task is None else [self._task] + done, _ = await asyncio.wait( + [*waits, *loop_ended], timeout=timeout, return_when=asyncio.FIRST_COMPLETED + ) + for task in waits: + task.cancel() + if not done: + raise WaitForScreenTimeout( + "Timed out while waiting for widgets to process pending messages." + ) + return scheduled + async def _press_keys(self, keys: Iterable[str]) -> None: """A task to send key events.""" import unicodedata app = self - driver = app._driver - assert driver is not None + # A typed key arrives after the app handled what it was given before it (a screen + # being pushed, a widget mounting): the first key lands on that settled app. + if not await app._settle(): + return for key in keys: if key.startswith("wait:"): _, wait_ms = key.split(":") @@ -2160,10 +2263,14 @@ async def _press_keys(self, keys: Iterable[str]) -> None: char = key if len(key) == 1 else None key_event = events.Key(key, char) key_event.set_sender(app) - driver.send_message(key_event) - await wait_for_idle(0) + # This runs on the app's loop: post now, so the settle below sees it owed. + # The driver's thread-safe ingress would land it one loop turn later. + app.post_message(key_event) + if not await app._settle(): + # The key stopped the app: the rest have nothing to land on. + return await app._animator.wait_until_complete() - await wait_for_idle(0) + await app._settle() def _flush(self, stderr: bool = False) -> None: """Called when stdout or stderr is flushed. @@ -2303,7 +2410,7 @@ async def run_app(app: App[ReturnType]) -> None: # Context manager returns pilot object to manipulate the app try: pilot = Pilot(app) - await pilot._wait_for_screen() + await app._settle() yield pilot finally: await asyncio.sleep(0) diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 125722dd99..a9482c401e 100644 --- a/src/textual/css/styles.py +++ b/src/textual/css/styles.py @@ -507,11 +507,13 @@ class StylesBase(BoxGeometry, PaintLayer, TextFormat): constrain_y: StringEnumProperty[Constrain] = StringEnumProperty( VALID_CONSTRAIN, "none" ) + # Wrapping and overflow decide how many lines the content measures, so an auto-sized + # widget lays out again, like line_pad below. text_wrap: StringEnumProperty[TextWrap] = StringEnumProperty( - VALID_TEXT_WRAP, TextFormat().line_wrap + VALID_TEXT_WRAP, TextFormat().line_wrap, layout=True ) text_overflow: StringEnumProperty[TextOverflow] = StringEnumProperty( - VALID_TEXT_OVERFLOW, TextFormat().line_overflow + VALID_TEXT_OVERFLOW, TextFormat().line_overflow, layout=True ) expand: StringEnumProperty[Expand] = StringEnumProperty(VALID_EXPAND, "greedy") line_pad = IntegerProperty(default=TextFormat().line_pad, layout=True) diff --git a/src/textual/pilot.py b/src/textual/pilot.py index f3b9daf3d4..7bcbbfcff9 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -13,7 +13,6 @@ import rich.repr -from textual._wait import wait_for_idle from textual.app import App, ReturnType from textual.drivers.headless_driver import HeadlessDriver from textual.events import Click, MouseDown, MouseEvent, MouseMove, MouseUp, Resize @@ -51,13 +50,6 @@ class OutOfBounds(Exception): """Raised when the pilot mouse target is outside of the (visible) screen.""" -class WaitForScreenTimeout(Exception): - """Exception raised if messages aren't being processed quickly enough. - - If this occurs, the most likely explanation is some kind of deadlock in the app code. - """ - - @rich.repr.auto(angular=True) class Pilot(Generic[ReturnType]): """Pilot object to drive an app.""" @@ -81,7 +73,7 @@ async def press(self, *keys: str) -> None: """ if keys: await self._app._press_keys(keys) - await self._wait_for_screen() + await self._app._settle() async def resize_terminal(self, width: int, height: int) -> None: """Resize the terminal to the given dimensions. @@ -461,94 +453,27 @@ async def _post_mouse_events( landed is not None and landed.widget is target_widget ) - async def _wait_for_screen(self, timeout: float = 30.0) -> bool: - """Wait for the current screen and its children to have processed all pending events. - - Args: - timeout: A timeout in seconds to wait. - - Returns: - `True` if all events were processed. `False` if an exception occurred, - meaning that not all events could be processed. - - Raises: - WaitForScreenTimeout: If the screen and its children didn't finish processing within the timeout. - """ - try: - screen = self.app.screen - except Exception: - return False - children = [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_after_pending(decrement_counter): - count += 1 - - if count: - # Wait for the count to return to zero, or a timeout, or an exception - wait_for = [ - asyncio.create_task(count_zero_event.wait()), - asyncio.create_task(self.app._exception_event.wait()), - ] - _, pending = await asyncio.wait( - wait_for, - timeout=timeout, - return_when=asyncio.FIRST_COMPLETED, - ) - - for task in pending: - task.cancel() - - timed_out = len(wait_for) == len(pending) - if timed_out: - raise WaitForScreenTimeout( - "Timed out while waiting for widgets to process pending messages." - ) - - # We've either timed out, encountered an exception, or we've finished - # decrementing all the counters (all events processed in children). - if count > 0: - 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. + delay: Seconds to pause after the app settles, or None for no extra time. """ - # 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: + await self._app._settle() + if delay is not None: await asyncio.sleep(delay) - self.app.screen._on_timer_update() + await self._app._settle() async def wait_for_animation(self) -> None: """Wait for any current animation to complete.""" await self._app.animator.wait_for_idle() - self.app.screen._on_timer_update() + await self._app._settle() 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._settle() await self._app.animator.wait_until_complete() - await self._wait_for_screen() - await wait_for_idle() - self.app.screen._on_timer_update() + await self._app._settle() async def exit(self, result: ReturnType) -> None: """Exit the app with the given result. @@ -556,6 +481,5 @@ 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() + await self._app._settle() self.app.exit(result) diff --git a/src/textual/screen.py b/src/textual/screen.py index 88fb162715..9c908c0190 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -1528,6 +1528,17 @@ def _layout_geometry_targets(self) -> Iterable[Widget]: """ return () + async def _update_now(self) -> None: + """Run the update the timer would, and the rest of a layout it splits into steps. + + The update pauses the timer. Work requested while it ran is only seen by the + screen's idle check, which resumes the timer for it, so the screen is prompted. + """ + self._on_timer_update() + if (remaining := self._stepped_layout_task) is not None: + await remaining + self.check_idle() + def _refresh_layout(self, size: Size | None = None, scroll: bool = False) -> None: """Refresh the layout (can change size and positions of widgets).""" self._stop_stepped_layout() diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index 791b3bcc4a..16d0c78e8e 100644 --- a/src/textual/widgets/_button.py +++ b/src/textual/widgets/_button.py @@ -417,9 +417,17 @@ def post_render( def primary_activation(self): return self.action_press + @property + def accepts_press(self) -> bool: + """Whether a click or Enter presses the button now. + + A press is refused while the active effect of the previous one shows, so one + gesture cannot press twice; a caller pressing again waits for this.""" + return not self.has_class("-active") + async def _on_click(self, event: events.Click) -> None: event.stop() - if not self.has_class("-active"): + if self.accepts_press: self.press() def press(self) -> Self: @@ -454,7 +462,7 @@ def _start_active_affect(self) -> None: @MethodBinding.declare("enter") def action_press(self) -> None: """Activate a press of the button.""" - if not self.has_class("-active"): + if self.accepts_press: self.press() @classmethod diff --git a/src/textual/widgets/_tabs.py b/src/textual/widgets/_tabs.py index 802a465c9f..2fa07ff14d 100644 --- a/src/textual/widgets/_tabs.py +++ b/src/textual/widgets/_tabs.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass +from typing import ClassVar import rich.repr from rich.style import Style @@ -212,6 +213,9 @@ def _watch_disabled(self, disabled: bool) -> None: class Tabs(MovesHorizontalCursor, Widget, can_focus=True): """A row of tabs.""" + UNDERLINE_DELAY: ClassVar[float] = 0.02 + """Seconds after a tab activates before its underline starts moving.""" + DEFAULT_CSS = """ Tabs { width: 100%; @@ -668,7 +672,7 @@ def move_underline(animate: bool) -> None: if animate and self.app.animation_level != "none": self.set_timer( - 0.02, + self.UNDERLINE_DELAY, lambda: self.call_after_refresh(move_underline, True), ) else: diff --git a/tests/animations/test_tabs_underline_animation.py b/tests/animations/test_tabs_underline_animation.py index fba5526332..41a1ced952 100644 --- a/tests/animations/test_tabs_underline_animation.py +++ b/tests/animations/test_tabs_underline_animation.py @@ -25,7 +25,7 @@ async def test_tabs_underline_animates_on_full() -> None: animator = app.animator animator._record_animation = animations.append app.query_one(Tabs).action_cursor_left() - await pilot.pause() + await pilot.pause(Tabs.UNDERLINE_DELAY) assert "highlight_start" in animations assert "highlight_end" in animations @@ -41,7 +41,7 @@ async def test_tabs_underline_animates_on_basic() -> None: animator = app.animator animator._record_animation = animations.append app.query_one(Tabs).action_cursor_left() - await pilot.pause() + await pilot.pause(Tabs.UNDERLINE_DELAY) assert "highlight_start" in animations assert "highlight_end" in animations diff --git a/tests/test_data_table.py b/tests/test_data_table.py index 6750c5ddbc..a301311022 100644 --- a/tests/test_data_table.py +++ b/tests/test_data_table.py @@ -4,7 +4,6 @@ from rich.panel import Panel from rich.text import Text -from textual._wait import wait_for_idle from textual.actions import SkipAction from textual.app import App, ComposeResult, RenderableType from textual.coordinate import Coordinate @@ -425,19 +424,19 @@ async def test_column_labels() -> None: async def test_initial_column_widths() -> None: app = DataTableApp() - async with app.run_test(): + async with app.run_test() as pilot: table = app.query_one(DataTable) foo, bar = table.add_columns("foo", "bar") assert table.columns[foo].width == 3 assert table.columns[bar].width == 3 table.add_row("Hello", "World!") - await wait_for_idle() + await pilot.pause() assert table.columns[foo].content_width == 5 assert table.columns[bar].content_width == 6 table.add_row("Hello World!!!", "fo") - await wait_for_idle() + await pilot.pause() assert table.columns[foo].content_width == 14 assert table.columns[bar].content_width == 6 @@ -753,14 +752,14 @@ async def test_update_cell_at_column_width(label, new_value, new_content_width): # that the width of the column is correct given the new cell content widths # and the label of the column the cell is in. app = DataTableApp() - async with app.run_test(): + async with app.run_test() as pilot: table = app.query_one(DataTable) key, _ = table.add_columns(label, "Column2") table.add_rows(ROWS) first_column = table.columns.get(key) table.update_cell_at(Coordinate(0, 0), new_value, update_width=True) - await wait_for_idle() + await pilot.pause() assert first_column.content_width == new_content_width assert ( first_column.get_render_width(table) diff --git a/tests/test_idle_wait.py b/tests/test_idle_wait.py deleted file mode 100644 index 6be1bbf155..0000000000 --- a/tests/test_idle_wait.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Pilot idleness concerns the event-loop owner, not unrelated worker CPU.""" - -import pytest - -from textual import _wait - - -class WorkloadClock: - """Deterministic elapsed time with independently controlled CPU owners.""" - - def __init__(self, *, ui_busy=False, worker_busy=False): - self.wall = self.ui = self.worker = 0.0 - self.ui_busy, self.worker_busy = ui_busy, worker_busy - self.samples = 0 - - async def sleep(self, seconds): - self.wall += seconds - self.ui += seconds if self.ui_busy else 0 - self.worker += seconds if self.worker_busy else 0 - self.samples += 1 - - def install(self, monkeypatch): - monkeypatch.setattr(_wait, "monotonic", lambda: self.wall) - monkeypatch.setattr(_wait, "process_time", lambda: self.ui + self.worker, raising=False) - monkeypatch.setattr(_wait, "thread_time", lambda: self.ui, raising=False) - monkeypatch.setattr(_wait, "sleep", self.sleep) - - -async def test_worker_cpu_does_not_extend_an_idle_ui_wait(monkeypatch): - clock = WorkloadClock(worker_busy=True) - clock.install(monkeypatch) - await _wait.wait_for_idle(min_sleep=0, max_sleep=1) - assert clock.samples == 1, "Unrelated worker activity extended an idle event-loop wait" - assert clock.worker > 0 and clock.ui == 0 - - -@pytest.mark.parametrize("worker_busy", [False, True]) -async def test_active_ui_still_waits_until_the_existing_deadline(monkeypatch, worker_busy): - clock = WorkloadClock(ui_busy=True, worker_busy=worker_busy) - clock.install(monkeypatch) - await _wait.wait_for_idle(min_sleep=0, max_sleep=1) - assert 1 <= clock.wall <= 1 + _wait.SLEEP_GRANULARITY - - -async def test_idle_ui_still_observes_minimum_wait(monkeypatch): - clock = WorkloadClock(worker_busy=True) - clock.install(monkeypatch) - await _wait.wait_for_idle(min_sleep=.1, max_sleep=1) - assert .1 < clock.wall <= .1 + _wait.SLEEP_GRANULARITY diff --git a/tests/test_logger.py b/tests/test_logger.py index 7522a2a0dc..209382f956 100644 --- a/tests/test_logger.py +++ b/tests/test_logger.py @@ -30,7 +30,6 @@ def _is_devtools_connected(self): def on_mount(self) -> None: self.do_work() - self.call_after_refresh(self.exit) @work(thread=True) def do_work(self) -> None: @@ -38,5 +37,7 @@ def do_work(self) -> None: app = LogApp() async with app.run_test() as pilot: + # The log call runs on the worker's thread: its receipt is the worker finishing. + await app.workers.wait_for_complete() await pilot.pause() assert ("HELLO from do_work",) in log_messages