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