From f2985e5e34643b7ca0ff14776a8ee159a660284c Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 17:58:14 -0500 Subject: [PATCH 1/7] The pilot settles on owed work, not on a CPU-time guess Pilot.press slept wait_for_idle(0) twice per key (at least 20 ms each, looping in 20 ms steps while the loop thread burned 1 ms) and Pilot.pause slept it again, beside the exact signal the queue already keeps: Owing after a put, Settled when its pump finds it empty. The sleep was hiding two bugs, which are fixed here too. - App._settle owns "the app has handled what it was given". It waits behind every owing pump on the screen until a round finds none, then waits for the frame those messages asked for (layout and paint run on the update timer, not a queue) through call_after_refresh, and checks the queues again. It stops when the app raises or exits: the app's own loop task ending is part of the wait. - press, pause, exit, wait_for_scheduled_animations and run_test's first settle all call it; Pilot._wait_for_screen is deleted, and WaitForScreenTimeout moves to app.py beside its raiser. - _press_keys posts the key on the loop it already runs on. driver.send_message hops through call_soon_threadsafe, so the key landed after the wait that followed it; the 20 ms sleep covered the race. - A key that stops the app ends _press_keys. It used to wait on the dead app's animator; only `assert driver is not None` got it out. - textual._wait has no caller left and goes with its test; test_data_table pauses through the pilot. tests/test_pilot.py, test_data_table.py, test_app.py: 265 passed in 12.8 s (main: 265 passed in 27.9 s). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- src/textual/_wait.py | 40 --------------- src/textual/app.py | 105 ++++++++++++++++++++++++++++++++++++--- src/textual/pilot.py | 89 +++------------------------------ tests/test_data_table.py | 11 ++-- tests/test_idle_wait.py | 49 ------------------ 5 files changed, 110 insertions(+), 184 deletions(-) delete mode 100644 src/textual/_wait.py delete mode 100644 tests/test_idle_wait.py diff --git a/src/textual/_wait.py b/src/textual/_wait.py deleted file mode 100644 index 88f73b982f5..00000000000 --- 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 47f2df6be26..84b6c8668dd 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,94 @@ def get_key_display(self, binding: Binding) -> str: key_tokens = modifiers + [key] return "+".join(key_tokens) + 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 frame 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 waits for that frame, then looks at the + queues again. A key's whole cascade (the app forwarding it, the focused widget + handling it, the layout and frame it asks for) is done when a frame leaves nothing + owed. + + 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. + """ + framed = False + while True: + 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: + framed = False + elif framed: + return True + else: + await self._wait_behind([screen.call_after_refresh], timeout) + framed = True + + 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 for key in keys: if key.startswith("wait:"): _, wait_ms = key.split(":") @@ -2160,10 +2247,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 +2394,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/pilot.py b/src/textual/pilot.py index f3b9daf3d40..3e8db88304c 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,79 +453,14 @@ 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() @@ -544,10 +471,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._settle() await self._app.animator.wait_until_complete() - await self._wait_for_screen() - await wait_for_idle() + await self._app._settle() self.app.screen._on_timer_update() async def exit(self, result: ReturnType) -> None: @@ -556,6 +482,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/tests/test_data_table.py b/tests/test_data_table.py index 6750c5ddbcb..a301311022b 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 6be1bbf1555..00000000000 --- 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 From 8f49f984daa1b0892d415269bb15a0aebe51d8d5 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 18:16:59 -0500 Subject: [PATCH 2/7] Settle's docstring: a running animation is the animator's to report, not owed work Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- src/textual/app.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/textual/app.py b/src/textual/app.py index 84b6c8668dd..2f8814f2a10 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -2153,7 +2153,8 @@ async def _settle(self, timeout: float = 30.0) -> bool: queue, so once the queues drain the settle waits for that frame, then looks at the queues again. A key's whole cascade (the app forwarding it, the focused widget handling it, the layout and frame it asks for) is done when a frame leaves nothing - owed. + 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. From 90c1b2f7501e6ff3a028e051a79f4fbd5baf01e7 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 18:37:41 -0500 Subject: [PATCH 3/7] Settle covers what the timers and the loop already hold, not only the queues The full suite found the settle returning before work that was owed but not queued: - Driver input is posted from a loop callback (call_soon_threadsafe): each round first gives the loop one turn. - A terminal resize waits on its 1/120 s debounce: the settle delivers it. - Layout and paint run on the screen's update timer: instead of waiting on call_after_refresh, whose callbacks a pending refresh that never publishes strands for good (49 suite tests hit the 30 s round timeout that way), the settle runs the update itself through Screen._update_now, which also awaits a layout split into steps and prompts the screen so its idle check resumes the timer for work requested meanwhile. - At most SETTLE_UPDATES (2) updates per settle: a layout's own consequences land in the second, and a widget that asks for a layout on every layout never stops asking. - _press_keys settles once before its first key: a typed key arrives after the app has handled what it was given (a screen push, a mount), and the command palette's tests showed the key landing before the palette existed. - Pilot.pause and the animation waits settle instead of running a bare timer update. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- src/textual/app.py | 39 +++++++++++++++++++++++++++------------ src/textual/pilot.py | 5 ++--- src/textual/screen.py | 11 +++++++++++ 3 files changed, 40 insertions(+), 15 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 2f8814f2a10..2054abb37b9 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -2143,18 +2143,23 @@ 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 frame those + 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 waits for that frame, then looks at the - queues again. A key's whole cascade (the app forwarding it, the focused widget - handling it, the layout and frame it asks for) is done when a frame 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). + 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. @@ -2165,8 +2170,11 @@ async def _settle(self, timeout: float = 30.0) -> bool: Raises: WaitForScreenTimeout: If one round did not finish within the timeout. """ - framed = False + 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: @@ -2178,12 +2186,15 @@ async def _settle(self, timeout: float = 30.0) -> bool: if self._exception_event.is_set() or self._exit: return False if owed: - framed = False - elif framed: + 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 - else: - await self._wait_behind([screen.call_after_refresh], timeout) - framed = True + await screen._update_now() + updates += 1 async def _wait_behind( self, schedulers: list[Callable[[Callable[[], object]], bool]], timeout: float @@ -2232,6 +2243,10 @@ async def _press_keys(self, keys: Iterable[str]) -> None: import unicodedata app = self + # 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(":") diff --git a/src/textual/pilot.py b/src/textual/pilot.py index 3e8db88304c..7bcbbfcff9f 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -462,19 +462,18 @@ async def pause(self, delay: float | None = None) -> None: 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._app._settle() await self._app.animator.wait_until_complete() await self._app._settle() - self.app.screen._on_timer_update() async def exit(self, result: ReturnType) -> None: """Exit the app with the given result. diff --git a/src/textual/screen.py b/src/textual/screen.py index 88fb162715f..9c908c01905 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() From 5263f008d6d02e994ef3aef40be9ce0e84caef3d Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 18:40:00 -0500 Subject: [PATCH 4/7] test_log_from_worker waits for its worker, the receipt of a log call made on another thread Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- tests/test_logger.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_logger.py b/tests/test_logger.py index 7522a2a0dc8..eb1dd253d96 100644 --- a/tests/test_logger.py +++ b/tests/test_logger.py @@ -38,5 +38,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 From ad8c0078724b2873d5f70946ce441651267bdca2 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 18:41:46 -0500 Subject: [PATCH 5/7] Two tests wait for what they assert on: the underline's declared delay, the worker's log - Tabs starts moving its underline UNDERLINE_DELAY (0.02 s) after a tab activates, a timer, not owed work; the literal moves onto the class and the animation tests pause for it instead of riding the pilot's old 20 ms sleep. - test_log_from_worker's app exited itself after its first refresh, racing the thread whose log the test asserts; run_test closes the app, so the self-exit goes and the test waits for the worker. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- src/textual/widgets/_tabs.py | 6 +++++- tests/animations/test_tabs_underline_animation.py | 4 ++-- tests/test_logger.py | 1 - 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/textual/widgets/_tabs.py b/src/textual/widgets/_tabs.py index 802a465c9fb..2fa07ff14d0 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 fba55263321..41a1ced952a 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_logger.py b/tests/test_logger.py index eb1dd253d96..209382f956e 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: From f5aa26563c29889db7cb6ff50bfb574cd0423fd6 Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 18:44:48 -0500 Subject: [PATCH 6/7] text_wrap and text_overflow lay out: they decide how many lines an auto-sized widget measures Declared repaint-only, they left an auto-height widget at its old height. On main the test that changes them passed only because the startup resize, still behind its 1/120 s debounce, re-laid out the screen after the change; the settle delivers resizes on time. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- src/textual/css/styles.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/textual/css/styles.py b/src/textual/css/styles.py index 125722dd99a..a9482c401ef 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) From 7560c05e7938c5f0b07fcf27e6610f583fb57dca Mon Sep 17 00:00:00 2001 From: Tristan Simas Date: Fri, 6 Nov 2026 19:15:48 -0500 Subject: [PATCH 7/7] Button.accepts_press owns whether a press lands now _on_click and action_press each restated the -active class test. A caller that presses the same button again within its 0.2 s effect is refused; with the pilot's sleeps gone, Toad's log controls did exactly that and need the receipt. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XbFMSqJMarXn2ZE8iAL7Sd --- src/textual/widgets/_button.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/textual/widgets/_button.py b/src/textual/widgets/_button.py index 791b3bcc4ac..16d0c78e8e1 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