Format: text/x-python
⬇ Download File (18.2 KB)
   1  """
   2  Module to define and register Terminal IPython shortcuts with
   3  :mod:`prompt_toolkit`
   4  """
   5  
   6  # Copyright (c) IPython Development Team.
   7  # Distributed under the terms of the Modified BSD License.
   8  
   9  import os
  10  import signal
  11  import sys
  12  import warnings
  13  from dataclasses import dataclass
  14  from typing import Callable, Any, Optional, List
  15  
  16  from prompt_toolkit.application.current import get_app
  17  from prompt_toolkit.key_binding import KeyBindings
  18  from prompt_toolkit.key_binding.key_processor import KeyPressEvent
  19  from prompt_toolkit.key_binding.bindings import named_commands as nc
  20  from prompt_toolkit.key_binding.bindings.completion import (
  21      display_completions_like_readline,
  22  )
  23  from prompt_toolkit.key_binding.vi_state import InputMode, ViState
  24  from prompt_toolkit.filters import Condition
  25  
  26  from IPython.core.getipython import get_ipython
  27  from . import auto_match as match
  28  from . import auto_suggest
  29  from .filters import filter_from_string
  30  from IPython.utils.decorators import undoc
  31  
  32  from prompt_toolkit.enums import DEFAULT_BUFFER
  33  
  34  __all__ = ["create_ipython_shortcuts"]
  35  
  36  
  37  @dataclass
  38  class BaseBinding:
  39      command: Callable[[KeyPressEvent], Any]
  40      keys: List[str]
  41  
  42  
  43  @dataclass
  44  class RuntimeBinding(BaseBinding):
  45      filter: Condition
  46  
  47  
  48  @dataclass
  49  class Binding(BaseBinding):
  50      # while filter could be created by referencing variables directly (rather
  51      # than created from strings), by using strings we ensure that users will
  52      # be able to create filters in configuration (e.g. JSON) files too, which
  53      # also benefits the documentation by enforcing human-readable filter names.
  54      condition: Optional[str] = None
  55  
  56      def __post_init__(self):
  57          if self.condition:
  58              self.filter = filter_from_string(self.condition)
  59          else:
  60              self.filter = None
  61  
  62  
  63  def create_identifier(handler: Callable):
  64      parts = handler.__module__.split(".")
  65      name = handler.__name__
  66      package = parts[0]
  67      if len(parts) > 1:
  68          final_module = parts[-1]
  69          return f"{package}:{final_module}.{name}"
  70      else:
  71          return f"{package}:{name}"
  72  
  73  
  74  AUTO_MATCH_BINDINGS = [
  75      *[
  76          Binding(
  77              cmd, [key], "focused_insert & auto_match & followed_by_closing_paren_or_end"
  78          )
  79          for key, cmd in match.auto_match_parens.items()
  80      ],
  81      *[
  82          # raw string
  83          Binding(cmd, [key], "focused_insert & auto_match & preceded_by_raw_str_prefix")
  84          for key, cmd in match.auto_match_parens_raw_string.items()
  85      ],
  86      Binding(
  87          match.double_quote,
  88          ['"'],
  89          "focused_insert"
  90          " & auto_match"
  91          " & not_inside_unclosed_string"
  92          " & preceded_by_paired_double_quotes"
  93          " & followed_by_closing_paren_or_end",
  94      ),
  95      Binding(
  96          match.single_quote,
  97          ["'"],
  98          "focused_insert"
  99          " & auto_match"
 100          " & not_inside_unclosed_string"
 101          " & preceded_by_paired_single_quotes"
 102          " & followed_by_closing_paren_or_end",
 103      ),
 104      Binding(
 105          match.docstring_double_quotes,
 106          ['"'],
 107          "focused_insert"
 108          " & auto_match"
 109          " & not_inside_unclosed_string"
 110          " & preceded_by_two_double_quotes",
 111      ),
 112      Binding(
 113          match.docstring_single_quotes,
 114          ["'"],
 115          "focused_insert"
 116          " & auto_match"
 117          " & not_inside_unclosed_string"
 118          " & preceded_by_two_single_quotes",
 119      ),
 120      Binding(
 121          match.skip_over,
 122          [")"],
 123          "focused_insert & auto_match & followed_by_closing_round_paren",
 124      ),
 125      Binding(
 126          match.skip_over,
 127          ["]"],
 128          "focused_insert & auto_match & followed_by_closing_bracket",
 129      ),
 130      Binding(
 131          match.skip_over,
 132          ["}"],
 133          "focused_insert & auto_match & followed_by_closing_brace",
 134      ),
 135      Binding(
 136          match.skip_over, ['"'], "focused_insert & auto_match & followed_by_double_quote"
 137      ),
 138      Binding(
 139          match.skip_over, ["'"], "focused_insert & auto_match & followed_by_single_quote"
 140      ),
 141      Binding(
 142          match.delete_pair,
 143          ["backspace"],
 144          "focused_insert"
 145          " & preceded_by_opening_round_paren"
 146          " & auto_match"
 147          " & followed_by_closing_round_paren",
 148      ),
 149      Binding(
 150          match.delete_pair,
 151          ["backspace"],
 152          "focused_insert"
 153          " & preceded_by_opening_bracket"
 154          " & auto_match"
 155          " & followed_by_closing_bracket",
 156      ),
 157      Binding(
 158          match.delete_pair,
 159          ["backspace"],
 160          "focused_insert"
 161          " & preceded_by_opening_brace"
 162          " & auto_match"
 163          " & followed_by_closing_brace",
 164      ),
 165      Binding(
 166          match.delete_pair,
 167          ["backspace"],
 168          "focused_insert"
 169          " & preceded_by_double_quote"
 170          " & auto_match"
 171          " & followed_by_double_quote",
 172      ),
 173      Binding(
 174          match.delete_pair,
 175          ["backspace"],
 176          "focused_insert"
 177          " & preceded_by_single_quote"
 178          " & auto_match"
 179          " & followed_by_single_quote",
 180      ),
 181  ]
 182  
 183  AUTO_SUGGEST_BINDINGS = [
 184      # there are two reasons for re-defining bindings defined upstream:
 185      # 1) prompt-toolkit does not execute autosuggestion bindings in vi mode,
 186      # 2) prompt-toolkit checks if we are at the end of text, not end of line
 187      #    hence it does not work in multi-line mode of navigable provider
 188      Binding(
 189          auto_suggest.accept_or_jump_to_end,
 190          ["end"],
 191          "has_suggestion & default_buffer_focused & emacs_like_insert_mode",
 192      ),
 193      Binding(
 194          auto_suggest.accept_or_jump_to_end,
 195          ["c-e"],
 196          "has_suggestion & default_buffer_focused & emacs_like_insert_mode",
 197      ),
 198      Binding(
 199          auto_suggest.accept,
 200          ["c-f"],
 201          "has_suggestion & default_buffer_focused & emacs_like_insert_mode",
 202      ),
 203      Binding(
 204          auto_suggest.accept,
 205          ["right"],
 206          "has_suggestion & default_buffer_focused & emacs_like_insert_mode & is_cursor_at_the_end_of_line",
 207      ),
 208      Binding(
 209          auto_suggest.accept_word,
 210          ["escape", "f"],
 211          "has_suggestion & default_buffer_focused & emacs_like_insert_mode",
 212      ),
 213      Binding(
 214          auto_suggest.accept_token,
 215          ["c-right"],
 216          "has_suggestion & default_buffer_focused & emacs_like_insert_mode",
 217      ),
 218      Binding(
 219          auto_suggest.discard,
 220          ["escape"],
 221          # note this one is using `emacs_insert_mode`, not `emacs_like_insert_mode`
 222          # as in `vi_insert_mode` we do not want `escape` to be shadowed (ever).
 223          "has_suggestion & default_buffer_focused & emacs_insert_mode",
 224      ),
 225      Binding(
 226          auto_suggest.discard,
 227          ["delete"],
 228          "has_suggestion & default_buffer_focused & emacs_insert_mode",
 229      ),
 230      Binding(
 231          auto_suggest.swap_autosuggestion_up,
 232          ["c-up"],
 233          "navigable_suggestions"
 234          " & ~has_line_above"
 235          " & has_suggestion"
 236          " & default_buffer_focused",
 237      ),
 238      Binding(
 239          auto_suggest.swap_autosuggestion_down,
 240          ["c-down"],
 241          "navigable_suggestions"
 242          " & ~has_line_below"
 243          " & has_suggestion"
 244          " & default_buffer_focused",
 245      ),
 246      Binding(
 247          auto_suggest.up_and_update_hint,
 248          ["c-up"],
 249          "has_line_above & navigable_suggestions & default_buffer_focused",
 250      ),
 251      Binding(
 252          auto_suggest.down_and_update_hint,
 253          ["c-down"],
 254          "has_line_below & navigable_suggestions & default_buffer_focused",
 255      ),
 256      Binding(
 257          auto_suggest.accept_character,
 258          ["escape", "right"],
 259          "has_suggestion & default_buffer_focused & emacs_like_insert_mode",
 260      ),
 261      Binding(
 262          auto_suggest.accept_and_move_cursor_left,
 263          ["c-left"],
 264          "has_suggestion & default_buffer_focused & emacs_like_insert_mode",
 265      ),
 266      Binding(
 267          auto_suggest.accept_and_keep_cursor,
 268          ["escape", "down"],
 269          "has_suggestion & default_buffer_focused & emacs_insert_mode",
 270      ),
 271      Binding(
 272          auto_suggest.backspace_and_resume_hint,
 273          ["backspace"],
 274          # no `has_suggestion` here to allow resuming if no suggestion
 275          "default_buffer_focused & emacs_like_insert_mode",
 276      ),
 277      Binding(
 278          auto_suggest.resume_hinting,
 279          ["right"],
 280          "is_cursor_at_the_end_of_line"
 281          " & default_buffer_focused"
 282          " & emacs_like_insert_mode"
 283          " & pass_through",
 284      ),
 285  ]
 286  
 287  
 288  SIMPLE_CONTROL_BINDINGS = [
 289      Binding(cmd, [key], "vi_insert_mode & default_buffer_focused & ebivim")
 290      for key, cmd in {
 291          "c-a": nc.beginning_of_line,
 292          "c-b": nc.backward_char,
 293          "c-k": nc.kill_line,
 294          "c-w": nc.backward_kill_word,
 295          "c-y": nc.yank,
 296          "c-_": nc.undo,
 297      }.items()
 298  ]
 299  
 300  
 301  ALT_AND_COMOBO_CONTROL_BINDINGS = [
 302      Binding(cmd, list(keys), "vi_insert_mode & default_buffer_focused & ebivim")
 303      for keys, cmd in {
 304          # Control Combos
 305          ("c-x", "c-e"): nc.edit_and_execute,
 306          ("c-x", "e"): nc.edit_and_execute,
 307          # Alt
 308          ("escape", "b"): nc.backward_word,
 309          ("escape", "c"): nc.capitalize_word,
 310          ("escape", "d"): nc.kill_word,
 311          ("escape", "h"): nc.backward_kill_word,
 312          ("escape", "l"): nc.downcase_word,
 313          ("escape", "u"): nc.uppercase_word,
 314          ("escape", "y"): nc.yank_pop,
 315          ("escape", "."): nc.yank_last_arg,
 316      }.items()
 317  ]
 318  
 319  
 320  def add_binding(bindings: KeyBindings, binding: Binding):
 321      bindings.add(
 322          *binding.keys,
 323          **({"filter": binding.filter} if binding.filter is not None else {}),
 324      )(binding.command)
 325  
 326  
 327  def create_ipython_shortcuts(shell, skip=None) -> KeyBindings:
 328      """Set up the prompt_toolkit keyboard shortcuts for IPython.
 329  
 330      Parameters
 331      ----------
 332      shell: InteractiveShell
 333          The current IPython shell Instance
 334      skip: List[Binding]
 335          Bindings to skip.
 336  
 337      Returns
 338      -------
 339      KeyBindings
 340          the keybinding instance for prompt toolkit.
 341  
 342      """
 343      kb = KeyBindings()
 344      skip = skip or []
 345      for binding in KEY_BINDINGS:
 346          skip_this_one = False
 347          for to_skip in skip:
 348              if (
 349                  to_skip.command == binding.command
 350                  and to_skip.filter == binding.filter
 351                  and to_skip.keys == binding.keys
 352              ):
 353                  skip_this_one = True
 354                  break
 355          if skip_this_one:
 356              continue
 357          add_binding(kb, binding)
 358  
 359      def get_input_mode(self):
 360          app = get_app()
 361          app.ttimeoutlen = shell.ttimeoutlen
 362          app.timeoutlen = shell.timeoutlen
 363  
 364          return self._input_mode
 365  
 366      def set_input_mode(self, mode):
 367          shape = {InputMode.NAVIGATION: 2, InputMode.REPLACE: 4}.get(mode, 6)
 368          cursor = "\x1b[{} q".format(shape)
 369  
 370          sys.stdout.write(cursor)
 371          sys.stdout.flush()
 372  
 373          self._input_mode = mode
 374  
 375      if shell.editing_mode == "vi" and shell.modal_cursor:
 376          ViState._input_mode = InputMode.INSERT  # type: ignore
 377          ViState.input_mode = property(get_input_mode, set_input_mode)  # type: ignore
 378  
 379      return kb
 380  
 381  
 382  def reformat_and_execute(event):
 383      """Reformat code and execute it"""
 384      shell = get_ipython()
 385      reformat_text_before_cursor(
 386          event.current_buffer, event.current_buffer.document, shell
 387      )
 388      event.current_buffer.validate_and_handle()
 389  
 390  
 391  def reformat_text_before_cursor(buffer, document, shell):
 392      text = buffer.delete_before_cursor(len(document.text[: document.cursor_position]))
 393      try:
 394          formatted_text = shell.reformat_handler(text)
 395          buffer.insert_text(formatted_text)
 396      except Exception as e:
 397          buffer.insert_text(text)
 398  
 399  
 400  def handle_return_or_newline_or_execute(event):
 401      shell = get_ipython()
 402      if getattr(shell, "handle_return", None):
 403          return shell.handle_return(shell)(event)
 404      else:
 405          return newline_or_execute_outer(shell)(event)
 406  
 407  
 408  def newline_or_execute_outer(shell):
 409      def newline_or_execute(event):
 410          """When the user presses return, insert a newline or execute the code."""
 411          b = event.current_buffer
 412          d = b.document
 413  
 414          if b.complete_state:
 415              cc = b.complete_state.current_completion
 416              if cc:
 417                  b.apply_completion(cc)
 418              else:
 419                  b.cancel_completion()
 420              return
 421  
 422          # If there's only one line, treat it as if the cursor is at the end.
 423          # See https://github.com/ipython/ipython/issues/10425
 424          if d.line_count == 1:
 425              check_text = d.text
 426          else:
 427              check_text = d.text[: d.cursor_position]
 428          status, indent = shell.check_complete(check_text)
 429  
 430          # if all we have after the cursor is whitespace: reformat current text
 431          # before cursor
 432          after_cursor = d.text[d.cursor_position :]
 433          reformatted = False
 434          if not after_cursor.strip():
 435              reformat_text_before_cursor(b, d, shell)
 436              reformatted = True
 437          if not (
 438              d.on_last_line
 439              or d.cursor_position_row >= d.line_count - d.empty_line_count_at_the_end()
 440          ):
 441              if shell.autoindent:
 442                  b.insert_text("\n" + indent)
 443              else:
 444                  b.insert_text("\n")
 445              return
 446  
 447          if (status != "incomplete") and b.accept_handler:
 448              if not reformatted:
 449                  reformat_text_before_cursor(b, d, shell)
 450              b.validate_and_handle()
 451          else:
 452              if shell.autoindent:
 453                  b.insert_text("\n" + indent)
 454              else:
 455                  b.insert_text("\n")
 456  
 457      return newline_or_execute
 458  
 459  
 460  def previous_history_or_previous_completion(event):
 461      """
 462      Control-P in vi edit mode on readline is history next, unlike default prompt toolkit.
 463  
 464      If completer is open this still select previous completion.
 465      """
 466      event.current_buffer.auto_up()
 467  
 468  
 469  def next_history_or_next_completion(event):
 470      """
 471      Control-N in vi edit mode on readline is history previous, unlike default prompt toolkit.
 472  
 473      If completer is open this still select next completion.
 474      """
 475      event.current_buffer.auto_down()
 476  
 477  
 478  def dismiss_completion(event):
 479      """Dismiss completion"""
 480      b = event.current_buffer
 481      if b.complete_state:
 482          b.cancel_completion()
 483  
 484  
 485  def reset_buffer(event):
 486      """Reset buffer"""
 487      b = event.current_buffer
 488      if b.complete_state:
 489          b.cancel_completion()
 490      else:
 491          b.reset()
 492  
 493  
 494  def reset_search_buffer(event):
 495      """Reset search buffer"""
 496      if event.current_buffer.document.text:
 497          event.current_buffer.reset()
 498      else:
 499          event.app.layout.focus(DEFAULT_BUFFER)
 500  
 501  
 502  def suspend_to_bg(event):
 503      """Suspend to background"""
 504      event.app.suspend_to_background()
 505  
 506  
 507  def quit(event):
 508      """
 509      Quit application with ``SIGQUIT`` if supported or ``sys.exit`` otherwise.
 510  
 511      On platforms that support SIGQUIT, send SIGQUIT to the current process.
 512      On other platforms, just exit the process with a message.
 513      """
 514      sigquit = getattr(signal, "SIGQUIT", None)
 515      if sigquit is not None:
 516          os.kill(0, signal.SIGQUIT)
 517      else:
 518          sys.exit("Quit")
 519  
 520  
 521  def indent_buffer(event):
 522      """Indent buffer"""
 523      event.current_buffer.insert_text(" " * 4)
 524  
 525  
 526  def newline_autoindent(event):
 527      """Insert a newline after the cursor indented appropriately.
 528  
 529      Fancier version of former ``newline_with_copy_margin`` which should
 530      compute the correct indentation of the inserted line. That is to say, indent
 531      by 4 extra space after a function definition, class definition, context
 532      manager... And dedent by 4 space after ``pass``, ``return``, ``raise ...``.
 533      """
 534      shell = get_ipython()
 535      inputsplitter = shell.input_transformer_manager
 536      b = event.current_buffer
 537      d = b.document
 538  
 539      if b.complete_state:
 540          b.cancel_completion()
 541      text = d.text[: d.cursor_position] + "\n"
 542      _, indent = inputsplitter.check_complete(text)
 543      b.insert_text("\n" + (" " * (indent or 0)), move_cursor=False)
 544  
 545  
 546  def open_input_in_editor(event):
 547      """Open code from input in external editor"""
 548      event.app.current_buffer.open_in_editor()
 549  
 550  
 551  if sys.platform == "win32":
 552      from IPython.core.error import TryNext
 553      from IPython.lib.clipboard import (
 554          ClipboardEmpty,
 555          tkinter_clipboard_get,
 556          win32_clipboard_get,
 557      )
 558  
 559      @undoc
 560      def win_paste(event):
 561          try:
 562              text = win32_clipboard_get()
 563          except TryNext:
 564              try:
 565                  text = tkinter_clipboard_get()
 566              except (TryNext, ClipboardEmpty):
 567                  return
 568          except ClipboardEmpty:
 569              return
 570          event.current_buffer.insert_text(text.replace("\t", " " * 4))
 571  
 572  else:
 573  
 574      @undoc
 575      def win_paste(event):
 576          """Stub used on other platforms"""
 577          pass
 578  
 579  
 580  KEY_BINDINGS = [
 581      Binding(
 582          handle_return_or_newline_or_execute,
 583          ["enter"],
 584          "default_buffer_focused & ~has_selection & insert_mode",
 585      ),
 586      Binding(
 587          reformat_and_execute,
 588          ["escape", "enter"],
 589          "default_buffer_focused & ~has_selection & insert_mode & ebivim",
 590      ),
 591      Binding(quit, ["c-\\"]),
 592      Binding(
 593          previous_history_or_previous_completion,
 594          ["c-p"],
 595          "vi_insert_mode & default_buffer_focused",
 596      ),
 597      Binding(
 598          next_history_or_next_completion,
 599          ["c-n"],
 600          "vi_insert_mode & default_buffer_focused",
 601      ),
 602      Binding(dismiss_completion, ["c-g"], "default_buffer_focused & has_completions"),
 603      Binding(reset_buffer, ["c-c"], "default_buffer_focused"),
 604      Binding(reset_search_buffer, ["c-c"], "search_buffer_focused"),
 605      Binding(suspend_to_bg, ["c-z"], "supports_suspend"),
 606      Binding(
 607          indent_buffer,
 608          ["tab"],  # Ctrl+I == Tab
 609          "default_buffer_focused"
 610          " & ~has_selection"
 611          " & insert_mode"
 612          " & cursor_in_leading_ws",
 613      ),
 614      Binding(newline_autoindent, ["c-o"], "default_buffer_focused & emacs_insert_mode"),
 615      Binding(open_input_in_editor, ["f2"], "default_buffer_focused"),
 616      *AUTO_MATCH_BINDINGS,
 617      *AUTO_SUGGEST_BINDINGS,
 618      Binding(
 619          display_completions_like_readline,
 620          ["c-i"],
 621          "readline_like_completions"
 622          " & default_buffer_focused"
 623          " & ~has_selection"
 624          " & insert_mode"
 625          " & ~cursor_in_leading_ws",
 626      ),
 627      Binding(win_paste, ["c-v"], "default_buffer_focused & ~vi_mode & is_windows_os"),
 628      *SIMPLE_CONTROL_BINDINGS,
 629      *ALT_AND_COMOBO_CONTROL_BINDINGS,
 630  ]
 631  
 632  UNASSIGNED_ALLOWED_COMMANDS = [
 633      auto_suggest.llm_autosuggestion,
 634      nc.beginning_of_buffer,
 635      nc.end_of_buffer,
 636      nc.end_of_line,
 637      nc.forward_word,
 638      nc.unix_line_discard,
 639  ]
 640