##// END OF EJS Templates
Allow to customise shortcuts using a traitlet (#13928)...
Matthias Bussonnier -
r28115:442c33cf merge
parent child Browse files
Show More
@@ -0,0 +1,256 b''
1 """
2 Filters restricting scope of IPython Terminal shortcuts.
3 """
4
5 # Copyright (c) IPython Development Team.
6 # Distributed under the terms of the Modified BSD License.
7
8 import ast
9 import re
10 import signal
11 import sys
12 from typing import Callable, Dict, Union
13
14 from prompt_toolkit.application.current import get_app
15 from prompt_toolkit.enums import DEFAULT_BUFFER, SEARCH_BUFFER
16 from prompt_toolkit.filters import Condition, emacs_insert_mode, has_completions
17 from prompt_toolkit.filters import has_focus as has_focus_impl
18 from prompt_toolkit.filters import (
19 Always,
20 has_selection,
21 has_suggestion,
22 vi_insert_mode,
23 vi_mode,
24 )
25 from prompt_toolkit.layout.layout import FocusableElement
26
27 from IPython.core.getipython import get_ipython
28 from IPython.core.guarded_eval import _find_dunder, BINARY_OP_DUNDERS, UNARY_OP_DUNDERS
29 from IPython.terminal.shortcuts import auto_suggest
30 from IPython.utils.decorators import undoc
31
32
33 @undoc
34 @Condition
35 def cursor_in_leading_ws():
36 before = get_app().current_buffer.document.current_line_before_cursor
37 return (not before) or before.isspace()
38
39
40 def has_focus(value: FocusableElement):
41 """Wrapper around has_focus adding a nice `__name__` to tester function"""
42 tester = has_focus_impl(value).func
43 tester.__name__ = f"is_focused({value})"
44 return Condition(tester)
45
46
47 @undoc
48 @Condition
49 def has_line_below() -> bool:
50 document = get_app().current_buffer.document
51 return document.cursor_position_row < len(document.lines) - 1
52
53
54 @undoc
55 @Condition
56 def has_line_above() -> bool:
57 document = get_app().current_buffer.document
58 return document.cursor_position_row != 0
59
60
61 @Condition
62 def ebivim():
63 shell = get_ipython()
64 return shell.emacs_bindings_in_vi_insert_mode
65
66
67 @Condition
68 def supports_suspend():
69 return hasattr(signal, "SIGTSTP")
70
71
72 @Condition
73 def auto_match():
74 shell = get_ipython()
75 return shell.auto_match
76
77
78 def all_quotes_paired(quote, buf):
79 paired = True
80 i = 0
81 while i < len(buf):
82 c = buf[i]
83 if c == quote:
84 paired = not paired
85 elif c == "\\":
86 i += 1
87 i += 1
88 return paired
89
90
91 _preceding_text_cache: Dict[Union[str, Callable], Condition] = {}
92 _following_text_cache: Dict[Union[str, Callable], Condition] = {}
93
94
95 def preceding_text(pattern: Union[str, Callable]):
96 if pattern in _preceding_text_cache:
97 return _preceding_text_cache[pattern]
98
99 if callable(pattern):
100
101 def _preceding_text():
102 app = get_app()
103 before_cursor = app.current_buffer.document.current_line_before_cursor
104 # mypy can't infer if(callable): https://github.com/python/mypy/issues/3603
105 return bool(pattern(before_cursor)) # type: ignore[operator]
106
107 else:
108 m = re.compile(pattern)
109
110 def _preceding_text():
111 app = get_app()
112 before_cursor = app.current_buffer.document.current_line_before_cursor
113 return bool(m.match(before_cursor))
114
115 _preceding_text.__name__ = f"preceding_text({pattern!r})"
116
117 condition = Condition(_preceding_text)
118 _preceding_text_cache[pattern] = condition
119 return condition
120
121
122 def following_text(pattern):
123 try:
124 return _following_text_cache[pattern]
125 except KeyError:
126 pass
127 m = re.compile(pattern)
128
129 def _following_text():
130 app = get_app()
131 return bool(m.match(app.current_buffer.document.current_line_after_cursor))
132
133 _following_text.__name__ = f"following_text({pattern!r})"
134
135 condition = Condition(_following_text)
136 _following_text_cache[pattern] = condition
137 return condition
138
139
140 @Condition
141 def not_inside_unclosed_string():
142 app = get_app()
143 s = app.current_buffer.document.text_before_cursor
144 # remove escaped quotes
145 s = s.replace('\\"', "").replace("\\'", "")
146 # remove triple-quoted string literals
147 s = re.sub(r"(?:\"\"\"[\s\S]*\"\"\"|'''[\s\S]*''')", "", s)
148 # remove single-quoted string literals
149 s = re.sub(r"""(?:"[^"]*["\n]|'[^']*['\n])""", "", s)
150 return not ('"' in s or "'" in s)
151
152
153 @Condition
154 def navigable_suggestions():
155 shell = get_ipython()
156 return isinstance(shell.auto_suggest, auto_suggest.NavigableAutoSuggestFromHistory)
157
158
159 @Condition
160 def readline_like_completions():
161 shell = get_ipython()
162 return shell.display_completions == "readlinelike"
163
164
165 @Condition
166 def is_windows_os():
167 return sys.platform == "win32"
168
169
170 # these one is callable and re-used multiple times hence needs to be
171 # only defined once beforhand so that transforming back to human-readable
172 # names works well in the documentation.
173 default_buffer_focused = has_focus(DEFAULT_BUFFER)
174
175 KEYBINDING_FILTERS = {
176 "always": Always(),
177 "has_line_below": has_line_below,
178 "has_line_above": has_line_above,
179 "has_selection": has_selection,
180 "has_suggestion": has_suggestion,
181 "vi_mode": vi_mode,
182 "vi_insert_mode": vi_insert_mode,
183 "emacs_insert_mode": emacs_insert_mode,
184 "has_completions": has_completions,
185 "insert_mode": vi_insert_mode | emacs_insert_mode,
186 "default_buffer_focused": default_buffer_focused,
187 "search_buffer_focused": has_focus(SEARCH_BUFFER),
188 "ebivim": ebivim,
189 "supports_suspend": supports_suspend,
190 "is_windows_os": is_windows_os,
191 "auto_match": auto_match,
192 "focused_insert": (vi_insert_mode | emacs_insert_mode) & default_buffer_focused,
193 "not_inside_unclosed_string": not_inside_unclosed_string,
194 "readline_like_completions": readline_like_completions,
195 "preceded_by_paired_double_quotes": preceding_text(
196 lambda line: all_quotes_paired('"', line)
197 ),
198 "preceded_by_paired_single_quotes": preceding_text(
199 lambda line: all_quotes_paired("'", line)
200 ),
201 "preceded_by_raw_str_prefix": preceding_text(r".*(r|R)[\"'](-*)$"),
202 "preceded_by_two_double_quotes": preceding_text(r'^.*""$'),
203 "preceded_by_two_single_quotes": preceding_text(r"^.*''$"),
204 "followed_by_closing_paren_or_end": following_text(r"[,)}\]]|$"),
205 "preceded_by_opening_round_paren": preceding_text(r".*\($"),
206 "preceded_by_opening_bracket": preceding_text(r".*\[$"),
207 "preceded_by_opening_brace": preceding_text(r".*\{$"),
208 "preceded_by_double_quote": preceding_text('.*"$'),
209 "preceded_by_single_quote": preceding_text(r".*'$"),
210 "followed_by_closing_round_paren": following_text(r"^\)"),
211 "followed_by_closing_bracket": following_text(r"^\]"),
212 "followed_by_closing_brace": following_text(r"^\}"),
213 "followed_by_double_quote": following_text('^"'),
214 "followed_by_single_quote": following_text("^'"),
215 "navigable_suggestions": navigable_suggestions,
216 "cursor_in_leading_ws": cursor_in_leading_ws,
217 }
218
219
220 def eval_node(node: Union[ast.AST, None]):
221 if node is None:
222 return None
223 if isinstance(node, ast.Expression):
224 return eval_node(node.body)
225 if isinstance(node, ast.BinOp):
226 left = eval_node(node.left)
227 right = eval_node(node.right)
228 dunders = _find_dunder(node.op, BINARY_OP_DUNDERS)
229 if dunders:
230 return getattr(left, dunders[0])(right)
231 raise ValueError(f"Unknown binary operation: {node.op}")
232 if isinstance(node, ast.UnaryOp):
233 value = eval_node(node.operand)
234 dunders = _find_dunder(node.op, UNARY_OP_DUNDERS)
235 if dunders:
236 return getattr(value, dunders[0])()
237 raise ValueError(f"Unknown unary operation: {node.op}")
238 if isinstance(node, ast.Name):
239 if node.id in KEYBINDING_FILTERS:
240 return KEYBINDING_FILTERS[node.id]
241 else:
242 sep = "\n - "
243 known_filters = sep.join(sorted(KEYBINDING_FILTERS))
244 raise NameError(
245 f"{node.id} is not a known shortcut filter."
246 f" Known filters are: {sep}{known_filters}."
247 )
248 raise ValueError("Unhandled node", ast.dump(node))
249
250
251 def filter_from_string(code: str):
252 expression = ast.parse(code, mode="eval")
253 return eval_node(expression)
254
255
256 __all__ = ["KEYBINDING_FILTERS", "filter_from_string"]
@@ -0,0 +1,21 b''
1 Terminal shortcuts customization
2 ================================
3
4 Previously modifying shortcuts was only possible by hooking into startup files
5 and practically limited to adding new shortcuts or removing all shortcuts bound
6 to a specific key. This release enables users to override existing terminal
7 shortcuts, disable them or add new keybindings.
8
9 For example, to set the :kbd:`right` to accept a single character of auto-suggestion
10 you could use::
11
12 my_shortcuts = [
13 {
14 "command": "IPython:auto_suggest.accept_character",
15 "new_keys": ["right"]
16 }
17 ]
18 %config TerminalInteractiveShell.shortcuts = my_shortcuts
19
20 You can learn more in :std:configtrait:`TerminalInteractiveShell.shortcuts`
21 configuration reference. No newline at end of file
@@ -16,6 +16,7 b' from traitlets import ('
16 Unicode,
16 Unicode,
17 Dict,
17 Dict,
18 Integer,
18 Integer,
19 List,
19 observe,
20 observe,
20 Instance,
21 Instance,
21 Type,
22 Type,
@@ -29,7 +30,7 b' from traitlets import ('
29
30
30 from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
31 from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
31 from prompt_toolkit.enums import DEFAULT_BUFFER, EditingMode
32 from prompt_toolkit.enums import DEFAULT_BUFFER, EditingMode
32 from prompt_toolkit.filters import (HasFocus, Condition, IsDone)
33 from prompt_toolkit.filters import HasFocus, Condition, IsDone
33 from prompt_toolkit.formatted_text import PygmentsTokens
34 from prompt_toolkit.formatted_text import PygmentsTokens
34 from prompt_toolkit.history import History
35 from prompt_toolkit.history import History
35 from prompt_toolkit.layout.processors import ConditionalProcessor, HighlightMatchingBracketProcessor
36 from prompt_toolkit.layout.processors import ConditionalProcessor, HighlightMatchingBracketProcessor
@@ -49,7 +50,13 b' from .magics import TerminalMagics'
49 from .pt_inputhooks import get_inputhook_name_and_func
50 from .pt_inputhooks import get_inputhook_name_and_func
50 from .prompts import Prompts, ClassicPrompts, RichPromptDisplayHook
51 from .prompts import Prompts, ClassicPrompts, RichPromptDisplayHook
51 from .ptutils import IPythonPTCompleter, IPythonPTLexer
52 from .ptutils import IPythonPTCompleter, IPythonPTLexer
52 from .shortcuts import create_ipython_shortcuts
53 from .shortcuts import (
54 create_ipython_shortcuts,
55 create_identifier,
56 RuntimeBinding,
57 add_binding,
58 )
59 from .shortcuts.filters import KEYBINDING_FILTERS, filter_from_string
53 from .shortcuts.auto_suggest import (
60 from .shortcuts.auto_suggest import (
54 NavigableAutoSuggestFromHistory,
61 NavigableAutoSuggestFromHistory,
55 AppendAutoSuggestionInAnyLine,
62 AppendAutoSuggestionInAnyLine,
@@ -415,6 +422,165 b' class TerminalInteractiveShell(InteractiveShell):'
415 provider = change.new
422 provider = change.new
416 self._set_autosuggestions(provider)
423 self._set_autosuggestions(provider)
417
424
425 shortcuts = List(
426 trait=Dict(
427 key_trait=Enum(
428 [
429 "command",
430 "match_keys",
431 "match_filter",
432 "new_keys",
433 "new_filter",
434 "create",
435 ]
436 ),
437 per_key_traits={
438 "command": Unicode(),
439 "match_keys": List(Unicode()),
440 "match_filter": Unicode(),
441 "new_keys": List(Unicode()),
442 "new_filter": Unicode(),
443 "create": Bool(False),
444 },
445 ),
446 help="""Add, disable or modifying shortcuts.
447
448 Each entry on the list should be a dictionary with ``command`` key
449 identifying the target function executed by the shortcut and at least
450 one of the following:
451
452 - ``match_keys``: list of keys used to match an existing shortcut,
453 - ``match_filter``: shortcut filter used to match an existing shortcut,
454 - ``new_keys``: list of keys to set,
455 - ``new_filter``: a new shortcut filter to set
456
457 The filters have to be composed of pre-defined verbs and joined by one
458 of the following conjunctions: ``&`` (and), ``|`` (or), ``~`` (not).
459 The pre-defined verbs are:
460
461 {}
462
463
464 To disable a shortcut set ``new_keys`` to an empty list.
465 To add a shortcut add key ``create`` with value ``True``.
466
467 When modifying/disabling shortcuts, ``match_keys``/``match_filter`` can
468 be omitted if the provided specification uniquely identifies a shortcut
469 to be modified/disabled. When modifying a shortcut ``new_filter`` or
470 ``new_keys`` can be omitted which will result in reuse of the existing
471 filter/keys.
472
473 Only shortcuts defined in IPython (and not default prompt-toolkit
474 shortcuts) can be modified or disabled. The full list of shortcuts,
475 command identifiers and filters is available under
476 :ref:`terminal-shortcuts-list`.
477 """.format(
478 "\n ".join([f"- `{k}`" for k in KEYBINDING_FILTERS])
479 ),
480 ).tag(config=True)
481
482 @observe("shortcuts")
483 def _shortcuts_changed(self, change):
484 if self.pt_app:
485 self.pt_app.key_bindings = self._merge_shortcuts(user_shortcuts=change.new)
486
487 def _merge_shortcuts(self, user_shortcuts):
488 # rebuild the bindings list from scratch
489 key_bindings = create_ipython_shortcuts(self)
490
491 # for now we only allow adding shortcuts for commands which are already
492 # registered; this is a security precaution.
493 known_commands = {
494 create_identifier(binding.handler): binding.handler
495 for binding in key_bindings.bindings
496 }
497 shortcuts_to_skip = []
498 shortcuts_to_add = []
499
500 for shortcut in user_shortcuts:
501 command_id = shortcut["command"]
502 if command_id not in known_commands:
503 allowed_commands = "\n - ".join(known_commands)
504 raise ValueError(
505 f"{command_id} is not a known shortcut command."
506 f" Allowed commands are: \n - {allowed_commands}"
507 )
508 old_keys = shortcut.get("match_keys", None)
509 old_filter = (
510 filter_from_string(shortcut["match_filter"])
511 if "match_filter" in shortcut
512 else None
513 )
514 matching = [
515 binding
516 for binding in key_bindings.bindings
517 if (
518 (old_filter is None or binding.filter == old_filter)
519 and (old_keys is None or [k for k in binding.keys] == old_keys)
520 and create_identifier(binding.handler) == command_id
521 )
522 ]
523
524 new_keys = shortcut.get("new_keys", None)
525 new_filter = shortcut.get("new_filter", None)
526
527 command = known_commands[command_id]
528
529 creating_new = shortcut.get("create", False)
530 modifying_existing = not creating_new and (
531 new_keys is not None or new_filter
532 )
533
534 if creating_new and new_keys == []:
535 raise ValueError("Cannot add a shortcut without keys")
536
537 if modifying_existing:
538 specification = {
539 key: shortcut[key]
540 for key in ["command", "filter"]
541 if key in shortcut
542 }
543 if len(matching) == 0:
544 raise ValueError(f"No shortcuts matching {specification} found")
545 elif len(matching) > 1:
546 raise ValueError(
547 f"Multiple shortcuts matching {specification} found,"
548 f" please add keys/filter to select one of: {matching}"
549 )
550
551 matched = matching[0]
552 old_filter = matched.filter
553 old_keys = list(matched.keys)
554 shortcuts_to_skip.append(
555 RuntimeBinding(
556 command,
557 keys=old_keys,
558 filter=old_filter,
559 )
560 )
561
562 if new_keys != []:
563 shortcuts_to_add.append(
564 RuntimeBinding(
565 command,
566 keys=new_keys or old_keys,
567 filter=filter_from_string(new_filter)
568 if new_filter is not None
569 else (
570 old_filter
571 if old_filter is not None
572 else filter_from_string("always")
573 ),
574 )
575 )
576
577 # rebuild the bindings list from scratch
578 key_bindings = create_ipython_shortcuts(self, skip=shortcuts_to_skip)
579 for binding in shortcuts_to_add:
580 add_binding(key_bindings, binding)
581
582 return key_bindings
583
418 prompt_includes_vi_mode = Bool(True,
584 prompt_includes_vi_mode = Bool(True,
419 help="Display the current vi mode (when using vi editing mode)."
585 help="Display the current vi mode (when using vi editing mode)."
420 ).tag(config=True)
586 ).tag(config=True)
@@ -452,8 +618,7 b' class TerminalInteractiveShell(InteractiveShell):'
452 return
618 return
453
619
454 # Set up keyboard shortcuts
620 # Set up keyboard shortcuts
455 key_bindings = create_ipython_shortcuts(self)
621 key_bindings = self._merge_shortcuts(user_shortcuts=self.shortcuts)
456
457
622
458 # Pre-populate history from IPython's history database
623 # Pre-populate history from IPython's history database
459 history = PtkHistoryAdapter(self)
624 history = PtkHistoryAdapter(self)
@@ -477,7 +642,7 b' class TerminalInteractiveShell(InteractiveShell):'
477 enable_open_in_editor=self.extra_open_editor_shortcuts,
642 enable_open_in_editor=self.extra_open_editor_shortcuts,
478 color_depth=self.color_depth,
643 color_depth=self.color_depth,
479 tempfile_suffix=".py",
644 tempfile_suffix=".py",
480 **self._extra_prompt_options()
645 **self._extra_prompt_options(),
481 )
646 )
482 if isinstance(self.auto_suggest, NavigableAutoSuggestFromHistory):
647 if isinstance(self.auto_suggest, NavigableAutoSuggestFromHistory):
483 self.auto_suggest.connect(self.pt_app)
648 self.auto_suggest.connect(self.pt_app)
This diff has been collapsed as it changes many lines, (742 lines changed) Show them Hide them
@@ -7,414 +7,269 b' Module to define and register Terminal IPython shortcuts with'
7 # Distributed under the terms of the Modified BSD License.
7 # Distributed under the terms of the Modified BSD License.
8
8
9 import os
9 import os
10 import re
11 import signal
10 import signal
12 import sys
11 import sys
13 import warnings
12 import warnings
14 from typing import Callable, Dict, Union
13 from dataclasses import dataclass
14 from typing import Callable, Any, Optional, List
15
15
16 from prompt_toolkit.application.current import get_app
16 from prompt_toolkit.application.current import get_app
17 from prompt_toolkit.enums import DEFAULT_BUFFER, SEARCH_BUFFER
18 from prompt_toolkit.filters import Condition, emacs_insert_mode, has_completions
19 from prompt_toolkit.filters import has_focus as has_focus_impl
20 from prompt_toolkit.filters import (
21 has_selection,
22 has_suggestion,
23 vi_insert_mode,
24 vi_mode,
25 )
26 from prompt_toolkit.key_binding import KeyBindings
17 from prompt_toolkit.key_binding import KeyBindings
18 from prompt_toolkit.key_binding.key_processor import KeyPressEvent
27 from prompt_toolkit.key_binding.bindings import named_commands as nc
19 from prompt_toolkit.key_binding.bindings import named_commands as nc
28 from prompt_toolkit.key_binding.bindings.completion import (
20 from prompt_toolkit.key_binding.bindings.completion import (
29 display_completions_like_readline,
21 display_completions_like_readline,
30 )
22 )
31 from prompt_toolkit.key_binding.vi_state import InputMode, ViState
23 from prompt_toolkit.key_binding.vi_state import InputMode, ViState
32 from prompt_toolkit.layout.layout import FocusableElement
24 from prompt_toolkit.filters import Condition
33
25
26 from IPython.core.getipython import get_ipython
34 from IPython.terminal.shortcuts import auto_match as match
27 from IPython.terminal.shortcuts import auto_match as match
35 from IPython.terminal.shortcuts import auto_suggest
28 from IPython.terminal.shortcuts import auto_suggest
29 from IPython.terminal.shortcuts.filters import filter_from_string
36 from IPython.utils.decorators import undoc
30 from IPython.utils.decorators import undoc
37
31
38 __all__ = ["create_ipython_shortcuts"]
32 __all__ = ["create_ipython_shortcuts"]
39
33
40
34
41 @undoc
35 @dataclass
42 @Condition
36 class BaseBinding:
43 def cursor_in_leading_ws():
37 command: Callable[[KeyPressEvent], Any]
44 before = get_app().current_buffer.document.current_line_before_cursor
38 keys: List[str]
45 return (not before) or before.isspace()
46
47
48 def has_focus(value: FocusableElement):
49 """Wrapper around has_focus adding a nice `__name__` to tester function"""
50 tester = has_focus_impl(value).func
51 tester.__name__ = f"is_focused({value})"
52 return Condition(tester)
53
54
55 @undoc
56 @Condition
57 def has_line_below() -> bool:
58 document = get_app().current_buffer.document
59 return document.cursor_position_row < len(document.lines) - 1
60
61
62 @undoc
63 @Condition
64 def has_line_above() -> bool:
65 document = get_app().current_buffer.document
66 return document.cursor_position_row != 0
67
68
69 def create_ipython_shortcuts(shell, for_all_platforms: bool = False) -> KeyBindings:
70 """Set up the prompt_toolkit keyboard shortcuts for IPython.
71
72 Parameters
73 ----------
74 shell: InteractiveShell
75 The current IPython shell Instance
76 for_all_platforms: bool (default false)
77 This parameter is mostly used in generating the documentation
78 to create the shortcut binding for all the platforms, and export
79 them.
80
81 Returns
82 -------
83 KeyBindings
84 the keybinding instance for prompt toolkit.
85
39
86 """
87 # Warning: if possible, do NOT define handler functions in the locals
88 # scope of this function, instead define functions in the global
89 # scope, or a separate module, and include a user-friendly docstring
90 # describing the action.
91
40
92 kb = KeyBindings()
41 @dataclass
93 insert_mode = vi_insert_mode | emacs_insert_mode
42 class RuntimeBinding(BaseBinding):
43 filter: Condition
94
44
95 if getattr(shell, "handle_return", None):
96 return_handler = shell.handle_return(shell)
97 else:
98 return_handler = newline_or_execute_outer(shell)
99
45
100 kb.add("enter", filter=(has_focus(DEFAULT_BUFFER) & ~has_selection & insert_mode))(
46 @dataclass
101 return_handler
47 class Binding(BaseBinding):
102 )
48 # while filter could be created by referencing variables directly (rather
103
49 # than created from strings), by using strings we ensure that users will
104 @Condition
50 # be able to create filters in configuration (e.g. JSON) files too, which
105 def ebivim():
51 # also benefits the documentation by enforcing human-readable filter names.
106 return shell.emacs_bindings_in_vi_insert_mode
52 condition: Optional[str] = None
107
108 @kb.add(
109 "escape",
110 "enter",
111 filter=(has_focus(DEFAULT_BUFFER) & ~has_selection & insert_mode & ebivim),
112 )
113 def reformat_and_execute(event):
114 """Reformat code and execute it"""
115 reformat_text_before_cursor(
116 event.current_buffer, event.current_buffer.document, shell
117 )
118 event.current_buffer.validate_and_handle()
119
120 kb.add("c-\\")(quit)
121
122 kb.add("c-p", filter=(vi_insert_mode & has_focus(DEFAULT_BUFFER)))(
123 previous_history_or_previous_completion
124 )
125
126 kb.add("c-n", filter=(vi_insert_mode & has_focus(DEFAULT_BUFFER)))(
127 next_history_or_next_completion
128 )
129
130 kb.add("c-g", filter=(has_focus(DEFAULT_BUFFER) & has_completions))(
131 dismiss_completion
132 )
133
134 kb.add("c-c", filter=has_focus(DEFAULT_BUFFER))(reset_buffer)
135
136 kb.add("c-c", filter=has_focus(SEARCH_BUFFER))(reset_search_buffer)
137
138 supports_suspend = Condition(lambda: hasattr(signal, "SIGTSTP"))
139 kb.add("c-z", filter=supports_suspend)(suspend_to_bg)
140
141 # Ctrl+I == Tab
142 kb.add(
143 "tab",
144 filter=(
145 has_focus(DEFAULT_BUFFER)
146 & ~has_selection
147 & insert_mode
148 & cursor_in_leading_ws
149 ),
150 )(indent_buffer)
151 kb.add("c-o", filter=(has_focus(DEFAULT_BUFFER) & emacs_insert_mode))(
152 newline_autoindent_outer(shell.input_transformer_manager)
153 )
154
155 kb.add("f2", filter=has_focus(DEFAULT_BUFFER))(open_input_in_editor)
156
157 @Condition
158 def auto_match():
159 return shell.auto_match
160
161 def all_quotes_paired(quote, buf):
162 paired = True
163 i = 0
164 while i < len(buf):
165 c = buf[i]
166 if c == quote:
167 paired = not paired
168 elif c == "\\":
169 i += 1
170 i += 1
171 return paired
172
173 focused_insert = (vi_insert_mode | emacs_insert_mode) & has_focus(DEFAULT_BUFFER)
174 _preceding_text_cache: Dict[Union[str, Callable], Condition] = {}
175 _following_text_cache: Dict[Union[str, Callable], Condition] = {}
176
177 def preceding_text(pattern: Union[str, Callable]):
178 if pattern in _preceding_text_cache:
179 return _preceding_text_cache[pattern]
180
181 if callable(pattern):
182
183 def _preceding_text():
184 app = get_app()
185 before_cursor = app.current_buffer.document.current_line_before_cursor
186 # mypy can't infer if(callable): https://github.com/python/mypy/issues/3603
187 return bool(pattern(before_cursor)) # type: ignore[operator]
188
53
54 def __post_init__(self):
55 if self.condition:
56 self.filter = filter_from_string(self.condition)
189 else:
57 else:
190 m = re.compile(pattern)
58 self.filter = None
191
192 def _preceding_text():
193 app = get_app()
194 before_cursor = app.current_buffer.document.current_line_before_cursor
195 return bool(m.match(before_cursor))
196
197 _preceding_text.__name__ = f"preceding_text({pattern!r})"
198
199 condition = Condition(_preceding_text)
200 _preceding_text_cache[pattern] = condition
201 return condition
202
203 def following_text(pattern):
204 try:
205 return _following_text_cache[pattern]
206 except KeyError:
207 pass
208 m = re.compile(pattern)
209
210 def _following_text():
211 app = get_app()
212 return bool(m.match(app.current_buffer.document.current_line_after_cursor))
213
214 _following_text.__name__ = f"following_text({pattern!r})"
215
59
216 condition = Condition(_following_text)
217 _following_text_cache[pattern] = condition
218 return condition
219
60
220 @Condition
61 def create_identifier(handler: Callable):
221 def not_inside_unclosed_string():
62 parts = handler.__module__.split(".")
222 app = get_app()
63 name = handler.__name__
223 s = app.current_buffer.document.text_before_cursor
64 package = parts[0]
224 # remove escaped quotes
65 if len(parts) > 1:
225 s = s.replace('\\"', "").replace("\\'", "")
66 final_module = parts[-1]
226 # remove triple-quoted string literals
67 return f"{package}:{final_module}.{name}"
227 s = re.sub(r"(?:\"\"\"[\s\S]*\"\"\"|'''[\s\S]*''')", "", s)
68 else:
228 # remove single-quoted string literals
69 return f"{package}:{name}"
229 s = re.sub(r"""(?:"[^"]*["\n]|'[^']*['\n])""", "", s)
230 return not ('"' in s or "'" in s)
231
232 # auto match
233 for key, cmd in match.auto_match_parens.items():
234 kb.add(key, filter=focused_insert & auto_match & following_text(r"[,)}\]]|$"))(
235 cmd
236 )
237
70
238 # raw string
239 for key, cmd in match.auto_match_parens_raw_string.items():
240 kb.add(
241 key,
242 filter=focused_insert & auto_match & preceding_text(r".*(r|R)[\"'](-*)$"),
243 )(cmd)
244
245 kb.add(
246 '"',
247 filter=focused_insert
248 & auto_match
249 & not_inside_unclosed_string
250 & preceding_text(lambda line: all_quotes_paired('"', line))
251 & following_text(r"[,)}\]]|$"),
252 )(match.double_quote)
253
254 kb.add(
255 "'",
256 filter=focused_insert
257 & auto_match
258 & not_inside_unclosed_string
259 & preceding_text(lambda line: all_quotes_paired("'", line))
260 & following_text(r"[,)}\]]|$"),
261 )(match.single_quote)
262
263 kb.add(
264 '"',
265 filter=focused_insert
266 & auto_match
267 & not_inside_unclosed_string
268 & preceding_text(r'^.*""$'),
269 )(match.docstring_double_quotes)
270
271 kb.add(
272 "'",
273 filter=focused_insert
274 & auto_match
275 & not_inside_unclosed_string
276 & preceding_text(r"^.*''$"),
277 )(match.docstring_single_quotes)
278
279 # just move cursor
280 kb.add(")", filter=focused_insert & auto_match & following_text(r"^\)"))(
281 match.skip_over
282 )
283 kb.add("]", filter=focused_insert & auto_match & following_text(r"^\]"))(
284 match.skip_over
285 )
286 kb.add("}", filter=focused_insert & auto_match & following_text(r"^\}"))(
287 match.skip_over
288 )
289 kb.add('"', filter=focused_insert & auto_match & following_text('^"'))(
290 match.skip_over
291 )
292 kb.add("'", filter=focused_insert & auto_match & following_text("^'"))(
293 match.skip_over
294 )
295
71
296 kb.add(
72 AUTO_MATCH_BINDINGS = [
297 "backspace",
73 *[
298 filter=focused_insert
74 Binding(
299 & preceding_text(r".*\($")
75 cmd, [key], "focused_insert & auto_match & followed_by_closing_paren_or_end"
300 & auto_match
301 & following_text(r"^\)"),
302 )(match.delete_pair)
303 kb.add(
304 "backspace",
305 filter=focused_insert
306 & preceding_text(r".*\[$")
307 & auto_match
308 & following_text(r"^\]"),
309 )(match.delete_pair)
310 kb.add(
311 "backspace",
312 filter=focused_insert
313 & preceding_text(r".*\{$")
314 & auto_match
315 & following_text(r"^\}"),
316 )(match.delete_pair)
317 kb.add(
318 "backspace",
319 filter=focused_insert
320 & preceding_text('.*"$')
321 & auto_match
322 & following_text('^"'),
323 )(match.delete_pair)
324 kb.add(
325 "backspace",
326 filter=focused_insert
327 & preceding_text(r".*'$")
328 & auto_match
329 & following_text(r"^'"),
330 )(match.delete_pair)
331
332 if shell.display_completions == "readlinelike":
333 kb.add(
334 "c-i",
335 filter=(
336 has_focus(DEFAULT_BUFFER)
337 & ~has_selection
338 & insert_mode
339 & ~cursor_in_leading_ws
340 ),
341 )(display_completions_like_readline)
342
343 if sys.platform == "win32" or for_all_platforms:
344 kb.add("c-v", filter=(has_focus(DEFAULT_BUFFER) & ~vi_mode))(win_paste)
345
346 focused_insert_vi = has_focus(DEFAULT_BUFFER) & vi_insert_mode
347
348 # autosuggestions
349 @Condition
350 def navigable_suggestions():
351 return isinstance(
352 shell.auto_suggest, auto_suggest.NavigableAutoSuggestFromHistory
353 )
76 )
354
77 for key, cmd in match.auto_match_parens.items()
355 kb.add("end", filter=has_focus(DEFAULT_BUFFER) & (ebivim | ~vi_insert_mode))(
78 ],
356 auto_suggest.accept_in_vi_insert_mode
79 *[
357 )
80 # raw string
358 kb.add("c-e", filter=focused_insert_vi & ebivim)(
81 Binding(cmd, [key], "focused_insert & auto_match & preceded_by_raw_str_prefix")
359 auto_suggest.accept_in_vi_insert_mode
82 for key, cmd in match.auto_match_parens_raw_string.items()
360 )
83 ],
361 kb.add("c-f", filter=focused_insert_vi)(auto_suggest.accept)
84 Binding(
362 kb.add("escape", "f", filter=focused_insert_vi & ebivim)(auto_suggest.accept_word)
85 match.double_quote,
363 kb.add("c-right", filter=has_suggestion & has_focus(DEFAULT_BUFFER))(
86 ['"'],
364 auto_suggest.accept_token
87 "focused_insert"
365 )
88 " & auto_match"
366 kb.add(
89 " & not_inside_unclosed_string"
367 "escape", filter=has_suggestion & has_focus(DEFAULT_BUFFER) & emacs_insert_mode
90 " & preceded_by_paired_double_quotes"
368 )(auto_suggest.discard)
91 " & followed_by_closing_paren_or_end",
369 kb.add(
92 ),
370 "up",
93 Binding(
371 filter=navigable_suggestions
94 match.single_quote,
372 & ~has_line_above
95 ["'"],
373 & has_suggestion
96 "focused_insert"
374 & has_focus(DEFAULT_BUFFER),
97 " & auto_match"
375 )(auto_suggest.swap_autosuggestion_up(shell.auto_suggest))
98 " & not_inside_unclosed_string"
376 kb.add(
99 " & preceded_by_paired_single_quotes"
377 "down",
100 " & followed_by_closing_paren_or_end",
378 filter=navigable_suggestions
101 ),
379 & ~has_line_below
102 Binding(
380 & has_suggestion
103 match.docstring_double_quotes,
381 & has_focus(DEFAULT_BUFFER),
104 ['"'],
382 )(auto_suggest.swap_autosuggestion_down(shell.auto_suggest))
105 "focused_insert"
383 kb.add(
106 " & auto_match"
384 "up", filter=has_line_above & navigable_suggestions & has_focus(DEFAULT_BUFFER)
107 " & not_inside_unclosed_string"
385 )(auto_suggest.up_and_update_hint)
108 " & preceded_by_two_double_quotes",
386 kb.add(
109 ),
387 "down",
110 Binding(
388 filter=has_line_below & navigable_suggestions & has_focus(DEFAULT_BUFFER),
111 match.docstring_single_quotes,
389 )(auto_suggest.down_and_update_hint)
112 ["'"],
390 kb.add("right", filter=has_suggestion & has_focus(DEFAULT_BUFFER))(
113 "focused_insert"
391 auto_suggest.accept_character
114 " & auto_match"
392 )
115 " & not_inside_unclosed_string"
393 kb.add("c-left", filter=has_suggestion & has_focus(DEFAULT_BUFFER))(
116 " & preceded_by_two_single_quotes",
394 auto_suggest.accept_and_move_cursor_left
117 ),
395 )
118 Binding(
396 kb.add("c-down", filter=has_suggestion & has_focus(DEFAULT_BUFFER))(
119 match.skip_over,
397 auto_suggest.accept_and_keep_cursor
120 [")"],
398 )
121 "focused_insert & auto_match & followed_by_closing_round_paren",
399 kb.add("backspace", filter=has_suggestion & has_focus(DEFAULT_BUFFER))(
122 ),
400 auto_suggest.backspace_and_resume_hint
123 Binding(
401 )
124 match.skip_over,
402
125 ["]"],
403 # Simple Control keybindings
126 "focused_insert & auto_match & followed_by_closing_bracket",
404 key_cmd_dict = {
127 ),
128 Binding(
129 match.skip_over,
130 ["}"],
131 "focused_insert & auto_match & followed_by_closing_brace",
132 ),
133 Binding(
134 match.skip_over, ['"'], "focused_insert & auto_match & followed_by_double_quote"
135 ),
136 Binding(
137 match.skip_over, ["'"], "focused_insert & auto_match & followed_by_single_quote"
138 ),
139 Binding(
140 match.delete_pair,
141 ["backspace"],
142 "focused_insert"
143 " & preceded_by_opening_round_paren"
144 " & auto_match"
145 " & followed_by_closing_round_paren",
146 ),
147 Binding(
148 match.delete_pair,
149 ["backspace"],
150 "focused_insert"
151 " & preceded_by_opening_bracket"
152 " & auto_match"
153 " & followed_by_closing_bracket",
154 ),
155 Binding(
156 match.delete_pair,
157 ["backspace"],
158 "focused_insert"
159 " & preceded_by_opening_brace"
160 " & auto_match"
161 " & followed_by_closing_brace",
162 ),
163 Binding(
164 match.delete_pair,
165 ["backspace"],
166 "focused_insert"
167 " & preceded_by_double_quote"
168 " & auto_match"
169 " & followed_by_double_quote",
170 ),
171 Binding(
172 match.delete_pair,
173 ["backspace"],
174 "focused_insert"
175 " & preceded_by_single_quote"
176 " & auto_match"
177 " & followed_by_single_quote",
178 ),
179 ]
180
181 AUTO_SUGGEST_BINDINGS = [
182 Binding(
183 auto_suggest.accept_in_vi_insert_mode,
184 ["end"],
185 "default_buffer_focused & (ebivim | ~vi_insert_mode)",
186 ),
187 Binding(
188 auto_suggest.accept_in_vi_insert_mode,
189 ["c-e"],
190 "vi_insert_mode & default_buffer_focused & ebivim",
191 ),
192 Binding(auto_suggest.accept, ["c-f"], "vi_insert_mode & default_buffer_focused"),
193 Binding(
194 auto_suggest.accept_word,
195 ["escape", "f"],
196 "vi_insert_mode & default_buffer_focused & ebivim",
197 ),
198 Binding(
199 auto_suggest.accept_token,
200 ["c-right"],
201 "has_suggestion & default_buffer_focused",
202 ),
203 Binding(
204 auto_suggest.discard,
205 ["escape"],
206 "has_suggestion & default_buffer_focused & emacs_insert_mode",
207 ),
208 Binding(
209 auto_suggest.swap_autosuggestion_up,
210 ["up"],
211 "navigable_suggestions"
212 " & ~has_line_above"
213 " & has_suggestion"
214 " & default_buffer_focused",
215 ),
216 Binding(
217 auto_suggest.swap_autosuggestion_down,
218 ["down"],
219 "navigable_suggestions"
220 " & ~has_line_below"
221 " & has_suggestion"
222 " & default_buffer_focused",
223 ),
224 Binding(
225 auto_suggest.up_and_update_hint,
226 ["up"],
227 "has_line_above & navigable_suggestions & default_buffer_focused",
228 ),
229 Binding(
230 auto_suggest.down_and_update_hint,
231 ["down"],
232 "has_line_below & navigable_suggestions & default_buffer_focused",
233 ),
234 Binding(
235 auto_suggest.accept_character,
236 ["escape", "right"],
237 "has_suggestion & default_buffer_focused",
238 ),
239 Binding(
240 auto_suggest.accept_and_move_cursor_left,
241 ["c-left"],
242 "has_suggestion & default_buffer_focused",
243 ),
244 Binding(
245 auto_suggest.accept_and_keep_cursor,
246 ["c-down"],
247 "has_suggestion & default_buffer_focused",
248 ),
249 Binding(
250 auto_suggest.backspace_and_resume_hint,
251 ["backspace"],
252 "has_suggestion & default_buffer_focused",
253 ),
254 ]
255
256
257 SIMPLE_CONTROL_BINDINGS = [
258 Binding(cmd, [key], "vi_insert_mode & default_buffer_focused & ebivim")
259 for key, cmd in {
405 "c-a": nc.beginning_of_line,
260 "c-a": nc.beginning_of_line,
406 "c-b": nc.backward_char,
261 "c-b": nc.backward_char,
407 "c-k": nc.kill_line,
262 "c-k": nc.kill_line,
408 "c-w": nc.backward_kill_word,
263 "c-w": nc.backward_kill_word,
409 "c-y": nc.yank,
264 "c-y": nc.yank,
410 "c-_": nc.undo,
265 "c-_": nc.undo,
411 }
266 }.items()
267 ]
412
268
413 for key, cmd in key_cmd_dict.items():
414 kb.add(key, filter=focused_insert_vi & ebivim)(cmd)
415
269
416 # Alt and Combo Control keybindings
270 ALT_AND_COMOBO_CONTROL_BINDINGS = [
417 keys_cmd_dict = {
271 Binding(cmd, list(keys), "vi_insert_mode & default_buffer_focused & ebivim")
272 for keys, cmd in {
418 # Control Combos
273 # Control Combos
419 ("c-x", "c-e"): nc.edit_and_execute,
274 ("c-x", "c-e"): nc.edit_and_execute,
420 ("c-x", "e"): nc.edit_and_execute,
275 ("c-x", "e"): nc.edit_and_execute,
@@ -427,10 +282,48 b' def create_ipython_shortcuts(shell, for_all_platforms: bool = False) -> KeyBindi'
427 ("escape", "u"): nc.uppercase_word,
282 ("escape", "u"): nc.uppercase_word,
428 ("escape", "y"): nc.yank_pop,
283 ("escape", "y"): nc.yank_pop,
429 ("escape", "."): nc.yank_last_arg,
284 ("escape", "."): nc.yank_last_arg,
430 }
285 }.items()
286 ]
287
288
289 def add_binding(bindings: KeyBindings, binding: Binding):
290 bindings.add(
291 *binding.keys,
292 **({"filter": binding.filter} if binding.filter is not None else {}),
293 )(binding.command)
294
295
296 def create_ipython_shortcuts(shell, skip=None) -> KeyBindings:
297 """Set up the prompt_toolkit keyboard shortcuts for IPython.
431
298
432 for keys, cmd in keys_cmd_dict.items():
299 Parameters
433 kb.add(*keys, filter=focused_insert_vi & ebivim)(cmd)
300 ----------
301 shell: InteractiveShell
302 The current IPython shell Instance
303 skip: List[Binding]
304 Bindings to skip.
305
306 Returns
307 -------
308 KeyBindings
309 the keybinding instance for prompt toolkit.
310
311 """
312 kb = KeyBindings()
313 skip = skip or []
314 for binding in KEY_BINDINGS:
315 skip_this_one = False
316 for to_skip in skip:
317 if (
318 to_skip.command == binding.command
319 and to_skip.filter == binding.filter
320 and to_skip.keys == binding.keys
321 ):
322 skip_this_one = True
323 break
324 if skip_this_one:
325 continue
326 add_binding(kb, binding)
434
327
435 def get_input_mode(self):
328 def get_input_mode(self):
436 app = get_app()
329 app = get_app()
@@ -451,9 +344,19 b' def create_ipython_shortcuts(shell, for_all_platforms: bool = False) -> KeyBindi'
451 if shell.editing_mode == "vi" and shell.modal_cursor:
344 if shell.editing_mode == "vi" and shell.modal_cursor:
452 ViState._input_mode = InputMode.INSERT # type: ignore
345 ViState._input_mode = InputMode.INSERT # type: ignore
453 ViState.input_mode = property(get_input_mode, set_input_mode) # type: ignore
346 ViState.input_mode = property(get_input_mode, set_input_mode) # type: ignore
347
454 return kb
348 return kb
455
349
456
350
351 def reformat_and_execute(event):
352 """Reformat code and execute it"""
353 shell = get_ipython()
354 reformat_text_before_cursor(
355 event.current_buffer, event.current_buffer.document, shell
356 )
357 event.current_buffer.validate_and_handle()
358
359
457 def reformat_text_before_cursor(buffer, document, shell):
360 def reformat_text_before_cursor(buffer, document, shell):
458 text = buffer.delete_before_cursor(len(document.text[: document.cursor_position]))
361 text = buffer.delete_before_cursor(len(document.text[: document.cursor_position]))
459 try:
362 try:
@@ -463,6 +366,14 b' def reformat_text_before_cursor(buffer, document, shell):'
463 buffer.insert_text(text)
366 buffer.insert_text(text)
464
367
465
368
369 def handle_return_or_newline_or_execute(event):
370 shell = get_ipython()
371 if getattr(shell, "handle_return", None):
372 return shell.handle_return(shell)(event)
373 else:
374 return newline_or_execute_outer(shell)(event)
375
376
466 def newline_or_execute_outer(shell):
377 def newline_or_execute_outer(shell):
467 def newline_or_execute(event):
378 def newline_or_execute(event):
468 """When the user presses return, insert a newline or execute the code."""
379 """When the user presses return, insert a newline or execute the code."""
@@ -512,8 +423,6 b' def newline_or_execute_outer(shell):'
512 else:
423 else:
513 b.insert_text("\n")
424 b.insert_text("\n")
514
425
515 newline_or_execute.__qualname__ = "newline_or_execute"
516
517 return newline_or_execute
426 return newline_or_execute
518
427
519
428
@@ -610,30 +519,24 b' def newline_with_copy_margin(event):'
610 b.cursor_right(count=pos_diff)
519 b.cursor_right(count=pos_diff)
611
520
612
521
613 def newline_autoindent_outer(inputsplitter) -> Callable[..., None]:
522 def newline_autoindent(event):
614 """
523 """Insert a newline after the cursor indented appropriately.
615 Return a function suitable for inserting a indented newline after the cursor.
616
524
617 Fancier version of deprecated ``newline_with_copy_margin`` which should
525 Fancier version of deprecated ``newline_with_copy_margin`` which should
618 compute the correct indentation of the inserted line. That is to say, indent
526 compute the correct indentation of the inserted line. That is to say, indent
619 by 4 extra space after a function definition, class definition, context
527 by 4 extra space after a function definition, class definition, context
620 manager... And dedent by 4 space after ``pass``, ``return``, ``raise ...``.
528 manager... And dedent by 4 space after ``pass``, ``return``, ``raise ...``.
621 """
529 """
530 shell = get_ipython()
531 inputsplitter = shell.input_transformer_manager
532 b = event.current_buffer
533 d = b.document
622
534
623 def newline_autoindent(event):
535 if b.complete_state:
624 """Insert a newline after the cursor indented appropriately."""
536 b.cancel_completion()
625 b = event.current_buffer
537 text = d.text[: d.cursor_position] + "\n"
626 d = b.document
538 _, indent = inputsplitter.check_complete(text)
627
539 b.insert_text("\n" + (" " * (indent or 0)), move_cursor=False)
628 if b.complete_state:
629 b.cancel_completion()
630 text = d.text[: d.cursor_position] + "\n"
631 _, indent = inputsplitter.check_complete(text)
632 b.insert_text("\n" + (" " * (indent or 0)), move_cursor=False)
633
634 newline_autoindent.__qualname__ = "newline_autoindent"
635
636 return newline_autoindent
637
540
638
541
639 def open_input_in_editor(event):
542 def open_input_in_editor(event):
@@ -666,5 +569,58 b' else:'
666
569
667 @undoc
570 @undoc
668 def win_paste(event):
571 def win_paste(event):
669 """Stub used when auto-generating shortcuts for documentation"""
572 """Stub used on other platforms"""
670 pass
573 pass
574
575
576 KEY_BINDINGS = [
577 Binding(
578 handle_return_or_newline_or_execute,
579 ["enter"],
580 "default_buffer_focused & ~has_selection & insert_mode",
581 ),
582 Binding(
583 reformat_and_execute,
584 ["escape", "enter"],
585 "default_buffer_focused & ~has_selection & insert_mode & ebivim",
586 ),
587 Binding(quit, ["c-\\"]),
588 Binding(
589 previous_history_or_previous_completion,
590 ["c-p"],
591 "vi_insert_mode & default_buffer_focused",
592 ),
593 Binding(
594 next_history_or_next_completion,
595 ["c-n"],
596 "vi_insert_mode & default_buffer_focused",
597 ),
598 Binding(dismiss_completion, ["c-g"], "default_buffer_focused & has_completions"),
599 Binding(reset_buffer, ["c-c"], "default_buffer_focused"),
600 Binding(reset_search_buffer, ["c-c"], "search_buffer_focused"),
601 Binding(suspend_to_bg, ["c-z"], "supports_suspend"),
602 Binding(
603 indent_buffer,
604 ["tab"], # Ctrl+I == Tab
605 "default_buffer_focused"
606 " & ~has_selection"
607 " & insert_mode"
608 " & cursor_in_leading_ws",
609 ),
610 Binding(newline_autoindent, ["c-o"], "default_buffer_focused & emacs_insert_mode"),
611 Binding(open_input_in_editor, ["f2"], "default_buffer_focused"),
612 *AUTO_MATCH_BINDINGS,
613 *AUTO_SUGGEST_BINDINGS,
614 Binding(
615 display_completions_like_readline,
616 ["c-i"],
617 "readline_like_completions"
618 " & default_buffer_focused"
619 " & ~has_selection"
620 " & insert_mode"
621 " & ~cursor_in_leading_ws",
622 ),
623 Binding(win_paste, ["c-v"], "default_buffer_focused & ~vi_mode & is_windows_os"),
624 *SIMPLE_CONTROL_BINDINGS,
625 *ALT_AND_COMOBO_CONTROL_BINDINGS,
626 ]
@@ -1,7 +1,7 b''
1 """
1 """
2 Utilities function for keybinding with prompt toolkit.
2 Utilities function for keybinding with prompt toolkit.
3
3
4 This will be bound to specific key press and filter modes,
4 This will be bound to specific key press and filter modes,
5 like whether we are in edit mode, and whether the completer is open.
5 like whether we are in edit mode, and whether the completer is open.
6 """
6 """
7 import re
7 import re
@@ -84,9 +84,9 b' def raw_string_braces(event: KeyPressEvent):'
84
84
85
85
86 def skip_over(event: KeyPressEvent):
86 def skip_over(event: KeyPressEvent):
87 """Skip over automatically added parenthesis.
87 """Skip over automatically added parenthesis/quote.
88
88
89 (rather than adding another parenthesis)"""
89 (rather than adding another parenthesis/quote)"""
90 event.current_buffer.cursor_right()
90 event.current_buffer.cursor_right()
91
91
92
92
@@ -1,7 +1,7 b''
1 import re
1 import re
2 import tokenize
2 import tokenize
3 from io import StringIO
3 from io import StringIO
4 from typing import Callable, List, Optional, Union, Generator, Tuple, Sequence
4 from typing import Callable, List, Optional, Union, Generator, Tuple
5
5
6 from prompt_toolkit.buffer import Buffer
6 from prompt_toolkit.buffer import Buffer
7 from prompt_toolkit.key_binding import KeyPressEvent
7 from prompt_toolkit.key_binding import KeyPressEvent
@@ -16,6 +16,7 b' from prompt_toolkit.layout.processors import ('
16 TransformationInput,
16 TransformationInput,
17 )
17 )
18
18
19 from IPython.core.getipython import get_ipython
19 from IPython.utils.tokenutil import generate_tokens
20 from IPython.utils.tokenutil import generate_tokens
20
21
21
22
@@ -346,33 +347,29 b' def _swap_autosuggestion('
346 buffer.suggestion = new_suggestion
347 buffer.suggestion = new_suggestion
347
348
348
349
349 def swap_autosuggestion_up(provider: Provider):
350 def swap_autosuggestion_up(event: KeyPressEvent):
350 def swap_autosuggestion_up(event: KeyPressEvent):
351 """Get next autosuggestion from history."""
351 """Get next autosuggestion from history."""
352 shell = get_ipython()
352 if not isinstance(provider, NavigableAutoSuggestFromHistory):
353 provider = shell.auto_suggest
353 return
354
354
355 return _swap_autosuggestion(
355 if not isinstance(provider, NavigableAutoSuggestFromHistory):
356 buffer=event.current_buffer, provider=provider, direction_method=provider.up
356 return
357 )
358
357
359 swap_autosuggestion_up.__name__ = "swap_autosuggestion_up"
358 return _swap_autosuggestion(
360 return swap_autosuggestion_up
359 buffer=event.current_buffer, provider=provider, direction_method=provider.up
360 )
361
361
362
362
363 def swap_autosuggestion_down(
363 def swap_autosuggestion_down(event: KeyPressEvent):
364 provider: Union[AutoSuggestFromHistory, NavigableAutoSuggestFromHistory, None]
364 """Get previous autosuggestion from history."""
365 ):
365 shell = get_ipython()
366 def swap_autosuggestion_down(event: KeyPressEvent):
366 provider = shell.auto_suggest
367 """Get previous autosuggestion from history."""
368 if not isinstance(provider, NavigableAutoSuggestFromHistory):
369 return
370
367
371 return _swap_autosuggestion(
368 if not isinstance(provider, NavigableAutoSuggestFromHistory):
372 buffer=event.current_buffer,
369 return
373 provider=provider,
374 direction_method=provider.down,
375 )
376
370
377 swap_autosuggestion_down.__name__ = "swap_autosuggestion_down"
371 return _swap_autosuggestion(
378 return swap_autosuggestion_down
372 buffer=event.current_buffer,
373 provider=provider,
374 direction_method=provider.down,
375 )
@@ -9,9 +9,8 b' import os'
9
9
10 from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
10 from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
11
11
12 from IPython.core.inputtransformer import InputTransformer
12
13 from IPython.testing import tools as tt
13 from IPython.testing import tools as tt
14 from IPython.utils.capture import capture_output
15
14
16 from IPython.terminal.ptutils import _elide, _adjust_completion_text_based_on_context
15 from IPython.terminal.ptutils import _elide, _adjust_completion_text_based_on_context
17 from IPython.terminal.shortcuts.auto_suggest import NavigableAutoSuggestFromHistory
16 from IPython.terminal.shortcuts.auto_suggest import NavigableAutoSuggestFromHistory
@@ -11,6 +11,8 b' from IPython.terminal.shortcuts.auto_suggest import ('
11 swap_autosuggestion_up,
11 swap_autosuggestion_up,
12 swap_autosuggestion_down,
12 swap_autosuggestion_down,
13 )
13 )
14 from IPython.terminal.shortcuts.auto_match import skip_over
15 from IPython.terminal.shortcuts import create_ipython_shortcuts
14
16
15 from prompt_toolkit.history import InMemoryHistory
17 from prompt_toolkit.history import InMemoryHistory
16 from prompt_toolkit.buffer import Buffer
18 from prompt_toolkit.buffer import Buffer
@@ -192,18 +194,20 b' def test_autosuggest_token_empty():'
192 def test_other_providers():
194 def test_other_providers():
193 """Ensure that swapping autosuggestions does not break with other providers"""
195 """Ensure that swapping autosuggestions does not break with other providers"""
194 provider = AutoSuggestFromHistory()
196 provider = AutoSuggestFromHistory()
195 up = swap_autosuggestion_up(provider)
197 ip = get_ipython()
196 down = swap_autosuggestion_down(provider)
198 ip.auto_suggest = provider
197 event = Mock()
199 event = Mock()
198 event.current_buffer = Buffer()
200 event.current_buffer = Buffer()
199 assert up(event) is None
201 assert swap_autosuggestion_up(event) is None
200 assert down(event) is None
202 assert swap_autosuggestion_down(event) is None
201
203
202
204
203 async def test_navigable_provider():
205 async def test_navigable_provider():
204 provider = NavigableAutoSuggestFromHistory()
206 provider = NavigableAutoSuggestFromHistory()
205 history = InMemoryHistory(history_strings=["very_a", "very", "very_b", "very_c"])
207 history = InMemoryHistory(history_strings=["very_a", "very", "very_b", "very_c"])
206 buffer = Buffer(history=history)
208 buffer = Buffer(history=history)
209 ip = get_ipython()
210 ip.auto_suggest = provider
207
211
208 async for _ in history.load():
212 async for _ in history.load():
209 pass
213 pass
@@ -211,8 +215,8 b' async def test_navigable_provider():'
211 buffer.cursor_position = 5
215 buffer.cursor_position = 5
212 buffer.text = "very"
216 buffer.text = "very"
213
217
214 up = swap_autosuggestion_up(provider)
218 up = swap_autosuggestion_up
215 down = swap_autosuggestion_down(provider)
219 down = swap_autosuggestion_down
216
220
217 event = Mock()
221 event = Mock()
218 event.current_buffer = buffer
222 event.current_buffer = buffer
@@ -254,14 +258,16 b' async def test_navigable_provider_multiline_entries():'
254 provider = NavigableAutoSuggestFromHistory()
258 provider = NavigableAutoSuggestFromHistory()
255 history = InMemoryHistory(history_strings=["very_a\nvery_b", "very_c"])
259 history = InMemoryHistory(history_strings=["very_a\nvery_b", "very_c"])
256 buffer = Buffer(history=history)
260 buffer = Buffer(history=history)
261 ip = get_ipython()
262 ip.auto_suggest = provider
257
263
258 async for _ in history.load():
264 async for _ in history.load():
259 pass
265 pass
260
266
261 buffer.cursor_position = 5
267 buffer.cursor_position = 5
262 buffer.text = "very"
268 buffer.text = "very"
263 up = swap_autosuggestion_up(provider)
269 up = swap_autosuggestion_up
264 down = swap_autosuggestion_down(provider)
270 down = swap_autosuggestion_down
265
271
266 event = Mock()
272 event = Mock()
267 event.current_buffer = buffer
273 event.current_buffer = buffer
@@ -316,3 +322,140 b' def test_navigable_provider_connection():'
316 session_1.default_buffer.on_text_insert.fire()
322 session_1.default_buffer.on_text_insert.fire()
317 session_2.default_buffer.on_text_insert.fire()
323 session_2.default_buffer.on_text_insert.fire()
318 assert provider.skip_lines == 3
324 assert provider.skip_lines == 3
325
326
327 @pytest.fixture
328 def ipython_with_prompt():
329 ip = get_ipython()
330 ip.pt_app = Mock()
331 ip.pt_app.key_bindings = create_ipython_shortcuts(ip)
332 try:
333 yield ip
334 finally:
335 ip.pt_app = None
336
337
338 def find_bindings_by_command(command):
339 ip = get_ipython()
340 return [
341 binding
342 for binding in ip.pt_app.key_bindings.bindings
343 if binding.handler == command
344 ]
345
346
347 def test_modify_unique_shortcut(ipython_with_prompt):
348 original = find_bindings_by_command(accept_token)
349 assert len(original) == 1
350
351 ipython_with_prompt.shortcuts = [
352 {"command": "IPython:auto_suggest.accept_token", "new_keys": ["a", "b", "c"]}
353 ]
354 matched = find_bindings_by_command(accept_token)
355 assert len(matched) == 1
356 assert list(matched[0].keys) == ["a", "b", "c"]
357 assert list(matched[0].keys) != list(original[0].keys)
358 assert matched[0].filter == original[0].filter
359
360 ipython_with_prompt.shortcuts = [
361 {"command": "IPython:auto_suggest.accept_token", "new_filter": "always"}
362 ]
363 matched = find_bindings_by_command(accept_token)
364 assert len(matched) == 1
365 assert list(matched[0].keys) != ["a", "b", "c"]
366 assert list(matched[0].keys) == list(original[0].keys)
367 assert matched[0].filter != original[0].filter
368
369
370 def test_disable_shortcut(ipython_with_prompt):
371 matched = find_bindings_by_command(accept_token)
372 assert len(matched) == 1
373
374 ipython_with_prompt.shortcuts = [
375 {"command": "IPython:auto_suggest.accept_token", "new_keys": []}
376 ]
377 matched = find_bindings_by_command(accept_token)
378 assert len(matched) == 0
379
380 ipython_with_prompt.shortcuts = []
381 matched = find_bindings_by_command(accept_token)
382 assert len(matched) == 1
383
384
385 def test_modify_shortcut_with_filters(ipython_with_prompt):
386 matched = find_bindings_by_command(skip_over)
387 matched_keys = {m.keys[0] for m in matched}
388 assert matched_keys == {")", "]", "}", "'", '"'}
389
390 with pytest.raises(ValueError, match="Multiple shortcuts matching"):
391 ipython_with_prompt.shortcuts = [
392 {"command": "IPython:auto_match.skip_over", "new_keys": ["x"]}
393 ]
394
395 ipython_with_prompt.shortcuts = [
396 {
397 "command": "IPython:auto_match.skip_over",
398 "new_keys": ["x"],
399 "match_filter": "focused_insert & auto_match & followed_by_single_quote",
400 }
401 ]
402 matched = find_bindings_by_command(skip_over)
403 matched_keys = {m.keys[0] for m in matched}
404 assert matched_keys == {")", "]", "}", "x", '"'}
405
406
407 def example_command():
408 pass
409
410
411 def test_add_shortcut_for_new_command(ipython_with_prompt):
412 matched = find_bindings_by_command(example_command)
413 assert len(matched) == 0
414
415 with pytest.raises(ValueError, match="example_command is not a known"):
416 ipython_with_prompt.shortcuts = [
417 {"command": "example_command", "new_keys": ["x"]}
418 ]
419 matched = find_bindings_by_command(example_command)
420 assert len(matched) == 0
421
422
423 def test_modify_shortcut_failure(ipython_with_prompt):
424 with pytest.raises(ValueError, match="No shortcuts matching"):
425 ipython_with_prompt.shortcuts = [
426 {
427 "command": "IPython:auto_match.skip_over",
428 "match_keys": ["x"],
429 "new_keys": ["y"],
430 }
431 ]
432
433
434 def test_add_shortcut_for_existing_command(ipython_with_prompt):
435 matched = find_bindings_by_command(skip_over)
436 assert len(matched) == 5
437
438 with pytest.raises(ValueError, match="Cannot add a shortcut without keys"):
439 ipython_with_prompt.shortcuts = [
440 {"command": "IPython:auto_match.skip_over", "new_keys": [], "create": True}
441 ]
442
443 ipython_with_prompt.shortcuts = [
444 {"command": "IPython:auto_match.skip_over", "new_keys": ["x"], "create": True}
445 ]
446 matched = find_bindings_by_command(skip_over)
447 assert len(matched) == 6
448
449 ipython_with_prompt.shortcuts = []
450 matched = find_bindings_by_command(skip_over)
451 assert len(matched) == 5
452
453
454 def test_setting_shortcuts_before_pt_app_init():
455 ipython = get_ipython()
456 assert ipython.pt_app is None
457 shortcuts = [
458 {"command": "IPython:auto_match.skip_over", "new_keys": ["x"], "create": True}
459 ]
460 ipython.shortcuts = shortcuts
461 assert ipython.shortcuts == shortcuts
@@ -1,7 +1,7 b''
1 from dataclasses import dataclass
1 from dataclasses import dataclass
2 from inspect import getsource
2 from inspect import getsource
3 from pathlib import Path
3 from pathlib import Path
4 from typing import cast, Callable, List, Union
4 from typing import cast, List, Union
5 from html import escape as html_escape
5 from html import escape as html_escape
6 import re
6 import re
7
7
@@ -10,7 +10,8 b' from prompt_toolkit.key_binding import KeyBindingsBase'
10 from prompt_toolkit.filters import Filter, Condition
10 from prompt_toolkit.filters import Filter, Condition
11 from prompt_toolkit.shortcuts import PromptSession
11 from prompt_toolkit.shortcuts import PromptSession
12
12
13 from IPython.terminal.shortcuts import create_ipython_shortcuts
13 from IPython.terminal.shortcuts import create_ipython_shortcuts, create_identifier
14 from IPython.terminal.shortcuts.filters import KEYBINDING_FILTERS
14
15
15
16
16 @dataclass
17 @dataclass
@@ -44,11 +45,16 b' class _Invert(Filter):'
44 filter: Filter
45 filter: Filter
45
46
46
47
47 conjunctions_labels = {"_AndList": "and", "_OrList": "or"}
48 conjunctions_labels = {"_AndList": "&", "_OrList": "|"}
48
49
49 ATOMIC_CLASSES = {"Never", "Always", "Condition"}
50 ATOMIC_CLASSES = {"Never", "Always", "Condition"}
50
51
51
52
53 HUMAN_NAMES_FOR_FILTERS = {
54 filter_: name for name, filter_ in KEYBINDING_FILTERS.items()
55 }
56
57
52 def format_filter(
58 def format_filter(
53 filter_: Union[Filter, _NestedFilter, Condition, _Invert],
59 filter_: Union[Filter, _NestedFilter, Condition, _Invert],
54 is_top_level=True,
60 is_top_level=True,
@@ -58,6 +64,8 b' def format_filter('
58 s = filter_.__class__.__name__
64 s = filter_.__class__.__name__
59 if s == "Condition":
65 if s == "Condition":
60 func = cast(Condition, filter_).func
66 func = cast(Condition, filter_).func
67 if filter_ in HUMAN_NAMES_FOR_FILTERS:
68 return HUMAN_NAMES_FOR_FILTERS[filter_]
61 name = func.__name__
69 name = func.__name__
62 if name == "<lambda>":
70 if name == "<lambda>":
63 source = getsource(func)
71 source = getsource(func)
@@ -66,10 +74,12 b' def format_filter('
66 elif s == "_Invert":
74 elif s == "_Invert":
67 operand = cast(_Invert, filter_).filter
75 operand = cast(_Invert, filter_).filter
68 if operand.__class__.__name__ in ATOMIC_CLASSES:
76 if operand.__class__.__name__ in ATOMIC_CLASSES:
69 return f"not {format_filter(operand, is_top_level=False)}"
77 return f"~{format_filter(operand, is_top_level=False)}"
70 return f"not ({format_filter(operand, is_top_level=False)})"
78 return f"~({format_filter(operand, is_top_level=False)})"
71 elif s in conjunctions_labels:
79 elif s in conjunctions_labels:
72 filters = cast(_NestedFilter, filter_).filters
80 filters = cast(_NestedFilter, filter_).filters
81 if filter_ in HUMAN_NAMES_FOR_FILTERS:
82 return HUMAN_NAMES_FOR_FILTERS[filter_]
73 conjunction = conjunctions_labels[s]
83 conjunction = conjunctions_labels[s]
74 glue = f" {conjunction} "
84 glue = f" {conjunction} "
75 result = glue.join(format_filter(x, is_top_level=False) for x in filters)
85 result = glue.join(format_filter(x, is_top_level=False) for x in filters)
@@ -104,17 +114,6 b' class _DummyTerminal:'
104 auto_suggest = None
114 auto_suggest = None
105
115
106
116
107 def create_identifier(handler: Callable):
108 parts = handler.__module__.split(".")
109 name = handler.__name__
110 package = parts[0]
111 if len(parts) > 1:
112 final_module = parts[-1]
113 return f"{package}:{final_module}.{name}"
114 else:
115 return f"{package}:{name}"
116
117
118 def bindings_from_prompt_toolkit(prompt_bindings: KeyBindingsBase) -> List[Binding]:
117 def bindings_from_prompt_toolkit(prompt_bindings: KeyBindingsBase) -> List[Binding]:
119 """Collect bindings to a simple format that does not depend on prompt-toolkit internals"""
118 """Collect bindings to a simple format that does not depend on prompt-toolkit internals"""
120 bindings: List[Binding] = []
119 bindings: List[Binding] = []
@@ -178,11 +177,12 b' def format_prompt_keys(keys: str, add_alternatives=True) -> str:'
178
177
179 return result
178 return result
180
179
180
181 if __name__ == '__main__':
181 if __name__ == '__main__':
182 here = Path(__file__).parent
182 here = Path(__file__).parent
183 dest = here / "source" / "config" / "shortcuts"
183 dest = here / "source" / "config" / "shortcuts"
184
184
185 ipy_bindings = create_ipython_shortcuts(_DummyTerminal(), for_all_platforms=True)
185 ipy_bindings = create_ipython_shortcuts(_DummyTerminal())
186
186
187 session = PromptSession(key_bindings=ipy_bindings)
187 session = PromptSession(key_bindings=ipy_bindings)
188 prompt_bindings = session.app.key_bindings
188 prompt_bindings = session.app.key_bindings
@@ -16,3 +16,4 b' dependencies:'
16 - prompt_toolkit
16 - prompt_toolkit
17 - ipykernel
17 - ipykernel
18 - stack_data
18 - stack_data
19 - -e ..
@@ -200,10 +200,20 b' With (X)EMacs >= 24, You can enable IPython in python-mode with:'
200 Keyboard Shortcuts
200 Keyboard Shortcuts
201 ==================
201 ==================
202
202
203 .. versionadded:: 8.11
204
205 You can modify, disable or modify keyboard shortcuts for IPython Terminal using
206 :std:configtrait:`TerminalInteractiveShell.shortcuts` traitlet.
207
208 The list of shortcuts is available in the Configuring IPython :ref:`terminal-shortcuts-list` section.
209
210 Advanced configuration
211 ----------------------
212
203 .. versionchanged:: 5.0
213 .. versionchanged:: 5.0
204
214
205 You can customise keyboard shortcuts for terminal IPython. Put code like this in
215 Creating custom commands requires adding custom code to a
206 a :ref:`startup file <startup_files>`::
216 :ref:`startup file <startup_files>`::
207
217
208 from IPython import get_ipython
218 from IPython import get_ipython
209 from prompt_toolkit.enums import DEFAULT_BUFFER
219 from prompt_toolkit.enums import DEFAULT_BUFFER
@@ -1,8 +1,10 b''
1 .. _terminal-shortcuts-list:
2
1 =================
3 =================
2 IPython shortcuts
4 IPython shortcuts
3 =================
5 =================
4
6
5 Available shortcuts in an IPython terminal.
7 Shortcuts available in an IPython terminal.
6
8
7 .. note::
9 .. note::
8
10
@@ -12,7 +14,10 b' Available shortcuts in an IPython terminal.'
12
14
13 * Comma-separated keys, e.g. :kbd:`Esc`, :kbd:`f`, indicate a sequence which can be activated by pressing the listed keys in succession.
15 * Comma-separated keys, e.g. :kbd:`Esc`, :kbd:`f`, indicate a sequence which can be activated by pressing the listed keys in succession.
14 * Plus-separated keys, e.g. :kbd:`Esc` + :kbd:`f` indicate a combination which requires pressing all keys simultaneously.
16 * Plus-separated keys, e.g. :kbd:`Esc` + :kbd:`f` indicate a combination which requires pressing all keys simultaneously.
15 * Hover over the ⓘ icon in the filter column to see when the shortcut is active.g
17 * Hover over the ⓘ icon in the filter column to see when the shortcut is active.
18
19 You can use :std:configtrait:`TerminalInteractiveShell.shortcuts` configuration
20 to modify, disable or add shortcuts.
16
21
17 .. role:: raw-html(raw)
22 .. role:: raw-html(raw)
18 :format: html
23 :format: html
General Comments 0
You need to be logged in to leave comments. Login now