Show More
@@ -0,0 +1,255 | |||
|
1 | import re | |
|
2 | import tokenize | |
|
3 | from io import StringIO | |
|
4 | from typing import Callable, List, Optional, Union | |
|
5 | ||
|
6 | from prompt_toolkit.buffer import Buffer | |
|
7 | from prompt_toolkit.key_binding import KeyPressEvent | |
|
8 | from prompt_toolkit.key_binding.bindings import named_commands as nc | |
|
9 | from prompt_toolkit.auto_suggest import AutoSuggestFromHistory, Suggestion | |
|
10 | from prompt_toolkit.document import Document | |
|
11 | from prompt_toolkit.history import History | |
|
12 | from prompt_toolkit.shortcuts import PromptSession | |
|
13 | ||
|
14 | from IPython.utils.tokenutil import generate_tokens | |
|
15 | ||
|
16 | ||
|
17 | def _get_query(document: Document): | |
|
18 | return document.text.rsplit("\n", 1)[-1] | |
|
19 | ||
|
20 | ||
|
21 | class NavigableAutoSuggestFromHistory(AutoSuggestFromHistory): | |
|
22 | """ """ | |
|
23 | ||
|
24 | def __init__( | |
|
25 | self, | |
|
26 | ): | |
|
27 | self.skip_lines = 0 | |
|
28 | self._connected_apps = [] | |
|
29 | ||
|
30 | def reset_history_position(self, _: Buffer): | |
|
31 | self.skip_lines = 0 | |
|
32 | ||
|
33 | def disconnect(self): | |
|
34 | for pt_app in self._connected_apps: | |
|
35 | text_insert_event = pt_app.default_buffer.on_text_insert | |
|
36 | text_insert_event.remove_handler(self.reset_history_position) | |
|
37 | ||
|
38 | def connect(self, pt_app: PromptSession): | |
|
39 | self._connected_apps.append(pt_app) | |
|
40 | pt_app.default_buffer.on_text_insert.add_handler(self.reset_history_position) | |
|
41 | ||
|
42 | def get_suggestion( | |
|
43 | self, buffer: Buffer, document: Document | |
|
44 | ) -> Optional[Suggestion]: | |
|
45 | text = _get_query(document) | |
|
46 | ||
|
47 | if text.strip(): | |
|
48 | for suggestion, _ in self._find_next_match( | |
|
49 | text, self.skip_lines, buffer.history | |
|
50 | ): | |
|
51 | return Suggestion(suggestion) | |
|
52 | ||
|
53 | return None | |
|
54 | ||
|
55 | def _find_match( | |
|
56 | self, text: str, skip_lines: float, history: History, previous: bool | |
|
57 | ): | |
|
58 | line_number = -1 | |
|
59 | ||
|
60 | for string in reversed(list(history.get_strings())): | |
|
61 | for line in reversed(string.splitlines()): | |
|
62 | line_number += 1 | |
|
63 | if not previous and line_number < skip_lines: | |
|
64 | continue | |
|
65 | # do not return empty suggestions as these | |
|
66 | # close the auto-suggestion overlay (and are useless) | |
|
67 | if line.startswith(text) and len(line) > len(text): | |
|
68 | yield line[len(text) :], line_number | |
|
69 | if previous and line_number >= skip_lines: | |
|
70 | return | |
|
71 | ||
|
72 | def _find_next_match(self, text: str, skip_lines: float, history: History): | |
|
73 | return self._find_match(text, skip_lines, history, previous=False) | |
|
74 | ||
|
75 | def _find_previous_match(self, text: str, skip_lines: float, history: History): | |
|
76 | return reversed( | |
|
77 | list(self._find_match(text, skip_lines, history, previous=True)) | |
|
78 | ) | |
|
79 | ||
|
80 | def up(self, query: str, other_than: str, history: History): | |
|
81 | for suggestion, line_number in self._find_next_match( | |
|
82 | query, self.skip_lines, history | |
|
83 | ): | |
|
84 | # if user has history ['very.a', 'very', 'very.b'] and typed 'very' | |
|
85 | # we want to switch from 'very.b' to 'very.a' because a) if they | |
|
86 | # suggestion equals current text, prompt-toolit aborts suggesting | |
|
87 | # b) user likely would not be interested in 'very' anyways (they | |
|
88 | # already typed it). | |
|
89 | if query + suggestion != other_than: | |
|
90 | self.skip_lines = line_number | |
|
91 | break | |
|
92 | else: | |
|
93 | # no matches found, cycle back to beginning | |
|
94 | self.skip_lines = 0 | |
|
95 | ||
|
96 | def down(self, query: str, other_than: str, history: History): | |
|
97 | for suggestion, line_number in self._find_previous_match( | |
|
98 | query, self.skip_lines, history | |
|
99 | ): | |
|
100 | if query + suggestion != other_than: | |
|
101 | self.skip_lines = line_number | |
|
102 | break | |
|
103 | else: | |
|
104 | # no matches found, cycle to end | |
|
105 | for suggestion, line_number in self._find_previous_match( | |
|
106 | query, float("Inf"), history | |
|
107 | ): | |
|
108 | if query + suggestion != other_than: | |
|
109 | self.skip_lines = line_number | |
|
110 | break | |
|
111 | ||
|
112 | ||
|
113 | # Needed for to accept autosuggestions in vi insert mode | |
|
114 | def accept_in_vi_insert_mode(event: KeyPressEvent): | |
|
115 | """Apply autosuggestion if at end of line.""" | |
|
116 | b = event.current_buffer | |
|
117 | d = b.document | |
|
118 | after_cursor = d.text[d.cursor_position :] | |
|
119 | lines = after_cursor.split("\n") | |
|
120 | end_of_current_line = lines[0].strip() | |
|
121 | suggestion = b.suggestion | |
|
122 | if (suggestion is not None) and (suggestion.text) and (end_of_current_line == ""): | |
|
123 | b.insert_text(suggestion.text) | |
|
124 | else: | |
|
125 | nc.end_of_line(event) | |
|
126 | ||
|
127 | ||
|
128 | def accept(event: KeyPressEvent): | |
|
129 | """Accept autosuggestion""" | |
|
130 | b = event.current_buffer | |
|
131 | suggestion = b.suggestion | |
|
132 | if suggestion: | |
|
133 | b.insert_text(suggestion.text) | |
|
134 | else: | |
|
135 | nc.forward_char(event) | |
|
136 | ||
|
137 | ||
|
138 | def accept_word(event: KeyPressEvent): | |
|
139 | """Fill partial autosuggestion by word""" | |
|
140 | b = event.current_buffer | |
|
141 | suggestion = b.suggestion | |
|
142 | if suggestion: | |
|
143 | t = re.split(r"(\S+\s+)", suggestion.text) | |
|
144 | b.insert_text(next((x for x in t if x), "")) | |
|
145 | else: | |
|
146 | nc.forward_word(event) | |
|
147 | ||
|
148 | ||
|
149 | def accept_character(event: KeyPressEvent): | |
|
150 | """Fill partial autosuggestion by character""" | |
|
151 | b = event.current_buffer | |
|
152 | suggestion = b.suggestion | |
|
153 | if suggestion and suggestion.text: | |
|
154 | b.insert_text(suggestion.text[0]) | |
|
155 | ||
|
156 | ||
|
157 | def accept_token(event: KeyPressEvent): | |
|
158 | """Fill partial autosuggestion by token""" | |
|
159 | b = event.current_buffer | |
|
160 | suggestion = b.suggestion | |
|
161 | ||
|
162 | if suggestion: | |
|
163 | prefix = _get_query(b.document) | |
|
164 | text = prefix + suggestion.text | |
|
165 | ||
|
166 | tokens: List[Optional[str]] = [None, None, None] | |
|
167 | substrings = [""] | |
|
168 | i = 0 | |
|
169 | ||
|
170 | for token in generate_tokens(StringIO(text).readline): | |
|
171 | if token.type == tokenize.NEWLINE: | |
|
172 | index = len(text) | |
|
173 | else: | |
|
174 | index = text.index(token[1], len(substrings[-1])) | |
|
175 | substrings.append(text[:index]) | |
|
176 | tokenized_so_far = substrings[-1] | |
|
177 | if tokenized_so_far.startswith(prefix): | |
|
178 | if i == 0 and len(tokenized_so_far) > len(prefix): | |
|
179 | tokens[0] = tokenized_so_far[len(prefix) :] | |
|
180 | substrings.append(tokenized_so_far) | |
|
181 | i += 1 | |
|
182 | tokens[i] = token[1] | |
|
183 | if i == 2: | |
|
184 | break | |
|
185 | i += 1 | |
|
186 | ||
|
187 | if tokens[0]: | |
|
188 | to_insert: str | |
|
189 | insert_text = substrings[-2] | |
|
190 | if tokens[1] and len(tokens[1]) == 1: | |
|
191 | insert_text = substrings[-1] | |
|
192 | to_insert = insert_text[len(prefix) :] | |
|
193 | b.insert_text(to_insert) | |
|
194 | return | |
|
195 | ||
|
196 | nc.forward_word(event) | |
|
197 | ||
|
198 | ||
|
199 | Provider = Union[AutoSuggestFromHistory, NavigableAutoSuggestFromHistory, None] | |
|
200 | ||
|
201 | ||
|
202 | def _swap_autosuggestion( | |
|
203 | buffer: Buffer, | |
|
204 | provider: NavigableAutoSuggestFromHistory, | |
|
205 | direction_method: Callable, | |
|
206 | ): | |
|
207 | """ | |
|
208 | We skip most recent history entry (in either direction) if it equals the | |
|
209 | current autosuggestion because if user cycles when auto-suggestion is shown | |
|
210 | they most likely want something else than what was suggested (othewrise | |
|
211 | they would have accepted the suggestion). | |
|
212 | """ | |
|
213 | suggestion = buffer.suggestion | |
|
214 | if not suggestion: | |
|
215 | return | |
|
216 | ||
|
217 | query = _get_query(buffer.document) | |
|
218 | current = query + suggestion.text | |
|
219 | ||
|
220 | direction_method(query=query, other_than=current, history=buffer.history) | |
|
221 | ||
|
222 | new_suggestion = provider.get_suggestion(buffer, buffer.document) | |
|
223 | buffer.suggestion = new_suggestion | |
|
224 | ||
|
225 | ||
|
226 | def swap_autosuggestion_up(provider: Provider): | |
|
227 | def swap_autosuggestion_up(event: KeyPressEvent): | |
|
228 | """Get next autosuggestion from history.""" | |
|
229 | if not isinstance(provider, NavigableAutoSuggestFromHistory): | |
|
230 | return | |
|
231 | ||
|
232 | return _swap_autosuggestion( | |
|
233 | buffer=event.current_buffer, provider=provider, direction_method=provider.up | |
|
234 | ) | |
|
235 | ||
|
236 | swap_autosuggestion_up.__name__ = "swap_autosuggestion_up" | |
|
237 | return swap_autosuggestion_up | |
|
238 | ||
|
239 | ||
|
240 | def swap_autosuggestion_down( | |
|
241 | provider: Union[AutoSuggestFromHistory, NavigableAutoSuggestFromHistory, None] | |
|
242 | ): | |
|
243 | def swap_autosuggestion_down(event: KeyPressEvent): | |
|
244 | """Get previous autosuggestion from history.""" | |
|
245 | if not isinstance(provider, NavigableAutoSuggestFromHistory): | |
|
246 | return | |
|
247 | ||
|
248 | return _swap_autosuggestion( | |
|
249 | buffer=event.current_buffer, | |
|
250 | provider=provider, | |
|
251 | direction_method=provider.down, | |
|
252 | ) | |
|
253 | ||
|
254 | swap_autosuggestion_down.__name__ = "swap_autosuggestion_down" | |
|
255 | return swap_autosuggestion_down |
@@ -4,6 +4,7 import asyncio | |||
|
4 | 4 | import os |
|
5 | 5 | import sys |
|
6 | 6 | from warnings import warn |
|
7 | from typing import Union as UnionType | |
|
7 | 8 | |
|
8 | 9 | from IPython.core.async_helpers import get_asyncio_loop |
|
9 | 10 | from IPython.core.interactiveshell import InteractiveShell, InteractiveShellABC |
@@ -49,6 +50,7 from .pt_inputhooks import get_inputhook_name_and_func | |||
|
49 | 50 | from .prompts import Prompts, ClassicPrompts, RichPromptDisplayHook |
|
50 | 51 | from .ptutils import IPythonPTCompleter, IPythonPTLexer |
|
51 | 52 | from .shortcuts import create_ipython_shortcuts |
|
53 | from .shortcuts.auto_suggest import NavigableAutoSuggestFromHistory | |
|
52 | 54 | |
|
53 | 55 | PTK3 = ptk_version.startswith('3.') |
|
54 | 56 | |
@@ -183,7 +185,7 class TerminalInteractiveShell(InteractiveShell): | |||
|
183 | 185 | 'menus, decrease for short and wide.' |
|
184 | 186 | ).tag(config=True) |
|
185 | 187 | |
|
186 | pt_app = None | |
|
188 | pt_app: UnionType[PromptSession, None] = None | |
|
187 | 189 | debugger_history = None |
|
188 | 190 | |
|
189 | 191 | debugger_history_file = Unicode( |
@@ -376,18 +378,25 class TerminalInteractiveShell(InteractiveShell): | |||
|
376 | 378 | ).tag(config=True) |
|
377 | 379 | |
|
378 | 380 | autosuggestions_provider = Unicode( |
|
379 | "AutoSuggestFromHistory", | |
|
381 | "NavigableAutoSuggestFromHistory", | |
|
380 | 382 | help="Specifies from which source automatic suggestions are provided. " |
|
381 |
"Can be set to `'AutoSuggestFromHistory |
|
|
382 |
" |
|
|
383 | "Can be set to ``'NavigableAutoSuggestFromHistory'`` (:kbd:`up` and " | |
|
384 | ":kbd:`down` swap suggestions), ``'AutoSuggestFromHistory'``, " | |
|
385 | " or ``None`` to disable automatic suggestions. " | |
|
386 | "Default is `'NavigableAutoSuggestFromHistory`'.", | |
|
383 | 387 | allow_none=True, |
|
384 | 388 | ).tag(config=True) |
|
385 | 389 | |
|
386 | 390 | def _set_autosuggestions(self, provider): |
|
391 | # disconnect old handler | |
|
392 | if self.auto_suggest and isinstance(self.auto_suggest, NavigableAutoSuggestFromHistory): | |
|
393 | self.auto_suggest.disconnect() | |
|
387 | 394 | if provider is None: |
|
388 | 395 | self.auto_suggest = None |
|
389 | 396 | elif provider == "AutoSuggestFromHistory": |
|
390 | 397 | self.auto_suggest = AutoSuggestFromHistory() |
|
398 | elif provider == "NavigableAutoSuggestFromHistory": | |
|
399 | self.auto_suggest = NavigableAutoSuggestFromHistory() | |
|
391 | 400 | else: |
|
392 | 401 | raise ValueError("No valid provider.") |
|
393 | 402 | if self.pt_app: |
@@ -462,6 +471,8 class TerminalInteractiveShell(InteractiveShell): | |||
|
462 | 471 | tempfile_suffix=".py", |
|
463 | 472 | **self._extra_prompt_options() |
|
464 | 473 | ) |
|
474 | if isinstance(self.auto_suggest, NavigableAutoSuggestFromHistory): | |
|
475 | self.auto_suggest.connect(self.pt_app) | |
|
465 | 476 | |
|
466 | 477 | def _make_style_from_name_or_cls(self, name_or_cls): |
|
467 | 478 | """ |
@@ -649,6 +660,7 class TerminalInteractiveShell(InteractiveShell): | |||
|
649 | 660 | |
|
650 | 661 | def __init__(self, *args, **kwargs): |
|
651 | 662 | super(TerminalInteractiveShell, self).__init__(*args, **kwargs) |
|
663 | self.auto_suggest: UnionType[AutoSuggestFromHistory, NavigableAutoSuggestFromHistory, None] = None | |
|
652 | 664 | self._set_autosuggestions(self.autosuggestions_provider) |
|
653 | 665 | self.init_prompt_toolkit_cli() |
|
654 | 666 | self.init_term_title() |
@@ -34,12 +34,24 from prompt_toolkit.key_binding.vi_state import InputMode, ViState | |||
|
34 | 34 | from prompt_toolkit.layout.layout import FocusableElement |
|
35 | 35 | |
|
36 | 36 | from IPython.utils.decorators import undoc |
|
37 |
from . import auto_match as match, autosuggest |
|
|
37 | from . import auto_match as match, auto_suggest | |
|
38 | 38 | |
|
39 | 39 | |
|
40 | 40 | __all__ = ["create_ipython_shortcuts"] |
|
41 | 41 | |
|
42 | 42 | |
|
43 | try: | |
|
44 | # only added in 3.0.30 | |
|
45 | from prompt_toolkit.filters import has_suggestion | |
|
46 | except ImportError: | |
|
47 | ||
|
48 | @undoc | |
|
49 | @Condition | |
|
50 | def has_suggestion(): | |
|
51 | buffer = get_app().current_buffer | |
|
52 | return buffer.suggestion is not None and buffer.suggestion.text != "" | |
|
53 | ||
|
54 | ||
|
43 | 55 | @undoc |
|
44 | 56 | @Condition |
|
45 | 57 | def cursor_in_leading_ws(): |
@@ -324,16 +336,27 def create_ipython_shortcuts(shell, for_all_platforms: bool = False): | |||
|
324 | 336 | |
|
325 | 337 | # autosuggestions |
|
326 | 338 | kb.add("end", filter=has_focus(DEFAULT_BUFFER) & (ebivim | ~vi_insert_mode))( |
|
327 |
autosuggest |
|
|
339 | auto_suggest.accept_in_vi_insert_mode | |
|
328 | 340 | ) |
|
329 | 341 | kb.add("c-e", filter=focused_insert_vi & ebivim)( |
|
330 |
autosuggest |
|
|
342 | auto_suggest.accept_in_vi_insert_mode | |
|
343 | ) | |
|
344 | kb.add("c-f", filter=focused_insert_vi)(auto_suggest.accept) | |
|
345 | kb.add("escape", "f", filter=focused_insert_vi & ebivim)(auto_suggest.accept_word) | |
|
346 | kb.add("c-right", filter=has_suggestion & has_focus(DEFAULT_BUFFER))( | |
|
347 | auto_suggest.accept_token | |
|
348 | ) | |
|
349 | from functools import partial | |
|
350 | ||
|
351 | kb.add("up", filter=has_suggestion & has_focus(DEFAULT_BUFFER))( | |
|
352 | auto_suggest.swap_autosuggestion_up(shell.auto_suggest) | |
|
353 | ) | |
|
354 | kb.add("down", filter=has_suggestion & has_focus(DEFAULT_BUFFER))( | |
|
355 | auto_suggest.swap_autosuggestion_down(shell.auto_suggest) | |
|
331 | 356 | ) |
|
332 | kb.add("c-f", filter=focused_insert_vi)(autosuggestions.accept) | |
|
333 | kb.add("escape", "f", filter=focused_insert_vi & ebivim)( | |
|
334 | autosuggestions.accept_word | |
|
357 | kb.add("right", filter=has_suggestion & has_focus(DEFAULT_BUFFER))( | |
|
358 | auto_suggest.accept_character | |
|
335 | 359 | ) |
|
336 | kb.add("c-right", filter=has_focus(DEFAULT_BUFFER))(autosuggestions.accept_token) | |
|
337 | 360 | |
|
338 | 361 | # Simple Control keybindings |
|
339 | 362 | key_cmd_dict = { |
@@ -1,5 +1,5 | |||
|
1 | 1 | import pytest |
|
2 |
from IPython.terminal.shortcuts.autosuggest |
|
|
2 | from IPython.terminal.shortcuts.auto_suggest import ( | |
|
3 | 3 | accept_in_vi_insert_mode, |
|
4 | 4 | accept_token, |
|
5 | 5 | ) |
@@ -101,6 +101,7 class _DummyTerminal: | |||
|
101 | 101 | input_transformer_manager = None |
|
102 | 102 | display_completions = None |
|
103 | 103 | editing_mode = "emacs" |
|
104 | auto_suggest = None | |
|
104 | 105 | |
|
105 | 106 | |
|
106 | 107 | def create_identifier(handler: Callable): |
|
1 | NO CONTENT: file was removed |
General Comments 0
You need to be logged in to leave comments.
Login now