diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a4854f20f..ce63429298 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ 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.69.0] - 2024-06-16 + +### Added + +- Added `App.simulate_key` https://github.com/Textualize/textual/pull/4657 + +### Fixed + +- 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 @@ -2132,6 +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.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/docs/guide/actions.md b/docs/guide/actions.md index 19df24336c..312ca53c8e 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.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] - [action_toggle_dark][textual.app.App.action_toggle_dark] diff --git a/pyproject.toml b/pyproject.toml index a42d4dec35..5e1fc962ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "textual" -version = "0.68.0" +version = "0.69.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 a9237dc6ec..eb1d1e8978 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 - async def check_bindings(self, key: str, priority: bool = False) -> bool: + 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. @@ -3049,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: @@ -3138,7 +3147,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. @@ -3175,6 +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.") + return False async def _broker_event( @@ -3230,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: @@ -3461,14 +3471,15 @@ 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: - """An [action](/guide/actions) to handle a key press using the binding system. + async def action_simulate_key(self, key: str) -> None: + """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. """ - 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/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.""" 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 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") 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