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