diff --git a/src/prompt_toolkit/input/ansi_escape_sequences.py b/src/prompt_toolkit/input/ansi_escape_sequences.py index 1fba418b7..bb73b5e84 100644 --- a/src/prompt_toolkit/input/ansi_escape_sequences.py +++ b/src/prompt_toolkit/input/ansi_escape_sequences.py @@ -77,6 +77,8 @@ "\x1b[Z": Keys.BackTab, # shift + tab "\x1b\x09": Keys.BackTab, # Linux console "\x1b[~": Keys.BackTab, # Windows console + "\x1b[I": Keys.FocusIn, + "\x1b[O": Keys.FocusOut, # -- # Function keys. "\x1bOP": Keys.F1, diff --git a/src/prompt_toolkit/key_binding/bindings/focus.py b/src/prompt_toolkit/key_binding/bindings/focus.py index 24aa3ce39..12baf7bac 100644 --- a/src/prompt_toolkit/key_binding/bindings/focus.py +++ b/src/prompt_toolkit/key_binding/bindings/focus.py @@ -1,10 +1,13 @@ from __future__ import annotations +from prompt_toolkit.key_binding.key_bindings import KeyBindings from prompt_toolkit.key_binding.key_processor import KeyPressEvent +from prompt_toolkit.keys import Keys __all__ = [ "focus_next", "focus_previous", + "load_focus_bindings", ] E = KeyPressEvent @@ -24,3 +27,20 @@ def focus_previous(event: E) -> None: (Often bound to the `BackTab` key.) """ event.app.layout.focus_previous() + + +def load_focus_bindings() -> KeyBindings: + """Key bindings for terminal focus events.""" + key_bindings = KeyBindings() + + @key_bindings.add(Keys.FocusIn, save_before=lambda e: False) + def _(event: E) -> None: + """Redraw the application after the terminal restores its screen.""" + event.app.renderer.erase(leave_alternate_screen=False) + event.app.invalidate() + + @key_bindings.add(Keys.FocusOut, save_before=lambda e: False) + def _(event: E) -> None: + """Consume the focus-out event.""" + + return key_bindings diff --git a/src/prompt_toolkit/key_binding/defaults.py b/src/prompt_toolkit/key_binding/defaults.py index 6c2657116..3b8c4ad3e 100644 --- a/src/prompt_toolkit/key_binding/defaults.py +++ b/src/prompt_toolkit/key_binding/defaults.py @@ -15,6 +15,7 @@ load_emacs_search_bindings, load_emacs_shift_selection_bindings, ) +from prompt_toolkit.key_binding.bindings.focus import load_focus_bindings from prompt_toolkit.key_binding.bindings.mouse import load_mouse_bindings from prompt_toolkit.key_binding.bindings.vi import ( load_vi_bindings, @@ -59,5 +60,6 @@ def load_key_bindings() -> KeyBindingsBase: # Active, even when no buffer has been focused. load_mouse_bindings(), load_cpr_bindings(), + load_focus_bindings(), ] ) diff --git a/src/prompt_toolkit/keys.py b/src/prompt_toolkit/keys.py index ee52aee86..b526732bc 100644 --- a/src/prompt_toolkit/keys.py +++ b/src/prompt_toolkit/keys.py @@ -182,6 +182,8 @@ class Keys(str, Enum): CPRResponse = "" Vt100MouseEvent = "" + FocusIn = "" + FocusOut = "" WindowsMouseEvent = "" BracketedPaste = "" diff --git a/src/prompt_toolkit/output/base.py b/src/prompt_toolkit/output/base.py index 6ba09fdd0..da6f5790c 100644 --- a/src/prompt_toolkit/output/base.py +++ b/src/prompt_toolkit/output/base.py @@ -187,6 +187,17 @@ def enable_bracketed_paste(self) -> None: def disable_bracketed_paste(self) -> None: "For vt100 only." + @property + def supports_focus_reporting(self) -> bool: + "True if this output can receive terminal focus events." + return False + + def enable_focus_reporting(self) -> None: + "For vt100 only." + + def disable_focus_reporting(self) -> None: + "For vt100 only." + def reset_cursor_key_mode(self) -> None: """ For vt100 only. diff --git a/src/prompt_toolkit/output/conemu.py b/src/prompt_toolkit/output/conemu.py index 636994494..e24175681 100644 --- a/src/prompt_toolkit/output/conemu.py +++ b/src/prompt_toolkit/output/conemu.py @@ -56,6 +56,9 @@ def __getattr__(self, name: str) -> Any: "get_win32_screen_buffer_info", "enable_bracketed_paste", "disable_bracketed_paste", + "supports_focus_reporting", + "enable_focus_reporting", + "disable_focus_reporting", ): return getattr(self.win32_output, name) else: diff --git a/src/prompt_toolkit/output/vt100.py b/src/prompt_toolkit/output/vt100.py index b2712254f..cf94a8f0e 100644 --- a/src/prompt_toolkit/output/vt100.py +++ b/src/prompt_toolkit/output/vt100.py @@ -611,6 +611,22 @@ def enable_bracketed_paste(self) -> None: def disable_bracketed_paste(self) -> None: self.write_raw("\x1b[?2004l") + @property + def supports_focus_reporting(self) -> bool: + if is_dumb_terminal(self.term): + return False + + try: + return self.stdout.isatty() + except ValueError: + return False + + def enable_focus_reporting(self) -> None: + self.write_raw("\x1b[?1004h") + + def disable_focus_reporting(self) -> None: + self.write_raw("\x1b[?1004l") + def reset_cursor_key_mode(self) -> None: """ For vt100 only. diff --git a/src/prompt_toolkit/output/windows10.py b/src/prompt_toolkit/output/windows10.py index 2b7e596e4..784f3621f 100644 --- a/src/prompt_toolkit/output/windows10.py +++ b/src/prompt_toolkit/output/windows10.py @@ -76,6 +76,9 @@ def __getattr__(self, name: str) -> Any: "get_rows_below_cursor_position", "scroll_buffer_to_prompt", "get_win32_screen_buffer_info", + "supports_focus_reporting", + "enable_focus_reporting", + "disable_focus_reporting", # "enable_mouse_support", # "disable_mouse_support", # "enable_bracketed_paste", diff --git a/src/prompt_toolkit/renderer.py b/src/prompt_toolkit/renderer.py index fcfde223e..7be245ee2 100644 --- a/src/prompt_toolkit/renderer.py +++ b/src/prompt_toolkit/renderer.py @@ -362,6 +362,7 @@ def __init__( self._in_alternate_screen = False self._mouse_support_enabled = False self._bracketed_paste_enabled = False + self._focus_reporting_enabled = False self._cursor_key_mode_reset = False # Future set when we are waiting for a CPR flag. @@ -421,6 +422,10 @@ def reset(self, _scroll: bool = False, leave_alternate_screen: bool = True) -> N self.output.disable_bracketed_paste() self._bracketed_paste_enabled = False + if self._focus_reporting_enabled: + self.output.disable_focus_reporting() + self._focus_reporting_enabled = False + self.output.reset_cursor_shape() self.output.show_cursor() @@ -609,6 +614,11 @@ def render( self.output.enable_bracketed_paste() self._bracketed_paste_enabled = True + # Enable focus event reporting on terminals that support it. + if not self._focus_reporting_enabled and output.supports_focus_reporting: + output.enable_focus_reporting() + self._focus_reporting_enabled = True + # Reset cursor key mode. if not self._cursor_key_mode_reset: self.output.reset_cursor_key_mode() diff --git a/tests/test_focus_bindings.py b/tests/test_focus_bindings.py new file mode 100644 index 000000000..434bb56eb --- /dev/null +++ b/tests/test_focus_bindings.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +from contextlib import contextmanager + +from prompt_toolkit.application import Application +from prompt_toolkit.application.current import set_app +from prompt_toolkit.data_structures import Point +from prompt_toolkit.input.defaults import create_pipe_input +from prompt_toolkit.key_binding.key_processor import KeyPress +from prompt_toolkit.keys import Keys +from prompt_toolkit.layout import Layout, Window +from prompt_toolkit.layout.screen import Screen +from prompt_toolkit.output import DummyOutput + + +class _RecordingOutput(DummyOutput): + def __init__(self) -> None: + self.calls: list[tuple[str, tuple[int, ...]]] = [] + + @property + def supports_focus_reporting(self) -> bool: + return True + + def enable_focus_reporting(self) -> None: + self.calls.append(("enable_focus_reporting", ())) + + def disable_focus_reporting(self) -> None: + self.calls.append(("disable_focus_reporting", ())) + + def cursor_backward(self, amount: int) -> None: + self.calls.append(("cursor_backward", (amount,))) + + def cursor_up(self, amount: int) -> None: + self.calls.append(("cursor_up", (amount,))) + + def erase_down(self) -> None: + self.calls.append(("erase_down", ())) + + +@contextmanager +def _create_app(output: DummyOutput): + with create_pipe_input() as pipe_input: + app = Application( + layout=Layout(Window()), + input=pipe_input, + output=output, + ) + + def create_background_task(coroutine, **kwargs): + coroutine.close() + return None + + app.create_background_task = create_background_task + + with set_app(app): + yield app + + +def test_focus_in_erases_and_invalidates_screen(): + output = _RecordingOutput() + + with _create_app(output) as app: + invalidated = False + + def invalidate() -> None: + nonlocal invalidated + invalidated = True + + app.invalidate = invalidate + app.renderer._last_screen = Screen() + app.renderer._in_alternate_screen = True + app.renderer._cursor_pos = Point(x=7, y=2) + output.calls.clear() + + app.key_processor.feed(KeyPress(Keys.FocusIn, "\x1b[I")) + app.key_processor.process_keys() + + assert app.renderer._last_screen is None + assert app.renderer._in_alternate_screen + assert invalidated + assert output.calls == [ + ("cursor_backward", (7,)), + ("cursor_up", (2,)), + ("erase_down", ()), + ] + + +def test_renderer_enables_and_disables_focus_reporting(): + output = _RecordingOutput() + + with _create_app(output) as app: + app.renderer.render(app, app.layout) + app.renderer.render(app, app.layout) + + focus_calls = [name for name, _ in output.calls if "focus_reporting" in name] + assert focus_calls == ["enable_focus_reporting"] + + app.renderer.reset() + + focus_calls = [name for name, _ in output.calls if "focus_reporting" in name] + assert focus_calls == ["enable_focus_reporting", "disable_focus_reporting"] diff --git a/tests/test_inputstream.py b/tests/test_inputstream.py index ab1b03689..7588ee476 100644 --- a/tests/test_inputstream.py +++ b/tests/test_inputstream.py @@ -139,3 +139,10 @@ def test_cpr_response_2(processor, stream): assert len(processor.keys) == 2 assert processor.keys[0].key == Keys.CPRResponse assert processor.keys[1].key == Keys.ControlJ + + +def test_focus_events(processor, stream): + stream.feed("\x1b[I\x1b[O") + + assert [key.key for key in processor.keys] == [Keys.FocusIn, Keys.FocusOut] + assert [key.data for key in processor.keys] == ["\x1b[I", "\x1b[O"] diff --git a/tests/test_vt100_output.py b/tests/test_vt100_output.py index ca62be54e..01fa5d0fe 100644 --- a/tests/test_vt100_output.py +++ b/tests/test_vt100_output.py @@ -1,6 +1,18 @@ from __future__ import annotations -from prompt_toolkit.output.vt100 import _256_colors, _get_closest_ansi_color +from io import StringIO + +from prompt_toolkit.data_structures import Size +from prompt_toolkit.output.vt100 import ( + Vt100_Output, + _256_colors, + _get_closest_ansi_color, +) + + +class _TTYStringIO(StringIO): + def isatty(self) -> bool: + return True def test_get_closest_ansi_color(): @@ -29,3 +41,17 @@ def test_256_colors(): # Grayscale assert _256_colors[(8, 8, 8)] == 232 # First grayscale level assert _256_colors[(238, 238, 238)] == 255 # Last grayscale level + + +def test_focus_reporting(): + stdout = _TTYStringIO() + output = Vt100_Output(stdout, lambda: Size(rows=24, columns=80), term="xterm") + + assert output.supports_focus_reporting + + output.enable_focus_reporting() + output.flush() + output.disable_focus_reporting() + output.flush() + + assert stdout.getvalue() == "\x1b[?1004h\x1b[?1004l"