""" Module to define and register Terminal IPython shortcuts with :mod:`prompt_toolkit` """ # Copyright (c) IPython Development Team. # Distributed under the terms of the Modified BSD License. import os import signal import sys import warnings from dataclasses import dataclass from typing import Any, Optional, List from collections.abc import Callable from prompt_toolkit.application.current import get_app from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.key_binding.key_processor import KeyPressEvent from prompt_toolkit.key_binding.bindings import named_commands as nc from prompt_toolkit.key_binding.bindings.completion import ( display_completions_like_readline, ) from prompt_toolkit.key_binding.vi_state import InputMode, ViState from prompt_toolkit.filters import Condition from IPython.core.getipython import get_ipython from . import auto_match as match from . import auto_suggest from .filters import filter_from_string from IPython.utils.decorators import undoc from prompt_toolkit.enums import DEFAULT_BUFFER __all__ = ["create_ipython_shortcuts"] @dataclass class BaseBinding: command: Callable[[KeyPressEvent], Any] keys: List[str] @dataclass class RuntimeBinding(BaseBinding): filter: Condition @dataclass class Binding(BaseBinding): # while filter could be created by referencing variables directly (rather # than created from strings), by using strings we ensure that users will # be able to create filters in configuration (e.g. JSON) files too, which # also benefits the documentation by enforcing human-readable filter names. condition: Optional[str] = None def __post_init__(self): if self.condition: self.filter = filter_from_string(self.condition) else: self.filter = None def create_identifier(handler: Callable): parts = handler.__module__.split(".") name = handler.__name__ package = parts[0] if len(parts) > 1: final_module = parts[-1] return f"{package}:{final_module}.{name}" else: return f"{package}:{name}" AUTO_MATCH_BINDINGS = [ *[ Binding( cmd, [key], "focused_insert & auto_match & followed_by_closing_paren_or_end" ) for key, cmd in match.auto_match_parens.items() ], *[ # raw string Binding(cmd, [key], "focused_insert & auto_match & preceded_by_raw_str_prefix") for key, cmd in match.auto_match_parens_raw_string.items() ], Binding( match.double_quote, ['"'], "focused_insert" " & auto_match" " & not_inside_unclosed_string" " & preceded_by_paired_double_quotes" " & followed_by_closing_paren_or_end", ), Binding( match.single_quote, ["'"], "focused_insert" " & auto_match" " & not_inside_unclosed_string" " & preceded_by_paired_single_quotes" " & followed_by_closing_paren_or_end", ), Binding( match.docstring_double_quotes, ['"'], "focused_insert" " & auto_match" " & not_inside_unclosed_string" " & preceded_by_two_double_quotes", ), Binding( match.docstring_single_quotes, ["'"], "focused_insert" " & auto_match" " & not_inside_unclosed_string" " & preceded_by_two_single_quotes", ), Binding( match.skip_over, [")"], "focused_insert & auto_match & followed_by_closing_round_paren", ), Binding( match.skip_over, ["]"], "focused_insert & auto_match & followed_by_closing_bracket", ), Binding( match.skip_over, ["}"], "focused_insert & auto_match & followed_by_closing_brace", ), Binding( match.skip_over, ['"'], "focused_insert & auto_match & followed_by_double_quote" ), Binding( match.skip_over, ["'"], "focused_insert & auto_match & followed_by_single_quote" ), Binding( match.delete_pair, ["backspace"], "focused_insert" " & preceded_by_opening_round_paren" " & auto_match" " & followed_by_closing_round_paren", ), Binding( match.delete_pair, ["backspace"], "focused_insert" " & preceded_by_opening_bracket" " & auto_match" " & followed_by_closing_bracket", ), Binding( match.delete_pair, ["backspace"], "focused_insert" " & preceded_by_opening_brace" " & auto_match" " & followed_by_closing_brace", ), Binding( match.delete_pair, ["backspace"], "focused_insert" " & preceded_by_double_quote" " & auto_match" " & followed_by_double_quote", ), Binding( match.delete_pair, ["backspace"], "focused_insert" " & preceded_by_single_quote" " & auto_match" " & followed_by_single_quote", ), ] AUTO_SUGGEST_BINDINGS = [ # there are two reasons for re-defining bindings defined upstream: # 1) prompt-toolkit does not execute autosuggestion bindings in vi mode, # 2) prompt-toolkit checks if we are at the end of text, not end of line # hence it does not work in multi-line mode of navigable provider Binding( auto_suggest.accept_or_jump_to_end, ["end"], "has_suggestion & default_buffer_focused & emacs_like_insert_mode", ), Binding( auto_suggest.accept_or_jump_to_end, ["c-e"], "has_suggestion & default_buffer_focused & emacs_like_insert_mode", ), Binding( auto_suggest.accept, ["c-f"], "has_suggestion & default_buffer_focused & emacs_like_insert_mode", ), Binding( auto_suggest.accept, ["right"], "has_suggestion & default_buffer_focused & emacs_like_insert_mode & is_cursor_at_the_end_of_line", ), Binding( auto_suggest.accept_word, ["escape", "f"], "has_suggestion & default_buffer_focused & emacs_like_insert_mode", ), Binding( auto_suggest.accept_token, ["c-right"], "has_suggestion & default_buffer_focused & emacs_like_insert_mode", ), Binding( auto_suggest.discard, ["escape"], # note this one is using `emacs_insert_mode`, not `emacs_like_insert_mode` # as in `vi_insert_mode` we do not want `escape` to be shadowed (ever). "has_suggestion & default_buffer_focused & emacs_insert_mode", ), Binding( auto_suggest.discard, ["delete"], "has_suggestion & default_buffer_focused & emacs_insert_mode", ), Binding( auto_suggest.swap_autosuggestion_up, ["c-up"], "navigable_suggestions" " & ~has_line_above" " & has_suggestion" " & default_buffer_focused", ), Binding( auto_suggest.swap_autosuggestion_down, ["c-down"], "navigable_suggestions" " & ~has_line_below" " & has_suggestion" " & default_buffer_focused", ), Binding( auto_suggest.up_and_update_hint, ["c-up"], "has_line_above & navigable_suggestions & default_buffer_focused", ), Binding( auto_suggest.down_and_update_hint, ["c-down"], "has_line_below & navigable_suggestions & default_buffer_focused", ), Binding( auto_suggest.accept_character, ["escape", "right"], "has_suggestion & default_buffer_focused & emacs_like_insert_mode", ), Binding( auto_suggest.accept_and_move_cursor_left, ["c-left"], "has_suggestion & default_buffer_focused & emacs_like_insert_mode", ), Binding( auto_suggest.accept_and_keep_cursor, ["escape", "down"], "has_suggestion & default_buffer_focused & emacs_insert_mode", ), Binding( auto_suggest.backspace_and_resume_hint, ["backspace"], # no `has_suggestion` here to allow resuming if no suggestion "default_buffer_focused & emacs_like_insert_mode", ), Binding( auto_suggest.resume_hinting, ["right"], "is_cursor_at_the_end_of_line" " & default_buffer_focused" " & emacs_like_insert_mode" " & pass_through", ), ] SIMPLE_CONTROL_BINDINGS = [ Binding(cmd, [key], "vi_insert_mode & default_buffer_focused & ebivim") for key, cmd in { "c-a": nc.beginning_of_line, "c-b": nc.backward_char, "c-k": nc.kill_line, "c-w": nc.backward_kill_word, "c-y": nc.yank, "c-_": nc.undo, }.items() ] ALT_AND_COMOBO_CONTROL_BINDINGS = [ Binding(cmd, list(keys), "vi_insert_mode & default_buffer_focused & ebivim") for keys, cmd in { # Control Combos ("c-x", "c-e"): nc.edit_and_execute, ("c-x", "e"): nc.edit_and_execute, # Alt ("escape", "b"): nc.backward_word, ("escape", "c"): nc.capitalize_word, ("escape", "d"): nc.kill_word, ("escape", "h"): nc.backward_kill_word, ("escape", "l"): nc.downcase_word, ("escape", "u"): nc.uppercase_word, ("escape", "y"): nc.yank_pop, ("escape", "."): nc.yank_last_arg, }.items() ] def add_binding(bindings: KeyBindings, binding: Binding): bindings.add( *binding.keys, **({"filter": binding.filter} if binding.filter is not None else {}), )(binding.command) def create_ipython_shortcuts(shell, skip=None) -> KeyBindings: """Set up the prompt_toolkit keyboard shortcuts for IPython. Parameters ---------- shell: InteractiveShell The current IPython shell Instance skip: List[Binding] Bindings to skip. Returns ------- KeyBindings the keybinding instance for prompt toolkit. """ kb = KeyBindings() skip = skip or [] for binding in KEY_BINDINGS: skip_this_one = False for to_skip in skip: if ( to_skip.command == binding.command and to_skip.filter == binding.filter and to_skip.keys == binding.keys ): skip_this_one = True break if skip_this_one: continue add_binding(kb, binding) def get_input_mode(self): app = get_app() app.ttimeoutlen = shell.ttimeoutlen app.timeoutlen = shell.timeoutlen return self._input_mode def set_input_mode(self, mode): shape = {InputMode.NAVIGATION: 2, InputMode.REPLACE: 4}.get(mode, 6) cursor = "\x1b[{} q".format(shape) sys.stdout.write(cursor) sys.stdout.flush() self._input_mode = mode if shell.editing_mode == "vi" and shell.modal_cursor: ViState._input_mode = InputMode.INSERT # type: ignore ViState.input_mode = property(get_input_mode, set_input_mode) # type: ignore return kb def reformat_and_execute(event): """Reformat code and execute it""" shell = get_ipython() reformat_text_before_cursor( event.current_buffer, event.current_buffer.document, shell ) event.current_buffer.validate_and_handle() def reformat_text_before_cursor(buffer, document, shell): text = buffer.delete_before_cursor(len(document.text[: document.cursor_position])) try: formatted_text = shell.reformat_handler(text) buffer.insert_text(formatted_text) except Exception as e: buffer.insert_text(text) def handle_return_or_newline_or_execute(event): shell = get_ipython() if getattr(shell, "handle_return", None): return shell.handle_return(shell)(event) else: return newline_or_execute_outer(shell)(event) def newline_or_execute_outer(shell): def newline_or_execute(event): """When the user presses return, insert a newline or execute the code.""" b = event.current_buffer d = b.document if b.complete_state: cc = b.complete_state.current_completion if cc: b.apply_completion(cc) else: b.cancel_completion() return # If there's only one line, treat it as if the cursor is at the end. # See https://github.com/ipython/ipython/issues/10425 if d.line_count == 1: check_text = d.text else: check_text = d.text[: d.cursor_position] status, indent = shell.check_complete(check_text) # if all we have after the cursor is whitespace: reformat current text # before cursor after_cursor = d.text[d.cursor_position :] reformatted = False if not after_cursor.strip(): reformat_text_before_cursor(b, d, shell) reformatted = True if not ( d.on_last_line or d.cursor_position_row >= d.line_count - d.empty_line_count_at_the_end() ): if shell.autoindent: b.insert_text("\n" + indent) else: b.insert_text("\n") return if (status != "incomplete") and b.accept_handler: if not reformatted: reformat_text_before_cursor(b, d, shell) b.validate_and_handle() else: if shell.autoindent: b.insert_text("\n" + indent) else: b.insert_text("\n") return newline_or_execute def previous_history_or_previous_completion(event): """ Control-P in vi edit mode on readline is history next, unlike default prompt toolkit. If completer is open this still select previous completion. """ event.current_buffer.auto_up() def next_history_or_next_completion(event): """ Control-N in vi edit mode on readline is history previous, unlike default prompt toolkit. If completer is open this still select next completion. """ event.current_buffer.auto_down() def dismiss_completion(event): """Dismiss completion""" b = event.current_buffer if b.complete_state: b.cancel_completion() def reset_buffer(event): """Reset buffer""" b = event.current_buffer if b.complete_state: b.cancel_completion() else: b.reset() def reset_search_buffer(event): """Reset search buffer""" if event.current_buffer.document.text: event.current_buffer.reset() else: event.app.layout.focus(DEFAULT_BUFFER) def suspend_to_bg(event): """Suspend to background""" event.app.suspend_to_background() def quit(event): """ Quit application with ``SIGQUIT`` if supported or ``sys.exit`` otherwise. On platforms that support SIGQUIT, send SIGQUIT to the current process. On other platforms, just exit the process with a message. """ sigquit = getattr(signal, "SIGQUIT", None) if sigquit is not None: os.kill(0, signal.SIGQUIT) else: sys.exit("Quit") def indent_buffer(event): """Indent buffer""" event.current_buffer.insert_text(" " * 4) def newline_autoindent(event): """Insert a newline after the cursor indented appropriately. Fancier version of former ``newline_with_copy_margin`` which should compute the correct indentation of the inserted line. That is to say, indent by 4 extra space after a function definition, class definition, context manager... And dedent by 4 space after ``pass``, ``return``, ``raise ...``. """ shell = get_ipython() inputsplitter = shell.input_transformer_manager b = event.current_buffer d = b.document if b.complete_state: b.cancel_completion() text = d.text[: d.cursor_position] + "\n" _, indent = inputsplitter.check_complete(text) b.insert_text("\n" + (" " * (indent or 0)), move_cursor=False) def open_input_in_editor(event): """Open code from input in external editor""" event.app.current_buffer.open_in_editor() if sys.platform == "win32": from IPython.core.error import TryNext from IPython.lib.clipboard import ( ClipboardEmpty, tkinter_clipboard_get, win32_clipboard_get, ) @undoc def win_paste(event): try: text = win32_clipboard_get() except TryNext: try: text = tkinter_clipboard_get() except (TryNext, ClipboardEmpty): return except ClipboardEmpty: return event.current_buffer.insert_text(text.replace("\t", " " * 4)) else: @undoc def win_paste(event): """Stub used on other platforms""" pass KEY_BINDINGS = [ Binding( handle_return_or_newline_or_execute, ["enter"], "default_buffer_focused & ~has_selection & insert_mode", ), Binding( reformat_and_execute, ["escape", "enter"], "default_buffer_focused & ~has_selection & insert_mode & ebivim", ), Binding(quit, ["c-\\"]), Binding( previous_history_or_previous_completion, ["c-p"], "vi_insert_mode & default_buffer_focused", ), Binding( next_history_or_next_completion, ["c-n"], "vi_insert_mode & default_buffer_focused", ), Binding(dismiss_completion, ["c-g"], "default_buffer_focused & has_completions"), Binding(reset_buffer, ["c-c"], "default_buffer_focused"), Binding(reset_search_buffer, ["c-c"], "search_buffer_focused"), Binding(suspend_to_bg, ["c-z"], "supports_suspend"), Binding( indent_buffer, ["tab"], # Ctrl+I == Tab "default_buffer_focused & ~has_selection & insert_mode & cursor_in_leading_ws", ), Binding(newline_autoindent, ["c-o"], "default_buffer_focused & emacs_insert_mode"), Binding(open_input_in_editor, ["f2"], "default_buffer_focused"), *AUTO_MATCH_BINDINGS, *AUTO_SUGGEST_BINDINGS, Binding( display_completions_like_readline, ["c-i"], "readline_like_completions" " & default_buffer_focused" " & ~has_selection" " & insert_mode" " & ~cursor_in_leading_ws", ), Binding(win_paste, ["c-v"], "default_buffer_focused & ~vi_mode & is_windows_os"), *SIMPLE_CONTROL_BINDINGS, *ALT_AND_COMOBO_CONTROL_BINDINGS, ] UNASSIGNED_ALLOWED_COMMANDS = [ auto_suggest.llm_autosuggestion, nc.beginning_of_buffer, nc.end_of_buffer, nc.end_of_line, nc.forward_char, nc.forward_word, nc.unix_line_discard, nc.unix_word_rubout, ]