diff --git a/CHANGELOG.md b/CHANGELOG.md
index e04f4bdbb7..03cfc63017 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,24 +9,38 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Fixed
+- Fix priority bindings not appearing in footer when key clashes with focused widget https://github.com/Textualize/textual/pull/4342
+
+### Changed
+
+- App.namespace_bindings renamed to App.active_bindings and now returns a list instead of a dict https://github.com/Textualize/textual/pull/4342
+
+## [0.54.0] - 2024-03-26
+
+### Fixed
+
- Fixed a crash in `TextArea` when undoing an edit to a selection the selection was made backwards https://github.com/Textualize/textual/issues/4301
- Fixed issue with flickering scrollbars https://github.com/Textualize/textual/pull/4315
- Fixed issue where narrow TextArea would repeatedly wrap due to scrollbar appearing/disappearing https://github.com/Textualize/textual/pull/4334
- Fix progress bar ETA not updating when setting `total` reactive https://github.com/Textualize/textual/pull/4316
-- Fix priority bindings not appearing in footer when key clashes with focused widget https://github.com/Textualize/textual/pull/4342
+- Exceptions inside `Widget.compose` or workers weren't bubbling up in tests https://github.com/Textualize/textual/issues/4282
-### Changed
+### Changed
- ProgressBar won't show ETA until there is at least one second of samples https://github.com/Textualize/textual/pull/4316
-- App.namespace_bindings renamed to App.active_bindings and now returns a list instead of a dict https://github.com/Textualize/textual/pull/4342
+- `Input` waits until an edit has been made, after entry to the widget, before offering a suggestion https://github.com/Textualize/textual/pull/4335
+
+### Added
+
+- Added `Document.start` and `end` location properties for convenience https://github.com/Textualize/textual/pull/4267
-## [0.53.1] - 2023-03-18
+## [0.53.1] - 2024-03-18
### Fixed
- Fixed issue with data binding https://github.com/Textualize/textual/pull/4308
-## [0.53.0] - 2023-03-18
+## [0.53.0] - 2024-03-18
### Added
@@ -1802,6 +1816,7 @@ https://textual.textualize.io/blog/2022/11/08/version-040/#version-040
- New handler system for messages that doesn't require inheritance
- Improved traceback handling
+[0.54.0]: https://github.com/Textualize/textual/compare/v0.53.1...v0.54.0
[0.53.1]: https://github.com/Textualize/textual/compare/v0.53.0...v0.53.1
[0.53.0]: https://github.com/Textualize/textual/compare/v0.52.1...v0.53.0
[0.52.1]: https://github.com/Textualize/textual/compare/v0.52.0...v0.52.1
diff --git a/docs/events/load.md b/docs/events/load.md
index 16f5e3d153..f1142f3546 100644
--- a/docs/events/load.md
+++ b/docs/events/load.md
@@ -1,3 +1,7 @@
::: textual.events.Load
options:
heading_level: 1
+
+## See also
+
+- [Mount](mount.md)
diff --git a/docs/events/mount.md b/docs/events/mount.md
index 885b8f4e9e..955391f056 100644
--- a/docs/events/mount.md
+++ b/docs/events/mount.md
@@ -1,3 +1,8 @@
::: textual.events.Mount
options:
heading_level: 1
+
+## See also
+
+- [Load](load.md)
+- [Unmount](unmount.md)
diff --git a/docs/events/mouse_capture.md b/docs/events/mouse_capture.md
index 945da83085..953a086bf5 100644
--- a/docs/events/mouse_capture.md
+++ b/docs/events/mouse_capture.md
@@ -8,4 +8,6 @@ title: MouseCapture
## See also
+- [capture_mouse][textual.widget.Widget.capture_mouse]
+- [release_mouse][textual.widget.Widget.release_mouse]
- [MouseRelease](mouse_release.md)
diff --git a/docs/events/mouse_release.md b/docs/events/mouse_release.md
index 438e03569b..a6e0a91cbe 100644
--- a/docs/events/mouse_release.md
+++ b/docs/events/mouse_release.md
@@ -8,4 +8,6 @@ title: MouseRelease
## See also
+- [capture_mouse][textual.widget.Widget.capture_mouse]
+- [release_mouse][textual.widget.Widget.release_mouse]
- [MouseCapture](mouse_capture.md)
diff --git a/docs/events/unmount.md b/docs/events/unmount.md
index 8e17c76924..2df6dccdd5 100644
--- a/docs/events/unmount.md
+++ b/docs/events/unmount.md
@@ -1,3 +1,7 @@
::: textual.events.Unmount
options:
heading_level: 1
+
+## See also
+
+- [Mount](mount.md)
diff --git a/docs/widgets/text_area.md b/docs/widgets/text_area.md
index c0a95c265a..9e24442d30 100644
--- a/docs/widgets/text_area.md
+++ b/docs/widgets/text_area.md
@@ -84,6 +84,10 @@ This method is the programmatic equivalent of selecting some text and then pasti
Some other convenient methods are available, such as [`insert`][textual.widgets._text_area.TextArea.insert], [`delete`][textual.widgets._text_area.TextArea.delete], and [`clear`][textual.widgets._text_area.TextArea.clear].
+!!! tip
+ The `TextArea.document.end` property returns the location at the end of the
+ document, which might be convenient when editing programmatically.
+
### Working with the cursor
#### Moving the cursor
diff --git a/pyproject.toml b/pyproject.toml
index 5d8ebf16de..630cc27599 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[tool.poetry]
name = "textual"
-version = "0.53.1"
+version = "0.54.0"
homepage = "https://github.com/Textualize/textual"
repository = "https://github.com/Textualize/textual"
documentation = "https://textual.textualize.io/"
diff --git a/src/textual/app.py b/src/textual/app.py
index 372cf4b278..91d67cc78a 100644
--- a/src/textual/app.py
+++ b/src/textual/app.py
@@ -3006,11 +3006,18 @@ async def _broker_event(
return False
else:
event.stop()
- if isinstance(action, (str, tuple)):
+ if isinstance(action, str) or (isinstance(action, tuple) and len(action) == 2):
await self.run_action(action, default_namespace=default_namespace) # type: ignore[arg-type]
elif callable(action):
await action()
else:
+ if isinstance(action, tuple) and self.debug:
+ # It's a tuple and made it this far, which means it'll be a
+ # malformed action. This is a no-op, but let's log that
+ # anyway.
+ log.warning(
+ f"Can't parse @{event_name} action from style meta; check your console markup syntax"
+ )
return False
return True
diff --git a/src/textual/document/_document.py b/src/textual/document/_document.py
index 3a4e5729b2..ca850285b5 100644
--- a/src/textual/document/_document.py
+++ b/src/textual/document/_document.py
@@ -171,6 +171,17 @@ def prepare_query(self, query: str) -> Query | None:
def line_count(self) -> int:
"""Returns the number of lines in the document."""
+ @property
+ @abstractmethod
+ def start(self) -> Location:
+ """Returns the location of the start of the document (0, 0)."""
+ return (0, 0)
+
+ @property
+ @abstractmethod
+ def end(self) -> Location:
+ """Returns the location of the end of the document."""
+
@overload
def __getitem__(self, line_index: int) -> str: ...
@@ -331,6 +342,17 @@ def line_count(self) -> int:
"""Returns the number of lines in the document."""
return len(self._lines)
+ @property
+ def start(self) -> Location:
+ """Returns the location of the start of the document (0, 0)."""
+ return super().start
+
+ @property
+ def end(self) -> Location:
+ """Returns the location of the end of the document."""
+ last_line = self._lines[-1]
+ return (self.line_count, len(last_line))
+
def get_index_from_location(self, location: Location) -> int:
"""Given a location, returns the index from the document's text.
diff --git a/src/textual/events.py b/src/textual/events.py
index 7e5c158bc5..68c3ae8f3d 100644
--- a/src/textual/events.py
+++ b/src/textual/events.py
@@ -59,7 +59,7 @@ class Load(Event, bubble=False):
"""
Sent when the App is running but *before* the terminal is in application mode.
- Use this event to run any set up that doesn't require any visuals such as loading
+ Use this event to run any setup that doesn't require any visuals such as loading
configuration and binding keys.
- [ ] Bubbles
@@ -129,6 +129,9 @@ def __rich_repr__(self) -> rich.repr.Result:
class Compose(Event, bubble=False, verbose=True):
"""Sent to a widget to request it to compose and mount children.
+ This event is used internally by Textual.
+ You won't typically need to explicitly handle it,
+
- [ ] Bubbles
- [X] Verbose
"""
@@ -151,7 +154,7 @@ class Unmount(Event, bubble=False, verbose=False):
class Show(Event, bubble=False):
- """Sent when a widget has become visible.
+ """Sent when a widget is first displayed.
- [ ] Bubbles
- [ ] Verbose
@@ -164,13 +167,17 @@ class Hide(Event, bubble=False):
- [ ] Bubbles
- [ ] Verbose
- A widget may be hidden by setting its `visible` flag to `False`, if it is no longer in a layout,
- or if it has been offset beyond the edges of the terminal.
+ Sent when any of the following conditions apply:
+
+ - The widget is removed from the DOM.
+ - The widget is no longer displayed because it has been scrolled or clipped from the terminal or its container.
+ - The widget has its `display` attribute set to `False`.
+ - The widget's `display` style is set to `"none"`.
"""
class Ready(Event, bubble=False):
- """Sent to the app when the DOM is ready.
+ """Sent to the `App` when the DOM is ready and the first frame has been displayed.
- [ ] Bubbles
- [ ] Verbose
@@ -232,7 +239,7 @@ class Key(InputEvent):
Args:
key: The key that was pressed.
- character: A printable character or ``None`` if it is not printable.
+ character: A printable character or `None` if it is not printable.
"""
__slots__ = ["key", "character", "aliases"]
diff --git a/src/textual/widget.py b/src/textual/widget.py
index aac76e8778..4bfaabdee0 100644
--- a/src/textual/widget.py
+++ b/src/textual/widget.py
@@ -3618,10 +3618,8 @@ async def _compose(self) -> None:
raise TypeError(
f"{self!r} compose() method returned an invalid result; {error}"
) from error
- except Exception:
- from rich.traceback import Traceback
-
- self.app.panic(Traceback())
+ except Exception as error:
+ self.app._handle_exception(error)
else:
self._extend_compose(widgets)
await self.mount_composed_widgets(widgets)
diff --git a/src/textual/widgets/_input.py b/src/textual/widgets/_input.py
index 0d3149f2d3..0b3ae57ee1 100644
--- a/src/textual/widgets/_input.py
+++ b/src/textual/widgets/_input.py
@@ -519,6 +519,7 @@ def _on_focus(self, _: Focus) -> None:
if self.cursor_blink:
self._blink_timer.resume()
self.app.cursor_position = self.cursor_screen_offset
+ self._suggestion = ""
async def _on_key(self, event: events.Key) -> None:
self._cursor_visible = True
diff --git a/src/textual/widgets/_text_area.py b/src/textual/widgets/_text_area.py
index 71e1b3fd7c..4ad2023aad 100644
--- a/src/textual/widgets/_text_area.py
+++ b/src/textual/widgets/_text_area.py
@@ -2026,10 +2026,7 @@ def clear(self) -> EditResult:
Returns:
An EditResult relating to the deletion of all content.
"""
- document = self.document
- last_line = document[-1]
- document_end = (document.line_count, len(last_line))
- return self.delete((0, 0), document_end, maintain_selection_offset=False)
+ return self.delete((0, 0), self.document.end, maintain_selection_offset=False)
def _delete_via_keyboard(
self,
diff --git a/src/textual/worker.py b/src/textual/worker.py
index 7719cd4dca..f7f10b60ae 100644
--- a/src/textual/worker.py
+++ b/src/textual/worker.py
@@ -375,7 +375,8 @@ async def _run(self, app: App) -> None:
app.log.worker(Traceback())
if self.exit_on_error:
- app._fatal_error()
+ worker_failed = WorkerFailed(self._error)
+ app._handle_exception(worker_failed)
else:
self.state = WorkerState.SUCCESS
app.log.worker(self)
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
index 83eaf4a490..c375135d8b 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
@@ -20627,137 +20627,137 @@
font-weight: 700;
}
- .terminal-2073605363-matrix {
+ .terminal-2577839347-matrix {
font-family: Fira Code, monospace;
font-size: 20px;
line-height: 24.4px;
font-variant-east-asian: full-width;
}
- .terminal-2073605363-title {
+ .terminal-2577839347-title {
font-size: 18px;
font-weight: bold;
font-family: arial;
}
- .terminal-2073605363-r1 { fill: #1e1e1e }
- .terminal-2073605363-r2 { fill: #0178d4 }
- .terminal-2073605363-r3 { fill: #c5c8c6 }
- .terminal-2073605363-r4 { fill: #e2e2e2 }
- .terminal-2073605363-r5 { fill: #1e1e1e;font-style: italic; }
- .terminal-2073605363-r6 { fill: #ff0000;font-style: italic; }
- .terminal-2073605363-r7 { fill: #121212 }
- .terminal-2073605363-r8 { fill: #e1e1e1 }
+ .terminal-2577839347-r1 { fill: #1e1e1e }
+ .terminal-2577839347-r2 { fill: #0178d4 }
+ .terminal-2577839347-r3 { fill: #c5c8c6 }
+ .terminal-2577839347-r4 { fill: #e2e2e2 }
+ .terminal-2577839347-r5 { fill: #1e1e1e;font-style: italic; }
+ .terminal-2577839347-r6 { fill: #ff0000;font-style: italic; }
+ .terminal-2577839347-r7 { fill: #121212 }
+ .terminal-2577839347-r8 { fill: #e1e1e1 }
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
-
+
- FruitsApp
+ FruitsApp
-
-
-
- ▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎
- ▊strawberry▎
- ▊▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎
- ▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎
- ▊straw▎
- ▊▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎
- ▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎
- ▊p▎
- ▊▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎
- ▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎
- ▊b▎
- ▊▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎
- ▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎
- ▊a▎
- ▊▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎
-
-
-
-
-
-
-
-
+
+
+
+ ▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎
+ ▊strawberry▎
+ ▊▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎
+ ▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎
+ ▊straw▎
+ ▊▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎
+ ▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎
+ ▊p▎
+ ▊▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎
+ ▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎
+ ▊b▎
+ ▊▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎
+ ▊▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▔▎
+ ▊a▎
+ ▊▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▎
+
+
+
+
+
+
+
+
diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py
index 228ad28608..cf6ccb2fa3 100644
--- a/tests/snapshot_tests/test_snapshots.py
+++ b/tests/snapshot_tests/test_snapshots.py
@@ -105,7 +105,7 @@ async def run_before(pilot):
pilot.app.query(Input).first().cursor_blink = False
assert snap_compare(
- SNAPSHOT_APPS_DIR / "input_suggestions.py", press=[], run_before=run_before
+ SNAPSHOT_APPS_DIR / "input_suggestions.py", press=["b"], run_before=run_before
)
diff --git a/tests/test_issue_4248.py b/tests/test_issue_4248.py
new file mode 100644
index 0000000000..de3eee0928
--- /dev/null
+++ b/tests/test_issue_4248.py
@@ -0,0 +1,46 @@
+"""Test https://github.com/Textualize/textual/issues/4248"""
+
+from textual.app import App, ComposeResult
+from textual.widgets import Label
+
+
+async def test_issue_4248() -> None:
+ """Various forms of click parameters should be fine."""
+
+ bumps = 0
+
+ class ActionApp(App[None]):
+
+ def compose(self) -> ComposeResult:
+ yield Label("[@click]click me and crash[/]", id="nothing")
+ yield Label("[@click=]click me and crash[/]", id="no-params")
+ yield Label("[@click=()]click me and crash[/]", id="empty-params")
+ yield Label("[@click=foobar]click me[/]", id="unknown-sans-parens")
+ yield Label("[@click=foobar()]click me[/]", id="unknown-with-parens")
+ yield Label("[@click=bump]click me[/]", id="known-sans-parens")
+ yield Label("[@click=bump()]click me[/]", id="known-empty-parens")
+ yield Label("[@click=bump(100)]click me[/]", id="known-with-param")
+
+ def action_bump(self, by_value: int = 1) -> None:
+ nonlocal bumps
+ bumps += by_value
+
+ app = ActionApp()
+ async with app.run_test() as pilot:
+ assert bumps == 0
+ await pilot.click("#nothing")
+ assert bumps == 0
+ await pilot.click("#no-params")
+ assert bumps == 0
+ await pilot.click("#empty-params")
+ assert bumps == 0
+ await pilot.click("#unknown-sans-parens")
+ assert bumps == 0
+ await pilot.click("#unknown-with-parens")
+ assert bumps == 0
+ await pilot.click("#known-sans-parens")
+ assert bumps == 1
+ await pilot.click("#known-empty-parens")
+ assert bumps == 2
+ await pilot.click("#known-with-param")
+ assert bumps == 102
diff --git a/tests/test_pilot.py b/tests/test_pilot.py
index d436f8b5fc..9451237fab 100644
--- a/tests/test_pilot.py
+++ b/tests/test_pilot.py
@@ -2,12 +2,14 @@
import pytest
-from textual import events
+from textual import events, work
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Center, Middle
from textual.pilot import OutOfBounds
+from textual.screen import Screen
from textual.widgets import Button, Label
+from textual.worker import WorkerFailed
KEY_CHARACTERS_TO_TEST = "akTW03" + punctuation
"""Test some "simple" characters (letters + digits) and all punctuation."""
@@ -56,7 +58,7 @@ def on_key(self, event: events.Key) -> None:
assert keys_pressed[-1] == char
-async def test_pilot_exception_catching_compose():
+async def test_pilot_exception_catching_app_compose():
"""Ensuring that test frameworks are aware of exceptions
inside compose methods when running via Pilot run_test()."""
@@ -70,6 +72,21 @@ def compose(self) -> ComposeResult:
pass
+async def test_pilot_exception_catching_widget_compose():
+ class SomeScreen(Screen[None]):
+ def compose(self) -> ComposeResult:
+ 1 / 0
+ yield Label("Beep")
+
+ class FailingApp(App[None]):
+ def on_mount(self) -> None:
+ self.push_screen(SomeScreen())
+
+ with pytest.raises(ZeroDivisionError):
+ async with FailingApp().run_test():
+ pass
+
+
async def test_pilot_exception_catching_action():
"""Ensure that exceptions inside action handlers are presented
to the test framework when running via Pilot run_test()."""
@@ -85,6 +102,21 @@ def action_beep(self) -> None:
await pilot.press("b")
+async def test_pilot_exception_catching_worker():
+ class SimpleAppThatCrashes(App[None]):
+ def on_mount(self) -> None:
+ self.crash()
+
+ @work(name="crash")
+ async def crash(self) -> None:
+ 1 / 0
+
+ with pytest.raises(WorkerFailed) as exc:
+ async with SimpleAppThatCrashes().run_test():
+ pass
+ assert exc.type is ZeroDivisionError
+
+
async def test_pilot_click_screen():
"""Regression test for https://github.com/Textualize/textual/issues/3395.
diff --git a/tests/workers/test_worker.py b/tests/workers/test_worker.py
index 7b553d122f..61af91f638 100644
--- a/tests/workers/test_worker.py
+++ b/tests/workers/test_worker.py
@@ -113,10 +113,10 @@ class ErrorApp(App):
pass
app = ErrorApp()
- async with app.run_test():
- worker: Worker[str] = Worker(app, run_error)
- worker._start(app)
- with pytest.raises(WorkerFailed):
+ with pytest.raises(WorkerFailed):
+ async with app.run_test():
+ worker: Worker[str] = Worker(app, run_error)
+ worker._start(app)
await worker.wait()
@@ -218,12 +218,12 @@ async def self_referential_work():
await get_current_worker().wait()
app = App()
- async with app.run_test():
- worker = Worker(app, self_referential_work)
- worker._start(app)
- with pytest.raises(WorkerFailed) as exc:
+ with pytest.raises(WorkerFailed) as exc:
+ async with app.run_test():
+ worker = Worker(app, self_referential_work)
+ worker._start(app)
await worker.wait()
- assert exc.type is DeadlockError
+ assert exc.type is DeadlockError
async def test_wait_without_start():