Show More
@@ -0,0 +1,255 b'' | |||||
|
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 b' import asyncio' | |||||
4 | import os |
|
4 | import os | |
5 | import sys |
|
5 | import sys | |
6 | from warnings import warn |
|
6 | from warnings import warn | |
|
7 | from typing import Union as UnionType | |||
7 |
|
8 | |||
8 | from IPython.core.async_helpers import get_asyncio_loop |
|
9 | from IPython.core.async_helpers import get_asyncio_loop | |
9 | from IPython.core.interactiveshell import InteractiveShell, InteractiveShellABC |
|
10 | from IPython.core.interactiveshell import InteractiveShell, InteractiveShellABC | |
@@ -49,6 +50,7 b' from .pt_inputhooks import get_inputhook_name_and_func' | |||||
49 | from .prompts import Prompts, ClassicPrompts, RichPromptDisplayHook |
|
50 | from .prompts import Prompts, ClassicPrompts, RichPromptDisplayHook | |
50 | from .ptutils import IPythonPTCompleter, IPythonPTLexer |
|
51 | from .ptutils import IPythonPTCompleter, IPythonPTLexer | |
51 | from .shortcuts import create_ipython_shortcuts |
|
52 | from .shortcuts import create_ipython_shortcuts | |
|
53 | from .shortcuts.auto_suggest import NavigableAutoSuggestFromHistory | |||
52 |
|
54 | |||
53 | PTK3 = ptk_version.startswith('3.') |
|
55 | PTK3 = ptk_version.startswith('3.') | |
54 |
|
56 | |||
@@ -183,7 +185,7 b' class TerminalInteractiveShell(InteractiveShell):' | |||||
183 | 'menus, decrease for short and wide.' |
|
185 | 'menus, decrease for short and wide.' | |
184 | ).tag(config=True) |
|
186 | ).tag(config=True) | |
185 |
|
187 | |||
186 | pt_app = None |
|
188 | pt_app: UnionType[PromptSession, None] = None | |
187 | debugger_history = None |
|
189 | debugger_history = None | |
188 |
|
190 | |||
189 | debugger_history_file = Unicode( |
|
191 | debugger_history_file = Unicode( | |
@@ -376,18 +378,25 b' class TerminalInteractiveShell(InteractiveShell):' | |||||
376 | ).tag(config=True) |
|
378 | ).tag(config=True) | |
377 |
|
379 | |||
378 | autosuggestions_provider = Unicode( |
|
380 | autosuggestions_provider = Unicode( | |
379 | "AutoSuggestFromHistory", |
|
381 | "NavigableAutoSuggestFromHistory", | |
380 | help="Specifies from which source automatic suggestions are provided. " |
|
382 | help="Specifies from which source automatic suggestions are provided. " | |
381 |
"Can be set to `'AutoSuggestFromHistory |
|
383 | "Can be set to ``'NavigableAutoSuggestFromHistory'`` (:kbd:`up` and " | |
382 |
" |
|
384 | ":kbd:`down` swap suggestions), ``'AutoSuggestFromHistory'``, " | |
|
385 | " or ``None`` to disable automatic suggestions. " | |||
|
386 | "Default is `'NavigableAutoSuggestFromHistory`'.", | |||
383 | allow_none=True, |
|
387 | allow_none=True, | |
384 | ).tag(config=True) |
|
388 | ).tag(config=True) | |
385 |
|
389 | |||
386 | def _set_autosuggestions(self, provider): |
|
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 | if provider is None: |
|
394 | if provider is None: | |
388 | self.auto_suggest = None |
|
395 | self.auto_suggest = None | |
389 | elif provider == "AutoSuggestFromHistory": |
|
396 | elif provider == "AutoSuggestFromHistory": | |
390 | self.auto_suggest = AutoSuggestFromHistory() |
|
397 | self.auto_suggest = AutoSuggestFromHistory() | |
|
398 | elif provider == "NavigableAutoSuggestFromHistory": | |||
|
399 | self.auto_suggest = NavigableAutoSuggestFromHistory() | |||
391 | else: |
|
400 | else: | |
392 | raise ValueError("No valid provider.") |
|
401 | raise ValueError("No valid provider.") | |
393 | if self.pt_app: |
|
402 | if self.pt_app: | |
@@ -462,6 +471,8 b' class TerminalInteractiveShell(InteractiveShell):' | |||||
462 | tempfile_suffix=".py", |
|
471 | tempfile_suffix=".py", | |
463 | **self._extra_prompt_options() |
|
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 | def _make_style_from_name_or_cls(self, name_or_cls): |
|
477 | def _make_style_from_name_or_cls(self, name_or_cls): | |
467 | """ |
|
478 | """ | |
@@ -649,6 +660,7 b' class TerminalInteractiveShell(InteractiveShell):' | |||||
649 |
|
660 | |||
650 | def __init__(self, *args, **kwargs): |
|
661 | def __init__(self, *args, **kwargs): | |
651 | super(TerminalInteractiveShell, self).__init__(*args, **kwargs) |
|
662 | super(TerminalInteractiveShell, self).__init__(*args, **kwargs) | |
|
663 | self.auto_suggest: UnionType[AutoSuggestFromHistory, NavigableAutoSuggestFromHistory, None] = None | |||
652 | self._set_autosuggestions(self.autosuggestions_provider) |
|
664 | self._set_autosuggestions(self.autosuggestions_provider) | |
653 | self.init_prompt_toolkit_cli() |
|
665 | self.init_prompt_toolkit_cli() | |
654 | self.init_term_title() |
|
666 | self.init_term_title() |
@@ -34,12 +34,24 b' from prompt_toolkit.key_binding.vi_state import InputMode, ViState' | |||||
34 | from prompt_toolkit.layout.layout import FocusableElement |
|
34 | from prompt_toolkit.layout.layout import FocusableElement | |
35 |
|
35 | |||
36 | from IPython.utils.decorators import undoc |
|
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 | __all__ = ["create_ipython_shortcuts"] |
|
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 | @undoc |
|
55 | @undoc | |
44 | @Condition |
|
56 | @Condition | |
45 | def cursor_in_leading_ws(): |
|
57 | def cursor_in_leading_ws(): | |
@@ -324,16 +336,27 b' def create_ipython_shortcuts(shell, for_all_platforms: bool = False):' | |||||
324 |
|
336 | |||
325 | # autosuggestions |
|
337 | # autosuggestions | |
326 | kb.add("end", filter=has_focus(DEFAULT_BUFFER) & (ebivim | ~vi_insert_mode))( |
|
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 | kb.add("c-e", filter=focused_insert_vi & ebivim)( |
|
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) |
|
357 | kb.add("right", filter=has_suggestion & has_focus(DEFAULT_BUFFER))( | |
333 | kb.add("escape", "f", filter=focused_insert_vi & ebivim)( |
|
358 | auto_suggest.accept_character | |
334 | autosuggestions.accept_word |
|
|||
335 | ) |
|
359 | ) | |
336 | kb.add("c-right", filter=has_focus(DEFAULT_BUFFER))(autosuggestions.accept_token) |
|
|||
337 |
|
360 | |||
338 | # Simple Control keybindings |
|
361 | # Simple Control keybindings | |
339 | key_cmd_dict = { |
|
362 | key_cmd_dict = { |
@@ -1,5 +1,5 b'' | |||||
1 | import pytest |
|
1 | import pytest | |
2 |
from IPython.terminal.shortcuts.autosuggest |
|
2 | from IPython.terminal.shortcuts.auto_suggest import ( | |
3 | accept_in_vi_insert_mode, |
|
3 | accept_in_vi_insert_mode, | |
4 | accept_token, |
|
4 | accept_token, | |
5 | ) |
|
5 | ) |
@@ -101,6 +101,7 b' class _DummyTerminal:' | |||||
101 | input_transformer_manager = None |
|
101 | input_transformer_manager = None | |
102 | display_completions = None |
|
102 | display_completions = None | |
103 | editing_mode = "emacs" |
|
103 | editing_mode = "emacs" | |
|
104 | auto_suggest = None | |||
104 |
|
105 | |||
105 |
|
106 | |||
106 | def create_identifier(handler: Callable): |
|
107 | def create_identifier(handler: Callable): |
1 | NO CONTENT: file was removed |
|
NO CONTENT: file was removed |
General Comments 0
You need to be logged in to leave comments.
Login now