diff --git a/src/textual/_ansi_sequences.py b/src/textual/_ansi_sequences.py index 8681115e5..1465ccf13 100644 --- a/src/textual/_ansi_sequences.py +++ b/src/textual/_ansi_sequences.py @@ -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. diff --git a/src/textual/_text_backend.py b/src/textual/_text_backend.py index 8865ca7b1..59e204671 100644 --- a/src/textual/_text_backend.py +++ b/src/textual/_text_backend.py @@ -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. diff --git a/src/textual/_xterm_parser.py b/src/textual/_xterm_parser.py index 5eaa45106..009fb6fe5 100644 --- a/src/textual/_xterm_parser.py +++ b/src/textual/_xterm_parser.py @@ -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+);(?P\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)) diff --git a/src/textual/app.py b/src/textual/app.py index ee96289e0..398f38324 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -111,9 +111,9 @@ class App(Generic[ReturnType], DOMNode): """The base class for Textual Applications""" CSS = """ - App { + App { background: $surface; - color: $text-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) diff --git a/src/textual/driver.py b/src/textual/driver.py index a349540f3..93e1fdd69 100644 --- a/src/textual/driver.py +++ b/src/textual/driver.py @@ -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: ... diff --git a/src/textual/events.py b/src/textual/events.py index d47c1a9a8..c5b96dacb 100644 --- a/src/textual/events.py +++ b/src/textual/events.py @@ -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 diff --git a/src/textual/keys.py b/src/textual/keys.py index b22816d1f..14092a749 100644 --- a/src/textual/keys.py +++ b/src/textual/keys.py @@ -180,7 +180,6 @@ class Keys(str, Enum): CPRResponse = "" Vt100MouseEvent = "" WindowsMouseEvent = "" - BracketedPaste = "" # For internal use: key which is ignored. # (The key binding for this key should not do anything.) diff --git a/src/textual/widgets/text_input.py b/src/textual/widgets/text_input.py index bed64adcd..5e094dc62 100644 --- a/src/textual/widgets/text_input.py +++ b/src/textual/widgets/text_input.py @@ -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 diff --git a/tests/test_text_backend.py b/tests/test_text_backend.py index b4b98a711..bf9296720 100644 --- a/tests/test_text_backend.py +++ b/tests/test_text_backend.py @@ -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)