##// END OF EJS Templates
Allow to customize shortcuts
krassowski -
Show More
@@ -0,0 +1,258 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 "focused_insert_vi": vi_insert_mode & default_buffer_focused,
194 "not_inside_unclosed_string": not_inside_unclosed_string,
195 "readline_like_completions": readline_like_completions,
196 "preceded_by_paired_double_quotes": preceding_text(
197 lambda line: all_quotes_paired('"', line)
198 ),
199 "preceded_by_paired_single_quotes": preceding_text(
200 lambda line: all_quotes_paired("'", line)
201 ),
202 "preceded_by_raw_str_prefix": preceding_text(r".*(r|R)[\"'](-*)$"),
203 "preceded_by_two_double_quotes": preceding_text(r'^.*""$'),
204 "preceded_by_two_single_quotes": preceding_text(r"^.*''$"),
205 "followed_by_closing_paren_or_end": following_text(r"[,)}\]]|$"),
206 # match
207 "preceded_by_opening_round_paren": preceding_text(r".*\($"),
208 "preceded_by_opening_bracket": preceding_text(r".*\[$"),
209 "preceded_by_opening_brace": preceding_text(r".*\{$"),
210 "preceded_by_double_quote": preceding_text('.*"$'),
211 "preceded_by_single_quote": preceding_text(r".*'$"),
212 "followed_by_closing_round_paren": following_text(r"^\)"),
213 "followed_by_closing_bracket": following_text(r"^\]"),
214 "followed_by_closing_brace": following_text(r"^\}"),
215 "followed_by_double_quote": following_text('^"'),
216 "followed_by_single_quote": following_text("^'"),
217 "navigable_suggestions": navigable_suggestions,
218 "cursor_in_leading_ws": cursor_in_leading_ws,
219 }
220
221
222 def eval_node(node: Union[ast.AST, None]):
223 if node is None:
224 return None
225 if isinstance(node, ast.Expression):
226 return eval_node(node.body)
227 if isinstance(node, ast.BinOp):
228 left = eval_node(node.left)
229 right = eval_node(node.right)
230 dunders = _find_dunder(node.op, BINARY_OP_DUNDERS)
231 if dunders:
232 return getattr(left, dunders[0])(right)
233 raise ValueError(f"Unknown binary operation: {node.op}")
234 if isinstance(node, ast.UnaryOp):
235 value = eval_node(node.operand)
236 dunders = _find_dunder(node.op, UNARY_OP_DUNDERS)
237 if dunders:
238 return getattr(value, dunders[0])()
239 raise ValueError(f"Unknown unary operation: {node.op}")
240 if isinstance(node, ast.Name):
241 if node.id in KEYBINDING_FILTERS:
242 return KEYBINDING_FILTERS[node.id]
243 else:
244 sep = "\n - "
245 known_filters = sep.join(sorted(KEYBINDING_FILTERS))
246 raise NameError(
247 f"{node.id} is not a known shortcut filter."
248 f" Known filters are: {sep}{known_filters}."
249 )
250 raise ValueError("Unhandled node", ast.dump(node))
251
252
253 def filter_from_string(code: str):
254 expression = ast.parse(code, mode="eval")
255 return eval_node(expression)
256
257
258 __all__ = ["KEYBINDING_FILTERS", "filter_from_string"]
@@ -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,14 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 Binding,
58 add_binding,
59 )
60 from .shortcuts.filters import KEYBINDING_FILTERS, filter_from_string
53 from .shortcuts.auto_suggest import (
61 from .shortcuts.auto_suggest import (
54 NavigableAutoSuggestFromHistory,
62 NavigableAutoSuggestFromHistory,
55 AppendAutoSuggestionInAnyLine,
63 AppendAutoSuggestionInAnyLine,
@@ -415,6 +423,145 b' class TerminalInteractiveShell(InteractiveShell):'
415 provider = change.new
423 provider = change.new
416 self._set_autosuggestions(provider)
424 self._set_autosuggestions(provider)
417
425
426 shortcuts = List(
427 trait=Dict(
428 key_trait=Enum(
429 [
430 "command",
431 "match_keys",
432 "match_filter",
433 "new_keys",
434 "new_filter",
435 "create",
436 ]
437 ),
438 per_key_traits={
439 "command": Unicode(),
440 "match_keys": List(Unicode()),
441 "match_filter": Unicode(),
442 "new_keys": List(Unicode()),
443 "new_filter": Unicode(),
444 "create": Bool(default=False),
445 },
446 ),
447 help=f"""Add, disable or modifying shortcuts.
448
449 Each entry on the list should be a dictionary with ``command`` key
450 identifying the target function executed by the shortcut and at least
451 one of the following::
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: ({', '.join(KEYBINDING_FILTERS)}).
460
461 To disable a shortcut set ``new_keys`` to an empty list.
462 To add a shortcut add key ``create`` with value ``True``.
463
464 When modifying/disabling shortcuts, ``match_keys``/``match_filter`` can
465 be omitted if the provided specification uniquely identifies a shortcut
466 to be modified/disabled. When modifying a shortcut ``new_filter`` or
467 ``new_keys`` can be omitted which will result in reuse of the existing
468 filter/keys.
469
470 Only shortcuts defined in IPython (and not default prompt toolkit
471 shortcuts) can be modified or disabled.
472 """,
473 ).tag(config=True)
474
475 @observe("shortcuts")
476 def _shortcuts_changed(self, change):
477 user_shortcuts = change.new
478 # rebuild the bindings list from scratch
479 key_bindings = create_ipython_shortcuts(self)
480
481 # for now we only allow adding shortcuts for commands which are already
482 # registered; this is a security precaution.
483 known_commands = {
484 create_identifier(binding.handler): binding.handler
485 for binding in key_bindings.bindings
486 }
487 shortcuts_to_skip = []
488 shortcuts_to_add = []
489
490 for shortcut in user_shortcuts:
491 command_id = shortcut["command"]
492 if command_id not in known_commands:
493 allowed_commands = "\n - ".join(known_commands)
494 raise ValueError(
495 f"{command_id} is not a known shortcut command."
496 f" Allowed commands are: \n - {allowed_commands}"
497 )
498 old_keys = shortcut.get("match_keys", None)
499 old_filter = (
500 filter_from_string(shortcut["match_filter"])
501 if "match_filter" in shortcut
502 else None
503 )
504 matching = [
505 binding
506 for binding in key_bindings.bindings
507 if (
508 (old_filter is None or binding.filter == old_filter)
509 and (old_keys is None or [k for k in binding.keys] == old_keys)
510 and create_identifier(binding.handler) == command_id
511 )
512 ]
513
514 new_keys = shortcut.get("new_keys", None)
515 new_filter = shortcut.get("new_filter", None)
516
517 command = known_commands[command_id]
518
519 creating_new = shortcut.get("create", False)
520 modifying_existing = not creating_new and (
521 new_keys is not None or new_filter
522 )
523
524 if creating_new and new_keys == []:
525 raise ValueError("Cannot add a shortcut without keys")
526
527 if modifying_existing:
528 specification = {
529 key: shortcut[key]
530 for key in ["command", "filter"]
531 if key in shortcut
532 }
533 if len(matching) == 0:
534 raise ValueError(f"No shortcuts matching {specification} found")
535 elif len(matching) > 1:
536 raise ValueError(
537 f"Multiple shortcuts matching {specification} found,"
538 f" please add keys/filter to select one of: {matching}"
539 )
540
541 for matched in matching:
542 shortcuts_to_skip.append(
543 RuntimeBinding(
544 command,
545 keys=[k for k in matching[0].keys],
546 filter=matching[0].filter,
547 )
548 )
549
550 if new_keys != []:
551 shortcuts_to_add.append(
552 Binding(
553 command,
554 keys=new_keys,
555 condition=new_filter if new_filter is not None else "always",
556 )
557 )
558
559 # rebuild the bindings list from scratch
560 key_bindings = create_ipython_shortcuts(self, skip=shortcuts_to_skip)
561 for binding in shortcuts_to_add:
562 add_binding(key_bindings, binding)
563 self.pt_app.key_bindings = key_bindings
564
418 prompt_includes_vi_mode = Bool(True,
565 prompt_includes_vi_mode = Bool(True,
419 help="Display the current vi mode (when using vi editing mode)."
566 help="Display the current vi mode (when using vi editing mode)."
420 ).tag(config=True)
567 ).tag(config=True)
@@ -477,7 +624,7 b' class TerminalInteractiveShell(InteractiveShell):'
477 enable_open_in_editor=self.extra_open_editor_shortcuts,
624 enable_open_in_editor=self.extra_open_editor_shortcuts,
478 color_depth=self.color_depth,
625 color_depth=self.color_depth,
479 tempfile_suffix=".py",
626 tempfile_suffix=".py",
480 **self._extra_prompt_options()
627 **self._extra_prompt_options(),
481 )
628 )
482 if isinstance(self.auto_suggest, NavigableAutoSuggestFromHistory):
629 if isinstance(self.auto_suggest, NavigableAutoSuggestFromHistory):
483 self.auto_suggest.connect(self.pt_app)
630 self.auto_suggest.connect(self.pt_app)
This diff has been collapsed as it changes many lines, (712 lines changed) Show them Hide them
@@ -7,414 +7,263 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
39
68
40
69 def create_ipython_shortcuts(shell, for_all_platforms: bool = False) -> KeyBindings:
41 @dataclass
70 """Set up the prompt_toolkit keyboard shortcuts for IPython.
42 class RuntimeBinding(BaseBinding):
43 filter: Condition
71
44
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
45
81 Returns
46 @dataclass
82 -------
47 class Binding(BaseBinding):
83 KeyBindings
48 # while filter could be created by referencing variables directly (rather
84 the keybinding instance for prompt toolkit.
49 # than created from strings), by using strings we ensure that users will
50 # be able to create filters in configuration (e.g. JSON) files too, which
51 # also benefits the documentation by enforcing human-readable filter names.
52 condition: Optional[str] = None
85
53
86 """
54 def __post_init__(self):
87 # Warning: if possible, do NOT define handler functions in the locals
55 if self.condition:
88 # scope of this function, instead define functions in the global
56 self.filter = filter_from_string(self.condition)
89 # scope, or a separate module, and include a user-friendly docstring
90 # describing the action.
91
92 kb = KeyBindings()
93 insert_mode = vi_insert_mode | emacs_insert_mode
94
95 if getattr(shell, "handle_return", None):
96 return_handler = shell.handle_return(shell)
97 else:
57 else:
98 return_handler = newline_or_execute_outer(shell)
58 self.filter = None
99
100 kb.add("enter", filter=(has_focus(DEFAULT_BUFFER) & ~has_selection & insert_mode))(
101 return_handler
102 )
103
104 @Condition
105 def ebivim():
106 return shell.emacs_bindings_in_vi_insert_mode
107
59
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
60
61 def create_identifier(handler: Callable):
62 parts = handler.__module__.split(".")
63 name = handler.__name__
64 package = parts[0]
65 if len(parts) > 1:
66 final_module = parts[-1]
67 return f"{package}:{final_module}.{name}"
189 else:
68 else:
190 m = re.compile(pattern)
69 return f"{package}:{name}"
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
70
199 condition = Condition(_preceding_text)
200 _preceding_text_cache[pattern] = condition
201 return condition
202
71
203 def following_text(pattern):
72 AUTO_MATCH_BINDINGS = [
204 try:
73 *[
205 return _following_text_cache[pattern]
74 Binding(
206 except KeyError:
75 cmd, [key], "focused_insert & auto_match & followed_by_closing_paren_or_end"
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
216 condition = Condition(_following_text)
217 _following_text_cache[pattern] = condition
218 return condition
219
220 @Condition
221 def not_inside_unclosed_string():
222 app = get_app()
223 s = app.current_buffer.document.text_before_cursor
224 # remove escaped quotes
225 s = s.replace('\\"', "").replace("\\'", "")
226 # remove triple-quoted string literals
227 s = re.sub(r"(?:\"\"\"[\s\S]*\"\"\"|'''[\s\S]*''')", "", s)
228 # remove single-quoted string literals
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 )
76 )
237
77 for key, cmd in match.auto_match_parens.items()
78 ],
79 *[
238 # raw string
80 # raw string
239 for key, cmd in match.auto_match_parens_raw_string.items():
81 Binding(cmd, [key], "focused_insert & auto_match & preceded_by_raw_str_prefix")
240 kb.add(
82 for key, cmd in match.auto_match_parens_raw_string.items()
241 key,
83 ],
242 filter=focused_insert & auto_match & preceding_text(r".*(r|R)[\"'](-*)$"),
84 Binding(
243 )(cmd)
85 match.double_quote,
244
86 ['"'],
245 kb.add(
87 "focused_insert"
246 '"',
88 " & auto_match"
247 filter=focused_insert
89 " & not_inside_unclosed_string"
248 & auto_match
90 " & preceded_by_paired_double_quotes"
249 & not_inside_unclosed_string
91 " & followed_by_closing_paren_or_end",
250 & preceding_text(lambda line: all_quotes_paired('"', line))
92 ),
251 & following_text(r"[,)}\]]|$"),
93 Binding(
252 )(match.double_quote)
94 match.single_quote,
253
95 ["'"],
254 kb.add(
96 "focused_insert"
255 "'",
97 " & auto_match"
256 filter=focused_insert
98 " & not_inside_unclosed_string"
257 & auto_match
99 " & preceded_by_paired_single_quotes"
258 & not_inside_unclosed_string
100 " & followed_by_closing_paren_or_end",
259 & preceding_text(lambda line: all_quotes_paired("'", line))
101 ),
260 & following_text(r"[,)}\]]|$"),
102 Binding(
261 )(match.single_quote)
103 match.docstring_double_quotes,
262
104 ['"'],
263 kb.add(
105 "focused_insert"
264 '"',
106 " & auto_match"
265 filter=focused_insert
107 " & not_inside_unclosed_string"
266 & auto_match
108 " & preceded_by_two_double_quotes",
267 & not_inside_unclosed_string
109 ),
268 & preceding_text(r'^.*""$'),
110 Binding(
269 )(match.docstring_double_quotes)
111 match.docstring_single_quotes,
270
112 ["'"],
271 kb.add(
113 "focused_insert"
272 "'",
114 " & auto_match"
273 filter=focused_insert
115 " & not_inside_unclosed_string"
274 & auto_match
116 " & preceded_by_two_single_quotes",
275 & not_inside_unclosed_string
117 ),
276 & preceding_text(r"^.*''$"),
118 Binding(
277 )(match.docstring_single_quotes)
119 match.skip_over,
278
120 [")"],
279 # just move cursor
121 "focused_insert & auto_match & followed_by_closing_round_paren",
280 kb.add(")", filter=focused_insert & auto_match & following_text(r"^\)"))(
122 ),
281 match.skip_over
123 Binding(
282 )
124 match.skip_over,
283 kb.add("]", filter=focused_insert & auto_match & following_text(r"^\]"))(
125 ["]"],
284 match.skip_over
126 "focused_insert & auto_match & followed_by_closing_bracket",
285 )
127 ),
286 kb.add("}", filter=focused_insert & auto_match & following_text(r"^\}"))(
128 Binding(
287 match.skip_over
129 match.skip_over,
288 )
130 ["}"],
289 kb.add('"', filter=focused_insert & auto_match & following_text('^"'))(
131 "focused_insert & auto_match & followed_by_closing_brace",
290 match.skip_over
132 ),
291 )
133 Binding(
292 kb.add("'", filter=focused_insert & auto_match & following_text("^'"))(
134 match.skip_over, ['"'], "focused_insert & auto_match & followed_by_double_quote"
293 match.skip_over
135 ),
294 )
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 ]
295
180
296 kb.add(
181 AUTO_SUGGEST_BINDINGS = [
297 "backspace",
182 Binding(
298 filter=focused_insert
183 auto_suggest.accept_in_vi_insert_mode,
299 & preceding_text(r".*\($")
184 ["end"],
300 & auto_match
185 "default_buffer_focused & (ebivim | ~vi_insert_mode)",
301 & following_text(r"^\)"),
186 ),
302 )(match.delete_pair)
187 Binding(
303 kb.add(
188 auto_suggest.accept_in_vi_insert_mode, ["c-e"], "focused_insert_vi & ebivim"
304 "backspace",
189 ),
305 filter=focused_insert
190 Binding(auto_suggest.accept, ["c-f"], "focused_insert_vi"),
306 & preceding_text(r".*\[$")
191 Binding(auto_suggest.accept_word, ["escape", "f"], "focused_insert_vi & ebivim"),
307 & auto_match
192 Binding(
308 & following_text(r"^\]"),
193 auto_suggest.accept_token,
309 )(match.delete_pair)
194 ["c-right"],
310 kb.add(
195 "has_suggestion & default_buffer_focused",
311 "backspace",
196 ),
312 filter=focused_insert
197 Binding(
313 & preceding_text(r".*\{$")
198 auto_suggest.discard,
314 & auto_match
199 ["escape"],
315 & following_text(r"^\}"),
200 "has_suggestion & default_buffer_focused & emacs_insert_mode",
316 )(match.delete_pair)
201 ),
317 kb.add(
202 Binding(
318 "backspace",
203 auto_suggest.swap_autosuggestion_up,
319 filter=focused_insert
204 ["up"],
320 & preceding_text('.*"$')
205 "navigable_suggestions"
321 & auto_match
206 " & ~has_line_above"
322 & following_text('^"'),
207 " & has_suggestion"
323 )(match.delete_pair)
208 " & default_buffer_focused",
324 kb.add(
209 ),
325 "backspace",
210 Binding(
326 filter=focused_insert
211 auto_suggest.swap_autosuggestion_down,
327 & preceding_text(r".*'$")
212 ["down"],
328 & auto_match
213 "navigable_suggestions"
329 & following_text(r"^'"),
214 " & ~has_line_below"
330 )(match.delete_pair)
215 " & has_suggestion"
331
216 " & default_buffer_focused",
332 if shell.display_completions == "readlinelike":
217 ),
333 kb.add(
218 Binding(
334 "c-i",
219 auto_suggest.up_and_update_hint,
335 filter=(
220 ["up"],
336 has_focus(DEFAULT_BUFFER)
221 "has_line_above & navigable_suggestions & default_buffer_focused",
337 & ~has_selection
222 ),
338 & insert_mode
223 Binding(
339 & ~cursor_in_leading_ws
224 auto_suggest.down_and_update_hint,
340 ),
225 ["down"],
341 )(display_completions_like_readline)
226 "has_line_below & navigable_suggestions & default_buffer_focused",
342
227 ),
343 if sys.platform == "win32" or for_all_platforms:
228 Binding(
344 kb.add("c-v", filter=(has_focus(DEFAULT_BUFFER) & ~vi_mode))(win_paste)
229 auto_suggest.accept_character,
345
230 ["right"],
346 focused_insert_vi = has_focus(DEFAULT_BUFFER) & vi_insert_mode
231 "has_suggestion & default_buffer_focused",
347
232 ),
348 # autosuggestions
233 Binding(
349 @Condition
234 auto_suggest.accept_and_move_cursor_left,
350 def navigable_suggestions():
235 ["c-left"],
351 return isinstance(
236 "has_suggestion & default_buffer_focused",
352 shell.auto_suggest, auto_suggest.NavigableAutoSuggestFromHistory
237 ),
353 )
238 Binding(
239 auto_suggest.accept_and_keep_cursor,
240 ["c-down"],
241 "has_suggestion & default_buffer_focused",
242 ),
243 Binding(
244 auto_suggest.backspace_and_resume_hint,
245 ["backspace"],
246 "has_suggestion & default_buffer_focused",
247 ),
248 ]
354
249
355 kb.add("end", filter=has_focus(DEFAULT_BUFFER) & (ebivim | ~vi_insert_mode))(
356 auto_suggest.accept_in_vi_insert_mode
357 )
358 kb.add("c-e", filter=focused_insert_vi & ebivim)(
359 auto_suggest.accept_in_vi_insert_mode
360 )
361 kb.add("c-f", filter=focused_insert_vi)(auto_suggest.accept)
362 kb.add("escape", "f", filter=focused_insert_vi & ebivim)(auto_suggest.accept_word)
363 kb.add("c-right", filter=has_suggestion & has_focus(DEFAULT_BUFFER))(
364 auto_suggest.accept_token
365 )
366 kb.add(
367 "escape", filter=has_suggestion & has_focus(DEFAULT_BUFFER) & emacs_insert_mode
368 )(auto_suggest.discard)
369 kb.add(
370 "up",
371 filter=navigable_suggestions
372 & ~has_line_above
373 & has_suggestion
374 & has_focus(DEFAULT_BUFFER),
375 )(auto_suggest.swap_autosuggestion_up(shell.auto_suggest))
376 kb.add(
377 "down",
378 filter=navigable_suggestions
379 & ~has_line_below
380 & has_suggestion
381 & has_focus(DEFAULT_BUFFER),
382 )(auto_suggest.swap_autosuggestion_down(shell.auto_suggest))
383 kb.add(
384 "up", filter=has_line_above & navigable_suggestions & has_focus(DEFAULT_BUFFER)
385 )(auto_suggest.up_and_update_hint)
386 kb.add(
387 "down",
388 filter=has_line_below & navigable_suggestions & has_focus(DEFAULT_BUFFER),
389 )(auto_suggest.down_and_update_hint)
390 kb.add("right", filter=has_suggestion & has_focus(DEFAULT_BUFFER))(
391 auto_suggest.accept_character
392 )
393 kb.add("c-left", filter=has_suggestion & has_focus(DEFAULT_BUFFER))(
394 auto_suggest.accept_and_move_cursor_left
395 )
396 kb.add("c-down", filter=has_suggestion & has_focus(DEFAULT_BUFFER))(
397 auto_suggest.accept_and_keep_cursor
398 )
399 kb.add("backspace", filter=has_suggestion & has_focus(DEFAULT_BUFFER))(
400 auto_suggest.backspace_and_resume_hint
401 )
402
250
403 # Simple Control keybindings
251 SIMPLE_CONTROL_BINDINGS = [
404 key_cmd_dict = {
252 Binding(cmd, [key], "focused_insert_vi & ebivim")
253 for key, cmd in {
405 "c-a": nc.beginning_of_line,
254 "c-a": nc.beginning_of_line,
406 "c-b": nc.backward_char,
255 "c-b": nc.backward_char,
407 "c-k": nc.kill_line,
256 "c-k": nc.kill_line,
408 "c-w": nc.backward_kill_word,
257 "c-w": nc.backward_kill_word,
409 "c-y": nc.yank,
258 "c-y": nc.yank,
410 "c-_": nc.undo,
259 "c-_": nc.undo,
411 }
260 }.items()
261 ]
412
262
413 for key, cmd in key_cmd_dict.items():
414 kb.add(key, filter=focused_insert_vi & ebivim)(cmd)
415
263
416 # Alt and Combo Control keybindings
264 ALT_AND_COMOBO_CONTROL_BINDINGS = [
417 keys_cmd_dict = {
265 Binding(cmd, list(keys), "focused_insert_vi & ebivim")
266 for keys, cmd in {
418 # Control Combos
267 # Control Combos
419 ("c-x", "c-e"): nc.edit_and_execute,
268 ("c-x", "c-e"): nc.edit_and_execute,
420 ("c-x", "e"): nc.edit_and_execute,
269 ("c-x", "e"): nc.edit_and_execute,
@@ -427,10 +276,48 b' def create_ipython_shortcuts(shell, for_all_platforms: bool = False) -> KeyBindi'
427 ("escape", "u"): nc.uppercase_word,
276 ("escape", "u"): nc.uppercase_word,
428 ("escape", "y"): nc.yank_pop,
277 ("escape", "y"): nc.yank_pop,
429 ("escape", "."): nc.yank_last_arg,
278 ("escape", "."): nc.yank_last_arg,
430 }
279 }.items()
280 ]
281
282
283 def add_binding(bindings: KeyBindings, binding: Binding):
284 bindings.add(
285 *binding.keys,
286 **({"filter": binding.filter} if binding.filter is not None else {}),
287 )(binding.command)
288
289
290 def create_ipython_shortcuts(shell, skip=None) -> KeyBindings:
291 """Set up the prompt_toolkit keyboard shortcuts for IPython.
292
293 Parameters
294 ----------
295 shell: InteractiveShell
296 The current IPython shell Instance
297 skip: List[Binding]
298 Bindings to skip.
299
300 Returns
301 -------
302 KeyBindings
303 the keybinding instance for prompt toolkit.
431
304
432 for keys, cmd in keys_cmd_dict.items():
305 """
433 kb.add(*keys, filter=focused_insert_vi & ebivim)(cmd)
306 kb = KeyBindings()
307 skip = skip or []
308 for binding in KEY_BINDINGS:
309 skip_this_one = False
310 for to_skip in skip:
311 if (
312 to_skip.command == binding.command
313 and to_skip.filter == binding.filter
314 and to_skip.keys == binding.keys
315 ):
316 skip_this_one = True
317 break
318 if skip_this_one:
319 continue
320 add_binding(kb, binding)
434
321
435 def get_input_mode(self):
322 def get_input_mode(self):
436 app = get_app()
323 app = get_app()
@@ -451,9 +338,19 b' def create_ipython_shortcuts(shell, for_all_platforms: bool = False) -> KeyBindi'
451 if shell.editing_mode == "vi" and shell.modal_cursor:
338 if shell.editing_mode == "vi" and shell.modal_cursor:
452 ViState._input_mode = InputMode.INSERT # type: ignore
339 ViState._input_mode = InputMode.INSERT # type: ignore
453 ViState.input_mode = property(get_input_mode, set_input_mode) # type: ignore
340 ViState.input_mode = property(get_input_mode, set_input_mode) # type: ignore
341
454 return kb
342 return kb
455
343
456
344
345 def reformat_and_execute(event):
346 """Reformat code and execute it"""
347 shell = get_ipython()
348 reformat_text_before_cursor(
349 event.current_buffer, event.current_buffer.document, shell
350 )
351 event.current_buffer.validate_and_handle()
352
353
457 def reformat_text_before_cursor(buffer, document, shell):
354 def reformat_text_before_cursor(buffer, document, shell):
458 text = buffer.delete_before_cursor(len(document.text[: document.cursor_position]))
355 text = buffer.delete_before_cursor(len(document.text[: document.cursor_position]))
459 try:
356 try:
@@ -463,6 +360,14 b' def reformat_text_before_cursor(buffer, document, shell):'
463 buffer.insert_text(text)
360 buffer.insert_text(text)
464
361
465
362
363 def handle_return_or_newline_or_execute(event):
364 shell = get_ipython()
365 if getattr(shell, "handle_return", None):
366 return shell.handle_return(shell)(event)
367 else:
368 return newline_or_execute_outer(shell)(event)
369
370
466 def newline_or_execute_outer(shell):
371 def newline_or_execute_outer(shell):
467 def newline_or_execute(event):
372 def newline_or_execute(event):
468 """When the user presses return, insert a newline or execute the code."""
373 """When the user presses return, insert a newline or execute the code."""
@@ -512,8 +417,6 b' def newline_or_execute_outer(shell):'
512 else:
417 else:
513 b.insert_text("\n")
418 b.insert_text("\n")
514
419
515 newline_or_execute.__qualname__ = "newline_or_execute"
516
517 return newline_or_execute
420 return newline_or_execute
518
421
519
422
@@ -610,18 +513,16 b' def newline_with_copy_margin(event):'
610 b.cursor_right(count=pos_diff)
513 b.cursor_right(count=pos_diff)
611
514
612
515
613 def newline_autoindent_outer(inputsplitter) -> Callable[..., None]:
516 def newline_autoindent(event):
614 """
517 """Insert a newline after the cursor indented appropriately.
615 Return a function suitable for inserting a indented newline after the cursor.
616
518
617 Fancier version of deprecated ``newline_with_copy_margin`` which should
519 Fancier version of deprecated ``newline_with_copy_margin`` which should
618 compute the correct indentation of the inserted line. That is to say, indent
520 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
521 by 4 extra space after a function definition, class definition, context
620 manager... And dedent by 4 space after ``pass``, ``return``, ``raise ...``.
522 manager... And dedent by 4 space after ``pass``, ``return``, ``raise ...``.
621 """
523 """
622
524 shell = get_ipython()
623 def newline_autoindent(event):
525 inputsplitter = shell.input_transformer_manager
624 """Insert a newline after the cursor indented appropriately."""
625 b = event.current_buffer
526 b = event.current_buffer
626 d = b.document
527 d = b.document
627
528
@@ -631,10 +532,6 b' def newline_autoindent_outer(inputsplitter) -> Callable[..., None]:'
631 _, indent = inputsplitter.check_complete(text)
532 _, indent = inputsplitter.check_complete(text)
632 b.insert_text("\n" + (" " * (indent or 0)), move_cursor=False)
533 b.insert_text("\n" + (" " * (indent or 0)), move_cursor=False)
633
534
634 newline_autoindent.__qualname__ = "newline_autoindent"
635
636 return newline_autoindent
637
638
535
639 def open_input_in_editor(event):
536 def open_input_in_editor(event):
640 """Open code from input in external editor"""
537 """Open code from input in external editor"""
@@ -666,5 +563,58 b' else:'
666
563
667 @undoc
564 @undoc
668 def win_paste(event):
565 def win_paste(event):
669 """Stub used when auto-generating shortcuts for documentation"""
566 """Stub used on other platforms"""
670 pass
567 pass
568
569
570 KEY_BINDINGS = [
571 Binding(
572 handle_return_or_newline_or_execute,
573 ["enter"],
574 "default_buffer_focused & ~has_selection & insert_mode",
575 ),
576 Binding(
577 reformat_and_execute,
578 ["escape", "enter"],
579 "default_buffer_focused & ~has_selection & insert_mode & ebivim",
580 ),
581 Binding(quit, ["c-\\"]),
582 Binding(
583 previous_history_or_previous_completion,
584 ["c-p"],
585 "vi_insert_mode & default_buffer_focused",
586 ),
587 Binding(
588 next_history_or_next_completion,
589 ["c-n"],
590 "vi_insert_mode & default_buffer_focused",
591 ),
592 Binding(dismiss_completion, ["c-g"], "default_buffer_focused & has_completions"),
593 Binding(reset_buffer, ["c-c"], "default_buffer_focused"),
594 Binding(reset_search_buffer, ["c-c"], "search_buffer_focused"),
595 Binding(suspend_to_bg, ["c-z"], "supports_suspend"),
596 Binding(
597 indent_buffer,
598 ["tab"], # Ctrl+I == Tab
599 "default_buffer_focused"
600 " & ~has_selection"
601 " & insert_mode"
602 " & cursor_in_leading_ws",
603 ),
604 Binding(newline_autoindent, ["c-o"], "default_buffer_focused & emacs_insert_mode"),
605 Binding(open_input_in_editor, ["f2"], "default_buffer_focused"),
606 *AUTO_MATCH_BINDINGS,
607 *AUTO_SUGGEST_BINDINGS,
608 Binding(
609 display_completions_like_readline,
610 ["c-i"],
611 "readline_like_completions"
612 " & default_buffer_focused"
613 " & ~has_selection"
614 " & insert_mode"
615 " & ~cursor_in_leading_ws",
616 ),
617 Binding(win_paste, ["c-v"], "default_buffer_focused & ~vi_mode & is_windows_os"),
618 *SIMPLE_CONTROL_BINDINGS,
619 *ALT_AND_COMOBO_CONTROL_BINDINGS,
620 ]
@@ -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,9 +347,11 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()
353 provider = shell.auto_suggest
354
352 if not isinstance(provider, NavigableAutoSuggestFromHistory):
355 if not isinstance(provider, NavigableAutoSuggestFromHistory):
353 return
356 return
354
357
@@ -356,15 +359,12 b' def swap_autosuggestion_up(provider: Provider):'
356 buffer=event.current_buffer, provider=provider, direction_method=provider.up
359 buffer=event.current_buffer, provider=provider, direction_method=provider.up
357 )
360 )
358
361
359 swap_autosuggestion_up.__name__ = "swap_autosuggestion_up"
360 return swap_autosuggestion_up
361
362
362
363 def swap_autosuggestion_down(
364 provider: Union[AutoSuggestFromHistory, NavigableAutoSuggestFromHistory, None]
365 ):
366 def swap_autosuggestion_down(event: KeyPressEvent):
363 def swap_autosuggestion_down(event: KeyPressEvent):
367 """Get previous autosuggestion from history."""
364 """Get previous autosuggestion from history."""
365 shell = get_ipython()
366 provider = shell.auto_suggest
367
368 if not isinstance(provider, NavigableAutoSuggestFromHistory):
368 if not isinstance(provider, NavigableAutoSuggestFromHistory):
369 return
369 return
370
370
@@ -373,6 +373,3 b' def swap_autosuggestion_down('
373 provider=provider,
373 provider=provider,
374 direction_method=provider.down,
374 direction_method=provider.down,
375 )
375 )
376
377 swap_autosuggestion_down.__name__ = "swap_autosuggestion_down"
378 return swap_autosuggestion_down
@@ -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,101 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 matched = find_bindings_by_command(accept_token)
349 assert len(matched) == 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
358 ipython_with_prompt.shortcuts = [
359 {"command": "IPython:auto_suggest.accept_token", "new_keys": []}
360 ]
361 matched = find_bindings_by_command(accept_token)
362 assert len(matched) == 0
363
364 ipython_with_prompt.shortcuts = []
365 matched = find_bindings_by_command(accept_token)
366 assert len(matched) == 1
367
368
369 def test_modify_shortcut_with_filters(ipython_with_prompt):
370 matched = find_bindings_by_command(skip_over)
371 matched_keys = {m.keys[0] for m in matched}
372 assert matched_keys == {")", "]", "}", "'", '"'}
373
374 with pytest.raises(ValueError, match="Multiple shortcuts matching"):
375 ipython_with_prompt.shortcuts = [
376 {"command": "IPython:auto_match.skip_over", "new_keys": ["x"]}
377 ]
378
379 ipython_with_prompt.shortcuts = [
380 {
381 "command": "IPython:auto_match.skip_over",
382 "new_keys": ["x"],
383 "match_filter": "focused_insert & auto_match & followed_by_single_quote",
384 }
385 ]
386 matched = find_bindings_by_command(skip_over)
387 matched_keys = {m.keys[0] for m in matched}
388 assert matched_keys == {")", "]", "}", "x", '"'}
389
390
391 def test_command():
392 pass
393
394
395 def test_add_shortcut_for_new_command(ipython_with_prompt):
396 matched = find_bindings_by_command(test_command)
397 assert len(matched) == 0
398
399 with pytest.raises(ValueError, match="test_command is not a known"):
400 ipython_with_prompt.shortcuts = [{"command": "test_command", "new_keys": ["x"]}]
401 matched = find_bindings_by_command(test_command)
402 assert len(matched) == 0
403
404
405 def test_add_shortcut_for_existing_command(ipython_with_prompt):
406 matched = find_bindings_by_command(skip_over)
407 assert len(matched) == 5
408
409 with pytest.raises(ValueError, match="Cannot add a shortcut without keys"):
410 ipython_with_prompt.shortcuts = [
411 {"command": "IPython:auto_match.skip_over", "new_keys": [], "create": True}
412 ]
413
414 ipython_with_prompt.shortcuts = [
415 {"command": "IPython:auto_match.skip_over", "new_keys": ["x"], "create": True}
416 ]
417 matched = find_bindings_by_command(skip_over)
418 assert len(matched) == 6
419
420 ipython_with_prompt.shortcuts = []
421 matched = find_bindings_by_command(skip_over)
422 assert len(matched) == 5
@@ -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
General Comments 0
You need to be logged in to leave comments. Login now