From b4e0833a0fb5f43ebffd8ca7185439d72db67e20 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 28 Sep 2025 17:08:54 +0100 Subject: [PATCH 01/22] fix for progress bar crash --- src/textual/widgets/_progress_bar.py | 2 +- tests/snapshot_tests/test_snapshots.py | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/textual/widgets/_progress_bar.py b/src/textual/widgets/_progress_bar.py index 669df2e90d..ddbe7ec979 100644 --- a/src/textual/widgets/_progress_bar.py +++ b/src/textual/widgets/_progress_bar.py @@ -121,7 +121,7 @@ def render_indeterminate(self) -> RenderResult: total_imaginary_width = width + highlighted_bar_width start: float end: float - if self.app.animation_level == "none": + if self.app.animation_level == "none" or width == 0: start = 0 end = width else: diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 2b3eade06c..1fdb7a47c5 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -4626,3 +4626,20 @@ def compose(self) -> ComposeResult: yield Header() assert snap_compare(HeaderApp()) + + +def test_progress_bar_width_1fr(snap_compare): + """Regression test for https://github.com/Textualize/textual/issues/6127""" + + class WideBarApp(App[None]): + + CSS = """ + ProgressBar Bar { + width: 1fr; + } + """ + + def compose(self) -> ComposeResult: + yield ProgressBar() + + assert snap_compare(WideBarApp()) From f7de26ab4ef3412f1c397eb95187ceb931e16cbf Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 28 Sep 2025 17:18:43 +0100 Subject: [PATCH 02/22] null visual --- src/textual/visual.py | 25 +++++++++++++++++++++++++ src/textual/widget.py | 7 +++++-- src/textual/widgets/_progress_bar.py | 2 +- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/textual/visual.py b/src/textual/visual.py index 8231a5574c..ec8a6c9950 100644 --- a/src/textual/visual.py +++ b/src/textual/visual.py @@ -258,6 +258,31 @@ def to_strips( return strips +@rich.repr.auto +class NullVisual(Visual): + """A visual with no size, which therefore renders nothing. + + This is a placeholder for when a Widget has been reduced to zero area. + """ + + def __rich_repr__(self) -> rich.repr.Result: + yield from () + + def render_strips( + self, width: int, height: int | None, style: Style, options: RenderOptions + ) -> list[Strip]: + return [] + + def get_optimal_width(self, rules: RulesMap, container_width: int) -> int: + return 0 + + def get_minimal_width(self, rules: RulesMap) -> int: + return 0 + + def get_height(self, rules: RulesMap, width: int) -> int: + return 0 + + @rich.repr.auto class RichVisual(Visual): """A Visual to wrap a Rich renderable.""" diff --git a/src/textual/widget.py b/src/textual/widget.py index 79a3a94d2e..532c907571 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -90,7 +90,7 @@ from textual.selection import Selection from textual.strip import Strip from textual.style import Style as VisualStyle -from textual.visual import Visual, VisualType, visualize +from textual.visual import NullVisual, Visual, VisualType, visualize if TYPE_CHECKING: from textual.app import App, ComposeResult @@ -4322,7 +4322,10 @@ def _render(self) -> Visual: if cached_visual is not None: assert isinstance(cached_visual, Visual) return cached_visual - visual = visualize(self, self.render(), markup=self._render_markup) + if self.size.width: + visual = visualize(self, self.render(), markup=self._render_markup) + else: + visual = NullVisual() self._layout_cache[cache_key] = visual return visual diff --git a/src/textual/widgets/_progress_bar.py b/src/textual/widgets/_progress_bar.py index ddbe7ec979..669df2e90d 100644 --- a/src/textual/widgets/_progress_bar.py +++ b/src/textual/widgets/_progress_bar.py @@ -121,7 +121,7 @@ def render_indeterminate(self) -> RenderResult: total_imaginary_width = width + highlighted_bar_width start: float end: float - if self.app.animation_level == "none" or width == 0: + if self.app.animation_level == "none": start = 0 end = width else: From 5e610153b070acd408525714c116b7a7f32f3300 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 28 Sep 2025 17:25:31 +0100 Subject: [PATCH 03/22] simpler fix --- src/textual/visual.py | 25 ------------------------- src/textual/widget.py | 7 ++----- src/textual/widgets/_progress_bar.py | 6 +++++- tests/snapshot_tests/test_snapshots.py | 5 ++++- 4 files changed, 11 insertions(+), 32 deletions(-) diff --git a/src/textual/visual.py b/src/textual/visual.py index ec8a6c9950..8231a5574c 100644 --- a/src/textual/visual.py +++ b/src/textual/visual.py @@ -258,31 +258,6 @@ def to_strips( return strips -@rich.repr.auto -class NullVisual(Visual): - """A visual with no size, which therefore renders nothing. - - This is a placeholder for when a Widget has been reduced to zero area. - """ - - def __rich_repr__(self) -> rich.repr.Result: - yield from () - - def render_strips( - self, width: int, height: int | None, style: Style, options: RenderOptions - ) -> list[Strip]: - return [] - - def get_optimal_width(self, rules: RulesMap, container_width: int) -> int: - return 0 - - def get_minimal_width(self, rules: RulesMap) -> int: - return 0 - - def get_height(self, rules: RulesMap, width: int) -> int: - return 0 - - @rich.repr.auto class RichVisual(Visual): """A Visual to wrap a Rich renderable.""" diff --git a/src/textual/widget.py b/src/textual/widget.py index 532c907571..79a3a94d2e 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -90,7 +90,7 @@ from textual.selection import Selection from textual.strip import Strip from textual.style import Style as VisualStyle -from textual.visual import NullVisual, Visual, VisualType, visualize +from textual.visual import Visual, VisualType, visualize if TYPE_CHECKING: from textual.app import App, ComposeResult @@ -4322,10 +4322,7 @@ def _render(self) -> Visual: if cached_visual is not None: assert isinstance(cached_visual, Visual) return cached_visual - if self.size.width: - visual = visualize(self, self.render(), markup=self._render_markup) - else: - visual = NullVisual() + visual = visualize(self, self.render(), markup=self._render_markup) self._layout_cache[cache_key] = visual return visual diff --git a/src/textual/widgets/_progress_bar.py b/src/textual/widgets/_progress_bar.py index 669df2e90d..934594d4d6 100644 --- a/src/textual/widgets/_progress_bar.py +++ b/src/textual/widgets/_progress_bar.py @@ -127,7 +127,11 @@ def render_indeterminate(self) -> RenderResult: else: speed = 30 # Cells per second. # Compute the position of the bar. - start = (speed * self._clock.time) % (2 * total_imaginary_width) + start = ( + (speed * self._clock.time) % (2 * total_imaginary_width) + if total_imaginary_width + else 0 + ) if start > total_imaginary_width: # If the bar is to the right of its width, wrap it back from right to left. start = 2 * total_imaginary_width - start # = (tiw - (start - tiw)) diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 1fdb7a47c5..f94fde23f0 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -4629,7 +4629,10 @@ def compose(self) -> ComposeResult: def test_progress_bar_width_1fr(snap_compare): - """Regression test for https://github.com/Textualize/textual/issues/6127""" + """Regression test for https://github.com/Textualize/textual/issues/6127 + + You should see a progress bar, and it shouldn't crash. + """ class WideBarApp(App[None]): From a0a3d81c4a8baf7f7111b8e4e0d7871d51899b9c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 28 Sep 2025 17:26:24 +0100 Subject: [PATCH 04/22] snapshit --- .../test_progress_bar_width_1fr.svg | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 tests/snapshot_tests/__snapshots__/test_snapshots/test_progress_bar_width_1fr.svg diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_progress_bar_width_1fr.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_progress_bar_width_1fr.svg new file mode 100644 index 0000000000..87dd86afa3 --- /dev/null +++ b/tests/snapshot_tests/__snapshots__/test_snapshots/test_progress_bar_width_1fr.svg @@ -0,0 +1,152 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + WideBarApp + + + + + + + + + + ━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━--%--:--:-- + + + + + + + + + + + + + + + + + + + + + + + + + + From 8226f215957f4f2f9217d78dbcbfa264fb62f337 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 28 Sep 2025 17:56:03 +0100 Subject: [PATCH 05/22] replace snapshot test --- tests/snapshot_tests/test_snapshots.py | 20 -------------------- tests/test_progress_bar.py | 24 +++++++++++++++++++++++- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index cde58ae63e..9a1591cb6a 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -4653,23 +4653,3 @@ def compose(self) -> ComposeResult: yield TextArea(placeholder=TEXT) assert snap_compare(PlaceholderApp()) - - -def test_progress_bar_width_1fr(snap_compare): - """Regression test for https://github.com/Textualize/textual/issues/6127 - - You should see a progress bar, and it shouldn't crash. - """ - - class WideBarApp(App[None]): - - CSS = """ - ProgressBar Bar { - width: 1fr; - } - """ - - def compose(self) -> ComposeResult: - yield ProgressBar() - - assert snap_compare(WideBarApp()) diff --git a/tests/test_progress_bar.py b/tests/test_progress_bar.py index 83bcb724bc..2edebc289f 100644 --- a/tests/test_progress_bar.py +++ b/tests/test_progress_bar.py @@ -3,7 +3,7 @@ from rich.console import Console from rich.text import Text -from textual.app import App +from textual.app import App, ComposeResult from textual.color import Gradient from textual.css.query import NoMatches from textual.renderables.bar import _apply_gradient @@ -181,3 +181,25 @@ def test_apply_gradient(): _apply_gradient(text, gradient, 1) console = Console() assert text.get_style_at_offset(console, 0).color.triplet == (255, 0, 0) + + +async def test_progress_bar_width_1fr(snap_compare): + """Regression test for https://github.com/Textualize/textual/issues/6127 + + Just shouldn't crash... + """ + + class WideBarApp(App[None]): + + CSS = """ + ProgressBar Bar { + width: 1fr; + } + """ + + def compose(self) -> ComposeResult: + yield ProgressBar() + + app = WideBarApp() + async with app.run_test() as pilot: + await pilot.pause() From 9063dad0effcc46945e776ae0c9b09bef43a7495 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 28 Sep 2025 18:14:44 +0100 Subject: [PATCH 06/22] unused snapshot --- .../test_progress_bar_width_1fr.svg | 152 ------------------ 1 file changed, 152 deletions(-) delete mode 100644 tests/snapshot_tests/__snapshots__/test_snapshots/test_progress_bar_width_1fr.svg diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots/test_progress_bar_width_1fr.svg b/tests/snapshot_tests/__snapshots__/test_snapshots/test_progress_bar_width_1fr.svg deleted file mode 100644 index 87dd86afa3..0000000000 --- a/tests/snapshot_tests/__snapshots__/test_snapshots/test_progress_bar_width_1fr.svg +++ /dev/null @@ -1,152 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - WideBarApp - - - - - - - - - - ━━╺━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━--%--:--:-- - - - - - - - - - - - - - - - - - - - - - - - - - - From 374d29683f6961bbda8354fccdadb94d064a9560 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 28 Sep 2025 20:50:56 +0100 Subject: [PATCH 07/22] Skip bindings change on first refresh --- src/textual/widgets/_footer.py | 3 --- tests/test_modal.py | 1 - 2 files changed, 4 deletions(-) diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index aa6c57f13b..7c363885d7 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -1,6 +1,5 @@ from __future__ import annotations -import asyncio from collections import defaultdict from itertools import groupby from typing import TYPE_CHECKING @@ -349,8 +348,6 @@ def _on_mouse_scroll_up(self, event: events.MouseScrollUp) -> None: event.prevent_default() async def on_mount(self) -> None: - await asyncio.sleep(0) - self.call_next(self.bindings_changed, self.screen) self.screen.bindings_updated_signal.subscribe(self, self.bindings_changed) def on_unmount(self) -> None: diff --git a/tests/test_modal.py b/tests/test_modal.py index e33fe29973..0f34a5c5da 100644 --- a/tests/test_modal.py +++ b/tests/test_modal.py @@ -91,7 +91,6 @@ async def test_modal_pop_screen(): # Pause to ensure the footer is fully composed to avoid flakiness in CI await pilot.pause() await app.wait_for_refresh() - await pilot.pause() # Check clicking the footer brings up the quit screen await pilot.click(Footer, offset=(1, 0)) await pilot.pause() From 11182843d7e90d182f3b6ef408c4b8492bed0693 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 28 Sep 2025 21:02:21 +0100 Subject: [PATCH 08/22] try wait for refresh --- tests/test_modal.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_modal.py b/tests/test_modal.py index 0f34a5c5da..dd6d97aab4 100644 --- a/tests/test_modal.py +++ b/tests/test_modal.py @@ -94,6 +94,7 @@ async def test_modal_pop_screen(): # Check clicking the footer brings up the quit screen await pilot.click(Footer, offset=(1, 0)) await pilot.pause() + await app.wait_for_refresh() assert isinstance(pilot.app.screen, QuitScreen) # Check activating the quit button exits the app await pilot.press("enter") From f4c32a3ac45e7ee4c86aff5bc3fd5209ea2a7c14 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 28 Sep 2025 21:17:47 +0100 Subject: [PATCH 09/22] depressing --- tests/test_modal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_modal.py b/tests/test_modal.py index dd6d97aab4..91cd6be682 100644 --- a/tests/test_modal.py +++ b/tests/test_modal.py @@ -93,7 +93,7 @@ async def test_modal_pop_screen(): await app.wait_for_refresh() # Check clicking the footer brings up the quit screen await pilot.click(Footer, offset=(1, 0)) - await pilot.pause() + await pilot.pause(1 / 10) await app.wait_for_refresh() assert isinstance(pilot.app.screen, QuitScreen) # Check activating the quit button exits the app From 167af7e15d383abf82034b4b97f2d55c0d148d14 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 28 Sep 2025 21:32:17 +0100 Subject: [PATCH 10/22] change click --- tests/test_modal.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_modal.py b/tests/test_modal.py index 91cd6be682..5b84f95565 100644 --- a/tests/test_modal.py +++ b/tests/test_modal.py @@ -92,8 +92,7 @@ async def test_modal_pop_screen(): await pilot.pause() await app.wait_for_refresh() # Check clicking the footer brings up the quit screen - await pilot.click(Footer, offset=(1, 0)) - await pilot.pause(1 / 10) + assert await pilot.click(offset=(1, app.size.height - 1)) await app.wait_for_refresh() assert isinstance(pilot.app.screen, QuitScreen) # Check activating the quit button exits the app From a9d451d8bbb3f15f84c511a010ac1671e6590060 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 29 Sep 2025 11:17:17 +0100 Subject: [PATCH 11/22] running out of ideas --- src/textual/widgets/_footer.py | 2 +- tests/test_modal.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index 7c363885d7..b1bf868e43 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -326,7 +326,7 @@ def compose(self) -> ComposeResult: tooltip=binding.tooltip or binding.description, ) - async def bindings_changed(self, screen: Screen) -> None: + def bindings_changed(self, screen: Screen) -> None: self._bindings_ready = True if not screen.app.app_focus: return diff --git a/tests/test_modal.py b/tests/test_modal.py index 5b84f95565..289c0cb825 100644 --- a/tests/test_modal.py +++ b/tests/test_modal.py @@ -94,6 +94,7 @@ async def test_modal_pop_screen(): # Check clicking the footer brings up the quit screen assert await pilot.click(offset=(1, app.size.height - 1)) await app.wait_for_refresh() + await pilot.pause() assert isinstance(pilot.app.screen, QuitScreen) # Check activating the quit button exits the app await pilot.press("enter") From 1ec7fa81f593842d48b5e689f49b3d43bf2fe32c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 29 Sep 2025 11:32:21 +0100 Subject: [PATCH 12/22] sigh --- tests/test_modal.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_modal.py b/tests/test_modal.py index 289c0cb825..80a9befbbe 100644 --- a/tests/test_modal.py +++ b/tests/test_modal.py @@ -94,8 +94,8 @@ async def test_modal_pop_screen(): # Check clicking the footer brings up the quit screen assert await pilot.click(offset=(1, app.size.height - 1)) await app.wait_for_refresh() - await pilot.pause() - assert isinstance(pilot.app.screen, QuitScreen) + await pilot.pause(1 / 10) + assert isinstance(app.screen, QuitScreen) # Check activating the quit button exits the app await pilot.press("enter") assert pilot.app._exit From ad63a575d38e8e666406b1d2ba271825a64cf748 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 29 Sep 2025 12:15:34 +0100 Subject: [PATCH 13/22] debug --- tests/test_modal.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/test_modal.py b/tests/test_modal.py index 80a9befbbe..960da75999 100644 --- a/tests/test_modal.py +++ b/tests/test_modal.py @@ -91,10 +91,14 @@ async def test_modal_pop_screen(): # Pause to ensure the footer is fully composed to avoid flakiness in CI await pilot.pause() await app.wait_for_refresh() + print(1, app.screen) # Check clicking the footer brings up the quit screen assert await pilot.click(offset=(1, app.size.height - 1)) + print(2, app.screen) await app.wait_for_refresh() - await pilot.pause(1 / 10) + print(3, app.screen) + await pilot.pause(1) + print(4, app.screen) assert isinstance(app.screen, QuitScreen) # Check activating the quit button exits the app await pilot.press("enter") From bf1d975c5ccaf75d30d5c6c7a8fc96299622a9ab Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 29 Sep 2025 14:13:51 +0100 Subject: [PATCH 14/22] fix wait_for_refresh --- CHANGELOG.md | 1 + src/textual/message_pump.py | 11 ++++++++--- src/textual/pilot.py | 3 ++- src/textual/widgets/_footer.py | 2 +- tests/test_modal.py | 13 +++---------- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bb51c397f..94101c949c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - The :hover pseudo-class no applies to the first widget under the mouse with a hover style set https://github.com/Textualize/textual/pull/6132 - The footer key hover background is more visible https://github.com/Textualize/textual/pull/6132 - Made `App.delay_update` public https://github.com/Textualize/textual/pull/6137 +- Pilot.click will return True if the initial mouse down is on the specified target https://github.com/Textualize/textual/pull/6139 ### Added diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 857db03a35..d3144be70c 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -457,19 +457,24 @@ def call_after_refresh(self, callback: Callback, *args: Any, **kwargs: Any) -> b message = messages.InvokeLater(partial(callback, *args, **kwargs)) return self.post_message(message) - async def wait_for_refresh(self) -> None: + async def wait_for_refresh(self) -> bool: """Wait for the next refresh. This method should only be called from a task other than the one running this widget. If called from the same task, it will return immediately to avoid blocking the event loop. + Returns: + `True` if waiting for refresh was successful, or `False` if the call was a null-op + due to calling it within the node's own task. + """ - if self._task is None or asyncio.current_task() is not self._task: - return + if self._task is None or asyncio.current_task() is self._task: + return False refreshed_event = asyncio.Event() self.call_after_refresh(refreshed_event.set) await refreshed_event.wait() + return True def call_later(self, callback: Callback, *args: Any, **kwargs: Any) -> bool: """Schedule a callback to run after all messages are processed in this object. diff --git a/src/textual/pilot.py b/src/textual/pilot.py index 73cff9413a..676cafd0e4 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -444,7 +444,8 @@ async def _post_mouse_events( if mouse_event_cls is Click: kwargs = {**kwargs, "chain": chain} - widget_at, _ = app.get_widget_at(*offset) + if widget_at is None: + widget_at, _ = app.get_widget_at(*offset) event = mouse_event_cls(**kwargs) # Bypass event processing in App.on_event. Because App.on_event # is responsible for updating App.mouse_position, and because diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index b1bf868e43..14aa56cb81 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -347,7 +347,7 @@ def _on_mouse_scroll_up(self, event: events.MouseScrollUp) -> None: event.stop() event.prevent_default() - async def on_mount(self) -> None: + def on_mount(self) -> None: self.screen.bindings_updated_signal.subscribe(self, self.bindings_changed) def on_unmount(self) -> None: diff --git a/tests/test_modal.py b/tests/test_modal.py index 960da75999..a40ab4086c 100644 --- a/tests/test_modal.py +++ b/tests/test_modal.py @@ -68,7 +68,7 @@ class ModalApp(App): def compose(self) -> ComposeResult: yield Header() - yield Label(TEXT * 8) + yield Label(TEXT) yield Footer() def action_request_quit(self) -> None: @@ -90,15 +90,8 @@ async def test_modal_pop_screen(): async with app.run_test() as pilot: # Pause to ensure the footer is fully composed to avoid flakiness in CI await pilot.pause() - await app.wait_for_refresh() - print(1, app.screen) - # Check clicking the footer brings up the quit screen - assert await pilot.click(offset=(1, app.size.height - 1)) - print(2, app.screen) - await app.wait_for_refresh() - print(3, app.screen) - await pilot.pause(1) - print(4, app.screen) + assert await pilot.click("FooterKey") + assert await app.wait_for_refresh() assert isinstance(app.screen, QuitScreen) # Check activating the quit button exits the app await pilot.press("enter") From cd8c6955f20e93a3f949027131ace781e34f9530 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 29 Sep 2025 14:30:58 +0100 Subject: [PATCH 15/22] app task --- src/textual/app.py | 2 +- tests/test_reactive.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/app.py b/src/textual/app.py index 2b04f3df9e..2c61a16340 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -2099,7 +2099,7 @@ async def run_app(app: App[ReturnType]) -> None: # Launch the app in the "background" - app_task = create_task(run_app(app), name=f"run_test {app}") + self._task = app_task = create_task(run_app(app), name=f"run_test {app}") # Wait until the app has performed all startup routines. await app_ready_event.wait() diff --git a/tests/test_reactive.py b/tests/test_reactive.py index 86a1f4ecfd..6663f7d79f 100644 --- a/tests/test_reactive.py +++ b/tests/test_reactive.py @@ -545,7 +545,7 @@ def on_mount(self): def update(self): self.holder.attr = "hello world" - async def callback(self): + def callback(self): nonlocal from_app from_app = True From b3e1ef98992da81177d9c30ea59b3354354232ce Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 29 Sep 2025 14:42:34 +0100 Subject: [PATCH 16/22] another wait for refresh --- tests/test_modal.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_modal.py b/tests/test_modal.py index a40ab4086c..923b680859 100644 --- a/tests/test_modal.py +++ b/tests/test_modal.py @@ -90,6 +90,7 @@ async def test_modal_pop_screen(): async with app.run_test() as pilot: # Pause to ensure the footer is fully composed to avoid flakiness in CI await pilot.pause() + assert await app.wait_for_refresh() assert await pilot.click("FooterKey") assert await app.wait_for_refresh() assert isinstance(app.screen, QuitScreen) From 4e0f6f1d400eb7f19f010ae5e17c9b9b3ad27612 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 29 Sep 2025 15:18:36 +0100 Subject: [PATCH 17/22] implicit pause --- src/textual/message_pump.py | 6 ++++-- src/textual/pilot.py | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index d3144be70c..a4ed5e7c25 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -468,8 +468,10 @@ async def wait_for_refresh(self) -> bool: due to calling it within the node's own task. """ - - if self._task is None or asyncio.current_task() is self._task: + assert ( + self._task is not None + ), "Node must be running before calling wait_for_refresh" + if asyncio.current_task() is self._task: return False refreshed_event = asyncio.Event() self.call_after_refresh(refreshed_event.set) diff --git a/src/textual/pilot.py b/src/textual/pilot.py index 676cafd0e4..100909b57d 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -434,6 +434,7 @@ async def _post_mouse_events( widget_at = None for chain in range(1, times + 1): for mouse_event_cls in events: + await self.pause() # Get the widget under the mouse before the event because the app might # react to the event and move things around. We override on each iteration # because we assume the final event in `events` is the actual event we care @@ -453,8 +454,8 @@ async def _post_mouse_events( # we patch the offset in there as well. app.mouse_position = offset screen._forward_event(event) - await self.pause() + await self.pause() return widget is None or widget_at is target_widget async def _wait_for_screen(self, timeout: float = 30.0) -> bool: From 44ace196ddaeb6aac5ecd6d563fe8f159897ba48 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 29 Sep 2025 15:23:12 +0100 Subject: [PATCH 18/22] docs --- src/textual/pilot.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/textual/pilot.py b/src/textual/pilot.py index 100909b57d..b7d6aa7dbb 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -227,8 +227,8 @@ async def click( OutOfBounds: If the position to be clicked is outside of the (visible) screen. Returns: - True if no selector was specified or if the click landed on the selected - widget, False otherwise. + `True` if no selector was specified or if the selected widget was under the mouse + when the click was initiated. `False` is the selected widget was not under the pointer. """ try: return await self._post_mouse_events( @@ -284,8 +284,8 @@ async def double_click( OutOfBounds: If the position to be clicked is outside of the (visible) screen. Returns: - True if no selector was specified or if the clicks landed on the selected - widget, False otherwise. + `True` if no selector was specified or if the selected widget was under the mouse + when the click was initiated. `False` is the selected widget was not under the pointer. """ return await self.click(widget, offset, shift, meta, control, times=2) @@ -329,8 +329,8 @@ async def triple_click( OutOfBounds: If the position to be clicked is outside of the (visible) screen. Returns: - True if no selector was specified or if the clicks landed on the selected - widget, False otherwise. + `True` if no selector was specified or if the selected widget was under the mouse + when the click was initiated. `False` is the selected widget was not under the pointer. """ return await self.click(widget, offset, shift, meta, control, times=3) From a865903c78f3cd4a805a277660b0e4a5c1cefa17 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 29 Sep 2025 15:58:30 +0100 Subject: [PATCH 19/22] debug for CI --- src/textual/pilot.py | 3 ++- tests/test_modal.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/textual/pilot.py b/src/textual/pilot.py index b7d6aa7dbb..9b384bb721 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -414,7 +414,7 @@ async def _post_mouse_events( elif isinstance(widget, Widget): target_widget = widget else: - target_widget = app.screen.query_one(widget) + target_widget = screen.query_one(widget) message_arguments = _get_mouse_message_arguments( target_widget, @@ -447,6 +447,7 @@ async def _post_mouse_events( if widget_at is None: widget_at, _ = app.get_widget_at(*offset) + print("widget_at", repr(widget_at)) event = mouse_event_cls(**kwargs) # Bypass event processing in App.on_event. Because App.on_event # is responsible for updating App.mouse_position, and because diff --git a/tests/test_modal.py b/tests/test_modal.py index 923b680859..9568268ebc 100644 --- a/tests/test_modal.py +++ b/tests/test_modal.py @@ -91,6 +91,7 @@ async def test_modal_pop_screen(): # Pause to ensure the footer is fully composed to avoid flakiness in CI await pilot.pause() assert await app.wait_for_refresh() + await pilot.pause(1) # silly test assert await pilot.click("FooterKey") assert await app.wait_for_refresh() assert isinstance(app.screen, QuitScreen) From a883d39d16a31414849a435b10905680af821a62 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 29 Sep 2025 16:12:33 +0100 Subject: [PATCH 20/22] experiment --- .github/workflows/pythonpackage.yml | 3 ++- src/textual/pilot.py | 1 - tests/test_modal.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 0a77242851..b5e5848b6e 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -19,7 +19,8 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-latest, windows-latest, macos-latest] + #os: [ubuntu-latest, windows-latest, macos-latest] + os: [windows-latest] python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] defaults: run: diff --git a/src/textual/pilot.py b/src/textual/pilot.py index 9b384bb721..8b649e81c5 100644 --- a/src/textual/pilot.py +++ b/src/textual/pilot.py @@ -447,7 +447,6 @@ async def _post_mouse_events( if widget_at is None: widget_at, _ = app.get_widget_at(*offset) - print("widget_at", repr(widget_at)) event = mouse_event_cls(**kwargs) # Bypass event processing in App.on_event. Because App.on_event # is responsible for updating App.mouse_position, and because diff --git a/tests/test_modal.py b/tests/test_modal.py index 9568268ebc..38e5095084 100644 --- a/tests/test_modal.py +++ b/tests/test_modal.py @@ -91,7 +91,7 @@ async def test_modal_pop_screen(): # Pause to ensure the footer is fully composed to avoid flakiness in CI await pilot.pause() assert await app.wait_for_refresh() - await pilot.pause(1) # silly test + await pilot.pause() assert await pilot.click("FooterKey") assert await app.wait_for_refresh() assert isinstance(app.screen, QuitScreen) From fd7cb8a59289a855871ab3b701ed3c6614b039c0 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 29 Sep 2025 16:24:28 +0100 Subject: [PATCH 21/22] try simplify --- tests/test_modal.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_modal.py b/tests/test_modal.py index 38e5095084..3b07085cde 100644 --- a/tests/test_modal.py +++ b/tests/test_modal.py @@ -90,8 +90,7 @@ async def test_modal_pop_screen(): async with app.run_test() as pilot: # Pause to ensure the footer is fully composed to avoid flakiness in CI await pilot.pause() - assert await app.wait_for_refresh() - await pilot.pause() + await pilot.pause() # Required in Windows assert await pilot.click("FooterKey") assert await app.wait_for_refresh() assert isinstance(app.screen, QuitScreen) From 40bd3a5a2465db94d60ec4d6c860a9938f94e5b7 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 29 Sep 2025 16:35:30 +0100 Subject: [PATCH 22/22] restore tests --- .github/workflows/pythonpackage.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index b5e5848b6e..0a77242851 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -19,8 +19,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - #os: [ubuntu-latest, windows-latest, macos-latest] - os: [windows-latest] + os: [ubuntu-latest, windows-latest, macos-latest] python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] defaults: run: