##// END OF EJS Templates
Fix autosuggestions in multi-line mode, vi command mode delay
krassowski -
Show More
@@ -1,601 +1,616 b''
1 1 """
2 2 Module to define and register Terminal IPython shortcuts with
3 3 :mod:`prompt_toolkit`
4 4 """
5 5
6 6 # Copyright (c) IPython Development Team.
7 7 # Distributed under the terms of the Modified BSD License.
8 8
9 9 import os
10 10 import signal
11 11 import sys
12 12 import warnings
13 13 from dataclasses import dataclass
14 14 from typing import Callable, Any, Optional, List
15 15
16 16 from prompt_toolkit.application.current import get_app
17 17 from prompt_toolkit.key_binding import KeyBindings
18 18 from prompt_toolkit.key_binding.key_processor import KeyPressEvent
19 19 from prompt_toolkit.key_binding.bindings import named_commands as nc
20 20 from prompt_toolkit.key_binding.bindings.completion import (
21 21 display_completions_like_readline,
22 22 )
23 23 from prompt_toolkit.key_binding.vi_state import InputMode, ViState
24 24 from prompt_toolkit.filters import Condition
25 25
26 26 from IPython.core.getipython import get_ipython
27 27 from IPython.terminal.shortcuts import auto_match as match
28 28 from IPython.terminal.shortcuts import auto_suggest
29 29 from IPython.terminal.shortcuts.filters import filter_from_string
30 30 from IPython.utils.decorators import undoc
31 31
32 32 from prompt_toolkit.enums import DEFAULT_BUFFER
33 33
34 34 __all__ = ["create_ipython_shortcuts"]
35 35
36 36
37 37 @dataclass
38 38 class BaseBinding:
39 39 command: Callable[[KeyPressEvent], Any]
40 40 keys: List[str]
41 41
42 42
43 43 @dataclass
44 44 class RuntimeBinding(BaseBinding):
45 45 filter: Condition
46 46
47 47
48 48 @dataclass
49 49 class Binding(BaseBinding):
50 50 # while filter could be created by referencing variables directly (rather
51 51 # than created from strings), by using strings we ensure that users will
52 52 # be able to create filters in configuration (e.g. JSON) files too, which
53 53 # also benefits the documentation by enforcing human-readable filter names.
54 54 condition: Optional[str] = None
55 55
56 56 def __post_init__(self):
57 57 if self.condition:
58 58 self.filter = filter_from_string(self.condition)
59 59 else:
60 60 self.filter = None
61 61
62 62
63 63 def create_identifier(handler: Callable):
64 64 parts = handler.__module__.split(".")
65 65 name = handler.__name__
66 66 package = parts[0]
67 67 if len(parts) > 1:
68 68 final_module = parts[-1]
69 69 return f"{package}:{final_module}.{name}"
70 70 else:
71 71 return f"{package}:{name}"
72 72
73 73
74 74 AUTO_MATCH_BINDINGS = [
75 75 *[
76 76 Binding(
77 77 cmd, [key], "focused_insert & auto_match & followed_by_closing_paren_or_end"
78 78 )
79 79 for key, cmd in match.auto_match_parens.items()
80 80 ],
81 81 *[
82 82 # raw string
83 83 Binding(cmd, [key], "focused_insert & auto_match & preceded_by_raw_str_prefix")
84 84 for key, cmd in match.auto_match_parens_raw_string.items()
85 85 ],
86 86 Binding(
87 87 match.double_quote,
88 88 ['"'],
89 89 "focused_insert"
90 90 " & auto_match"
91 91 " & not_inside_unclosed_string"
92 92 " & preceded_by_paired_double_quotes"
93 93 " & followed_by_closing_paren_or_end",
94 94 ),
95 95 Binding(
96 96 match.single_quote,
97 97 ["'"],
98 98 "focused_insert"
99 99 " & auto_match"
100 100 " & not_inside_unclosed_string"
101 101 " & preceded_by_paired_single_quotes"
102 102 " & followed_by_closing_paren_or_end",
103 103 ),
104 104 Binding(
105 105 match.docstring_double_quotes,
106 106 ['"'],
107 107 "focused_insert"
108 108 " & auto_match"
109 109 " & not_inside_unclosed_string"
110 110 " & preceded_by_two_double_quotes",
111 111 ),
112 112 Binding(
113 113 match.docstring_single_quotes,
114 114 ["'"],
115 115 "focused_insert"
116 116 " & auto_match"
117 117 " & not_inside_unclosed_string"
118 118 " & preceded_by_two_single_quotes",
119 119 ),
120 120 Binding(
121 121 match.skip_over,
122 122 [")"],
123 123 "focused_insert & auto_match & followed_by_closing_round_paren",
124 124 ),
125 125 Binding(
126 126 match.skip_over,
127 127 ["]"],
128 128 "focused_insert & auto_match & followed_by_closing_bracket",
129 129 ),
130 130 Binding(
131 131 match.skip_over,
132 132 ["}"],
133 133 "focused_insert & auto_match & followed_by_closing_brace",
134 134 ),
135 135 Binding(
136 136 match.skip_over, ['"'], "focused_insert & auto_match & followed_by_double_quote"
137 137 ),
138 138 Binding(
139 139 match.skip_over, ["'"], "focused_insert & auto_match & followed_by_single_quote"
140 140 ),
141 141 Binding(
142 142 match.delete_pair,
143 143 ["backspace"],
144 144 "focused_insert"
145 145 " & preceded_by_opening_round_paren"
146 146 " & auto_match"
147 147 " & followed_by_closing_round_paren",
148 148 ),
149 149 Binding(
150 150 match.delete_pair,
151 151 ["backspace"],
152 152 "focused_insert"
153 153 " & preceded_by_opening_bracket"
154 154 " & auto_match"
155 155 " & followed_by_closing_bracket",
156 156 ),
157 157 Binding(
158 158 match.delete_pair,
159 159 ["backspace"],
160 160 "focused_insert"
161 161 " & preceded_by_opening_brace"
162 162 " & auto_match"
163 163 " & followed_by_closing_brace",
164 164 ),
165 165 Binding(
166 166 match.delete_pair,
167 167 ["backspace"],
168 168 "focused_insert"
169 169 " & preceded_by_double_quote"
170 170 " & auto_match"
171 171 " & followed_by_double_quote",
172 172 ),
173 173 Binding(
174 174 match.delete_pair,
175 175 ["backspace"],
176 176 "focused_insert"
177 177 " & preceded_by_single_quote"
178 178 " & auto_match"
179 179 " & followed_by_single_quote",
180 180 ),
181 181 ]
182 182
183 183 AUTO_SUGGEST_BINDINGS = [
184 # there are two reasons for re-defining bindings defined upstream:
185 # 1) prompt-toolkit does not execute autosuggestion bindings in vi mode,
186 # 2) prompt-toolkit checks if we are at the end of text, not end of line
187 # hence it does not work in multi-line mode of navigable provider
184 188 Binding(
185 189 auto_suggest.accept_in_vi_insert_mode,
186 190 ["end"],
187 "default_buffer_focused & (ebivim | ~vi_insert_mode)",
191 "has_suggestion & default_buffer_focused & emacs_like_insert_mode",
188 192 ),
189 193 Binding(
190 194 auto_suggest.accept_in_vi_insert_mode,
191 195 ["c-e"],
192 "vi_insert_mode & default_buffer_focused & ebivim",
196 "has_suggestion & default_buffer_focused & emacs_like_insert_mode",
197 ),
198 Binding(
199 auto_suggest.accept,
200 ["c-f"],
201 "has_suggestion & default_buffer_focused & emacs_like_insert_mode",
202 ),
203 Binding(
204 auto_suggest.accept,
205 ["right"],
206 "has_suggestion & default_buffer_focused & emacs_like_insert_mode",
193 207 ),
194 Binding(auto_suggest.accept, ["c-f"], "vi_insert_mode & default_buffer_focused"),
195 208 Binding(
196 209 auto_suggest.accept_word,
197 210 ["escape", "f"],
198 "vi_insert_mode & default_buffer_focused & ebivim",
211 "has_suggestion & default_buffer_focused & emacs_like_insert_mode",
199 212 ),
200 213 Binding(
201 214 auto_suggest.accept_token,
202 215 ["c-right"],
203 "has_suggestion & default_buffer_focused",
216 "has_suggestion & default_buffer_focused & emacs_like_insert_mode",
204 217 ),
205 218 Binding(
206 219 auto_suggest.discard,
207 220 ["escape"],
221 # note this one is using `emacs_insert_mode`, not `emacs_like_insert_mode`
222 # as in `vi_insert_mode` we do not want `escape` to be shadowed (ever).
208 223 "has_suggestion & default_buffer_focused & emacs_insert_mode",
209 224 ),
210 225 Binding(
211 226 auto_suggest.swap_autosuggestion_up,
212 227 ["up"],
213 228 "navigable_suggestions"
214 229 " & ~has_line_above"
215 230 " & has_suggestion"
216 231 " & default_buffer_focused",
217 232 ),
218 233 Binding(
219 234 auto_suggest.swap_autosuggestion_down,
220 235 ["down"],
221 236 "navigable_suggestions"
222 237 " & ~has_line_below"
223 238 " & has_suggestion"
224 239 " & default_buffer_focused",
225 240 ),
226 241 Binding(
227 242 auto_suggest.up_and_update_hint,
228 243 ["up"],
229 244 "has_line_above & navigable_suggestions & default_buffer_focused",
230 245 ),
231 246 Binding(
232 247 auto_suggest.down_and_update_hint,
233 248 ["down"],
234 249 "has_line_below & navigable_suggestions & default_buffer_focused",
235 250 ),
236 251 Binding(
237 252 auto_suggest.accept_character,
238 253 ["escape", "right"],
239 "has_suggestion & default_buffer_focused",
254 "has_suggestion & default_buffer_focused & emacs_like_insert_mode",
240 255 ),
241 256 Binding(
242 257 auto_suggest.accept_and_move_cursor_left,
243 258 ["c-left"],
244 "has_suggestion & default_buffer_focused",
259 "has_suggestion & default_buffer_focused & emacs_like_insert_mode",
245 260 ),
246 261 Binding(
247 262 auto_suggest.accept_and_keep_cursor,
248 263 ["c-down"],
249 "has_suggestion & default_buffer_focused",
264 "has_suggestion & default_buffer_focused & emacs_like_insert_mode",
250 265 ),
251 266 Binding(
252 267 auto_suggest.backspace_and_resume_hint,
253 268 ["backspace"],
254 "has_suggestion & default_buffer_focused",
269 "has_suggestion & default_buffer_focused & emacs_like_insert_mode",
255 270 ),
256 271 ]
257 272
258 273
259 274 SIMPLE_CONTROL_BINDINGS = [
260 275 Binding(cmd, [key], "vi_insert_mode & default_buffer_focused & ebivim")
261 276 for key, cmd in {
262 277 "c-a": nc.beginning_of_line,
263 278 "c-b": nc.backward_char,
264 279 "c-k": nc.kill_line,
265 280 "c-w": nc.backward_kill_word,
266 281 "c-y": nc.yank,
267 282 "c-_": nc.undo,
268 283 }.items()
269 284 ]
270 285
271 286
272 287 ALT_AND_COMOBO_CONTROL_BINDINGS = [
273 288 Binding(cmd, list(keys), "vi_insert_mode & default_buffer_focused & ebivim")
274 289 for keys, cmd in {
275 290 # Control Combos
276 291 ("c-x", "c-e"): nc.edit_and_execute,
277 292 ("c-x", "e"): nc.edit_and_execute,
278 293 # Alt
279 294 ("escape", "b"): nc.backward_word,
280 295 ("escape", "c"): nc.capitalize_word,
281 296 ("escape", "d"): nc.kill_word,
282 297 ("escape", "h"): nc.backward_kill_word,
283 298 ("escape", "l"): nc.downcase_word,
284 299 ("escape", "u"): nc.uppercase_word,
285 300 ("escape", "y"): nc.yank_pop,
286 301 ("escape", "."): nc.yank_last_arg,
287 302 }.items()
288 303 ]
289 304
290 305
291 306 def add_binding(bindings: KeyBindings, binding: Binding):
292 307 bindings.add(
293 308 *binding.keys,
294 309 **({"filter": binding.filter} if binding.filter is not None else {}),
295 310 )(binding.command)
296 311
297 312
298 313 def create_ipython_shortcuts(shell, skip=None) -> KeyBindings:
299 314 """Set up the prompt_toolkit keyboard shortcuts for IPython.
300 315
301 316 Parameters
302 317 ----------
303 318 shell: InteractiveShell
304 319 The current IPython shell Instance
305 320 skip: List[Binding]
306 321 Bindings to skip.
307 322
308 323 Returns
309 324 -------
310 325 KeyBindings
311 326 the keybinding instance for prompt toolkit.
312 327
313 328 """
314 329 kb = KeyBindings()
315 330 skip = skip or []
316 331 for binding in KEY_BINDINGS:
317 332 skip_this_one = False
318 333 for to_skip in skip:
319 334 if (
320 335 to_skip.command == binding.command
321 336 and to_skip.filter == binding.filter
322 337 and to_skip.keys == binding.keys
323 338 ):
324 339 skip_this_one = True
325 340 break
326 341 if skip_this_one:
327 342 continue
328 343 add_binding(kb, binding)
329 344
330 345 def get_input_mode(self):
331 346 app = get_app()
332 347 app.ttimeoutlen = shell.ttimeoutlen
333 348 app.timeoutlen = shell.timeoutlen
334 349
335 350 return self._input_mode
336 351
337 352 def set_input_mode(self, mode):
338 353 shape = {InputMode.NAVIGATION: 2, InputMode.REPLACE: 4}.get(mode, 6)
339 354 cursor = "\x1b[{} q".format(shape)
340 355
341 356 sys.stdout.write(cursor)
342 357 sys.stdout.flush()
343 358
344 359 self._input_mode = mode
345 360
346 361 if shell.editing_mode == "vi" and shell.modal_cursor:
347 362 ViState._input_mode = InputMode.INSERT # type: ignore
348 363 ViState.input_mode = property(get_input_mode, set_input_mode) # type: ignore
349 364
350 365 return kb
351 366
352 367
353 368 def reformat_and_execute(event):
354 369 """Reformat code and execute it"""
355 370 shell = get_ipython()
356 371 reformat_text_before_cursor(
357 372 event.current_buffer, event.current_buffer.document, shell
358 373 )
359 374 event.current_buffer.validate_and_handle()
360 375
361 376
362 377 def reformat_text_before_cursor(buffer, document, shell):
363 378 text = buffer.delete_before_cursor(len(document.text[: document.cursor_position]))
364 379 try:
365 380 formatted_text = shell.reformat_handler(text)
366 381 buffer.insert_text(formatted_text)
367 382 except Exception as e:
368 383 buffer.insert_text(text)
369 384
370 385
371 386 def handle_return_or_newline_or_execute(event):
372 387 shell = get_ipython()
373 388 if getattr(shell, "handle_return", None):
374 389 return shell.handle_return(shell)(event)
375 390 else:
376 391 return newline_or_execute_outer(shell)(event)
377 392
378 393
379 394 def newline_or_execute_outer(shell):
380 395 def newline_or_execute(event):
381 396 """When the user presses return, insert a newline or execute the code."""
382 397 b = event.current_buffer
383 398 d = b.document
384 399
385 400 if b.complete_state:
386 401 cc = b.complete_state.current_completion
387 402 if cc:
388 403 b.apply_completion(cc)
389 404 else:
390 405 b.cancel_completion()
391 406 return
392 407
393 408 # If there's only one line, treat it as if the cursor is at the end.
394 409 # See https://github.com/ipython/ipython/issues/10425
395 410 if d.line_count == 1:
396 411 check_text = d.text
397 412 else:
398 413 check_text = d.text[: d.cursor_position]
399 414 status, indent = shell.check_complete(check_text)
400 415
401 416 # if all we have after the cursor is whitespace: reformat current text
402 417 # before cursor
403 418 after_cursor = d.text[d.cursor_position :]
404 419 reformatted = False
405 420 if not after_cursor.strip():
406 421 reformat_text_before_cursor(b, d, shell)
407 422 reformatted = True
408 423 if not (
409 424 d.on_last_line
410 425 or d.cursor_position_row >= d.line_count - d.empty_line_count_at_the_end()
411 426 ):
412 427 if shell.autoindent:
413 428 b.insert_text("\n" + indent)
414 429 else:
415 430 b.insert_text("\n")
416 431 return
417 432
418 433 if (status != "incomplete") and b.accept_handler:
419 434 if not reformatted:
420 435 reformat_text_before_cursor(b, d, shell)
421 436 b.validate_and_handle()
422 437 else:
423 438 if shell.autoindent:
424 439 b.insert_text("\n" + indent)
425 440 else:
426 441 b.insert_text("\n")
427 442
428 443 return newline_or_execute
429 444
430 445
431 446 def previous_history_or_previous_completion(event):
432 447 """
433 448 Control-P in vi edit mode on readline is history next, unlike default prompt toolkit.
434 449
435 450 If completer is open this still select previous completion.
436 451 """
437 452 event.current_buffer.auto_up()
438 453
439 454
440 455 def next_history_or_next_completion(event):
441 456 """
442 457 Control-N in vi edit mode on readline is history previous, unlike default prompt toolkit.
443 458
444 459 If completer is open this still select next completion.
445 460 """
446 461 event.current_buffer.auto_down()
447 462
448 463
449 464 def dismiss_completion(event):
450 465 """Dismiss completion"""
451 466 b = event.current_buffer
452 467 if b.complete_state:
453 468 b.cancel_completion()
454 469
455 470
456 471 def reset_buffer(event):
457 472 """Reset buffer"""
458 473 b = event.current_buffer
459 474 if b.complete_state:
460 475 b.cancel_completion()
461 476 else:
462 477 b.reset()
463 478
464 479
465 480 def reset_search_buffer(event):
466 481 """Reset search buffer"""
467 482 if event.current_buffer.document.text:
468 483 event.current_buffer.reset()
469 484 else:
470 485 event.app.layout.focus(DEFAULT_BUFFER)
471 486
472 487
473 488 def suspend_to_bg(event):
474 489 """Suspend to background"""
475 490 event.app.suspend_to_background()
476 491
477 492
478 493 def quit(event):
479 494 """
480 495 Quit application with ``SIGQUIT`` if supported or ``sys.exit`` otherwise.
481 496
482 497 On platforms that support SIGQUIT, send SIGQUIT to the current process.
483 498 On other platforms, just exit the process with a message.
484 499 """
485 500 sigquit = getattr(signal, "SIGQUIT", None)
486 501 if sigquit is not None:
487 502 os.kill(0, signal.SIGQUIT)
488 503 else:
489 504 sys.exit("Quit")
490 505
491 506
492 507 def indent_buffer(event):
493 508 """Indent buffer"""
494 509 event.current_buffer.insert_text(" " * 4)
495 510
496 511
497 512 def newline_autoindent(event):
498 513 """Insert a newline after the cursor indented appropriately.
499 514
500 515 Fancier version of former ``newline_with_copy_margin`` which should
501 516 compute the correct indentation of the inserted line. That is to say, indent
502 517 by 4 extra space after a function definition, class definition, context
503 518 manager... And dedent by 4 space after ``pass``, ``return``, ``raise ...``.
504 519 """
505 520 shell = get_ipython()
506 521 inputsplitter = shell.input_transformer_manager
507 522 b = event.current_buffer
508 523 d = b.document
509 524
510 525 if b.complete_state:
511 526 b.cancel_completion()
512 527 text = d.text[: d.cursor_position] + "\n"
513 528 _, indent = inputsplitter.check_complete(text)
514 529 b.insert_text("\n" + (" " * (indent or 0)), move_cursor=False)
515 530
516 531
517 532 def open_input_in_editor(event):
518 533 """Open code from input in external editor"""
519 534 event.app.current_buffer.open_in_editor()
520 535
521 536
522 537 if sys.platform == "win32":
523 538 from IPython.core.error import TryNext
524 539 from IPython.lib.clipboard import (
525 540 ClipboardEmpty,
526 541 tkinter_clipboard_get,
527 542 win32_clipboard_get,
528 543 )
529 544
530 545 @undoc
531 546 def win_paste(event):
532 547 try:
533 548 text = win32_clipboard_get()
534 549 except TryNext:
535 550 try:
536 551 text = tkinter_clipboard_get()
537 552 except (TryNext, ClipboardEmpty):
538 553 return
539 554 except ClipboardEmpty:
540 555 return
541 556 event.current_buffer.insert_text(text.replace("\t", " " * 4))
542 557
543 558 else:
544 559
545 560 @undoc
546 561 def win_paste(event):
547 562 """Stub used on other platforms"""
548 563 pass
549 564
550 565
551 566 KEY_BINDINGS = [
552 567 Binding(
553 568 handle_return_or_newline_or_execute,
554 569 ["enter"],
555 570 "default_buffer_focused & ~has_selection & insert_mode",
556 571 ),
557 572 Binding(
558 573 reformat_and_execute,
559 574 ["escape", "enter"],
560 575 "default_buffer_focused & ~has_selection & insert_mode & ebivim",
561 576 ),
562 577 Binding(quit, ["c-\\"]),
563 578 Binding(
564 579 previous_history_or_previous_completion,
565 580 ["c-p"],
566 581 "vi_insert_mode & default_buffer_focused",
567 582 ),
568 583 Binding(
569 584 next_history_or_next_completion,
570 585 ["c-n"],
571 586 "vi_insert_mode & default_buffer_focused",
572 587 ),
573 588 Binding(dismiss_completion, ["c-g"], "default_buffer_focused & has_completions"),
574 589 Binding(reset_buffer, ["c-c"], "default_buffer_focused"),
575 590 Binding(reset_search_buffer, ["c-c"], "search_buffer_focused"),
576 591 Binding(suspend_to_bg, ["c-z"], "supports_suspend"),
577 592 Binding(
578 593 indent_buffer,
579 594 ["tab"], # Ctrl+I == Tab
580 595 "default_buffer_focused"
581 596 " & ~has_selection"
582 597 " & insert_mode"
583 598 " & cursor_in_leading_ws",
584 599 ),
585 600 Binding(newline_autoindent, ["c-o"], "default_buffer_focused & emacs_insert_mode"),
586 601 Binding(open_input_in_editor, ["f2"], "default_buffer_focused"),
587 602 *AUTO_MATCH_BINDINGS,
588 603 *AUTO_SUGGEST_BINDINGS,
589 604 Binding(
590 605 display_completions_like_readline,
591 606 ["c-i"],
592 607 "readline_like_completions"
593 608 " & default_buffer_focused"
594 609 " & ~has_selection"
595 610 " & insert_mode"
596 611 " & ~cursor_in_leading_ws",
597 612 ),
598 613 Binding(win_paste, ["c-v"], "default_buffer_focused & ~vi_mode & is_windows_os"),
599 614 *SIMPLE_CONTROL_BINDINGS,
600 615 *ALT_AND_COMOBO_CONTROL_BINDINGS,
601 616 ]
@@ -1,375 +1,383 b''
1 1 import re
2 2 import tokenize
3 3 from io import StringIO
4 4 from typing import Callable, List, Optional, Union, Generator, Tuple
5 5
6 6 from prompt_toolkit.buffer import Buffer
7 7 from prompt_toolkit.key_binding import KeyPressEvent
8 8 from prompt_toolkit.key_binding.bindings import named_commands as nc
9 9 from prompt_toolkit.auto_suggest import AutoSuggestFromHistory, Suggestion
10 10 from prompt_toolkit.document import Document
11 11 from prompt_toolkit.history import History
12 12 from prompt_toolkit.shortcuts import PromptSession
13 13 from prompt_toolkit.layout.processors import (
14 14 Processor,
15 15 Transformation,
16 16 TransformationInput,
17 17 )
18 18
19 19 from IPython.core.getipython import get_ipython
20 20 from IPython.utils.tokenutil import generate_tokens
21 21
22 22
23 23 def _get_query(document: Document):
24 24 return document.lines[document.cursor_position_row]
25 25
26 26
27 27 class AppendAutoSuggestionInAnyLine(Processor):
28 28 """
29 29 Append the auto suggestion to lines other than the last (appending to the
30 30 last line is natively supported by the prompt toolkit).
31 31 """
32 32
33 33 def __init__(self, style: str = "class:auto-suggestion") -> None:
34 34 self.style = style
35 35
36 36 def apply_transformation(self, ti: TransformationInput) -> Transformation:
37 37 is_last_line = ti.lineno == ti.document.line_count - 1
38 38 is_active_line = ti.lineno == ti.document.cursor_position_row
39 39
40 40 if not is_last_line and is_active_line:
41 41 buffer = ti.buffer_control.buffer
42 42
43 43 if buffer.suggestion and ti.document.is_cursor_at_the_end_of_line:
44 44 suggestion = buffer.suggestion.text
45 45 else:
46 46 suggestion = ""
47 47
48 48 return Transformation(fragments=ti.fragments + [(self.style, suggestion)])
49 49 else:
50 50 return Transformation(fragments=ti.fragments)
51 51
52 52
53 53 class NavigableAutoSuggestFromHistory(AutoSuggestFromHistory):
54 54 """
55 55 A subclass of AutoSuggestFromHistory that allow navigation to next/previous
56 56 suggestion from history. To do so it remembers the current position, but it
57 57 state need to carefully be cleared on the right events.
58 58 """
59 59
60 60 def __init__(
61 61 self,
62 62 ):
63 63 self.skip_lines = 0
64 64 self._connected_apps = []
65 65
66 66 def reset_history_position(self, _: Buffer):
67 67 self.skip_lines = 0
68 68
69 69 def disconnect(self):
70 70 for pt_app in self._connected_apps:
71 71 text_insert_event = pt_app.default_buffer.on_text_insert
72 72 text_insert_event.remove_handler(self.reset_history_position)
73 73
74 74 def connect(self, pt_app: PromptSession):
75 75 self._connected_apps.append(pt_app)
76 76 # note: `on_text_changed` could be used for a bit different behaviour
77 77 # on character deletion (i.e. reseting history position on backspace)
78 78 pt_app.default_buffer.on_text_insert.add_handler(self.reset_history_position)
79 79 pt_app.default_buffer.on_cursor_position_changed.add_handler(self._dismiss)
80 80
81 81 def get_suggestion(
82 82 self, buffer: Buffer, document: Document
83 83 ) -> Optional[Suggestion]:
84 84 text = _get_query(document)
85 85
86 86 if text.strip():
87 87 for suggestion, _ in self._find_next_match(
88 88 text, self.skip_lines, buffer.history
89 89 ):
90 90 return Suggestion(suggestion)
91 91
92 92 return None
93 93
94 94 def _dismiss(self, buffer, *args, **kwargs):
95 95 buffer.suggestion = None
96 96
97 97 def _find_match(
98 98 self, text: str, skip_lines: float, history: History, previous: bool
99 99 ) -> Generator[Tuple[str, float], None, None]:
100 100 """
101 101 text : str
102 102 Text content to find a match for, the user cursor is most of the
103 103 time at the end of this text.
104 104 skip_lines : float
105 105 number of items to skip in the search, this is used to indicate how
106 106 far in the list the user has navigated by pressing up or down.
107 107 The float type is used as the base value is +inf
108 108 history : History
109 109 prompt_toolkit History instance to fetch previous entries from.
110 110 previous : bool
111 111 Direction of the search, whether we are looking previous match
112 112 (True), or next match (False).
113 113
114 114 Yields
115 115 ------
116 116 Tuple with:
117 117 str:
118 118 current suggestion.
119 119 float:
120 120 will actually yield only ints, which is passed back via skip_lines,
121 121 which may be a +inf (float)
122 122
123 123
124 124 """
125 125 line_number = -1
126 126 for string in reversed(list(history.get_strings())):
127 127 for line in reversed(string.splitlines()):
128 128 line_number += 1
129 129 if not previous and line_number < skip_lines:
130 130 continue
131 131 # do not return empty suggestions as these
132 132 # close the auto-suggestion overlay (and are useless)
133 133 if line.startswith(text) and len(line) > len(text):
134 134 yield line[len(text) :], line_number
135 135 if previous and line_number >= skip_lines:
136 136 return
137 137
138 138 def _find_next_match(
139 139 self, text: str, skip_lines: float, history: History
140 140 ) -> Generator[Tuple[str, float], None, None]:
141 141 return self._find_match(text, skip_lines, history, previous=False)
142 142
143 143 def _find_previous_match(self, text: str, skip_lines: float, history: History):
144 144 return reversed(
145 145 list(self._find_match(text, skip_lines, history, previous=True))
146 146 )
147 147
148 148 def up(self, query: str, other_than: str, history: History) -> None:
149 149 for suggestion, line_number in self._find_next_match(
150 150 query, self.skip_lines, history
151 151 ):
152 152 # if user has history ['very.a', 'very', 'very.b'] and typed 'very'
153 153 # we want to switch from 'very.b' to 'very.a' because a) if the
154 154 # suggestion equals current text, prompt-toolkit aborts suggesting
155 155 # b) user likely would not be interested in 'very' anyways (they
156 156 # already typed it).
157 157 if query + suggestion != other_than:
158 158 self.skip_lines = line_number
159 159 break
160 160 else:
161 161 # no matches found, cycle back to beginning
162 162 self.skip_lines = 0
163 163
164 164 def down(self, query: str, other_than: str, history: History) -> None:
165 165 for suggestion, line_number in self._find_previous_match(
166 166 query, self.skip_lines, history
167 167 ):
168 168 if query + suggestion != other_than:
169 169 self.skip_lines = line_number
170 170 break
171 171 else:
172 172 # no matches found, cycle to end
173 173 for suggestion, line_number in self._find_previous_match(
174 174 query, float("Inf"), history
175 175 ):
176 176 if query + suggestion != other_than:
177 177 self.skip_lines = line_number
178 178 break
179 179
180 180
181 # Needed for to accept autosuggestions in vi insert mode
182 def accept_in_vi_insert_mode(event: KeyPressEvent):
183 """Apply autosuggestion if at end of line."""
181 def accept_or_jump_to_end(event: KeyPressEvent):
182 """Apply autosuggestion or jump to end of line."""
184 183 buffer = event.current_buffer
185 184 d = buffer.document
186 185 after_cursor = d.text[d.cursor_position :]
187 186 lines = after_cursor.split("\n")
188 187 end_of_current_line = lines[0].strip()
189 188 suggestion = buffer.suggestion
190 189 if (suggestion is not None) and (suggestion.text) and (end_of_current_line == ""):
191 190 buffer.insert_text(suggestion.text)
192 191 else:
193 192 nc.end_of_line(event)
194 193
195 194
195 def accept_in_vi_insert_mode(event: KeyPressEvent):
196 """Accept autosuggestion or jump to end of line.
197
198 .. deprecated:: 8.12
199 Use `accept_or_jump_to_end` instead.
200 """
201 return accept_or_jump_to_end(event)
202
203
196 204 def accept(event: KeyPressEvent):
197 205 """Accept autosuggestion"""
198 206 buffer = event.current_buffer
199 207 suggestion = buffer.suggestion
200 208 if suggestion:
201 209 buffer.insert_text(suggestion.text)
202 210 else:
203 211 nc.forward_char(event)
204 212
205 213
206 214 def discard(event: KeyPressEvent):
207 215 """Discard autosuggestion"""
208 216 buffer = event.current_buffer
209 217 buffer.suggestion = None
210 218
211 219
212 220 def accept_word(event: KeyPressEvent):
213 221 """Fill partial autosuggestion by word"""
214 222 buffer = event.current_buffer
215 223 suggestion = buffer.suggestion
216 224 if suggestion:
217 225 t = re.split(r"(\S+\s+)", suggestion.text)
218 226 buffer.insert_text(next((x for x in t if x), ""))
219 227 else:
220 228 nc.forward_word(event)
221 229
222 230
223 231 def accept_character(event: KeyPressEvent):
224 232 """Fill partial autosuggestion by character"""
225 233 b = event.current_buffer
226 234 suggestion = b.suggestion
227 235 if suggestion and suggestion.text:
228 236 b.insert_text(suggestion.text[0])
229 237
230 238
231 239 def accept_and_keep_cursor(event: KeyPressEvent):
232 240 """Accept autosuggestion and keep cursor in place"""
233 241 buffer = event.current_buffer
234 242 old_position = buffer.cursor_position
235 243 suggestion = buffer.suggestion
236 244 if suggestion:
237 245 buffer.insert_text(suggestion.text)
238 246 buffer.cursor_position = old_position
239 247
240 248
241 249 def accept_and_move_cursor_left(event: KeyPressEvent):
242 250 """Accept autosuggestion and move cursor left in place"""
243 251 accept_and_keep_cursor(event)
244 252 nc.backward_char(event)
245 253
246 254
247 255 def _update_hint(buffer: Buffer):
248 256 if buffer.auto_suggest:
249 257 suggestion = buffer.auto_suggest.get_suggestion(buffer, buffer.document)
250 258 buffer.suggestion = suggestion
251 259
252 260
253 261 def backspace_and_resume_hint(event: KeyPressEvent):
254 262 """Resume autosuggestions after deleting last character"""
255 263 current_buffer = event.current_buffer
256 264
257 265 def resume_hinting(buffer: Buffer):
258 266 _update_hint(buffer)
259 267 current_buffer.on_text_changed.remove_handler(resume_hinting)
260 268
261 269 current_buffer.on_text_changed.add_handler(resume_hinting)
262 270 nc.backward_delete_char(event)
263 271
264 272
265 273 def up_and_update_hint(event: KeyPressEvent):
266 274 """Go up and update hint"""
267 275 current_buffer = event.current_buffer
268 276
269 277 current_buffer.auto_up(count=event.arg)
270 278 _update_hint(current_buffer)
271 279
272 280
273 281 def down_and_update_hint(event: KeyPressEvent):
274 282 """Go down and update hint"""
275 283 current_buffer = event.current_buffer
276 284
277 285 current_buffer.auto_down(count=event.arg)
278 286 _update_hint(current_buffer)
279 287
280 288
281 289 def accept_token(event: KeyPressEvent):
282 290 """Fill partial autosuggestion by token"""
283 291 b = event.current_buffer
284 292 suggestion = b.suggestion
285 293
286 294 if suggestion:
287 295 prefix = _get_query(b.document)
288 296 text = prefix + suggestion.text
289 297
290 298 tokens: List[Optional[str]] = [None, None, None]
291 299 substrings = [""]
292 300 i = 0
293 301
294 302 for token in generate_tokens(StringIO(text).readline):
295 303 if token.type == tokenize.NEWLINE:
296 304 index = len(text)
297 305 else:
298 306 index = text.index(token[1], len(substrings[-1]))
299 307 substrings.append(text[:index])
300 308 tokenized_so_far = substrings[-1]
301 309 if tokenized_so_far.startswith(prefix):
302 310 if i == 0 and len(tokenized_so_far) > len(prefix):
303 311 tokens[0] = tokenized_so_far[len(prefix) :]
304 312 substrings.append(tokenized_so_far)
305 313 i += 1
306 314 tokens[i] = token[1]
307 315 if i == 2:
308 316 break
309 317 i += 1
310 318
311 319 if tokens[0]:
312 320 to_insert: str
313 321 insert_text = substrings[-2]
314 322 if tokens[1] and len(tokens[1]) == 1:
315 323 insert_text = substrings[-1]
316 324 to_insert = insert_text[len(prefix) :]
317 325 b.insert_text(to_insert)
318 326 return
319 327
320 328 nc.forward_word(event)
321 329
322 330
323 331 Provider = Union[AutoSuggestFromHistory, NavigableAutoSuggestFromHistory, None]
324 332
325 333
326 334 def _swap_autosuggestion(
327 335 buffer: Buffer,
328 336 provider: NavigableAutoSuggestFromHistory,
329 337 direction_method: Callable,
330 338 ):
331 339 """
332 340 We skip most recent history entry (in either direction) if it equals the
333 341 current autosuggestion because if user cycles when auto-suggestion is shown
334 342 they most likely want something else than what was suggested (otherwise
335 343 they would have accepted the suggestion).
336 344 """
337 345 suggestion = buffer.suggestion
338 346 if not suggestion:
339 347 return
340 348
341 349 query = _get_query(buffer.document)
342 350 current = query + suggestion.text
343 351
344 352 direction_method(query=query, other_than=current, history=buffer.history)
345 353
346 354 new_suggestion = provider.get_suggestion(buffer, buffer.document)
347 355 buffer.suggestion = new_suggestion
348 356
349 357
350 358 def swap_autosuggestion_up(event: KeyPressEvent):
351 359 """Get next autosuggestion from history."""
352 360 shell = get_ipython()
353 361 provider = shell.auto_suggest
354 362
355 363 if not isinstance(provider, NavigableAutoSuggestFromHistory):
356 364 return
357 365
358 366 return _swap_autosuggestion(
359 367 buffer=event.current_buffer, provider=provider, direction_method=provider.up
360 368 )
361 369
362 370
363 371 def swap_autosuggestion_down(event: KeyPressEvent):
364 372 """Get previous autosuggestion from history."""
365 373 shell = get_ipython()
366 374 provider = shell.auto_suggest
367 375
368 376 if not isinstance(provider, NavigableAutoSuggestFromHistory):
369 377 return
370 378
371 379 return _swap_autosuggestion(
372 380 buffer=event.current_buffer,
373 381 provider=provider,
374 382 direction_method=provider.down,
375 383 )
@@ -1,256 +1,279 b''
1 1 """
2 2 Filters restricting scope of IPython Terminal shortcuts.
3 3 """
4 4
5 5 # Copyright (c) IPython Development Team.
6 6 # Distributed under the terms of the Modified BSD License.
7 7
8 8 import ast
9 9 import re
10 10 import signal
11 11 import sys
12 12 from typing import Callable, Dict, Union
13 13
14 14 from prompt_toolkit.application.current import get_app
15 15 from prompt_toolkit.enums import DEFAULT_BUFFER, SEARCH_BUFFER
16 16 from prompt_toolkit.filters import Condition, emacs_insert_mode, has_completions
17 17 from prompt_toolkit.filters import has_focus as has_focus_impl
18 18 from prompt_toolkit.filters import (
19 19 Always,
20 20 has_selection,
21 21 has_suggestion,
22 22 vi_insert_mode,
23 23 vi_mode,
24 24 )
25 25 from prompt_toolkit.layout.layout import FocusableElement
26 26
27 27 from IPython.core.getipython import get_ipython
28 28 from IPython.core.guarded_eval import _find_dunder, BINARY_OP_DUNDERS, UNARY_OP_DUNDERS
29 29 from IPython.terminal.shortcuts import auto_suggest
30 30 from IPython.utils.decorators import undoc
31 31
32 32
33 33 @undoc
34 34 @Condition
35 35 def cursor_in_leading_ws():
36 36 before = get_app().current_buffer.document.current_line_before_cursor
37 37 return (not before) or before.isspace()
38 38
39 39
40 40 def has_focus(value: FocusableElement):
41 41 """Wrapper around has_focus adding a nice `__name__` to tester function"""
42 42 tester = has_focus_impl(value).func
43 43 tester.__name__ = f"is_focused({value})"
44 44 return Condition(tester)
45 45
46 46
47 47 @undoc
48 48 @Condition
49 49 def has_line_below() -> bool:
50 50 document = get_app().current_buffer.document
51 51 return document.cursor_position_row < len(document.lines) - 1
52 52
53 53
54 54 @undoc
55 55 @Condition
56 56 def has_line_above() -> bool:
57 57 document = get_app().current_buffer.document
58 58 return document.cursor_position_row != 0
59 59
60 60
61 61 @Condition
62 62 def ebivim():
63 63 shell = get_ipython()
64 64 return shell.emacs_bindings_in_vi_insert_mode
65 65
66 66
67 67 @Condition
68 68 def supports_suspend():
69 69 return hasattr(signal, "SIGTSTP")
70 70
71 71
72 72 @Condition
73 73 def auto_match():
74 74 shell = get_ipython()
75 75 return shell.auto_match
76 76
77 77
78 78 def all_quotes_paired(quote, buf):
79 79 paired = True
80 80 i = 0
81 81 while i < len(buf):
82 82 c = buf[i]
83 83 if c == quote:
84 84 paired = not paired
85 85 elif c == "\\":
86 86 i += 1
87 87 i += 1
88 88 return paired
89 89
90 90
91 91 _preceding_text_cache: Dict[Union[str, Callable], Condition] = {}
92 92 _following_text_cache: Dict[Union[str, Callable], Condition] = {}
93 93
94 94
95 95 def preceding_text(pattern: Union[str, Callable]):
96 96 if pattern in _preceding_text_cache:
97 97 return _preceding_text_cache[pattern]
98 98
99 99 if callable(pattern):
100 100
101 101 def _preceding_text():
102 102 app = get_app()
103 103 before_cursor = app.current_buffer.document.current_line_before_cursor
104 104 # mypy can't infer if(callable): https://github.com/python/mypy/issues/3603
105 105 return bool(pattern(before_cursor)) # type: ignore[operator]
106 106
107 107 else:
108 108 m = re.compile(pattern)
109 109
110 110 def _preceding_text():
111 111 app = get_app()
112 112 before_cursor = app.current_buffer.document.current_line_before_cursor
113 113 return bool(m.match(before_cursor))
114 114
115 115 _preceding_text.__name__ = f"preceding_text({pattern!r})"
116 116
117 117 condition = Condition(_preceding_text)
118 118 _preceding_text_cache[pattern] = condition
119 119 return condition
120 120
121 121
122 122 def following_text(pattern):
123 123 try:
124 124 return _following_text_cache[pattern]
125 125 except KeyError:
126 126 pass
127 127 m = re.compile(pattern)
128 128
129 129 def _following_text():
130 130 app = get_app()
131 131 return bool(m.match(app.current_buffer.document.current_line_after_cursor))
132 132
133 133 _following_text.__name__ = f"following_text({pattern!r})"
134 134
135 135 condition = Condition(_following_text)
136 136 _following_text_cache[pattern] = condition
137 137 return condition
138 138
139 139
140 140 @Condition
141 141 def not_inside_unclosed_string():
142 142 app = get_app()
143 143 s = app.current_buffer.document.text_before_cursor
144 144 # remove escaped quotes
145 145 s = s.replace('\\"', "").replace("\\'", "")
146 146 # remove triple-quoted string literals
147 147 s = re.sub(r"(?:\"\"\"[\s\S]*\"\"\"|'''[\s\S]*''')", "", s)
148 148 # remove single-quoted string literals
149 149 s = re.sub(r"""(?:"[^"]*["\n]|'[^']*['\n])""", "", s)
150 150 return not ('"' in s or "'" in s)
151 151
152 152
153 153 @Condition
154 154 def navigable_suggestions():
155 155 shell = get_ipython()
156 156 return isinstance(shell.auto_suggest, auto_suggest.NavigableAutoSuggestFromHistory)
157 157
158 158
159 159 @Condition
160 160 def readline_like_completions():
161 161 shell = get_ipython()
162 162 return shell.display_completions == "readlinelike"
163 163
164 164
165 165 @Condition
166 166 def is_windows_os():
167 167 return sys.platform == "win32"
168 168
169 169
170 170 # these one is callable and re-used multiple times hence needs to be
171 171 # only defined once beforhand so that transforming back to human-readable
172 172 # names works well in the documentation.
173 173 default_buffer_focused = has_focus(DEFAULT_BUFFER)
174 174
175 175 KEYBINDING_FILTERS = {
176 176 "always": Always(),
177 177 "has_line_below": has_line_below,
178 178 "has_line_above": has_line_above,
179 179 "has_selection": has_selection,
180 180 "has_suggestion": has_suggestion,
181 181 "vi_mode": vi_mode,
182 182 "vi_insert_mode": vi_insert_mode,
183 183 "emacs_insert_mode": emacs_insert_mode,
184 # https://github.com/ipython/ipython/pull/12603 argued for inclusion of
185 # emacs key bindings with a configurable `emacs_bindings_in_vi_insert_mode`
186 # toggle; when the toggle is on user can access keybindigns like `ctrl + e`
187 # in vi insert mode. Because some of the emacs bindings involve `escape`
188 # followed by another key, e.g. `escape` followed by `f`, prompt-toolkit
189 # needs to wait to see if there will be another character typed in before
190 # executing pure `escape` keybinding; in vi insert mode `escape` switches to
191 # command mode which is common and performance critical action for vi users.
192 # To avoid the delay users employ a workaround:
193 # https://github.com/ipython/ipython/issues/13443#issuecomment-1032753703
194 # which involves switching `emacs_bindings_in_vi_insert_mode` off.
195 #
196 # For the workaround to work:
197 # 1) end users need to toggle `emacs_bindings_in_vi_insert_mode` off
198 # 2) all keybindings which would involve `escape` need to respect that
199 # toggle by including either:
200 # - `vi_insert_mode & ebivim` for actions which have emacs keybindings
201 # predefined upstream in prompt-toolkit, or
202 # - `emacs_like_insert_mode` for actions which do not have existing
203 # emacs keybindings predefined upstream (or need overriding of the
204 # upstream bindings to modify behaviour), defined below.
205 "emacs_like_insert_mode": (vi_insert_mode & ebivim) | emacs_insert_mode,
184 206 "has_completions": has_completions,
185 207 "insert_mode": vi_insert_mode | emacs_insert_mode,
186 208 "default_buffer_focused": default_buffer_focused,
187 209 "search_buffer_focused": has_focus(SEARCH_BUFFER),
210 # `ebivim` stands for emacs bindings in vi insert mode
188 211 "ebivim": ebivim,
189 212 "supports_suspend": supports_suspend,
190 213 "is_windows_os": is_windows_os,
191 214 "auto_match": auto_match,
192 215 "focused_insert": (vi_insert_mode | emacs_insert_mode) & default_buffer_focused,
193 216 "not_inside_unclosed_string": not_inside_unclosed_string,
194 217 "readline_like_completions": readline_like_completions,
195 218 "preceded_by_paired_double_quotes": preceding_text(
196 219 lambda line: all_quotes_paired('"', line)
197 220 ),
198 221 "preceded_by_paired_single_quotes": preceding_text(
199 222 lambda line: all_quotes_paired("'", line)
200 223 ),
201 224 "preceded_by_raw_str_prefix": preceding_text(r".*(r|R)[\"'](-*)$"),
202 225 "preceded_by_two_double_quotes": preceding_text(r'^.*""$'),
203 226 "preceded_by_two_single_quotes": preceding_text(r"^.*''$"),
204 227 "followed_by_closing_paren_or_end": following_text(r"[,)}\]]|$"),
205 228 "preceded_by_opening_round_paren": preceding_text(r".*\($"),
206 229 "preceded_by_opening_bracket": preceding_text(r".*\[$"),
207 230 "preceded_by_opening_brace": preceding_text(r".*\{$"),
208 231 "preceded_by_double_quote": preceding_text('.*"$'),
209 232 "preceded_by_single_quote": preceding_text(r".*'$"),
210 233 "followed_by_closing_round_paren": following_text(r"^\)"),
211 234 "followed_by_closing_bracket": following_text(r"^\]"),
212 235 "followed_by_closing_brace": following_text(r"^\}"),
213 236 "followed_by_double_quote": following_text('^"'),
214 237 "followed_by_single_quote": following_text("^'"),
215 238 "navigable_suggestions": navigable_suggestions,
216 239 "cursor_in_leading_ws": cursor_in_leading_ws,
217 240 }
218 241
219 242
220 243 def eval_node(node: Union[ast.AST, None]):
221 244 if node is None:
222 245 return None
223 246 if isinstance(node, ast.Expression):
224 247 return eval_node(node.body)
225 248 if isinstance(node, ast.BinOp):
226 249 left = eval_node(node.left)
227 250 right = eval_node(node.right)
228 251 dunders = _find_dunder(node.op, BINARY_OP_DUNDERS)
229 252 if dunders:
230 253 return getattr(left, dunders[0])(right)
231 254 raise ValueError(f"Unknown binary operation: {node.op}")
232 255 if isinstance(node, ast.UnaryOp):
233 256 value = eval_node(node.operand)
234 257 dunders = _find_dunder(node.op, UNARY_OP_DUNDERS)
235 258 if dunders:
236 259 return getattr(value, dunders[0])()
237 260 raise ValueError(f"Unknown unary operation: {node.op}")
238 261 if isinstance(node, ast.Name):
239 262 if node.id in KEYBINDING_FILTERS:
240 263 return KEYBINDING_FILTERS[node.id]
241 264 else:
242 265 sep = "\n - "
243 266 known_filters = sep.join(sorted(KEYBINDING_FILTERS))
244 267 raise NameError(
245 268 f"{node.id} is not a known shortcut filter."
246 269 f" Known filters are: {sep}{known_filters}."
247 270 )
248 271 raise ValueError("Unhandled node", ast.dump(node))
249 272
250 273
251 274 def filter_from_string(code: str):
252 275 expression = ast.parse(code, mode="eval")
253 276 return eval_node(expression)
254 277
255 278
256 279 __all__ = ["KEYBINDING_FILTERS", "filter_from_string"]
General Comments 0
You need to be logged in to leave comments. Login now