Skip to content

Commit 4e09a26

Browse files
authored
Merge pull request #6139 from Textualize/fix-progress-bar-crash
fix for progress bar crash
2 parents ccfcc7f + 40bd3a5 commit 4e09a26

File tree

10 files changed

+60
-30
lines changed

10 files changed

+60
-30
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
1515
- 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
1616
- The footer key hover background is more visible https://github.com/Textualize/textual/pull/6132
1717
- Made `App.delay_update` public https://github.com/Textualize/textual/pull/6137
18+
- Pilot.click will return True if the initial mouse down is on the specified target https://github.com/Textualize/textual/pull/6139
1819

1920
### Added
2021

src/textual/app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2099,7 +2099,7 @@ async def run_app(app: App[ReturnType]) -> None:
20992099

21002100
# Launch the app in the "background"
21012101

2102-
app_task = create_task(run_app(app), name=f"run_test {app}")
2102+
self._task = app_task = create_task(run_app(app), name=f"run_test {app}")
21032103

21042104
# Wait until the app has performed all startup routines.
21052105
await app_ready_event.wait()

src/textual/message_pump.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -457,19 +457,26 @@ def call_after_refresh(self, callback: Callback, *args: Any, **kwargs: Any) -> b
457457
message = messages.InvokeLater(partial(callback, *args, **kwargs))
458458
return self.post_message(message)
459459

460-
async def wait_for_refresh(self) -> None:
460+
async def wait_for_refresh(self) -> bool:
461461
"""Wait for the next refresh.
462462
463463
This method should only be called from a task other than the one running this widget.
464464
If called from the same task, it will return immediately to avoid blocking the event loop.
465465
466-
"""
466+
Returns:
467+
`True` if waiting for refresh was successful, or `False` if the call was a null-op
468+
due to calling it within the node's own task.
467469
468-
if self._task is None or asyncio.current_task() is not self._task:
469-
return
470+
"""
471+
assert (
472+
self._task is not None
473+
), "Node must be running before calling wait_for_refresh"
474+
if asyncio.current_task() is self._task:
475+
return False
470476
refreshed_event = asyncio.Event()
471477
self.call_after_refresh(refreshed_event.set)
472478
await refreshed_event.wait()
479+
return True
473480

474481
def call_later(self, callback: Callback, *args: Any, **kwargs: Any) -> bool:
475482
"""Schedule a callback to run after all messages are processed in this object.

src/textual/pilot.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -227,8 +227,8 @@ async def click(
227227
OutOfBounds: If the position to be clicked is outside of the (visible) screen.
228228
229229
Returns:
230-
True if no selector was specified or if the click landed on the selected
231-
widget, False otherwise.
230+
`True` if no selector was specified or if the selected widget was under the mouse
231+
when the click was initiated. `False` is the selected widget was not under the pointer.
232232
"""
233233
try:
234234
return await self._post_mouse_events(
@@ -284,8 +284,8 @@ async def double_click(
284284
OutOfBounds: If the position to be clicked is outside of the (visible) screen.
285285
286286
Returns:
287-
True if no selector was specified or if the clicks landed on the selected
288-
widget, False otherwise.
287+
`True` if no selector was specified or if the selected widget was under the mouse
288+
when the click was initiated. `False` is the selected widget was not under the pointer.
289289
"""
290290
return await self.click(widget, offset, shift, meta, control, times=2)
291291

@@ -329,8 +329,8 @@ async def triple_click(
329329
OutOfBounds: If the position to be clicked is outside of the (visible) screen.
330330
331331
Returns:
332-
True if no selector was specified or if the clicks landed on the selected
333-
widget, False otherwise.
332+
`True` if no selector was specified or if the selected widget was under the mouse
333+
when the click was initiated. `False` is the selected widget was not under the pointer.
334334
"""
335335
return await self.click(widget, offset, shift, meta, control, times=3)
336336

@@ -414,7 +414,7 @@ async def _post_mouse_events(
414414
elif isinstance(widget, Widget):
415415
target_widget = widget
416416
else:
417-
target_widget = app.screen.query_one(widget)
417+
target_widget = screen.query_one(widget)
418418

419419
message_arguments = _get_mouse_message_arguments(
420420
target_widget,
@@ -434,6 +434,7 @@ async def _post_mouse_events(
434434
widget_at = None
435435
for chain in range(1, times + 1):
436436
for mouse_event_cls in events:
437+
await self.pause()
437438
# Get the widget under the mouse before the event because the app might
438439
# react to the event and move things around. We override on each iteration
439440
# because we assume the final event in `events` is the actual event we care
@@ -444,16 +445,17 @@ async def _post_mouse_events(
444445
if mouse_event_cls is Click:
445446
kwargs = {**kwargs, "chain": chain}
446447

447-
widget_at, _ = app.get_widget_at(*offset)
448+
if widget_at is None:
449+
widget_at, _ = app.get_widget_at(*offset)
448450
event = mouse_event_cls(**kwargs)
449451
# Bypass event processing in App.on_event. Because App.on_event
450452
# is responsible for updating App.mouse_position, and because
451453
# that's useful to other things (tooltip handling, for example),
452454
# we patch the offset in there as well.
453455
app.mouse_position = offset
454456
screen._forward_event(event)
455-
await self.pause()
456457

458+
await self.pause()
457459
return widget is None or widget_at is target_widget
458460

459461
async def _wait_for_screen(self, timeout: float = 30.0) -> bool:

src/textual/widgets/_footer.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
from __future__ import annotations
22

3-
import asyncio
43
from collections import defaultdict
54
from itertools import groupby
65
from typing import TYPE_CHECKING
@@ -327,7 +326,7 @@ def compose(self) -> ComposeResult:
327326
tooltip=binding.tooltip or binding.description,
328327
)
329328

330-
async def bindings_changed(self, screen: Screen) -> None:
329+
def bindings_changed(self, screen: Screen) -> None:
331330
self._bindings_ready = True
332331
if not screen.app.app_focus:
333332
return
@@ -348,9 +347,7 @@ def _on_mouse_scroll_up(self, event: events.MouseScrollUp) -> None:
348347
event.stop()
349348
event.prevent_default()
350349

351-
async def on_mount(self) -> None:
352-
await asyncio.sleep(0)
353-
self.call_next(self.bindings_changed, self.screen)
350+
def on_mount(self) -> None:
354351
self.screen.bindings_updated_signal.subscribe(self, self.bindings_changed)
355352

356353
def on_unmount(self) -> None:

src/textual/widgets/_progress_bar.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,11 @@ def render_indeterminate(self) -> RenderResult:
127127
else:
128128
speed = 30 # Cells per second.
129129
# Compute the position of the bar.
130-
start = (speed * self._clock.time) % (2 * total_imaginary_width)
130+
start = (
131+
(speed * self._clock.time) % (2 * total_imaginary_width)
132+
if total_imaginary_width
133+
else 0
134+
)
131135
if start > total_imaginary_width:
132136
# If the bar is to the right of its width, wrap it back from right to left.
133137
start = 2 * total_imaginary_width - start # = (tiw - (start - tiw))

tests/snapshot_tests/test_snapshots.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4630,7 +4630,6 @@ def compose(self) -> ComposeResult:
46304630

46314631
def test_long_textarea_placeholder(snap_compare) -> None:
46324632
"""Test multi-line placeholders are wrapped and rendered.
4633-
46344633
You should see a TextArea at 50% width, with several lines of wrapped text.
46354634
"""
46364635

tests/test_modal.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ class ModalApp(App):
6868

6969
def compose(self) -> ComposeResult:
7070
yield Header()
71-
yield Label(TEXT * 8)
71+
yield Label(TEXT)
7272
yield Footer()
7373

7474
def action_request_quit(self) -> None:
@@ -90,12 +90,10 @@ async def test_modal_pop_screen():
9090
async with app.run_test() as pilot:
9191
# Pause to ensure the footer is fully composed to avoid flakiness in CI
9292
await pilot.pause()
93-
await app.wait_for_refresh()
94-
await pilot.pause()
95-
# Check clicking the footer brings up the quit screen
96-
await pilot.click(Footer, offset=(1, 0))
97-
await pilot.pause()
98-
assert isinstance(pilot.app.screen, QuitScreen)
93+
await pilot.pause() # Required in Windows
94+
assert await pilot.click("FooterKey")
95+
assert await app.wait_for_refresh()
96+
assert isinstance(app.screen, QuitScreen)
9997
# Check activating the quit button exits the app
10098
await pilot.press("enter")
10199
assert pilot.app._exit

tests/test_progress_bar.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from rich.console import Console
44
from rich.text import Text
55

6-
from textual.app import App
6+
from textual.app import App, ComposeResult
77
from textual.color import Gradient
88
from textual.css.query import NoMatches
99
from textual.renderables.bar import _apply_gradient
@@ -181,3 +181,25 @@ def test_apply_gradient():
181181
_apply_gradient(text, gradient, 1)
182182
console = Console()
183183
assert text.get_style_at_offset(console, 0).color.triplet == (255, 0, 0)
184+
185+
186+
async def test_progress_bar_width_1fr(snap_compare):
187+
"""Regression test for https://github.com/Textualize/textual/issues/6127
188+
189+
Just shouldn't crash...
190+
"""
191+
192+
class WideBarApp(App[None]):
193+
194+
CSS = """
195+
ProgressBar Bar {
196+
width: 1fr;
197+
}
198+
"""
199+
200+
def compose(self) -> ComposeResult:
201+
yield ProgressBar()
202+
203+
app = WideBarApp()
204+
async with app.run_test() as pilot:
205+
await pilot.pause()

tests/test_reactive.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -545,7 +545,7 @@ def on_mount(self):
545545
def update(self):
546546
self.holder.attr = "hello world"
547547

548-
async def callback(self):
548+
def callback(self):
549549
nonlocal from_app
550550
from_app = True
551551

0 commit comments

Comments
 (0)