Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/prompt_toolkit/input/ansi_escape_sequences.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
20 changes: 20 additions & 0 deletions src/prompt_toolkit/key_binding/bindings/focus.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
2 changes: 2 additions & 0 deletions src/prompt_toolkit/key_binding/defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
]
)
2 changes: 2 additions & 0 deletions src/prompt_toolkit/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,8 @@ class Keys(str, Enum):

CPRResponse = "<cursor-position-response>"
Vt100MouseEvent = "<vt100-mouse-event>"
FocusIn = "<focus-in>"
FocusOut = "<focus-out>"
WindowsMouseEvent = "<windows-mouse-event>"
BracketedPaste = "<bracketed-paste>"

Expand Down
11 changes: 11 additions & 0 deletions src/prompt_toolkit/output/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions src/prompt_toolkit/output/conemu.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
16 changes: 16 additions & 0 deletions src/prompt_toolkit/output/vt100.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions src/prompt_toolkit/output/windows10.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 10 additions & 0 deletions src/prompt_toolkit/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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()
Expand Down
101 changes: 101 additions & 0 deletions tests/test_focus_bindings.py
Original file line number Diff line number Diff line change
@@ -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"]
7 changes: 7 additions & 0 deletions tests/test_inputstream.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
28 changes: 27 additions & 1 deletion tests/test_vt100_output.py
Original file line number Diff line number Diff line change
@@ -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():
Expand Down Expand Up @@ -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"
Loading