##// END OF EJS Templates
Discard auto-suggestion on `Esc`
krassowski -
Show More
@@ -1,635 +1,636
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 re
11 11 import signal
12 12 import sys
13 13 import warnings
14 14 from typing import Callable, Dict, Union
15 15
16 16 from prompt_toolkit.application.current import get_app
17 17 from prompt_toolkit.enums import DEFAULT_BUFFER, SEARCH_BUFFER
18 18 from prompt_toolkit.filters import Condition, emacs_insert_mode, has_completions
19 19 from prompt_toolkit.filters import has_focus as has_focus_impl
20 20 from prompt_toolkit.filters import (
21 21 has_selection,
22 22 has_suggestion,
23 23 vi_insert_mode,
24 24 vi_mode,
25 25 )
26 26 from prompt_toolkit.key_binding import KeyBindings
27 27 from prompt_toolkit.key_binding.bindings import named_commands as nc
28 28 from prompt_toolkit.key_binding.bindings.completion import (
29 29 display_completions_like_readline,
30 30 )
31 31 from prompt_toolkit.key_binding.vi_state import InputMode, ViState
32 32 from prompt_toolkit.layout.layout import FocusableElement
33 33
34 34 from IPython.terminal.shortcuts import auto_match as match
35 35 from IPython.terminal.shortcuts import auto_suggest
36 36 from IPython.utils.decorators import undoc
37 37
38 38 __all__ = ["create_ipython_shortcuts"]
39 39
40 40
41 41 @undoc
42 42 @Condition
43 43 def cursor_in_leading_ws():
44 44 before = get_app().current_buffer.document.current_line_before_cursor
45 45 return (not before) or before.isspace()
46 46
47 47
48 48 def has_focus(value: FocusableElement):
49 49 """Wrapper around has_focus adding a nice `__name__` to tester function"""
50 50 tester = has_focus_impl(value).func
51 51 tester.__name__ = f"is_focused({value})"
52 52 return Condition(tester)
53 53
54 54
55 55 def create_ipython_shortcuts(shell, for_all_platforms: bool = False) -> KeyBindings:
56 56 """Set up the prompt_toolkit keyboard shortcuts for IPython.
57 57
58 58 Parameters
59 59 ----------
60 60 shell: InteractiveShell
61 61 The current IPython shell Instance
62 62 for_all_platforms: bool (default false)
63 63 This parameter is mostly used in generating the documentation
64 64 to create the shortcut binding for all the platforms, and export
65 65 them.
66 66
67 67 Returns
68 68 -------
69 69 KeyBindings
70 70 the keybinding instance for prompt toolkit.
71 71
72 72 """
73 73 # Warning: if possible, do NOT define handler functions in the locals
74 74 # scope of this function, instead define functions in the global
75 75 # scope, or a separate module, and include a user-friendly docstring
76 76 # describing the action.
77 77
78 78 kb = KeyBindings()
79 79 insert_mode = vi_insert_mode | emacs_insert_mode
80 80
81 81 if getattr(shell, "handle_return", None):
82 82 return_handler = shell.handle_return(shell)
83 83 else:
84 84 return_handler = newline_or_execute_outer(shell)
85 85
86 86 kb.add("enter", filter=(has_focus(DEFAULT_BUFFER) & ~has_selection & insert_mode))(
87 87 return_handler
88 88 )
89 89
90 90 @Condition
91 91 def ebivim():
92 92 return shell.emacs_bindings_in_vi_insert_mode
93 93
94 94 @kb.add(
95 95 "escape",
96 96 "enter",
97 97 filter=(has_focus(DEFAULT_BUFFER) & ~has_selection & insert_mode & ebivim),
98 98 )
99 99 def reformat_and_execute(event):
100 100 """Reformat code and execute it"""
101 101 reformat_text_before_cursor(
102 102 event.current_buffer, event.current_buffer.document, shell
103 103 )
104 104 event.current_buffer.validate_and_handle()
105 105
106 106 kb.add("c-\\")(quit)
107 107
108 108 kb.add("c-p", filter=(vi_insert_mode & has_focus(DEFAULT_BUFFER)))(
109 109 previous_history_or_previous_completion
110 110 )
111 111
112 112 kb.add("c-n", filter=(vi_insert_mode & has_focus(DEFAULT_BUFFER)))(
113 113 next_history_or_next_completion
114 114 )
115 115
116 116 kb.add("c-g", filter=(has_focus(DEFAULT_BUFFER) & has_completions))(
117 117 dismiss_completion
118 118 )
119 119
120 120 kb.add("c-c", filter=has_focus(DEFAULT_BUFFER))(reset_buffer)
121 121
122 122 kb.add("c-c", filter=has_focus(SEARCH_BUFFER))(reset_search_buffer)
123 123
124 124 supports_suspend = Condition(lambda: hasattr(signal, "SIGTSTP"))
125 125 kb.add("c-z", filter=supports_suspend)(suspend_to_bg)
126 126
127 127 # Ctrl+I == Tab
128 128 kb.add(
129 129 "tab",
130 130 filter=(
131 131 has_focus(DEFAULT_BUFFER)
132 132 & ~has_selection
133 133 & insert_mode
134 134 & cursor_in_leading_ws
135 135 ),
136 136 )(indent_buffer)
137 137 kb.add("c-o", filter=(has_focus(DEFAULT_BUFFER) & emacs_insert_mode))(
138 138 newline_autoindent_outer(shell.input_transformer_manager)
139 139 )
140 140
141 141 kb.add("f2", filter=has_focus(DEFAULT_BUFFER))(open_input_in_editor)
142 142
143 143 @Condition
144 144 def auto_match():
145 145 return shell.auto_match
146 146
147 147 def all_quotes_paired(quote, buf):
148 148 paired = True
149 149 i = 0
150 150 while i < len(buf):
151 151 c = buf[i]
152 152 if c == quote:
153 153 paired = not paired
154 154 elif c == "\\":
155 155 i += 1
156 156 i += 1
157 157 return paired
158 158
159 159 focused_insert = (vi_insert_mode | emacs_insert_mode) & has_focus(DEFAULT_BUFFER)
160 160 _preceding_text_cache: Dict[Union[str, Callable], Condition] = {}
161 161 _following_text_cache: Dict[Union[str, Callable], Condition] = {}
162 162
163 163 def preceding_text(pattern: Union[str, Callable]):
164 164 if pattern in _preceding_text_cache:
165 165 return _preceding_text_cache[pattern]
166 166
167 167 if callable(pattern):
168 168
169 169 def _preceding_text():
170 170 app = get_app()
171 171 before_cursor = app.current_buffer.document.current_line_before_cursor
172 172 # mypy can't infer if(callable): https://github.com/python/mypy/issues/3603
173 173 return bool(pattern(before_cursor)) # type: ignore[operator]
174 174
175 175 else:
176 176 m = re.compile(pattern)
177 177
178 178 def _preceding_text():
179 179 app = get_app()
180 180 before_cursor = app.current_buffer.document.current_line_before_cursor
181 181 return bool(m.match(before_cursor))
182 182
183 183 _preceding_text.__name__ = f"preceding_text({pattern!r})"
184 184
185 185 condition = Condition(_preceding_text)
186 186 _preceding_text_cache[pattern] = condition
187 187 return condition
188 188
189 189 def following_text(pattern):
190 190 try:
191 191 return _following_text_cache[pattern]
192 192 except KeyError:
193 193 pass
194 194 m = re.compile(pattern)
195 195
196 196 def _following_text():
197 197 app = get_app()
198 198 return bool(m.match(app.current_buffer.document.current_line_after_cursor))
199 199
200 200 _following_text.__name__ = f"following_text({pattern!r})"
201 201
202 202 condition = Condition(_following_text)
203 203 _following_text_cache[pattern] = condition
204 204 return condition
205 205
206 206 @Condition
207 207 def not_inside_unclosed_string():
208 208 app = get_app()
209 209 s = app.current_buffer.document.text_before_cursor
210 210 # remove escaped quotes
211 211 s = s.replace('\\"', "").replace("\\'", "")
212 212 # remove triple-quoted string literals
213 213 s = re.sub(r"(?:\"\"\"[\s\S]*\"\"\"|'''[\s\S]*''')", "", s)
214 214 # remove single-quoted string literals
215 215 s = re.sub(r"""(?:"[^"]*["\n]|'[^']*['\n])""", "", s)
216 216 return not ('"' in s or "'" in s)
217 217
218 218 # auto match
219 219 for key, cmd in match.auto_match_parens.items():
220 220 kb.add(key, filter=focused_insert & auto_match & following_text(r"[,)}\]]|$"))(
221 221 cmd
222 222 )
223 223
224 224 # raw string
225 225 for key, cmd in match.auto_match_parens_raw_string.items():
226 226 kb.add(
227 227 key,
228 228 filter=focused_insert & auto_match & preceding_text(r".*(r|R)[\"'](-*)$"),
229 229 )(cmd)
230 230
231 231 kb.add(
232 232 '"',
233 233 filter=focused_insert
234 234 & auto_match
235 235 & not_inside_unclosed_string
236 236 & preceding_text(lambda line: all_quotes_paired('"', line))
237 237 & following_text(r"[,)}\]]|$"),
238 238 )(match.double_quote)
239 239
240 240 kb.add(
241 241 "'",
242 242 filter=focused_insert
243 243 & auto_match
244 244 & not_inside_unclosed_string
245 245 & preceding_text(lambda line: all_quotes_paired("'", line))
246 246 & following_text(r"[,)}\]]|$"),
247 247 )(match.single_quote)
248 248
249 249 kb.add(
250 250 '"',
251 251 filter=focused_insert
252 252 & auto_match
253 253 & not_inside_unclosed_string
254 254 & preceding_text(r'^.*""$'),
255 255 )(match.docstring_double_quotes)
256 256
257 257 kb.add(
258 258 "'",
259 259 filter=focused_insert
260 260 & auto_match
261 261 & not_inside_unclosed_string
262 262 & preceding_text(r"^.*''$"),
263 263 )(match.docstring_single_quotes)
264 264
265 265 # just move cursor
266 266 kb.add(")", filter=focused_insert & auto_match & following_text(r"^\)"))(
267 267 match.skip_over
268 268 )
269 269 kb.add("]", filter=focused_insert & auto_match & following_text(r"^\]"))(
270 270 match.skip_over
271 271 )
272 272 kb.add("}", filter=focused_insert & auto_match & following_text(r"^\}"))(
273 273 match.skip_over
274 274 )
275 275 kb.add('"', filter=focused_insert & auto_match & following_text('^"'))(
276 276 match.skip_over
277 277 )
278 278 kb.add("'", filter=focused_insert & auto_match & following_text("^'"))(
279 279 match.skip_over
280 280 )
281 281
282 282 kb.add(
283 283 "backspace",
284 284 filter=focused_insert
285 285 & preceding_text(r".*\($")
286 286 & auto_match
287 287 & following_text(r"^\)"),
288 288 )(match.delete_pair)
289 289 kb.add(
290 290 "backspace",
291 291 filter=focused_insert
292 292 & preceding_text(r".*\[$")
293 293 & auto_match
294 294 & following_text(r"^\]"),
295 295 )(match.delete_pair)
296 296 kb.add(
297 297 "backspace",
298 298 filter=focused_insert
299 299 & preceding_text(r".*\{$")
300 300 & auto_match
301 301 & following_text(r"^\}"),
302 302 )(match.delete_pair)
303 303 kb.add(
304 304 "backspace",
305 305 filter=focused_insert
306 306 & preceding_text('.*"$')
307 307 & auto_match
308 308 & following_text('^"'),
309 309 )(match.delete_pair)
310 310 kb.add(
311 311 "backspace",
312 312 filter=focused_insert
313 313 & preceding_text(r".*'$")
314 314 & auto_match
315 315 & following_text(r"^'"),
316 316 )(match.delete_pair)
317 317
318 318 if shell.display_completions == "readlinelike":
319 319 kb.add(
320 320 "c-i",
321 321 filter=(
322 322 has_focus(DEFAULT_BUFFER)
323 323 & ~has_selection
324 324 & insert_mode
325 325 & ~cursor_in_leading_ws
326 326 ),
327 327 )(display_completions_like_readline)
328 328
329 329 if sys.platform == "win32" or for_all_platforms:
330 330 kb.add("c-v", filter=(has_focus(DEFAULT_BUFFER) & ~vi_mode))(win_paste)
331 331
332 332 focused_insert_vi = has_focus(DEFAULT_BUFFER) & vi_insert_mode
333 333
334 334 # autosuggestions
335 335 kb.add("end", filter=has_focus(DEFAULT_BUFFER) & (ebivim | ~vi_insert_mode))(
336 336 auto_suggest.accept_in_vi_insert_mode
337 337 )
338 338 kb.add("c-e", filter=focused_insert_vi & ebivim)(
339 339 auto_suggest.accept_in_vi_insert_mode
340 340 )
341 341 kb.add("c-f", filter=focused_insert_vi)(auto_suggest.accept)
342 342 kb.add("escape", "f", filter=focused_insert_vi & ebivim)(auto_suggest.accept_word)
343 343 kb.add("c-right", filter=has_suggestion & has_focus(DEFAULT_BUFFER))(
344 344 auto_suggest.accept_token
345 345 )
346 from functools import partial
347
346 kb.add("escape", filter=has_suggestion & has_focus(DEFAULT_BUFFER), eager=True)(
347 auto_suggest.discard
348 )
348 349 kb.add("up", filter=has_suggestion & has_focus(DEFAULT_BUFFER))(
349 350 auto_suggest.swap_autosuggestion_up(shell.auto_suggest)
350 351 )
351 352 kb.add("down", filter=has_suggestion & has_focus(DEFAULT_BUFFER))(
352 353 auto_suggest.swap_autosuggestion_down(shell.auto_suggest)
353 354 )
354 355 kb.add("right", filter=has_suggestion & has_focus(DEFAULT_BUFFER))(
355 356 auto_suggest.accept_character
356 357 )
357 358 kb.add("left", filter=has_suggestion & has_focus(DEFAULT_BUFFER))(
358 359 auto_suggest.accept_and_move_cursor_left
359 360 )
360 361 kb.add("c-down", filter=has_suggestion & has_focus(DEFAULT_BUFFER))(
361 362 auto_suggest.accept_and_keep_cursor
362 363 )
363 364 kb.add("backspace", filter=has_suggestion & has_focus(DEFAULT_BUFFER))(
364 365 auto_suggest.backspace_and_resume_hint
365 366 )
366 367
367 368 # Simple Control keybindings
368 369 key_cmd_dict = {
369 370 "c-a": nc.beginning_of_line,
370 371 "c-b": nc.backward_char,
371 372 "c-k": nc.kill_line,
372 373 "c-w": nc.backward_kill_word,
373 374 "c-y": nc.yank,
374 375 "c-_": nc.undo,
375 376 }
376 377
377 378 for key, cmd in key_cmd_dict.items():
378 379 kb.add(key, filter=focused_insert_vi & ebivim)(cmd)
379 380
380 381 # Alt and Combo Control keybindings
381 382 keys_cmd_dict = {
382 383 # Control Combos
383 384 ("c-x", "c-e"): nc.edit_and_execute,
384 385 ("c-x", "e"): nc.edit_and_execute,
385 386 # Alt
386 387 ("escape", "b"): nc.backward_word,
387 388 ("escape", "c"): nc.capitalize_word,
388 389 ("escape", "d"): nc.kill_word,
389 390 ("escape", "h"): nc.backward_kill_word,
390 391 ("escape", "l"): nc.downcase_word,
391 392 ("escape", "u"): nc.uppercase_word,
392 393 ("escape", "y"): nc.yank_pop,
393 394 ("escape", "."): nc.yank_last_arg,
394 395 }
395 396
396 397 for keys, cmd in keys_cmd_dict.items():
397 398 kb.add(*keys, filter=focused_insert_vi & ebivim)(cmd)
398 399
399 400 def get_input_mode(self):
400 401 app = get_app()
401 402 app.ttimeoutlen = shell.ttimeoutlen
402 403 app.timeoutlen = shell.timeoutlen
403 404
404 405 return self._input_mode
405 406
406 407 def set_input_mode(self, mode):
407 408 shape = {InputMode.NAVIGATION: 2, InputMode.REPLACE: 4}.get(mode, 6)
408 409 cursor = "\x1b[{} q".format(shape)
409 410
410 411 sys.stdout.write(cursor)
411 412 sys.stdout.flush()
412 413
413 414 self._input_mode = mode
414 415
415 416 if shell.editing_mode == "vi" and shell.modal_cursor:
416 417 ViState._input_mode = InputMode.INSERT # type: ignore
417 418 ViState.input_mode = property(get_input_mode, set_input_mode) # type: ignore
418 419
419 420 return kb
420 421
421 422
422 423 def reformat_text_before_cursor(buffer, document, shell):
423 424 text = buffer.delete_before_cursor(len(document.text[: document.cursor_position]))
424 425 try:
425 426 formatted_text = shell.reformat_handler(text)
426 427 buffer.insert_text(formatted_text)
427 428 except Exception as e:
428 429 buffer.insert_text(text)
429 430
430 431
431 432 def newline_or_execute_outer(shell):
432 433 def newline_or_execute(event):
433 434 """When the user presses return, insert a newline or execute the code."""
434 435 b = event.current_buffer
435 436 d = b.document
436 437
437 438 if b.complete_state:
438 439 cc = b.complete_state.current_completion
439 440 if cc:
440 441 b.apply_completion(cc)
441 442 else:
442 443 b.cancel_completion()
443 444 return
444 445
445 446 # If there's only one line, treat it as if the cursor is at the end.
446 447 # See https://github.com/ipython/ipython/issues/10425
447 448 if d.line_count == 1:
448 449 check_text = d.text
449 450 else:
450 451 check_text = d.text[: d.cursor_position]
451 452 status, indent = shell.check_complete(check_text)
452 453
453 454 # if all we have after the cursor is whitespace: reformat current text
454 455 # before cursor
455 456 after_cursor = d.text[d.cursor_position :]
456 457 reformatted = False
457 458 if not after_cursor.strip():
458 459 reformat_text_before_cursor(b, d, shell)
459 460 reformatted = True
460 461 if not (
461 462 d.on_last_line
462 463 or d.cursor_position_row >= d.line_count - d.empty_line_count_at_the_end()
463 464 ):
464 465 if shell.autoindent:
465 466 b.insert_text("\n" + indent)
466 467 else:
467 468 b.insert_text("\n")
468 469 return
469 470
470 471 if (status != "incomplete") and b.accept_handler:
471 472 if not reformatted:
472 473 reformat_text_before_cursor(b, d, shell)
473 474 b.validate_and_handle()
474 475 else:
475 476 if shell.autoindent:
476 477 b.insert_text("\n" + indent)
477 478 else:
478 479 b.insert_text("\n")
479 480
480 481 newline_or_execute.__qualname__ = "newline_or_execute"
481 482
482 483 return newline_or_execute
483 484
484 485
485 486 def previous_history_or_previous_completion(event):
486 487 """
487 488 Control-P in vi edit mode on readline is history next, unlike default prompt toolkit.
488 489
489 490 If completer is open this still select previous completion.
490 491 """
491 492 event.current_buffer.auto_up()
492 493
493 494
494 495 def next_history_or_next_completion(event):
495 496 """
496 497 Control-N in vi edit mode on readline is history previous, unlike default prompt toolkit.
497 498
498 499 If completer is open this still select next completion.
499 500 """
500 501 event.current_buffer.auto_down()
501 502
502 503
503 504 def dismiss_completion(event):
504 505 """Dismiss completion"""
505 506 b = event.current_buffer
506 507 if b.complete_state:
507 508 b.cancel_completion()
508 509
509 510
510 511 def reset_buffer(event):
511 512 """Reset buffer"""
512 513 b = event.current_buffer
513 514 if b.complete_state:
514 515 b.cancel_completion()
515 516 else:
516 517 b.reset()
517 518
518 519
519 520 def reset_search_buffer(event):
520 521 """Reset search buffer"""
521 522 if event.current_buffer.document.text:
522 523 event.current_buffer.reset()
523 524 else:
524 525 event.app.layout.focus(DEFAULT_BUFFER)
525 526
526 527
527 528 def suspend_to_bg(event):
528 529 """Suspend to background"""
529 530 event.app.suspend_to_background()
530 531
531 532
532 533 def quit(event):
533 534 """
534 535 Quit application with ``SIGQUIT`` if supported or ``sys.exit`` otherwise.
535 536
536 537 On platforms that support SIGQUIT, send SIGQUIT to the current process.
537 538 On other platforms, just exit the process with a message.
538 539 """
539 540 sigquit = getattr(signal, "SIGQUIT", None)
540 541 if sigquit is not None:
541 542 os.kill(0, signal.SIGQUIT)
542 543 else:
543 544 sys.exit("Quit")
544 545
545 546
546 547 def indent_buffer(event):
547 548 """Indent buffer"""
548 549 event.current_buffer.insert_text(" " * 4)
549 550
550 551
551 552 @undoc
552 553 def newline_with_copy_margin(event):
553 554 """
554 555 DEPRECATED since IPython 6.0
555 556
556 557 See :any:`newline_autoindent_outer` for a replacement.
557 558
558 559 Preserve margin and cursor position when using
559 560 Control-O to insert a newline in EMACS mode
560 561 """
561 562 warnings.warn(
562 563 "`newline_with_copy_margin(event)` is deprecated since IPython 6.0. "
563 564 "see `newline_autoindent_outer(shell)(event)` for a replacement.",
564 565 DeprecationWarning,
565 566 stacklevel=2,
566 567 )
567 568
568 569 b = event.current_buffer
569 570 cursor_start_pos = b.document.cursor_position_col
570 571 b.newline(copy_margin=True)
571 572 b.cursor_up(count=1)
572 573 cursor_end_pos = b.document.cursor_position_col
573 574 if cursor_start_pos != cursor_end_pos:
574 575 pos_diff = cursor_start_pos - cursor_end_pos
575 576 b.cursor_right(count=pos_diff)
576 577
577 578
578 579 def newline_autoindent_outer(inputsplitter) -> Callable[..., None]:
579 580 """
580 581 Return a function suitable for inserting a indented newline after the cursor.
581 582
582 583 Fancier version of deprecated ``newline_with_copy_margin`` which should
583 584 compute the correct indentation of the inserted line. That is to say, indent
584 585 by 4 extra space after a function definition, class definition, context
585 586 manager... And dedent by 4 space after ``pass``, ``return``, ``raise ...``.
586 587 """
587 588
588 589 def newline_autoindent(event):
589 590 """Insert a newline after the cursor indented appropriately."""
590 591 b = event.current_buffer
591 592 d = b.document
592 593
593 594 if b.complete_state:
594 595 b.cancel_completion()
595 596 text = d.text[: d.cursor_position] + "\n"
596 597 _, indent = inputsplitter.check_complete(text)
597 598 b.insert_text("\n" + (" " * (indent or 0)), move_cursor=False)
598 599
599 600 newline_autoindent.__qualname__ = "newline_autoindent"
600 601
601 602 return newline_autoindent
602 603
603 604
604 605 def open_input_in_editor(event):
605 606 """Open code from input in external editor"""
606 607 event.app.current_buffer.open_in_editor()
607 608
608 609
609 610 if sys.platform == "win32":
610 611 from IPython.core.error import TryNext
611 612 from IPython.lib.clipboard import (
612 613 ClipboardEmpty,
613 614 tkinter_clipboard_get,
614 615 win32_clipboard_get,
615 616 )
616 617
617 618 @undoc
618 619 def win_paste(event):
619 620 try:
620 621 text = win32_clipboard_get()
621 622 except TryNext:
622 623 try:
623 624 text = tkinter_clipboard_get()
624 625 except (TryNext, ClipboardEmpty):
625 626 return
626 627 except ClipboardEmpty:
627 628 return
628 629 event.current_buffer.insert_text(text.replace("\t", " " * 4))
629 630
630 631 else:
631 632
632 633 @undoc
633 634 def win_paste(event):
634 635 """Stub used when auto-generating shortcuts for documentation"""
635 636 pass
@@ -1,318 +1,324
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, Sequence
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
14 14 from IPython.utils.tokenutil import generate_tokens
15 15
16 16
17 17 def _get_query(document: Document):
18 18 return document.text.rsplit("\n", 1)[-1]
19 19
20 20
21 21 class NavigableAutoSuggestFromHistory(AutoSuggestFromHistory):
22 22 """
23 23 A subclass of AutoSuggestFromHistory that allow navigation to next/previous
24 24 suggestion from history. To do so it remembers the current position, but it
25 25 state need to carefully be cleared on the right events.
26 26 """
27 27
28 28 def __init__(
29 29 self,
30 30 ):
31 31 self.skip_lines = 0
32 32 self._connected_apps = []
33 33
34 34 def reset_history_position(self, _: Buffer):
35 35 self.skip_lines = 0
36 36
37 37 def disconnect(self):
38 38 for pt_app in self._connected_apps:
39 39 text_insert_event = pt_app.default_buffer.on_text_insert
40 40 text_insert_event.remove_handler(self.reset_history_position)
41 41
42 42 def connect(self, pt_app: PromptSession):
43 43 self._connected_apps.append(pt_app)
44 44 # note: `on_text_changed` could be used for a bit different behaviour
45 45 # on character deletion (i.e. reseting history position on backspace)
46 46 pt_app.default_buffer.on_text_insert.add_handler(self.reset_history_position)
47 47
48 48 def get_suggestion(
49 49 self, buffer: Buffer, document: Document
50 50 ) -> Optional[Suggestion]:
51 51 text = _get_query(document)
52 52
53 53 if text.strip():
54 54 for suggestion, _ in self._find_next_match(
55 55 text, self.skip_lines, buffer.history
56 56 ):
57 57 return Suggestion(suggestion)
58 58
59 59 return None
60 60
61 61 def _find_match(
62 62 self, text: str, skip_lines: float, history: History, previous: bool
63 63 ) -> Generator[Tuple[str, float], None, None]:
64 64 """
65 65 text : str
66 66 Text content to find a match for, the user cursor is most of the
67 67 time at the end of this text.
68 68 skip_lines : float
69 69 number of items to skip in the search, this is used to indicate how
70 70 far in the list the user has navigated by pressing up or down.
71 71 The float type is used as the base value is +inf
72 72 history : History
73 73 prompt_toolkit History instance to fetch previous entries from.
74 74 previous : bool
75 75 Direction of the search, whether we are looking previous match
76 76 (True), or next match (False).
77 77
78 78 Yields
79 79 ------
80 80 Tuple with:
81 81 str:
82 82 current suggestion.
83 83 float:
84 84 will actually yield only ints, which is passed back via skip_lines,
85 85 which may be a +inf (float)
86 86
87 87
88 88 """
89 89 line_number = -1
90 90 for string in reversed(list(history.get_strings())):
91 91 for line in reversed(string.splitlines()):
92 92 line_number += 1
93 93 if not previous and line_number < skip_lines:
94 94 continue
95 95 # do not return empty suggestions as these
96 96 # close the auto-suggestion overlay (and are useless)
97 97 if line.startswith(text) and len(line) > len(text):
98 98 yield line[len(text) :], line_number
99 99 if previous and line_number >= skip_lines:
100 100 return
101 101
102 102 def _find_next_match(
103 103 self, text: str, skip_lines: float, history: History
104 104 ) -> Generator[Tuple[str, float], None, None]:
105 105 return self._find_match(text, skip_lines, history, previous=False)
106 106
107 107 def _find_previous_match(self, text: str, skip_lines: float, history: History):
108 108 return reversed(
109 109 list(self._find_match(text, skip_lines, history, previous=True))
110 110 )
111 111
112 112 def up(self, query: str, other_than: str, history: History) -> None:
113 113 for suggestion, line_number in self._find_next_match(
114 114 query, self.skip_lines, history
115 115 ):
116 116 # if user has history ['very.a', 'very', 'very.b'] and typed 'very'
117 117 # we want to switch from 'very.b' to 'very.a' because a) if the
118 118 # suggestion equals current text, prompt-toolkit aborts suggesting
119 119 # b) user likely would not be interested in 'very' anyways (they
120 120 # already typed it).
121 121 if query + suggestion != other_than:
122 122 self.skip_lines = line_number
123 123 break
124 124 else:
125 125 # no matches found, cycle back to beginning
126 126 self.skip_lines = 0
127 127
128 128 def down(self, query: str, other_than: str, history: History) -> None:
129 129 for suggestion, line_number in self._find_previous_match(
130 130 query, self.skip_lines, history
131 131 ):
132 132 if query + suggestion != other_than:
133 133 self.skip_lines = line_number
134 134 break
135 135 else:
136 136 # no matches found, cycle to end
137 137 for suggestion, line_number in self._find_previous_match(
138 138 query, float("Inf"), history
139 139 ):
140 140 if query + suggestion != other_than:
141 141 self.skip_lines = line_number
142 142 break
143 143
144 144
145 145 # Needed for to accept autosuggestions in vi insert mode
146 146 def accept_in_vi_insert_mode(event: KeyPressEvent):
147 147 """Apply autosuggestion if at end of line."""
148 148 buffer = event.current_buffer
149 149 d = buffer.document
150 150 after_cursor = d.text[d.cursor_position :]
151 151 lines = after_cursor.split("\n")
152 152 end_of_current_line = lines[0].strip()
153 153 suggestion = buffer.suggestion
154 154 if (suggestion is not None) and (suggestion.text) and (end_of_current_line == ""):
155 155 buffer.insert_text(suggestion.text)
156 156 else:
157 157 nc.end_of_line(event)
158 158
159 159
160 160 def accept(event: KeyPressEvent):
161 161 """Accept autosuggestion"""
162 162 buffer = event.current_buffer
163 163 suggestion = buffer.suggestion
164 164 if suggestion:
165 165 buffer.insert_text(suggestion.text)
166 166 else:
167 167 nc.forward_char(event)
168 168
169 169
170 def discard(event: KeyPressEvent):
171 """Discard autosuggestion"""
172 buffer = event.current_buffer
173 buffer.suggestion = None
174
175
170 176 def accept_word(event: KeyPressEvent):
171 177 """Fill partial autosuggestion by word"""
172 178 buffer = event.current_buffer
173 179 suggestion = buffer.suggestion
174 180 if suggestion:
175 181 t = re.split(r"(\S+\s+)", suggestion.text)
176 182 buffer.insert_text(next((x for x in t if x), ""))
177 183 else:
178 184 nc.forward_word(event)
179 185
180 186
181 187 def accept_character(event: KeyPressEvent):
182 188 """Fill partial autosuggestion by character"""
183 189 b = event.current_buffer
184 190 suggestion = b.suggestion
185 191 if suggestion and suggestion.text:
186 192 b.insert_text(suggestion.text[0])
187 193
188 194
189 195 def accept_and_keep_cursor(event: KeyPressEvent):
190 196 """Accept autosuggestion and keep cursor in place"""
191 197 buffer = event.current_buffer
192 198 old_position = buffer.cursor_position
193 199 suggestion = buffer.suggestion
194 200 if suggestion:
195 201 buffer.insert_text(suggestion.text)
196 202 buffer.cursor_position = old_position
197 203
198 204
199 205 def accept_and_move_cursor_left(event: KeyPressEvent):
200 206 """Accept autosuggestion and move cursor left in place"""
201 207 accept_and_keep_cursor(event)
202 208 nc.backward_char(event)
203 209
204 210
205 211 def backspace_and_resume_hint(event: KeyPressEvent):
206 212 """Resume autosuggestions after deleting last character"""
207 213 current_buffer = event.current_buffer
208 214
209 215 def resume_hinting(buffer: Buffer):
210 216 if buffer.auto_suggest:
211 217 suggestion = buffer.auto_suggest.get_suggestion(buffer, buffer.document)
212 218 if suggestion:
213 219 buffer.suggestion = suggestion
214 220 current_buffer.on_text_changed.remove_handler(resume_hinting)
215 221
216 222 current_buffer.on_text_changed.add_handler(resume_hinting)
217 223 nc.backward_delete_char(event)
218 224
219 225
220 226 def accept_token(event: KeyPressEvent):
221 227 """Fill partial autosuggestion by token"""
222 228 b = event.current_buffer
223 229 suggestion = b.suggestion
224 230
225 231 if suggestion:
226 232 prefix = _get_query(b.document)
227 233 text = prefix + suggestion.text
228 234
229 235 tokens: List[Optional[str]] = [None, None, None]
230 236 substrings = [""]
231 237 i = 0
232 238
233 239 for token in generate_tokens(StringIO(text).readline):
234 240 if token.type == tokenize.NEWLINE:
235 241 index = len(text)
236 242 else:
237 243 index = text.index(token[1], len(substrings[-1]))
238 244 substrings.append(text[:index])
239 245 tokenized_so_far = substrings[-1]
240 246 if tokenized_so_far.startswith(prefix):
241 247 if i == 0 and len(tokenized_so_far) > len(prefix):
242 248 tokens[0] = tokenized_so_far[len(prefix) :]
243 249 substrings.append(tokenized_so_far)
244 250 i += 1
245 251 tokens[i] = token[1]
246 252 if i == 2:
247 253 break
248 254 i += 1
249 255
250 256 if tokens[0]:
251 257 to_insert: str
252 258 insert_text = substrings[-2]
253 259 if tokens[1] and len(tokens[1]) == 1:
254 260 insert_text = substrings[-1]
255 261 to_insert = insert_text[len(prefix) :]
256 262 b.insert_text(to_insert)
257 263 return
258 264
259 265 nc.forward_word(event)
260 266
261 267
262 268 Provider = Union[AutoSuggestFromHistory, NavigableAutoSuggestFromHistory, None]
263 269
264 270
265 271 def _swap_autosuggestion(
266 272 buffer: Buffer,
267 273 provider: NavigableAutoSuggestFromHistory,
268 274 direction_method: Callable,
269 275 ):
270 276 """
271 277 We skip most recent history entry (in either direction) if it equals the
272 278 current autosuggestion because if user cycles when auto-suggestion is shown
273 279 they most likely want something else than what was suggested (otherwise
274 280 they would have accepted the suggestion).
275 281 """
276 282 suggestion = buffer.suggestion
277 283 if not suggestion:
278 284 return
279 285
280 286 query = _get_query(buffer.document)
281 287 current = query + suggestion.text
282 288
283 289 direction_method(query=query, other_than=current, history=buffer.history)
284 290
285 291 new_suggestion = provider.get_suggestion(buffer, buffer.document)
286 292 buffer.suggestion = new_suggestion
287 293
288 294
289 295 def swap_autosuggestion_up(provider: Provider):
290 296 def swap_autosuggestion_up(event: KeyPressEvent):
291 297 """Get next autosuggestion from history."""
292 298 if not isinstance(provider, NavigableAutoSuggestFromHistory):
293 299 return
294 300
295 301 return _swap_autosuggestion(
296 302 buffer=event.current_buffer, provider=provider, direction_method=provider.up
297 303 )
298 304
299 305 swap_autosuggestion_up.__name__ = "swap_autosuggestion_up"
300 306 return swap_autosuggestion_up
301 307
302 308
303 309 def swap_autosuggestion_down(
304 310 provider: Union[AutoSuggestFromHistory, NavigableAutoSuggestFromHistory, None]
305 311 ):
306 312 def swap_autosuggestion_down(event: KeyPressEvent):
307 313 """Get previous autosuggestion from history."""
308 314 if not isinstance(provider, NavigableAutoSuggestFromHistory):
309 315 return
310 316
311 317 return _swap_autosuggestion(
312 318 buffer=event.current_buffer,
313 319 provider=provider,
314 320 direction_method=provider.down,
315 321 )
316 322
317 323 swap_autosuggestion_down.__name__ = "swap_autosuggestion_down"
318 324 return swap_autosuggestion_down
@@ -1,267 +1,284
1 1 import pytest
2 2 from IPython.terminal.shortcuts.auto_suggest import (
3 3 accept,
4 4 accept_in_vi_insert_mode,
5 5 accept_token,
6 6 accept_character,
7 7 accept_word,
8 8 accept_and_keep_cursor,
9 discard,
9 10 NavigableAutoSuggestFromHistory,
10 11 swap_autosuggestion_up,
11 12 swap_autosuggestion_down,
12 13 )
13 14
14 15 from prompt_toolkit.history import InMemoryHistory
15 16 from prompt_toolkit.buffer import Buffer
16 17 from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
17 18
18 19 from unittest.mock import patch, Mock
19 20
20 21
21 22 def make_event(text, cursor, suggestion):
22 23 event = Mock()
23 24 event.current_buffer = Mock()
24 25 event.current_buffer.suggestion = Mock()
25 26 event.current_buffer.text = text
26 27 event.current_buffer.cursor_position = cursor
27 28 event.current_buffer.suggestion.text = suggestion
28 29 event.current_buffer.document = Mock()
29 30 event.current_buffer.document.get_end_of_line_position = Mock(return_value=0)
30 31 event.current_buffer.document.text = text
31 32 event.current_buffer.document.cursor_position = cursor
32 33 return event
33 34
34 35
35 36 @pytest.mark.parametrize(
36 37 "text, suggestion, expected",
37 38 [
38 39 ("", "def out(tag: str, n=50):", "def out(tag: str, n=50):"),
39 40 ("def ", "out(tag: str, n=50):", "out(tag: str, n=50):"),
40 41 ],
41 42 )
42 43 def test_accept(text, suggestion, expected):
43 44 event = make_event(text, len(text), suggestion)
44 45 buffer = event.current_buffer
45 46 buffer.insert_text = Mock()
46 47 accept(event)
47 48 assert buffer.insert_text.called
48 49 assert buffer.insert_text.call_args[0] == (expected,)
49 50
50 51
51 52 @pytest.mark.parametrize(
53 "text, suggestion",
54 [
55 ("", "def out(tag: str, n=50):"),
56 ("def ", "out(tag: str, n=50):"),
57 ],
58 )
59 def test_discard(text, suggestion):
60 event = make_event(text, len(text), suggestion)
61 buffer = event.current_buffer
62 buffer.insert_text = Mock()
63 discard(event)
64 assert not buffer.insert_text.called
65 assert buffer.suggestion is None
66
67
68 @pytest.mark.parametrize(
52 69 "text, cursor, suggestion, called",
53 70 [
54 71 ("123456", 6, "123456789", True),
55 72 ("123456", 3, "123456789", False),
56 73 ("123456 \n789", 6, "123456789", True),
57 74 ],
58 75 )
59 76 def test_autosuggest_at_EOL(text, cursor, suggestion, called):
60 77 """
61 78 test that autosuggest is only applied at end of line.
62 79 """
63 80
64 81 event = make_event(text, cursor, suggestion)
65 82 event.current_buffer.insert_text = Mock()
66 83 accept_in_vi_insert_mode(event)
67 84 if called:
68 85 event.current_buffer.insert_text.assert_called()
69 86 else:
70 87 event.current_buffer.insert_text.assert_not_called()
71 88 # event.current_buffer.document.get_end_of_line_position.assert_called()
72 89
73 90
74 91 @pytest.mark.parametrize(
75 92 "text, suggestion, expected",
76 93 [
77 94 ("", "def out(tag: str, n=50):", "def "),
78 95 ("d", "ef out(tag: str, n=50):", "ef "),
79 96 ("de ", "f out(tag: str, n=50):", "f "),
80 97 ("def", " out(tag: str, n=50):", " "),
81 98 ("def ", "out(tag: str, n=50):", "out("),
82 99 ("def o", "ut(tag: str, n=50):", "ut("),
83 100 ("def ou", "t(tag: str, n=50):", "t("),
84 101 ("def out", "(tag: str, n=50):", "("),
85 102 ("def out(", "tag: str, n=50):", "tag: "),
86 103 ("def out(t", "ag: str, n=50):", "ag: "),
87 104 ("def out(ta", "g: str, n=50):", "g: "),
88 105 ("def out(tag", ": str, n=50):", ": "),
89 106 ("def out(tag:", " str, n=50):", " "),
90 107 ("def out(tag: ", "str, n=50):", "str, "),
91 108 ("def out(tag: s", "tr, n=50):", "tr, "),
92 109 ("def out(tag: st", "r, n=50):", "r, "),
93 110 ("def out(tag: str", ", n=50):", ", n"),
94 111 ("def out(tag: str,", " n=50):", " n"),
95 112 ("def out(tag: str, ", "n=50):", "n="),
96 113 ("def out(tag: str, n", "=50):", "="),
97 114 ("def out(tag: str, n=", "50):", "50)"),
98 115 ("def out(tag: str, n=5", "0):", "0)"),
99 116 ("def out(tag: str, n=50", "):", "):"),
100 117 ("def out(tag: str, n=50)", ":", ":"),
101 118 ],
102 119 )
103 120 def test_autosuggest_token(text, suggestion, expected):
104 121 event = make_event(text, len(text), suggestion)
105 122 event.current_buffer.insert_text = Mock()
106 123 accept_token(event)
107 124 assert event.current_buffer.insert_text.called
108 125 assert event.current_buffer.insert_text.call_args[0] == (expected,)
109 126
110 127
111 128 @pytest.mark.parametrize(
112 129 "text, suggestion, expected",
113 130 [
114 131 ("", "def out(tag: str, n=50):", "d"),
115 132 ("d", "ef out(tag: str, n=50):", "e"),
116 133 ("de ", "f out(tag: str, n=50):", "f"),
117 134 ("def", " out(tag: str, n=50):", " "),
118 135 ],
119 136 )
120 137 def test_accept_character(text, suggestion, expected):
121 138 event = make_event(text, len(text), suggestion)
122 139 event.current_buffer.insert_text = Mock()
123 140 accept_character(event)
124 141 assert event.current_buffer.insert_text.called
125 142 assert event.current_buffer.insert_text.call_args[0] == (expected,)
126 143
127 144
128 145 @pytest.mark.parametrize(
129 146 "text, suggestion, expected",
130 147 [
131 148 ("", "def out(tag: str, n=50):", "def "),
132 149 ("d", "ef out(tag: str, n=50):", "ef "),
133 150 ("de", "f out(tag: str, n=50):", "f "),
134 151 ("def", " out(tag: str, n=50):", " "),
135 152 # (this is why we also have accept_token)
136 153 ("def ", "out(tag: str, n=50):", "out(tag: "),
137 154 ],
138 155 )
139 156 def test_accept_word(text, suggestion, expected):
140 157 event = make_event(text, len(text), suggestion)
141 158 event.current_buffer.insert_text = Mock()
142 159 accept_word(event)
143 160 assert event.current_buffer.insert_text.called
144 161 assert event.current_buffer.insert_text.call_args[0] == (expected,)
145 162
146 163
147 164 @pytest.mark.parametrize(
148 165 "text, suggestion, expected, cursor",
149 166 [
150 167 ("", "def out(tag: str, n=50):", "def out(tag: str, n=50):", 0),
151 168 ("def ", "out(tag: str, n=50):", "out(tag: str, n=50):", 4),
152 169 ],
153 170 )
154 171 def test_accept_and_keep_cursor(text, suggestion, expected, cursor):
155 172 event = make_event(text, cursor, suggestion)
156 173 buffer = event.current_buffer
157 174 buffer.insert_text = Mock()
158 175 accept_and_keep_cursor(event)
159 176 assert buffer.insert_text.called
160 177 assert buffer.insert_text.call_args[0] == (expected,)
161 178 assert buffer.cursor_position == cursor
162 179
163 180
164 181 def test_autosuggest_token_empty():
165 182 full = "def out(tag: str, n=50):"
166 183 event = make_event(full, len(full), "")
167 184 event.current_buffer.insert_text = Mock()
168 185
169 186 with patch(
170 187 "prompt_toolkit.key_binding.bindings.named_commands.forward_word"
171 188 ) as forward_word:
172 189 accept_token(event)
173 190 assert not event.current_buffer.insert_text.called
174 191 assert forward_word.called
175 192
176 193
177 194 def test_other_providers():
178 195 """Ensure that swapping autosuggestions does not break with other providers"""
179 196 provider = AutoSuggestFromHistory()
180 197 up = swap_autosuggestion_up(provider)
181 198 down = swap_autosuggestion_down(provider)
182 199 event = Mock()
183 200 event.current_buffer = Buffer()
184 201 assert up(event) is None
185 202 assert down(event) is None
186 203
187 204
188 205 async def test_navigable_provider():
189 206 provider = NavigableAutoSuggestFromHistory()
190 207 history = InMemoryHistory(history_strings=["very_a", "very", "very_b", "very_c"])
191 208 buffer = Buffer(history=history)
192 209
193 210 async for _ in history.load():
194 211 pass
195 212
196 213 buffer.cursor_position = 5
197 214 buffer.text = "very"
198 215
199 216 up = swap_autosuggestion_up(provider)
200 217 down = swap_autosuggestion_down(provider)
201 218
202 219 event = Mock()
203 220 event.current_buffer = buffer
204 221
205 222 def get_suggestion():
206 223 suggestion = provider.get_suggestion(buffer, buffer.document)
207 224 buffer.suggestion = suggestion
208 225 return suggestion
209 226
210 227 assert get_suggestion().text == "_c"
211 228
212 229 # should go up
213 230 up(event)
214 231 assert get_suggestion().text == "_b"
215 232
216 233 # should skip over 'very' which is identical to buffer content
217 234 up(event)
218 235 assert get_suggestion().text == "_a"
219 236
220 237 # should cycle back to beginning
221 238 up(event)
222 239 assert get_suggestion().text == "_c"
223 240
224 241 # should cycle back through end boundary
225 242 down(event)
226 243 assert get_suggestion().text == "_a"
227 244
228 245 down(event)
229 246 assert get_suggestion().text == "_b"
230 247
231 248 down(event)
232 249 assert get_suggestion().text == "_c"
233 250
234 251 down(event)
235 252 assert get_suggestion().text == "_a"
236 253
237 254
238 255 def create_session_mock():
239 256 session = Mock()
240 257 session.default_buffer = Buffer()
241 258 return session
242 259
243 260
244 261 def test_navigable_provider_connection():
245 262 provider = NavigableAutoSuggestFromHistory()
246 263 provider.skip_lines = 1
247 264
248 265 session_1 = create_session_mock()
249 266 provider.connect(session_1)
250 267
251 268 assert provider.skip_lines == 1
252 269 session_1.default_buffer.on_text_insert.fire()
253 270 assert provider.skip_lines == 0
254 271
255 272 session_2 = create_session_mock()
256 273 provider.connect(session_2)
257 274 provider.skip_lines = 2
258 275
259 276 assert provider.skip_lines == 2
260 277 session_2.default_buffer.on_text_insert.fire()
261 278 assert provider.skip_lines == 0
262 279
263 280 provider.skip_lines = 3
264 281 provider.disconnect()
265 282 session_1.default_buffer.on_text_insert.fire()
266 283 session_2.default_buffer.on_text_insert.fire()
267 284 assert provider.skip_lines == 3
General Comments 0
You need to be logged in to leave comments. Login now