Support for bracketed paste mode (#567)

* Detecting bracketed paste, sending paste events

* Bracketed pasting support in TextInput

* Restore debugging conditional

* Handle pasting of text in text-input, improve scrolling

* Fix ordering of handling in parser for bracketed pastes

* Docstrings

* Add docstrings
This commit is contained in:
darrenburns
2022-06-08 16:42:59 +01:00
committed by Will McGugan
parent 9c7d7b703e
commit 9111387e67
9 changed files with 176 additions and 68 deletions

View File

@@ -134,7 +134,6 @@ ANSI_SEQUENCES_KEYS: Mapping[str, Tuple[Keys, ...]] = {
# Tmux (Win32 subsystem) sends the following scroll events.
"\x1b[62~": (Keys.ScrollUp,),
"\x1b[63~": (Keys.ScrollDown,),
"\x1b[200~": (Keys.BracketedPaste,), # Start of bracketed paste.
# --
# Sequences generated by numpad 5. Not sure what it means. (It doesn't
# appear in 'infocmp'. Just ignore.

View File

@@ -124,7 +124,7 @@ class TextEditorBackend:
self.cursor_index = text_length
return True
def insert_at_cursor(self, text: str) -> bool:
def insert(self, text: str) -> bool:
"""Insert some text at the cursor position, and move the cursor
to the end of the newly inserted text.

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import re
from typing import Any, Callable, Generator, Iterable
from . import log, messages
from . import messages
from . import events
from ._types import MessageTarget
from ._parser import Awaitable, Parser, TokenCallback
@@ -14,6 +14,8 @@ _re_mouse_event = re.compile("^" + re.escape("\x1b[") + r"(<?[\d;]+[mM]|M...)\Z"
_re_terminal_mode_response = re.compile(
"^" + re.escape("\x1b[") + r"\?(?P<mode_id>\d+);(?P<setting_parameter>\d)\$y"
)
_re_bracketed_paste_start = re.compile(r"^\x1b\[200~$")
_re_bracketed_paste_end = re.compile(r"^\x1b\[201~$")
class XTermParser(Parser[events.Event]):
@@ -85,59 +87,102 @@ class XTermParser(Parser[events.Event]):
read1 = self.read1
get_key_ansi_sequence = ANSI_SEQUENCES_KEYS.get
more_data = self.more_data
paste_buffer: list[str] = []
bracketed_paste = False
while not self.is_eof:
if not bracketed_paste and paste_buffer:
# We're at the end of the bracketed paste.
# The paste buffer has content, but the bracketed paste has finished,
# so we flush the paste buffer. We have to remove the final character
# since if bracketed paste has come to an end, we'll have added the
# ESC from the closing bracket, since at that point we didn't know what
# the full escape code was.
pasted_text = "".join(paste_buffer[:-1])
self.debug_log(f"pasted_text={pasted_text!r}")
on_token(events.Paste(self.sender, text=pasted_text))
paste_buffer.clear()
character = yield read1()
# If we're currently performing a bracketed paste,
if bracketed_paste:
paste_buffer.append(character)
self.debug_log(f"paste_buffer={paste_buffer!r}")
self.debug_log(f"character={character!r}")
if character == ESC:
# Could be the escape key was pressed OR the start of an escape sequence
sequence: str = character
peek_buffer = yield self.peek_buffer()
if not peek_buffer:
# An escape arrived without any following characters
on_token(events.Key(self.sender, key="escape"))
continue
if peek_buffer and peek_buffer[0] == ESC:
# There is an escape in the buffer, so ESC ESC has arrived
yield read1()
on_token(events.Key(self.sender, key="escape"))
# If there is no further data, it is not part of a sequence,
# So we don't need to go in to the loop
if len(peek_buffer) == 1 and not more_data():
if not bracketed_paste:
peek_buffer = yield self.peek_buffer()
if not peek_buffer:
# An escape arrived without any following characters
on_token(events.Key(self.sender, key="escape"))
continue
if peek_buffer and peek_buffer[0] == ESC:
# There is an escape in the buffer, so ESC ESC has arrived
yield read1()
on_token(events.Key(self.sender, key="escape"))
# If there is no further data, it is not part of a sequence,
# So we don't need to go in to the loop
if len(peek_buffer) == 1 and not more_data():
continue
while True:
sequence += yield read1()
self.debug_log(f"sequence={sequence!r}")
# Was it a pressed key event that we received?
keys = get_key_ansi_sequence(sequence, None)
# Firstly, check if it's a bracketed paste escape code
bracketed_paste_start_match = _re_bracketed_paste_start.match(
sequence
)
self.debug_log(f"sequence = {repr(sequence)}")
self.debug_log(f"match = {repr(bracketed_paste_start_match)}")
if bracketed_paste_start_match is not None:
bracketed_paste = True
self.debug_log("BRACKETED PASTE START DETECTED")
break
bracketed_paste_end_match = _re_bracketed_paste_end.match(sequence)
if bracketed_paste_end_match is not None:
bracketed_paste = False
self.debug_log("BRACKETED PASTE ENDED")
break
if not bracketed_paste:
# Was it a pressed key event that we received?
keys = get_key_ansi_sequence(sequence, None)
if keys is not None:
for key in keys:
on_token(events.Key(self.sender, key=key))
break
# Or a mouse event?
mouse_match = _re_mouse_event.match(sequence)
if mouse_match is not None:
mouse_code = mouse_match.group(0)
event = self.parse_mouse_code(mouse_code, self.sender)
if event:
on_token(event)
break
# Or a mode report? (i.e. the terminal telling us if it supports a mode we requested)
mode_report_match = _re_terminal_mode_response.match(sequence)
if mode_report_match is not None:
if (
mode_report_match["mode_id"] == "2026"
and int(mode_report_match["setting_parameter"]) > 0
):
on_token(
messages.TerminalSupportsSynchronizedOutput(
self.sender
)
)
break
else:
if not bracketed_paste:
keys = get_key_ansi_sequence(character, None)
if keys is not None:
for key in keys:
on_token(events.Key(self.sender, key=key))
break
# Or a mouse event?
mouse_match = _re_mouse_event.match(sequence)
if mouse_match is not None:
mouse_code = mouse_match.group(0)
event = self.parse_mouse_code(mouse_code, self.sender)
if event:
on_token(event)
break
# Or a mode report? (i.e. the terminal telling us if it supports a mode we requested)
mode_report_match = _re_terminal_mode_response.match(sequence)
if mode_report_match is not None:
if (
mode_report_match["mode_id"] == "2026"
and int(mode_report_match["setting_parameter"]) > 0
):
on_token(
messages.TerminalSupportsSynchronizedOutput(self.sender)
)
break
else:
keys = get_key_ansi_sequence(character, None)
if keys is not None:
for key in keys:
on_token(events.Key(self.sender, key=key))
else:
on_token(events.Key(self.sender, key=character))
else:
on_token(events.Key(self.sender, key=character))

View File

@@ -111,7 +111,7 @@ class App(Generic[ReturnType], DOMNode):
"""The base class for Textual Applications"""
CSS = """
App {
App {
background: $surface;
color: $text-surface;
}
@@ -758,6 +758,7 @@ class App(Generic[ReturnType], DOMNode):
driver = self._driver = self.driver_class(self.console, self)
driver.start_application_mode()
driver.enable_bracketed_paste()
try:
with redirect_stdout(StdoutRedirector(self.devtools, self._log_file)): # type: ignore
mount_event = events.Mount(sender=self)
@@ -772,6 +773,7 @@ class App(Generic[ReturnType], DOMNode):
await self.animator.stop()
await self.close_all()
finally:
driver.disable_bracketed_paste()
driver.stop_application_mode()
except Exception as error:
self.on_exception(error)
@@ -1002,7 +1004,8 @@ class App(Generic[ReturnType], DOMNode):
else:
# Forward the event to the view
await self.screen.forward_event(event)
elif isinstance(event, events.Paste):
await self.focused.forward_event(event)
else:
await super().on_event(event)

View File

@@ -41,6 +41,18 @@ class Driver(ABC):
click_event = events.Click.from_event(event)
self.send_event(click_event)
def enable_bracketed_paste(self) -> None:
"""Write the ANSI escape code `ESC[?2004h`, which
enables bracketed paste mode."""
self.console.file.write("\x1b[?2004h")
self.console.file.flush()
def disable_bracketed_paste(self) -> None:
"""Write the ANSI escape code `ESC[?2004l`, which
disables bracketed paste mode."""
self.console.file.write("\x1b[?2004l")
self.console.file.flush()
@abstractmethod
def start_application_mode(self) -> None:
...

View File

@@ -413,3 +413,24 @@ class DescendantFocus(Event, verbosity=2, bubble=True):
class DescendantBlur(Event, verbosity=2, bubble=True):
pass
@rich.repr.auto
class Paste(Event, bubble=False):
"""Event containing text that was pasted into the Textual application.
This event will only appear when running in a terminal emulator that supports
bracketed paste mode. Textual will enable bracketed pastes when an app starts,
and disable it when the app shuts down.
"""
def __init__(self, sender: MessageTarget, text: str) -> None:
"""
Args:
sender (MessageTarget): The sender of the event, (in this case the app).
text: The text that has been pasted
"""
super().__init__(sender)
self.text = text
def __rich_repr__(self) -> rich.repr.Result:
yield "text", self.text

View File

@@ -180,7 +180,6 @@ class Keys(str, Enum):
CPRResponse = "<cursor-position-response>"
Vt100MouseEvent = "<vt100-mouse-event>"
WindowsMouseEvent = "<windowshift+mouse-event>"
BracketedPaste = "<bracketed-paste>"
# For internal use: key which is ignored.
# (The key binding for this key should not do anything.)

View File

@@ -5,7 +5,6 @@ from typing import Callable
from rich.cells import cell_len
from rich.console import RenderableType
from rich.padding import Padding
from rich.style import Style
from rich.text import Text
from textual import events, _clock
@@ -44,7 +43,7 @@ class TextWidgetBase(Widget):
changed = False
if event.is_printable:
changed = self._editor.insert_at_cursor(key)
changed = self._editor.insert(key)
elif key == "ctrl+h":
changed = self._editor.delete_back()
elif key == "ctrl+d":
@@ -197,6 +196,30 @@ class TextInput(TextWidgetBase, can_focus=True):
self._editor.cursor_index = new_cursor_index
self.refresh()
def on_paste(self, event: events.Paste) -> None:
"""Handle Paste event by stripping newlines from the text, and inserting
the text at the cursor position, sliding the visible window if required."""
text = "".join(event.text.splitlines())
width_behind_cursor = self._visible_content_to_cursor_cell_len
self._editor.insert(text)
paste_cell_len = cell_len(text)
available_width = self.content_region.width
# By inserting the pasted text, how far beyond the bounds of the text input
# will we move the cursor? We need to slide the visible window along by that.
scroll_amount = paste_cell_len + width_behind_cursor - available_width + 1
if scroll_amount > 0:
self._slide_window(scroll_amount)
self.refresh()
def _slide_window(self, amount: int) -> None:
"""Slide the visible window left or right by `amount`. Negative integers move
the window left, and positive integers move the window right."""
start, end = self.visible_range
self.visible_range = start + amount, end + amount
def _reset_visible_range(self):
"""Reset our window into the editor content. Used when the widget is resized."""
available_width = self.content_region.width
@@ -249,6 +272,20 @@ class TextInput(TextWidgetBase, can_focus=True):
display_text = self._apply_cursor_to_text(display_text, 0)
return display_text
@property
def _visible_content_to_cursor_cell_len(self) -> int:
"""The cell width of the visible content up to the cursor cell"""
start, _ = self.visible_range
visible_content_to_cursor = self._editor.get_range(
start, self._editor.cursor_index + 1
)
return cell_len(visible_content_to_cursor)
@property
def _cursor_at_right_edge(self) -> bool:
"""True if the cursor is at the right edge of the content area"""
return self._visible_content_to_cursor_cell_len == self.content_region.width
def on_key(self, event: events.Key) -> None:
key = event.key
if key in self.STOP_PROPAGATE:
@@ -267,19 +304,14 @@ class TextInput(TextWidgetBase, can_focus=True):
scrollable = cell_len(self._editor.content) >= available_width
# Check what content is visible from the editor, and how wide that content is
visible_content = self._editor.get_range(start, end)
visible_content_cell_len = cell_len(visible_content)
visible_content_to_cursor = self._editor.get_range(
start, self._editor.cursor_index + 1
)
visible_content_to_cursor_cell_len = cell_len(visible_content_to_cursor)
visible_content_to_cursor_cell_len = self._visible_content_to_cursor_cell_len
cursor_at_end = visible_content_to_cursor_cell_len == available_width
cursor_at_end = self._cursor_at_right_edge
key_cell_len = cell_len(key)
if event.is_printable:
# Check if we'll need to scroll to accommodate the new cell width after insertion.
if visible_content_to_cursor_cell_len + key_cell_len >= available_width:
self.visible_range = start + key_cell_len, end + key_cell_len
self._slide_window(key_cell_len)
self._update_suggestion(event)
elif key == "enter" and self._editor.content:
self.post_message_no_wait(TextInput.Submitted(self, self._editor.content))
@@ -296,12 +328,9 @@ class TextInput(TextWidgetBase, can_focus=True):
window_shift_amount = cell_width_character_to_right
else:
window_shift_amount = 1
self.visible_range = (
start + window_shift_amount,
end + window_shift_amount,
)
self._slide_window(window_shift_amount)
if self._suggestion_suffix and self._editor.cursor_at_end:
self._editor.insert_at_cursor(self._suggestion_suffix)
self._editor.insert(self._suggestion_suffix)
self._suggestion_suffix = ""
self._reset_visible_range()
elif key == "left":
@@ -315,7 +344,7 @@ class TextInput(TextWidgetBase, can_focus=True):
self.app.bell()
elif key == "ctrl+h":
if cursor_index == start and self._editor.query_cursor_left():
self.visible_range = start - 1, end - 1
self._slide_window(-1)
self._update_suggestion(event)
elif key == "ctrl+d":
self._update_suggestion(event)
@@ -420,9 +449,9 @@ class TextAreaChild(TextWidgetBase, can_focus=True):
event.stop()
if event.key == "enter":
self._editor.insert_at_cursor("\n")
self._editor.insert("\n")
elif event.key == "tab":
self._editor.insert_at_cursor("\t")
self._editor.insert("\t")
elif event.key == "escape":
self.app.focused = None

View File

@@ -136,21 +136,21 @@ def test_cursor_text_end_cursor_in_middle():
def test_insert_at_cursor_cursor_at_start():
editor = TextEditorBackend(CONTENT)
assert editor.insert_at_cursor("ABC")
assert editor.insert("ABC")
assert editor.content == "ABC" + CONTENT
assert editor.cursor_index == len("ABC")
def test_insert_at_cursor_cursor_in_middle():
start_cursor_index = 6
editor = TextEditorBackend(CONTENT, start_cursor_index)
assert editor.insert_at_cursor("ABC")
assert editor.insert("ABC")
assert editor.content == "Hello,ABC world!"
assert editor.cursor_index == start_cursor_index + len("ABC")
def test_insert_at_cursor_cursor_at_end():
editor = TextEditorBackend(CONTENT, len(CONTENT))
assert editor.insert_at_cursor("ABC")
assert editor.insert("ABC")
assert editor.content == CONTENT + "ABC"
assert editor.cursor_index == len(editor.content)