From 87622ad48c05dfa13fcdc0bde87630f2fae1ff8b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 16 Jun 2024 15:09:07 +0100 Subject: [PATCH 1/9] set active message pump on action --- src/textual/app.py | 5 ++++- src/textual/screen.py | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/textual/app.py b/src/textual/app.py index a9237dc6ec..a8115645b7 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -3138,7 +3138,7 @@ async def run_action( return False async def _dispatch_action( - self, namespace: object, action_name: str, params: Any + self, namespace: DOMNode, action_name: str, params: Any ) -> bool: """Dispatch an action to an action method. @@ -3159,6 +3159,7 @@ async def _dispatch_action( params=params, ) + reset_active_message_pump = active_message_pump.set(namespace) try: private_method = getattr(namespace, f"_action_{action_name}", None) if callable(private_method): @@ -3175,6 +3176,8 @@ async def _dispatch_action( except SkipAction: # The action method raised this to explicitly not handle the action log.system(f" {action_name!r} skipped.") + finally: + active_message_pump.reset(reset_active_message_pump) return False async def _broker_event( diff --git a/src/textual/screen.py b/src/textual/screen.py index 07dc235712..62894b83ea 100644 --- a/src/textual/screen.py +++ b/src/textual/screen.py @@ -69,6 +69,7 @@ """Type of a screen result callback function.""" +@rich.repr.auto class ResultCallback(Generic[ScreenResultType]): """Holds the details of a callback.""" From 4ec193550d92ef359c270ac53716fc229cb58b5f Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 16 Jun 2024 15:35:22 +0100 Subject: [PATCH 2/9] version bump --- CHANGELOG.md | 12 ++++++++++++ pyproject.toml | 2 +- src/textual/app.py | 17 ++++++++++++----- src/textual/widgets/_footer.py | 2 +- 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a4854f20f..44d28fdf26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). + +## [0.68.1] - 2024-06-16 + +### Added + +- Added `App.simulate_key` + +### Fixed + +- Fixed issue with pop_screen launched from an action + ## [0.68.0] - 2024-06-14 ### Added @@ -2132,6 +2143,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.68.1]: https://github.com/Textualize/textual/compare/v0.68.0...v0.68.1 [0.68.0]: https://github.com/Textualize/textual/compare/v0.67.1...v0.68.0 [0.67.1]: https://github.com/Textualize/textual/compare/v0.67.0...v0.67.1 [0.67.0]: https://github.com/Textualize/textual/compare/v0.66.0...v0.67.0 diff --git a/pyproject.toml b/pyproject.toml index a42d4dec35..b2049f0467 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "textual" -version = "0.68.0" +version = "0.68.1" 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 a8115645b7..7559afcaa4 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -2977,11 +2977,20 @@ def _binding_chain(self) -> list[tuple[DOMNode, _Bindings]]: return namespace_bindings + def simulate_key(self, key: str) -> None: + """Simulate a key press. + + This will perform the same action as if the user had pressed the key. + + Args: + key: Key to simulate. May also be the name of a key, e.g. "space". + """ + self.call_later(self.check_bindings, key) + async def check_bindings(self, key: str, priority: bool = False) -> bool: """Handle a key press. - This method is used internally by the bindings system, but may be called directly - if you wish to *simulate* a key being pressed. + This method is used internally by the bindings system. Args: key: A key. @@ -3159,7 +3168,6 @@ async def _dispatch_action( params=params, ) - reset_active_message_pump = active_message_pump.set(namespace) try: private_method = getattr(namespace, f"_action_{action_name}", None) if callable(private_method): @@ -3176,8 +3184,7 @@ async def _dispatch_action( except SkipAction: # The action method raised this to explicitly not handle the action log.system(f" {action_name!r} skipped.") - finally: - active_message_pump.reset(reset_active_message_pump) + return False async def _broker_event( diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index 01aff311f9..aa39e3719e 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -103,7 +103,7 @@ async def on_mouse_down(self) -> None: if self._disabled: self.app.bell() else: - await self.app.check_bindings(self.key) + self.app.simulate_key(self.key) def _watch_compact(self, compact: bool) -> None: self.set_class(compact, "-compact") From 473d7e8563775bc979229017462402cf454c227d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 16 Jun 2024 15:37:00 +0100 Subject: [PATCH 3/9] changelog --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44d28fdf26..d02b930c5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,11 +10,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added -- Added `App.simulate_key` +- Added `App.simulate_key` https://github.com/Textualize/textual/pull/4657 ### Fixed -- Fixed issue with pop_screen launched from an action +- Fixed issue with pop_screen launched from an action https://github.com/Textualize/textual/pull/4657 ## [0.68.0] - 2024-06-14 From daec5319ec65218f4be964ca5ef4d047d91ecac6 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 16 Jun 2024 17:10:47 +0100 Subject: [PATCH 4/9] renames --- CHANGELOG.md | 5 +++++ src/textual/app.py | 13 ++++++------- src/textual/widgets/_classic_footer.py | 2 +- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d02b930c5d..28709999dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fixed issue with pop_screen launched from an action https://github.com/Textualize/textual/pull/4657 +### Changed + +- `App.check_bindings` is now private +- `App.action_check_bindings` is now `App.action_simulate_key` + ## [0.68.0] - 2024-06-14 ### Added diff --git a/src/textual/app.py b/src/textual/app.py index 7559afcaa4..16d0da9336 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -2985,9 +2985,9 @@ def simulate_key(self, key: str) -> None: Args: key: Key to simulate. May also be the name of a key, e.g. "space". """ - self.call_later(self.check_bindings, key) + self.call_later(self._check_bindings, key) - async def check_bindings(self, key: str, priority: bool = False) -> bool: + async def _check_bindings(self, key: str, priority: bool = False) -> bool: """Handle a key press. This method is used internally by the bindings system. @@ -3058,7 +3058,7 @@ async def on_event(self, event: events.Event) -> None: self.screen._clear_tooltip() except NoScreen: pass - if not await self.check_bindings(event.key, priority=True): + if not await self._check_bindings(event.key, priority=True): forward_target = self.focused or self.screen forward_target._forward_event(event) else: @@ -3240,7 +3240,7 @@ async def _on_layout(self, message: messages.Layout) -> None: message.stop() async def _on_key(self, event: events.Key) -> None: - if not (await self.check_bindings(event.key)): + if not (await self._check_bindings(event.key)): await self.dispatch_key(event) async def _on_shutdown_request(self, event: events.ShutdownRequest) -> None: @@ -3471,14 +3471,13 @@ def _watch_app_focus(self, focus: bool) -> None: # Remove focus for now. self.screen.set_focus(None) - async def action_check_bindings(self, key: str) -> None: + async def action_simulate_ky(self, key: str) -> None: """An [action](/guide/actions) to handle a key press using the binding system. Args: key: The key to process. """ - if not await self.check_bindings(key, priority=True): - await self.check_bindings(key, priority=False) + self.simulate_key(key) async def action_quit(self) -> None: """An [action](/guide/actions) to quit the app as soon as possible.""" diff --git a/src/textual/widgets/_classic_footer.py b/src/textual/widgets/_classic_footer.py index 2477de74a5..d8df547300 100644 --- a/src/textual/widgets/_classic_footer.py +++ b/src/textual/widgets/_classic_footer.py @@ -137,7 +137,7 @@ def _make_key_text(self) -> Text: ), meta=( { - "@click": f"app.check_bindings('{binding.key}')", + "@click": f"app.simulate_key('{binding.key}')", "key": binding.key, } if enabled and app_focus From ae49d55b756d26e2efc91a30ee89365b29e47837 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 16 Jun 2024 17:16:19 +0100 Subject: [PATCH 5/9] fix typo --- docs/guide/actions.md | 6 +++--- src/textual/app.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/guide/actions.md b/docs/guide/actions.md index 19df24336c..a2ed7abc9f 100644 --- a/docs/guide/actions.md +++ b/docs/guide/actions.md @@ -207,16 +207,16 @@ Textual supports the following builtin actions which are defined on the app. - [action_add_class][textual.app.App.action_add_class] - [action_back][textual.app.App.action_back] - [action_bell][textual.app.App.action_bell] -- [action_check_bindings][textual.app.App.action_check_bindings] -- [action_focus][textual.app.App.action_focus] - [action_focus_next][textual.app.App.action_focus_next] - [action_focus_previous][textual.app.App.action_focus_previous] +- [action_focus][textual.app.App.action_focus] - [action_pop_screen][textual.app.App.action_pop_screen] - [action_push_screen][textual.app.App.action_push_screen] - [action_quit][textual.app.App.action_quit] - [action_remove_class][textual.app.App.action_remove_class] - [action_screenshot][textual.app.App.action_screenshot] -- [action_switch_screen][textual.app.App.action_switch_screen] +- [action_simulate_key][textual.app.App.ction_simulate_ky] - [action_suspend_process][textual.app.App.action_suspend_process] +- [action_switch_screen][textual.app.App.action_switch_screen] - [action_toggle_class][textual.app.App.action_toggle_class] - [action_toggle_dark][textual.app.App.action_toggle_dark] diff --git a/src/textual/app.py b/src/textual/app.py index 16d0da9336..8296f6e05a 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -3471,7 +3471,7 @@ def _watch_app_focus(self, focus: bool) -> None: # Remove focus for now. self.screen.set_focus(None) - async def action_simulate_ky(self, key: str) -> None: + async def action_simulate_key(self, key: str) -> None: """An [action](/guide/actions) to handle a key press using the binding system. Args: From 2b599699976acb6c88859edf18a0f8343b833f32 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 16 Jun 2024 17:26:57 +0100 Subject: [PATCH 6/9] version bump --- CHANGELOG.md | 4 ++-- pyproject.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28709999dc..ce63429298 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). -## [0.68.1] - 2024-06-16 +## [0.69.0] - 2024-06-16 ### Added @@ -2148,7 +2148,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.68.1]: https://github.com/Textualize/textual/compare/v0.68.0...v0.68.1 +[0.69.0]: https://github.com/Textualize/textual/compare/v0.68.0...v0.69.0 [0.68.0]: https://github.com/Textualize/textual/compare/v0.67.1...v0.68.0 [0.67.1]: https://github.com/Textualize/textual/compare/v0.67.0...v0.67.1 [0.67.0]: https://github.com/Textualize/textual/compare/v0.66.0...v0.67.0 diff --git a/pyproject.toml b/pyproject.toml index b2049f0467..5e1fc962ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "textual" -version = "0.68.1" +version = "0.69.0" homepage = "https://github.com/Textualize/textual" repository = "https://github.com/Textualize/textual" documentation = "https://textual.textualize.io/" From c6fdd71b8957a4b548feb3e8b9600a34f25d5b73 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 16 Jun 2024 17:43:03 +0100 Subject: [PATCH 7/9] fix docs --- docs/guide/actions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/actions.md b/docs/guide/actions.md index a2ed7abc9f..312ca53c8e 100644 --- a/docs/guide/actions.md +++ b/docs/guide/actions.md @@ -215,7 +215,7 @@ Textual supports the following builtin actions which are defined on the app. - [action_quit][textual.app.App.action_quit] - [action_remove_class][textual.app.App.action_remove_class] - [action_screenshot][textual.app.App.action_screenshot] -- [action_simulate_key][textual.app.App.ction_simulate_ky] +- [action_simulate_key][textual.app.App.action_simulate_key] - [action_suspend_process][textual.app.App.action_suspend_process] - [action_switch_screen][textual.app.App.action_switch_screen] - [action_toggle_class][textual.app.App.action_toggle_class] From c0aca7301ec4f7231422ad4691dfdd2b974ddb4d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 16 Jun 2024 17:44:08 +0100 Subject: [PATCH 8/9] docstring --- src/textual/app.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/textual/app.py b/src/textual/app.py index 8296f6e05a..eb1d1e8978 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -3472,7 +3472,9 @@ def _watch_app_focus(self, focus: bool) -> None: self.screen.set_focus(None) async def action_simulate_key(self, key: str) -> None: - """An [action](/guide/actions) to handle a key press using the binding system. + """An [action](/guide/actions) to simulate a key press. + + This will invoke the same actions as if the user had pressed the key. Args: key: The key to process. From 2375ed28f15fe929fddad7176d13c54e539eb00b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 16 Jun 2024 18:53:45 +0100 Subject: [PATCH 9/9] test --- tests/test_modal.py | 94 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 tests/test_modal.py diff --git a/tests/test_modal.py b/tests/test_modal.py new file mode 100644 index 0000000000..d0af4d6770 --- /dev/null +++ b/tests/test_modal.py @@ -0,0 +1,94 @@ +from textual.app import App, ComposeResult +from textual.containers import Grid +from textual.screen import ModalScreen +from textual.widgets import Button, Footer, Header, Label + +TEXT = """I must not fear. +Fear is the mind-killer. +Fear is the little-death that brings total obliteration. +I will face my fear. +I will permit it to pass over me and through me. +And when it has gone past, I will turn the inner eye to see its path. +Where the fear has gone there will be nothing. Only I will remain.""" + + +class QuitScreen(ModalScreen[bool]): # (1)! + """Screen with a dialog to quit.""" + + def compose(self) -> ComposeResult: + yield Grid( + Label("Are you sure you want to quit?", id="question"), + Button("Quit", variant="error", id="quit"), + Button("Cancel", variant="primary", id="cancel"), + id="dialog", + ) + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "quit": + self.dismiss(True) + else: + self.dismiss(False) + + +class ModalApp(App): + """An app with a modal dialog.""" + + BINDINGS = [("q", "request_quit", "Quit")] + + CSS = """ +QuitScreen { + align: center middle; +} + +#dialog { + grid-size: 2; + grid-gutter: 1 2; + grid-rows: 1fr 3; + padding: 0 1; + width: 60; + height: 11; + border: thick $background 80%; + background: $surface; +} + +#question { + column-span: 2; + height: 1fr; + width: 1fr; + content-align: center middle; +} + +Button { + width: 100%; +} + + """ + + def compose(self) -> ComposeResult: + yield Header() + yield Label(TEXT * 8) + yield Footer() + + def action_request_quit(self) -> None: + """Action to display the quit dialog.""" + + def check_quit(quit: bool) -> None: + """Called when QuitScreen is dismissed.""" + + if quit: + self.exit() + + self.push_screen(QuitScreen(), check_quit) + + +async def test_modal_pop_screen(): + # https://github.com/Textualize/textual/issues/4656 + + async with ModalApp().run_test() as pilot: + await pilot.pause() + # Check clicking the footer brings up the quit screen + await pilot.click(Footer) + assert isinstance(pilot.app.screen, QuitScreen) + # Check activating the quit button exits the app + await pilot.press("enter") + assert pilot.app._exit