diff --git a/prompt_toolkit/application/__init__.py b/prompt_toolkit/application/__init__.py index 343261cdc..24ffe4ef2 100644 --- a/prompt_toolkit/application/__init__.py +++ b/prompt_toolkit/application/__init__.py @@ -1,4 +1,4 @@ -from .application import Application +from .application import Application, handle_exit_on_key from .current import ( AppSession, create_app_session, @@ -13,6 +13,7 @@ __all__ = [ # Application. "Application", + "handle_exit_on_key", # Current. "AppSession", "get_app_session", diff --git a/prompt_toolkit/application/application.py b/prompt_toolkit/application/application.py index 25a063664..e64aed490 100644 --- a/prompt_toolkit/application/application.py +++ b/prompt_toolkit/application/application.py @@ -118,6 +118,7 @@ class Application(Generic[_AppResult]): :param key_bindings: :class:`~prompt_toolkit.key_binding.KeyBindingsBase` instance for the key bindings. + :param exit_on_key: (str|tuple) auto-bind key to exit the application :param clipboard: :class:`~prompt_toolkit.clipboard.Clipboard` to use. :param on_abort: What to do when Control-C is pressed. :param on_exit: What to do when Control-D is pressed. @@ -195,6 +196,7 @@ def __init__( include_default_pygments_style: FilterOrBool = True, style_transformation: Optional[StyleTransformation] = None, key_bindings: Optional[KeyBindingsBase] = None, + exit_on_key: Optional[Union[str, tuple]] = None, clipboard: Optional[Clipboard] = None, full_screen: bool = False, color_depth: Union[ @@ -242,6 +244,8 @@ def __init__( # Key bindings. self.key_bindings = key_bindings + self.exit_on_key = (exit_on_key,) if type(exit_on_key) is str else exit_on_key + self._merge_exit_key_binding() self._default_bindings = load_key_bindings() self._page_navigation_bindings = load_page_navigation_bindings() @@ -339,6 +343,20 @@ def __init__( # Trigger initialize callback. self.reset() + def _merge_exit_key_binding(self) -> None: + if self.exit_on_key is None: + return + + kb = KeyBindings() + kb.add(*self.exit_on_key)(handle_exit_on_key) + + if self.key_bindings is None: + self.key_bindings = kb + else: + self.key_bindings = merge_key_bindings([kb, self.key_bindings]) + + return + def _create_merged_style(self, include_default_pygments_style: Filter) -> BaseStyle: """ Create a `Style` object that merges the default UI style, the default @@ -1222,3 +1240,11 @@ def attach_winch_signal_handler( previous_winch_handler._callback, *previous_winch_handler._args, ) + + +def handle_exit_on_key(event: KeyPressEvent) -> None: + """ + Exit application function, to be used with a key binding + """ + event.app.exit() + return diff --git a/tests/test_application.py b/tests/test_application.py new file mode 100644 index 000000000..f1e48fb49 --- /dev/null +++ b/tests/test_application.py @@ -0,0 +1,96 @@ +import pytest + +from prompt_toolkit.application import Application, get_app, handle_exit_on_key +from prompt_toolkit.application.current import set_app +from prompt_toolkit.key_binding.key_bindings import KeyBindings, _parse_key +from prompt_toolkit.keys import Keys +from test_key_binding import handlers, set_dummy_app + + +def test_exit_on_key_simple(): + """ + Setting 'exit_on_key' should create a Binding in 'key_binding' + """ + with set_dummy_app(exit_on_key="c-c"): + exit_kb = KeyBindings() + exit_kb.add("c-c")(handle_exit_on_key) + + app = get_app() + + assert len(app.key_bindings.bindings) == 1 + + app_binding = app.key_bindings.bindings[0] + exit_binding = exit_kb.bindings[0] + + assert app_binding.keys == exit_binding.keys + assert app_binding.handler == exit_binding.handler + + +def test_without_exit_on_key(): + """ + Ommiting 'exit_on_key' should leave 'key_binding' unmodified + """ + kb = KeyBindings() + + @kb.add("c-d") + def bind(e): + pass + + with set_dummy_app(key_bindings=kb): + app = get_app() + + assert app.exit_on_key is None + assert len(app.key_bindings.bindings) == 1 + + app_binding = app.key_bindings.bindings[0] + + assert app_binding.keys == (_parse_key("c-d"),) + assert app_binding.handler == bind + + +def test_exit_on_key_not_override(handlers): + """ + 'exit_on_key' should NOT override a 'key_bindings' Binding with same keys + """ + kb = KeyBindings() + kb.add("c-c")(handlers.control_c) + + with set_dummy_app(exit_on_key="c-c", key_bindings=kb): + app = get_app() + + assert len(app.key_bindings.bindings) == 2 + + app_last_binding = app.key_bindings.bindings[-1] + key_bindings_arg = kb.bindings[0] + + # The last binding should be the one we passed in the key_bindings arg, + # not the one in exit_on_bind + assert app_last_binding.keys == key_bindings_arg.keys + assert app_last_binding.handler == key_bindings_arg.handler + + +def test_exit_on_key_mixed_bindings(): + """ + 'exit_on_key' should not interfere with other 'key_bindings' Bindings + """ + kb = KeyBindings() + + @kb.add("c-d") + def bind_c_d(event): + pass + + @kb.add("s-tab") + def bind_s_tab(event): + pass + + with set_dummy_app(exit_on_key="c-c", key_bindings=kb): + app = get_app() + + binding_tuples = [ + (binding.keys, binding.handler) for binding in app.key_bindings.bindings + ] + + assert len(binding_tuples) == 3 + assert ((_parse_key("c-c"),), handle_exit_on_key) in binding_tuples + assert ((_parse_key("c-d"),), bind_c_d) in binding_tuples + assert ((_parse_key("s-tab"),), bind_s_tab) in binding_tuples diff --git a/tests/test_key_binding.py b/tests/test_key_binding.py index f1ec5af36..639230d7a 100644 --- a/tests/test_key_binding.py +++ b/tests/test_key_binding.py @@ -21,14 +21,17 @@ def func(event): return func -def set_dummy_app(): +def set_dummy_app(**kwargs): """ Return a context manager that makes sure that this dummy application is active. This is important, because we need an `Application` with `is_done=False` flag, otherwise no keys will be processed. """ app = Application( - layout=Layout(Window()), output=DummyOutput(), input=create_pipe_input() + layout=Layout(Window()), + output=DummyOutput(), + input=create_pipe_input(), + **kwargs, ) return set_app(app)