Skip to content

Commit

Permalink
Merge pull request #4657 from Textualize/action-pump
Browse files Browse the repository at this point in the history
Action pump
  • Loading branch information
willmcgugan authored Jun 16, 2024
2 parents e9ad400 + 2375ed2 commit b7471e4
Show file tree
Hide file tree
Showing 8 changed files with 139 additions and 16 deletions.
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions docs/guide/actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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/"
Expand Down
31 changes: 21 additions & 10 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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> {action_name!r} skipped.")

return False

async def _broker_event(
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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."""
Expand Down
1 change: 1 addition & 0 deletions src/textual/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
"""Type of a screen result callback function."""


@rich.repr.auto
class ResultCallback(Generic[ScreenResultType]):
"""Holds the details of a callback."""

Expand Down
2 changes: 1 addition & 1 deletion src/textual/widgets/_classic_footer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/textual/widgets/_footer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
94 changes: 94 additions & 0 deletions tests/test_modal.py
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit b7471e4

Please sign in to comment.