##// END OF EJS Templates
Fix typos
Andrew Kreimer -
Show More
@@ -1,401 +1,401
1 import re
1 import re
2 import tokenize
2 import tokenize
3 from io import StringIO
3 from io import StringIO
4 from typing import Callable, List, Optional, Union, Generator, Tuple
4 from typing import Callable, List, Optional, Union, Generator, Tuple
5 import warnings
5 import warnings
6
6
7 from prompt_toolkit.buffer import Buffer
7 from prompt_toolkit.buffer import Buffer
8 from prompt_toolkit.key_binding import KeyPressEvent
8 from prompt_toolkit.key_binding import KeyPressEvent
9 from prompt_toolkit.key_binding.bindings import named_commands as nc
9 from prompt_toolkit.key_binding.bindings import named_commands as nc
10 from prompt_toolkit.auto_suggest import AutoSuggestFromHistory, Suggestion
10 from prompt_toolkit.auto_suggest import AutoSuggestFromHistory, Suggestion
11 from prompt_toolkit.document import Document
11 from prompt_toolkit.document import Document
12 from prompt_toolkit.history import History
12 from prompt_toolkit.history import History
13 from prompt_toolkit.shortcuts import PromptSession
13 from prompt_toolkit.shortcuts import PromptSession
14 from prompt_toolkit.layout.processors import (
14 from prompt_toolkit.layout.processors import (
15 Processor,
15 Processor,
16 Transformation,
16 Transformation,
17 TransformationInput,
17 TransformationInput,
18 )
18 )
19
19
20 from IPython.core.getipython import get_ipython
20 from IPython.core.getipython import get_ipython
21 from IPython.utils.tokenutil import generate_tokens
21 from IPython.utils.tokenutil import generate_tokens
22
22
23 from .filters import pass_through
23 from .filters import pass_through
24
24
25
25
26 def _get_query(document: Document):
26 def _get_query(document: Document):
27 return document.lines[document.cursor_position_row]
27 return document.lines[document.cursor_position_row]
28
28
29
29
30 class AppendAutoSuggestionInAnyLine(Processor):
30 class AppendAutoSuggestionInAnyLine(Processor):
31 """
31 """
32 Append the auto suggestion to lines other than the last (appending to the
32 Append the auto suggestion to lines other than the last (appending to the
33 last line is natively supported by the prompt toolkit).
33 last line is natively supported by the prompt toolkit).
34 """
34 """
35
35
36 def __init__(self, style: str = "class:auto-suggestion") -> None:
36 def __init__(self, style: str = "class:auto-suggestion") -> None:
37 self.style = style
37 self.style = style
38
38
39 def apply_transformation(self, ti: TransformationInput) -> Transformation:
39 def apply_transformation(self, ti: TransformationInput) -> Transformation:
40 is_last_line = ti.lineno == ti.document.line_count - 1
40 is_last_line = ti.lineno == ti.document.line_count - 1
41 is_active_line = ti.lineno == ti.document.cursor_position_row
41 is_active_line = ti.lineno == ti.document.cursor_position_row
42
42
43 if not is_last_line and is_active_line:
43 if not is_last_line and is_active_line:
44 buffer = ti.buffer_control.buffer
44 buffer = ti.buffer_control.buffer
45
45
46 if buffer.suggestion and ti.document.is_cursor_at_the_end_of_line:
46 if buffer.suggestion and ti.document.is_cursor_at_the_end_of_line:
47 suggestion = buffer.suggestion.text
47 suggestion = buffer.suggestion.text
48 else:
48 else:
49 suggestion = ""
49 suggestion = ""
50
50
51 return Transformation(fragments=ti.fragments + [(self.style, suggestion)])
51 return Transformation(fragments=ti.fragments + [(self.style, suggestion)])
52 else:
52 else:
53 return Transformation(fragments=ti.fragments)
53 return Transformation(fragments=ti.fragments)
54
54
55
55
56 class NavigableAutoSuggestFromHistory(AutoSuggestFromHistory):
56 class NavigableAutoSuggestFromHistory(AutoSuggestFromHistory):
57 """
57 """
58 A subclass of AutoSuggestFromHistory that allow navigation to next/previous
58 A subclass of AutoSuggestFromHistory that allow navigation to next/previous
59 suggestion from history. To do so it remembers the current position, but it
59 suggestion from history. To do so it remembers the current position, but it
60 state need to carefully be cleared on the right events.
60 state need to carefully be cleared on the right events.
61 """
61 """
62
62
63 def __init__(
63 def __init__(
64 self,
64 self,
65 ):
65 ):
66 self.skip_lines = 0
66 self.skip_lines = 0
67 self._connected_apps = []
67 self._connected_apps = []
68
68
69 def reset_history_position(self, _: Buffer):
69 def reset_history_position(self, _: Buffer):
70 self.skip_lines = 0
70 self.skip_lines = 0
71
71
72 def disconnect(self):
72 def disconnect(self):
73 for pt_app in self._connected_apps:
73 for pt_app in self._connected_apps:
74 text_insert_event = pt_app.default_buffer.on_text_insert
74 text_insert_event = pt_app.default_buffer.on_text_insert
75 text_insert_event.remove_handler(self.reset_history_position)
75 text_insert_event.remove_handler(self.reset_history_position)
76
76
77 def connect(self, pt_app: PromptSession):
77 def connect(self, pt_app: PromptSession):
78 self._connected_apps.append(pt_app)
78 self._connected_apps.append(pt_app)
79 # note: `on_text_changed` could be used for a bit different behaviour
79 # note: `on_text_changed` could be used for a bit different behaviour
80 # on character deletion (i.e. reseting history position on backspace)
80 # on character deletion (i.e. resetting history position on backspace)
81 pt_app.default_buffer.on_text_insert.add_handler(self.reset_history_position)
81 pt_app.default_buffer.on_text_insert.add_handler(self.reset_history_position)
82 pt_app.default_buffer.on_cursor_position_changed.add_handler(self._dismiss)
82 pt_app.default_buffer.on_cursor_position_changed.add_handler(self._dismiss)
83
83
84 def get_suggestion(
84 def get_suggestion(
85 self, buffer: Buffer, document: Document
85 self, buffer: Buffer, document: Document
86 ) -> Optional[Suggestion]:
86 ) -> Optional[Suggestion]:
87 text = _get_query(document)
87 text = _get_query(document)
88
88
89 if text.strip():
89 if text.strip():
90 for suggestion, _ in self._find_next_match(
90 for suggestion, _ in self._find_next_match(
91 text, self.skip_lines, buffer.history
91 text, self.skip_lines, buffer.history
92 ):
92 ):
93 return Suggestion(suggestion)
93 return Suggestion(suggestion)
94
94
95 return None
95 return None
96
96
97 def _dismiss(self, buffer, *args, **kwargs):
97 def _dismiss(self, buffer, *args, **kwargs):
98 buffer.suggestion = None
98 buffer.suggestion = None
99
99
100 def _find_match(
100 def _find_match(
101 self, text: str, skip_lines: float, history: History, previous: bool
101 self, text: str, skip_lines: float, history: History, previous: bool
102 ) -> Generator[Tuple[str, float], None, None]:
102 ) -> Generator[Tuple[str, float], None, None]:
103 """
103 """
104 text : str
104 text : str
105 Text content to find a match for, the user cursor is most of the
105 Text content to find a match for, the user cursor is most of the
106 time at the end of this text.
106 time at the end of this text.
107 skip_lines : float
107 skip_lines : float
108 number of items to skip in the search, this is used to indicate how
108 number of items to skip in the search, this is used to indicate how
109 far in the list the user has navigated by pressing up or down.
109 far in the list the user has navigated by pressing up or down.
110 The float type is used as the base value is +inf
110 The float type is used as the base value is +inf
111 history : History
111 history : History
112 prompt_toolkit History instance to fetch previous entries from.
112 prompt_toolkit History instance to fetch previous entries from.
113 previous : bool
113 previous : bool
114 Direction of the search, whether we are looking previous match
114 Direction of the search, whether we are looking previous match
115 (True), or next match (False).
115 (True), or next match (False).
116
116
117 Yields
117 Yields
118 ------
118 ------
119 Tuple with:
119 Tuple with:
120 str:
120 str:
121 current suggestion.
121 current suggestion.
122 float:
122 float:
123 will actually yield only ints, which is passed back via skip_lines,
123 will actually yield only ints, which is passed back via skip_lines,
124 which may be a +inf (float)
124 which may be a +inf (float)
125
125
126
126
127 """
127 """
128 line_number = -1
128 line_number = -1
129 for string in reversed(list(history.get_strings())):
129 for string in reversed(list(history.get_strings())):
130 for line in reversed(string.splitlines()):
130 for line in reversed(string.splitlines()):
131 line_number += 1
131 line_number += 1
132 if not previous and line_number < skip_lines:
132 if not previous and line_number < skip_lines:
133 continue
133 continue
134 # do not return empty suggestions as these
134 # do not return empty suggestions as these
135 # close the auto-suggestion overlay (and are useless)
135 # close the auto-suggestion overlay (and are useless)
136 if line.startswith(text) and len(line) > len(text):
136 if line.startswith(text) and len(line) > len(text):
137 yield line[len(text) :], line_number
137 yield line[len(text) :], line_number
138 if previous and line_number >= skip_lines:
138 if previous and line_number >= skip_lines:
139 return
139 return
140
140
141 def _find_next_match(
141 def _find_next_match(
142 self, text: str, skip_lines: float, history: History
142 self, text: str, skip_lines: float, history: History
143 ) -> Generator[Tuple[str, float], None, None]:
143 ) -> Generator[Tuple[str, float], None, None]:
144 return self._find_match(text, skip_lines, history, previous=False)
144 return self._find_match(text, skip_lines, history, previous=False)
145
145
146 def _find_previous_match(self, text: str, skip_lines: float, history: History):
146 def _find_previous_match(self, text: str, skip_lines: float, history: History):
147 return reversed(
147 return reversed(
148 list(self._find_match(text, skip_lines, history, previous=True))
148 list(self._find_match(text, skip_lines, history, previous=True))
149 )
149 )
150
150
151 def up(self, query: str, other_than: str, history: History) -> None:
151 def up(self, query: str, other_than: str, history: History) -> None:
152 for suggestion, line_number in self._find_next_match(
152 for suggestion, line_number in self._find_next_match(
153 query, self.skip_lines, history
153 query, self.skip_lines, history
154 ):
154 ):
155 # if user has history ['very.a', 'very', 'very.b'] and typed 'very'
155 # if user has history ['very.a', 'very', 'very.b'] and typed 'very'
156 # we want to switch from 'very.b' to 'very.a' because a) if the
156 # we want to switch from 'very.b' to 'very.a' because a) if the
157 # suggestion equals current text, prompt-toolkit aborts suggesting
157 # suggestion equals current text, prompt-toolkit aborts suggesting
158 # b) user likely would not be interested in 'very' anyways (they
158 # b) user likely would not be interested in 'very' anyways (they
159 # already typed it).
159 # already typed it).
160 if query + suggestion != other_than:
160 if query + suggestion != other_than:
161 self.skip_lines = line_number
161 self.skip_lines = line_number
162 break
162 break
163 else:
163 else:
164 # no matches found, cycle back to beginning
164 # no matches found, cycle back to beginning
165 self.skip_lines = 0
165 self.skip_lines = 0
166
166
167 def down(self, query: str, other_than: str, history: History) -> None:
167 def down(self, query: str, other_than: str, history: History) -> None:
168 for suggestion, line_number in self._find_previous_match(
168 for suggestion, line_number in self._find_previous_match(
169 query, self.skip_lines, history
169 query, self.skip_lines, history
170 ):
170 ):
171 if query + suggestion != other_than:
171 if query + suggestion != other_than:
172 self.skip_lines = line_number
172 self.skip_lines = line_number
173 break
173 break
174 else:
174 else:
175 # no matches found, cycle to end
175 # no matches found, cycle to end
176 for suggestion, line_number in self._find_previous_match(
176 for suggestion, line_number in self._find_previous_match(
177 query, float("Inf"), history
177 query, float("Inf"), history
178 ):
178 ):
179 if query + suggestion != other_than:
179 if query + suggestion != other_than:
180 self.skip_lines = line_number
180 self.skip_lines = line_number
181 break
181 break
182
182
183
183
184 def accept_or_jump_to_end(event: KeyPressEvent):
184 def accept_or_jump_to_end(event: KeyPressEvent):
185 """Apply autosuggestion or jump to end of line."""
185 """Apply autosuggestion or jump to end of line."""
186 buffer = event.current_buffer
186 buffer = event.current_buffer
187 d = buffer.document
187 d = buffer.document
188 after_cursor = d.text[d.cursor_position :]
188 after_cursor = d.text[d.cursor_position :]
189 lines = after_cursor.split("\n")
189 lines = after_cursor.split("\n")
190 end_of_current_line = lines[0].strip()
190 end_of_current_line = lines[0].strip()
191 suggestion = buffer.suggestion
191 suggestion = buffer.suggestion
192 if (suggestion is not None) and (suggestion.text) and (end_of_current_line == ""):
192 if (suggestion is not None) and (suggestion.text) and (end_of_current_line == ""):
193 buffer.insert_text(suggestion.text)
193 buffer.insert_text(suggestion.text)
194 else:
194 else:
195 nc.end_of_line(event)
195 nc.end_of_line(event)
196
196
197
197
198 def _deprected_accept_in_vi_insert_mode(event: KeyPressEvent):
198 def _deprected_accept_in_vi_insert_mode(event: KeyPressEvent):
199 """Accept autosuggestion or jump to end of line.
199 """Accept autosuggestion or jump to end of line.
200
200
201 .. deprecated:: 8.12
201 .. deprecated:: 8.12
202 Use `accept_or_jump_to_end` instead.
202 Use `accept_or_jump_to_end` instead.
203 """
203 """
204 return accept_or_jump_to_end(event)
204 return accept_or_jump_to_end(event)
205
205
206
206
207 def accept(event: KeyPressEvent):
207 def accept(event: KeyPressEvent):
208 """Accept autosuggestion"""
208 """Accept autosuggestion"""
209 buffer = event.current_buffer
209 buffer = event.current_buffer
210 suggestion = buffer.suggestion
210 suggestion = buffer.suggestion
211 if suggestion:
211 if suggestion:
212 buffer.insert_text(suggestion.text)
212 buffer.insert_text(suggestion.text)
213 else:
213 else:
214 nc.forward_char(event)
214 nc.forward_char(event)
215
215
216
216
217 def discard(event: KeyPressEvent):
217 def discard(event: KeyPressEvent):
218 """Discard autosuggestion"""
218 """Discard autosuggestion"""
219 buffer = event.current_buffer
219 buffer = event.current_buffer
220 buffer.suggestion = None
220 buffer.suggestion = None
221
221
222
222
223 def accept_word(event: KeyPressEvent):
223 def accept_word(event: KeyPressEvent):
224 """Fill partial autosuggestion by word"""
224 """Fill partial autosuggestion by word"""
225 buffer = event.current_buffer
225 buffer = event.current_buffer
226 suggestion = buffer.suggestion
226 suggestion = buffer.suggestion
227 if suggestion:
227 if suggestion:
228 t = re.split(r"(\S+\s+)", suggestion.text)
228 t = re.split(r"(\S+\s+)", suggestion.text)
229 buffer.insert_text(next((x for x in t if x), ""))
229 buffer.insert_text(next((x for x in t if x), ""))
230 else:
230 else:
231 nc.forward_word(event)
231 nc.forward_word(event)
232
232
233
233
234 def accept_character(event: KeyPressEvent):
234 def accept_character(event: KeyPressEvent):
235 """Fill partial autosuggestion by character"""
235 """Fill partial autosuggestion by character"""
236 b = event.current_buffer
236 b = event.current_buffer
237 suggestion = b.suggestion
237 suggestion = b.suggestion
238 if suggestion and suggestion.text:
238 if suggestion and suggestion.text:
239 b.insert_text(suggestion.text[0])
239 b.insert_text(suggestion.text[0])
240
240
241
241
242 def accept_and_keep_cursor(event: KeyPressEvent):
242 def accept_and_keep_cursor(event: KeyPressEvent):
243 """Accept autosuggestion and keep cursor in place"""
243 """Accept autosuggestion and keep cursor in place"""
244 buffer = event.current_buffer
244 buffer = event.current_buffer
245 old_position = buffer.cursor_position
245 old_position = buffer.cursor_position
246 suggestion = buffer.suggestion
246 suggestion = buffer.suggestion
247 if suggestion:
247 if suggestion:
248 buffer.insert_text(suggestion.text)
248 buffer.insert_text(suggestion.text)
249 buffer.cursor_position = old_position
249 buffer.cursor_position = old_position
250
250
251
251
252 def accept_and_move_cursor_left(event: KeyPressEvent):
252 def accept_and_move_cursor_left(event: KeyPressEvent):
253 """Accept autosuggestion and move cursor left in place"""
253 """Accept autosuggestion and move cursor left in place"""
254 accept_and_keep_cursor(event)
254 accept_and_keep_cursor(event)
255 nc.backward_char(event)
255 nc.backward_char(event)
256
256
257
257
258 def _update_hint(buffer: Buffer):
258 def _update_hint(buffer: Buffer):
259 if buffer.auto_suggest:
259 if buffer.auto_suggest:
260 suggestion = buffer.auto_suggest.get_suggestion(buffer, buffer.document)
260 suggestion = buffer.auto_suggest.get_suggestion(buffer, buffer.document)
261 buffer.suggestion = suggestion
261 buffer.suggestion = suggestion
262
262
263
263
264 def backspace_and_resume_hint(event: KeyPressEvent):
264 def backspace_and_resume_hint(event: KeyPressEvent):
265 """Resume autosuggestions after deleting last character"""
265 """Resume autosuggestions after deleting last character"""
266 nc.backward_delete_char(event)
266 nc.backward_delete_char(event)
267 _update_hint(event.current_buffer)
267 _update_hint(event.current_buffer)
268
268
269
269
270 def resume_hinting(event: KeyPressEvent):
270 def resume_hinting(event: KeyPressEvent):
271 """Resume autosuggestions"""
271 """Resume autosuggestions"""
272 pass_through.reply(event)
272 pass_through.reply(event)
273 # Order matters: if update happened first and event reply second, the
273 # Order matters: if update happened first and event reply second, the
274 # suggestion would be auto-accepted if both actions are bound to same key.
274 # suggestion would be auto-accepted if both actions are bound to same key.
275 _update_hint(event.current_buffer)
275 _update_hint(event.current_buffer)
276
276
277
277
278 def up_and_update_hint(event: KeyPressEvent):
278 def up_and_update_hint(event: KeyPressEvent):
279 """Go up and update hint"""
279 """Go up and update hint"""
280 current_buffer = event.current_buffer
280 current_buffer = event.current_buffer
281
281
282 current_buffer.auto_up(count=event.arg)
282 current_buffer.auto_up(count=event.arg)
283 _update_hint(current_buffer)
283 _update_hint(current_buffer)
284
284
285
285
286 def down_and_update_hint(event: KeyPressEvent):
286 def down_and_update_hint(event: KeyPressEvent):
287 """Go down and update hint"""
287 """Go down and update hint"""
288 current_buffer = event.current_buffer
288 current_buffer = event.current_buffer
289
289
290 current_buffer.auto_down(count=event.arg)
290 current_buffer.auto_down(count=event.arg)
291 _update_hint(current_buffer)
291 _update_hint(current_buffer)
292
292
293
293
294 def accept_token(event: KeyPressEvent):
294 def accept_token(event: KeyPressEvent):
295 """Fill partial autosuggestion by token"""
295 """Fill partial autosuggestion by token"""
296 b = event.current_buffer
296 b = event.current_buffer
297 suggestion = b.suggestion
297 suggestion = b.suggestion
298
298
299 if suggestion:
299 if suggestion:
300 prefix = _get_query(b.document)
300 prefix = _get_query(b.document)
301 text = prefix + suggestion.text
301 text = prefix + suggestion.text
302
302
303 tokens: List[Optional[str]] = [None, None, None]
303 tokens: List[Optional[str]] = [None, None, None]
304 substrings = [""]
304 substrings = [""]
305 i = 0
305 i = 0
306
306
307 for token in generate_tokens(StringIO(text).readline):
307 for token in generate_tokens(StringIO(text).readline):
308 if token.type == tokenize.NEWLINE:
308 if token.type == tokenize.NEWLINE:
309 index = len(text)
309 index = len(text)
310 else:
310 else:
311 index = text.index(token[1], len(substrings[-1]))
311 index = text.index(token[1], len(substrings[-1]))
312 substrings.append(text[:index])
312 substrings.append(text[:index])
313 tokenized_so_far = substrings[-1]
313 tokenized_so_far = substrings[-1]
314 if tokenized_so_far.startswith(prefix):
314 if tokenized_so_far.startswith(prefix):
315 if i == 0 and len(tokenized_so_far) > len(prefix):
315 if i == 0 and len(tokenized_so_far) > len(prefix):
316 tokens[0] = tokenized_so_far[len(prefix) :]
316 tokens[0] = tokenized_so_far[len(prefix) :]
317 substrings.append(tokenized_so_far)
317 substrings.append(tokenized_so_far)
318 i += 1
318 i += 1
319 tokens[i] = token[1]
319 tokens[i] = token[1]
320 if i == 2:
320 if i == 2:
321 break
321 break
322 i += 1
322 i += 1
323
323
324 if tokens[0]:
324 if tokens[0]:
325 to_insert: str
325 to_insert: str
326 insert_text = substrings[-2]
326 insert_text = substrings[-2]
327 if tokens[1] and len(tokens[1]) == 1:
327 if tokens[1] and len(tokens[1]) == 1:
328 insert_text = substrings[-1]
328 insert_text = substrings[-1]
329 to_insert = insert_text[len(prefix) :]
329 to_insert = insert_text[len(prefix) :]
330 b.insert_text(to_insert)
330 b.insert_text(to_insert)
331 return
331 return
332
332
333 nc.forward_word(event)
333 nc.forward_word(event)
334
334
335
335
336 Provider = Union[AutoSuggestFromHistory, NavigableAutoSuggestFromHistory, None]
336 Provider = Union[AutoSuggestFromHistory, NavigableAutoSuggestFromHistory, None]
337
337
338
338
339 def _swap_autosuggestion(
339 def _swap_autosuggestion(
340 buffer: Buffer,
340 buffer: Buffer,
341 provider: NavigableAutoSuggestFromHistory,
341 provider: NavigableAutoSuggestFromHistory,
342 direction_method: Callable,
342 direction_method: Callable,
343 ):
343 ):
344 """
344 """
345 We skip most recent history entry (in either direction) if it equals the
345 We skip most recent history entry (in either direction) if it equals the
346 current autosuggestion because if user cycles when auto-suggestion is shown
346 current autosuggestion because if user cycles when auto-suggestion is shown
347 they most likely want something else than what was suggested (otherwise
347 they most likely want something else than what was suggested (otherwise
348 they would have accepted the suggestion).
348 they would have accepted the suggestion).
349 """
349 """
350 suggestion = buffer.suggestion
350 suggestion = buffer.suggestion
351 if not suggestion:
351 if not suggestion:
352 return
352 return
353
353
354 query = _get_query(buffer.document)
354 query = _get_query(buffer.document)
355 current = query + suggestion.text
355 current = query + suggestion.text
356
356
357 direction_method(query=query, other_than=current, history=buffer.history)
357 direction_method(query=query, other_than=current, history=buffer.history)
358
358
359 new_suggestion = provider.get_suggestion(buffer, buffer.document)
359 new_suggestion = provider.get_suggestion(buffer, buffer.document)
360 buffer.suggestion = new_suggestion
360 buffer.suggestion = new_suggestion
361
361
362
362
363 def swap_autosuggestion_up(event: KeyPressEvent):
363 def swap_autosuggestion_up(event: KeyPressEvent):
364 """Get next autosuggestion from history."""
364 """Get next autosuggestion from history."""
365 shell = get_ipython()
365 shell = get_ipython()
366 provider = shell.auto_suggest
366 provider = shell.auto_suggest
367
367
368 if not isinstance(provider, NavigableAutoSuggestFromHistory):
368 if not isinstance(provider, NavigableAutoSuggestFromHistory):
369 return
369 return
370
370
371 return _swap_autosuggestion(
371 return _swap_autosuggestion(
372 buffer=event.current_buffer, provider=provider, direction_method=provider.up
372 buffer=event.current_buffer, provider=provider, direction_method=provider.up
373 )
373 )
374
374
375
375
376 def swap_autosuggestion_down(event: KeyPressEvent):
376 def swap_autosuggestion_down(event: KeyPressEvent):
377 """Get previous autosuggestion from history."""
377 """Get previous autosuggestion from history."""
378 shell = get_ipython()
378 shell = get_ipython()
379 provider = shell.auto_suggest
379 provider = shell.auto_suggest
380
380
381 if not isinstance(provider, NavigableAutoSuggestFromHistory):
381 if not isinstance(provider, NavigableAutoSuggestFromHistory):
382 return
382 return
383
383
384 return _swap_autosuggestion(
384 return _swap_autosuggestion(
385 buffer=event.current_buffer,
385 buffer=event.current_buffer,
386 provider=provider,
386 provider=provider,
387 direction_method=provider.down,
387 direction_method=provider.down,
388 )
388 )
389
389
390
390
391 def __getattr__(key):
391 def __getattr__(key):
392 if key == "accept_in_vi_insert_mode":
392 if key == "accept_in_vi_insert_mode":
393 warnings.warn(
393 warnings.warn(
394 "`accept_in_vi_insert_mode` is deprecated since IPython 8.12 and "
394 "`accept_in_vi_insert_mode` is deprecated since IPython 8.12 and "
395 "renamed to `accept_or_jump_to_end`. Please update your configuration "
395 "renamed to `accept_or_jump_to_end`. Please update your configuration "
396 "accordingly",
396 "accordingly",
397 DeprecationWarning,
397 DeprecationWarning,
398 stacklevel=2,
398 stacklevel=2,
399 )
399 )
400 return _deprected_accept_in_vi_insert_mode
400 return _deprected_accept_in_vi_insert_mode
401 raise AttributeError
401 raise AttributeError
@@ -1,322 +1,322
1 """
1 """
2 Filters restricting scope of IPython Terminal shortcuts.
2 Filters restricting scope of IPython Terminal shortcuts.
3 """
3 """
4
4
5 # Copyright (c) IPython Development Team.
5 # Copyright (c) IPython Development Team.
6 # Distributed under the terms of the Modified BSD License.
6 # Distributed under the terms of the Modified BSD License.
7
7
8 import ast
8 import ast
9 import re
9 import re
10 import signal
10 import signal
11 import sys
11 import sys
12 from typing import Callable, Dict, Union
12 from typing import Callable, Dict, Union
13
13
14 from prompt_toolkit.application.current import get_app
14 from prompt_toolkit.application.current import get_app
15 from prompt_toolkit.enums import DEFAULT_BUFFER, SEARCH_BUFFER
15 from prompt_toolkit.enums import DEFAULT_BUFFER, SEARCH_BUFFER
16 from prompt_toolkit.key_binding import KeyPressEvent
16 from prompt_toolkit.key_binding import KeyPressEvent
17 from prompt_toolkit.filters import Condition, Filter, emacs_insert_mode, has_completions
17 from prompt_toolkit.filters import Condition, Filter, emacs_insert_mode, has_completions
18 from prompt_toolkit.filters import has_focus as has_focus_impl
18 from prompt_toolkit.filters import has_focus as has_focus_impl
19 from prompt_toolkit.filters import (
19 from prompt_toolkit.filters import (
20 Always,
20 Always,
21 Never,
21 Never,
22 has_selection,
22 has_selection,
23 has_suggestion,
23 has_suggestion,
24 vi_insert_mode,
24 vi_insert_mode,
25 vi_mode,
25 vi_mode,
26 )
26 )
27 from prompt_toolkit.layout.layout import FocusableElement
27 from prompt_toolkit.layout.layout import FocusableElement
28
28
29 from IPython.core.getipython import get_ipython
29 from IPython.core.getipython import get_ipython
30 from IPython.core.guarded_eval import _find_dunder, BINARY_OP_DUNDERS, UNARY_OP_DUNDERS
30 from IPython.core.guarded_eval import _find_dunder, BINARY_OP_DUNDERS, UNARY_OP_DUNDERS
31 from IPython.terminal.shortcuts import auto_suggest
31 from IPython.terminal.shortcuts import auto_suggest
32 from IPython.utils.decorators import undoc
32 from IPython.utils.decorators import undoc
33
33
34
34
35 @undoc
35 @undoc
36 @Condition
36 @Condition
37 def cursor_in_leading_ws():
37 def cursor_in_leading_ws():
38 before = get_app().current_buffer.document.current_line_before_cursor
38 before = get_app().current_buffer.document.current_line_before_cursor
39 return (not before) or before.isspace()
39 return (not before) or before.isspace()
40
40
41
41
42 def has_focus(value: FocusableElement):
42 def has_focus(value: FocusableElement):
43 """Wrapper around has_focus adding a nice `__name__` to tester function"""
43 """Wrapper around has_focus adding a nice `__name__` to tester function"""
44 tester = has_focus_impl(value).func
44 tester = has_focus_impl(value).func
45 tester.__name__ = f"is_focused({value})"
45 tester.__name__ = f"is_focused({value})"
46 return Condition(tester)
46 return Condition(tester)
47
47
48
48
49 @undoc
49 @undoc
50 @Condition
50 @Condition
51 def has_line_below() -> bool:
51 def has_line_below() -> bool:
52 document = get_app().current_buffer.document
52 document = get_app().current_buffer.document
53 return document.cursor_position_row < len(document.lines) - 1
53 return document.cursor_position_row < len(document.lines) - 1
54
54
55
55
56 @undoc
56 @undoc
57 @Condition
57 @Condition
58 def is_cursor_at_the_end_of_line() -> bool:
58 def is_cursor_at_the_end_of_line() -> bool:
59 document = get_app().current_buffer.document
59 document = get_app().current_buffer.document
60 return document.is_cursor_at_the_end_of_line
60 return document.is_cursor_at_the_end_of_line
61
61
62
62
63 @undoc
63 @undoc
64 @Condition
64 @Condition
65 def has_line_above() -> bool:
65 def has_line_above() -> bool:
66 document = get_app().current_buffer.document
66 document = get_app().current_buffer.document
67 return document.cursor_position_row != 0
67 return document.cursor_position_row != 0
68
68
69
69
70 @Condition
70 @Condition
71 def ebivim():
71 def ebivim():
72 shell = get_ipython()
72 shell = get_ipython()
73 return shell.emacs_bindings_in_vi_insert_mode
73 return shell.emacs_bindings_in_vi_insert_mode
74
74
75
75
76 @Condition
76 @Condition
77 def supports_suspend():
77 def supports_suspend():
78 return hasattr(signal, "SIGTSTP")
78 return hasattr(signal, "SIGTSTP")
79
79
80
80
81 @Condition
81 @Condition
82 def auto_match():
82 def auto_match():
83 shell = get_ipython()
83 shell = get_ipython()
84 return shell.auto_match
84 return shell.auto_match
85
85
86
86
87 def all_quotes_paired(quote, buf):
87 def all_quotes_paired(quote, buf):
88 paired = True
88 paired = True
89 i = 0
89 i = 0
90 while i < len(buf):
90 while i < len(buf):
91 c = buf[i]
91 c = buf[i]
92 if c == quote:
92 if c == quote:
93 paired = not paired
93 paired = not paired
94 elif c == "\\":
94 elif c == "\\":
95 i += 1
95 i += 1
96 i += 1
96 i += 1
97 return paired
97 return paired
98
98
99
99
100 _preceding_text_cache: Dict[Union[str, Callable], Condition] = {}
100 _preceding_text_cache: Dict[Union[str, Callable], Condition] = {}
101 _following_text_cache: Dict[Union[str, Callable], Condition] = {}
101 _following_text_cache: Dict[Union[str, Callable], Condition] = {}
102
102
103
103
104 def preceding_text(pattern: Union[str, Callable]):
104 def preceding_text(pattern: Union[str, Callable]):
105 if pattern in _preceding_text_cache:
105 if pattern in _preceding_text_cache:
106 return _preceding_text_cache[pattern]
106 return _preceding_text_cache[pattern]
107
107
108 if callable(pattern):
108 if callable(pattern):
109
109
110 def _preceding_text():
110 def _preceding_text():
111 app = get_app()
111 app = get_app()
112 before_cursor = app.current_buffer.document.current_line_before_cursor
112 before_cursor = app.current_buffer.document.current_line_before_cursor
113 # mypy can't infer if(callable): https://github.com/python/mypy/issues/3603
113 # mypy can't infer if(callable): https://github.com/python/mypy/issues/3603
114 return bool(pattern(before_cursor)) # type: ignore[operator]
114 return bool(pattern(before_cursor)) # type: ignore[operator]
115
115
116 else:
116 else:
117 m = re.compile(pattern)
117 m = re.compile(pattern)
118
118
119 def _preceding_text():
119 def _preceding_text():
120 app = get_app()
120 app = get_app()
121 before_cursor = app.current_buffer.document.current_line_before_cursor
121 before_cursor = app.current_buffer.document.current_line_before_cursor
122 return bool(m.match(before_cursor))
122 return bool(m.match(before_cursor))
123
123
124 _preceding_text.__name__ = f"preceding_text({pattern!r})"
124 _preceding_text.__name__ = f"preceding_text({pattern!r})"
125
125
126 condition = Condition(_preceding_text)
126 condition = Condition(_preceding_text)
127 _preceding_text_cache[pattern] = condition
127 _preceding_text_cache[pattern] = condition
128 return condition
128 return condition
129
129
130
130
131 def following_text(pattern):
131 def following_text(pattern):
132 try:
132 try:
133 return _following_text_cache[pattern]
133 return _following_text_cache[pattern]
134 except KeyError:
134 except KeyError:
135 pass
135 pass
136 m = re.compile(pattern)
136 m = re.compile(pattern)
137
137
138 def _following_text():
138 def _following_text():
139 app = get_app()
139 app = get_app()
140 return bool(m.match(app.current_buffer.document.current_line_after_cursor))
140 return bool(m.match(app.current_buffer.document.current_line_after_cursor))
141
141
142 _following_text.__name__ = f"following_text({pattern!r})"
142 _following_text.__name__ = f"following_text({pattern!r})"
143
143
144 condition = Condition(_following_text)
144 condition = Condition(_following_text)
145 _following_text_cache[pattern] = condition
145 _following_text_cache[pattern] = condition
146 return condition
146 return condition
147
147
148
148
149 @Condition
149 @Condition
150 def not_inside_unclosed_string():
150 def not_inside_unclosed_string():
151 app = get_app()
151 app = get_app()
152 s = app.current_buffer.document.text_before_cursor
152 s = app.current_buffer.document.text_before_cursor
153 # remove escaped quotes
153 # remove escaped quotes
154 s = s.replace('\\"', "").replace("\\'", "")
154 s = s.replace('\\"', "").replace("\\'", "")
155 # remove triple-quoted string literals
155 # remove triple-quoted string literals
156 s = re.sub(r"(?:\"\"\"[\s\S]*\"\"\"|'''[\s\S]*''')", "", s)
156 s = re.sub(r"(?:\"\"\"[\s\S]*\"\"\"|'''[\s\S]*''')", "", s)
157 # remove single-quoted string literals
157 # remove single-quoted string literals
158 s = re.sub(r"""(?:"[^"]*["\n]|'[^']*['\n])""", "", s)
158 s = re.sub(r"""(?:"[^"]*["\n]|'[^']*['\n])""", "", s)
159 return not ('"' in s or "'" in s)
159 return not ('"' in s or "'" in s)
160
160
161
161
162 @Condition
162 @Condition
163 def navigable_suggestions():
163 def navigable_suggestions():
164 shell = get_ipython()
164 shell = get_ipython()
165 return isinstance(shell.auto_suggest, auto_suggest.NavigableAutoSuggestFromHistory)
165 return isinstance(shell.auto_suggest, auto_suggest.NavigableAutoSuggestFromHistory)
166
166
167
167
168 @Condition
168 @Condition
169 def readline_like_completions():
169 def readline_like_completions():
170 shell = get_ipython()
170 shell = get_ipython()
171 return shell.display_completions == "readlinelike"
171 return shell.display_completions == "readlinelike"
172
172
173
173
174 @Condition
174 @Condition
175 def is_windows_os():
175 def is_windows_os():
176 return sys.platform == "win32"
176 return sys.platform == "win32"
177
177
178
178
179 class PassThrough(Filter):
179 class PassThrough(Filter):
180 """A filter allowing to implement pass-through behaviour of keybindings.
180 """A filter allowing to implement pass-through behaviour of keybindings.
181
181
182 Prompt toolkit key processor dispatches only one event per binding match,
182 Prompt toolkit key processor dispatches only one event per binding match,
183 which means that adding a new shortcut will suppress the old shortcut
183 which means that adding a new shortcut will suppress the old shortcut
184 if the keybindings are the same (unless one is filtered out).
184 if the keybindings are the same (unless one is filtered out).
185
185
186 To stop a shortcut binding from suppressing other shortcuts:
186 To stop a shortcut binding from suppressing other shortcuts:
187 - add the `pass_through` filter to list of filter, and
187 - add the `pass_through` filter to list of filter, and
188 - call `pass_through.reply(event)` in the shortcut handler.
188 - call `pass_through.reply(event)` in the shortcut handler.
189 """
189 """
190
190
191 def __init__(self):
191 def __init__(self):
192 self._is_replying = False
192 self._is_replying = False
193
193
194 def reply(self, event: KeyPressEvent):
194 def reply(self, event: KeyPressEvent):
195 self._is_replying = True
195 self._is_replying = True
196 try:
196 try:
197 event.key_processor.reset()
197 event.key_processor.reset()
198 event.key_processor.feed_multiple(event.key_sequence)
198 event.key_processor.feed_multiple(event.key_sequence)
199 event.key_processor.process_keys()
199 event.key_processor.process_keys()
200 finally:
200 finally:
201 self._is_replying = False
201 self._is_replying = False
202
202
203 def __call__(self):
203 def __call__(self):
204 return not self._is_replying
204 return not self._is_replying
205
205
206
206
207 pass_through = PassThrough()
207 pass_through = PassThrough()
208
208
209 # these one is callable and re-used multiple times hence needs to be
209 # these one is callable and re-used multiple times hence needs to be
210 # only defined once beforhand so that transforming back to human-readable
210 # only defined once beforehand so that transforming back to human-readable
211 # names works well in the documentation.
211 # names works well in the documentation.
212 default_buffer_focused = has_focus(DEFAULT_BUFFER)
212 default_buffer_focused = has_focus(DEFAULT_BUFFER)
213
213
214 KEYBINDING_FILTERS = {
214 KEYBINDING_FILTERS = {
215 "always": Always(),
215 "always": Always(),
216 # never is used for exposing commands which have no default keybindings
216 # never is used for exposing commands which have no default keybindings
217 "never": Never(),
217 "never": Never(),
218 "has_line_below": has_line_below,
218 "has_line_below": has_line_below,
219 "has_line_above": has_line_above,
219 "has_line_above": has_line_above,
220 "is_cursor_at_the_end_of_line": is_cursor_at_the_end_of_line,
220 "is_cursor_at_the_end_of_line": is_cursor_at_the_end_of_line,
221 "has_selection": has_selection,
221 "has_selection": has_selection,
222 "has_suggestion": has_suggestion,
222 "has_suggestion": has_suggestion,
223 "vi_mode": vi_mode,
223 "vi_mode": vi_mode,
224 "vi_insert_mode": vi_insert_mode,
224 "vi_insert_mode": vi_insert_mode,
225 "emacs_insert_mode": emacs_insert_mode,
225 "emacs_insert_mode": emacs_insert_mode,
226 # https://github.com/ipython/ipython/pull/12603 argued for inclusion of
226 # https://github.com/ipython/ipython/pull/12603 argued for inclusion of
227 # emacs key bindings with a configurable `emacs_bindings_in_vi_insert_mode`
227 # emacs key bindings with a configurable `emacs_bindings_in_vi_insert_mode`
228 # toggle; when the toggle is on user can access keybindigns like `ctrl + e`
228 # toggle; when the toggle is on user can access keybindigns like `ctrl + e`
229 # in vi insert mode. Because some of the emacs bindings involve `escape`
229 # in vi insert mode. Because some of the emacs bindings involve `escape`
230 # followed by another key, e.g. `escape` followed by `f`, prompt-toolkit
230 # followed by another key, e.g. `escape` followed by `f`, prompt-toolkit
231 # needs to wait to see if there will be another character typed in before
231 # needs to wait to see if there will be another character typed in before
232 # executing pure `escape` keybinding; in vi insert mode `escape` switches to
232 # executing pure `escape` keybinding; in vi insert mode `escape` switches to
233 # command mode which is common and performance critical action for vi users.
233 # command mode which is common and performance critical action for vi users.
234 # To avoid the delay users employ a workaround:
234 # To avoid the delay users employ a workaround:
235 # https://github.com/ipython/ipython/issues/13443#issuecomment-1032753703
235 # https://github.com/ipython/ipython/issues/13443#issuecomment-1032753703
236 # which involves switching `emacs_bindings_in_vi_insert_mode` off.
236 # which involves switching `emacs_bindings_in_vi_insert_mode` off.
237 #
237 #
238 # For the workaround to work:
238 # For the workaround to work:
239 # 1) end users need to toggle `emacs_bindings_in_vi_insert_mode` off
239 # 1) end users need to toggle `emacs_bindings_in_vi_insert_mode` off
240 # 2) all keybindings which would involve `escape` need to respect that
240 # 2) all keybindings which would involve `escape` need to respect that
241 # toggle by including either:
241 # toggle by including either:
242 # - `vi_insert_mode & ebivim` for actions which have emacs keybindings
242 # - `vi_insert_mode & ebivim` for actions which have emacs keybindings
243 # predefined upstream in prompt-toolkit, or
243 # predefined upstream in prompt-toolkit, or
244 # - `emacs_like_insert_mode` for actions which do not have existing
244 # - `emacs_like_insert_mode` for actions which do not have existing
245 # emacs keybindings predefined upstream (or need overriding of the
245 # emacs keybindings predefined upstream (or need overriding of the
246 # upstream bindings to modify behaviour), defined below.
246 # upstream bindings to modify behaviour), defined below.
247 "emacs_like_insert_mode": (vi_insert_mode & ebivim) | emacs_insert_mode,
247 "emacs_like_insert_mode": (vi_insert_mode & ebivim) | emacs_insert_mode,
248 "has_completions": has_completions,
248 "has_completions": has_completions,
249 "insert_mode": vi_insert_mode | emacs_insert_mode,
249 "insert_mode": vi_insert_mode | emacs_insert_mode,
250 "default_buffer_focused": default_buffer_focused,
250 "default_buffer_focused": default_buffer_focused,
251 "search_buffer_focused": has_focus(SEARCH_BUFFER),
251 "search_buffer_focused": has_focus(SEARCH_BUFFER),
252 # `ebivim` stands for emacs bindings in vi insert mode
252 # `ebivim` stands for emacs bindings in vi insert mode
253 "ebivim": ebivim,
253 "ebivim": ebivim,
254 "supports_suspend": supports_suspend,
254 "supports_suspend": supports_suspend,
255 "is_windows_os": is_windows_os,
255 "is_windows_os": is_windows_os,
256 "auto_match": auto_match,
256 "auto_match": auto_match,
257 "focused_insert": (vi_insert_mode | emacs_insert_mode) & default_buffer_focused,
257 "focused_insert": (vi_insert_mode | emacs_insert_mode) & default_buffer_focused,
258 "not_inside_unclosed_string": not_inside_unclosed_string,
258 "not_inside_unclosed_string": not_inside_unclosed_string,
259 "readline_like_completions": readline_like_completions,
259 "readline_like_completions": readline_like_completions,
260 "preceded_by_paired_double_quotes": preceding_text(
260 "preceded_by_paired_double_quotes": preceding_text(
261 lambda line: all_quotes_paired('"', line)
261 lambda line: all_quotes_paired('"', line)
262 ),
262 ),
263 "preceded_by_paired_single_quotes": preceding_text(
263 "preceded_by_paired_single_quotes": preceding_text(
264 lambda line: all_quotes_paired("'", line)
264 lambda line: all_quotes_paired("'", line)
265 ),
265 ),
266 "preceded_by_raw_str_prefix": preceding_text(r".*(r|R)[\"'](-*)$"),
266 "preceded_by_raw_str_prefix": preceding_text(r".*(r|R)[\"'](-*)$"),
267 "preceded_by_two_double_quotes": preceding_text(r'^.*""$'),
267 "preceded_by_two_double_quotes": preceding_text(r'^.*""$'),
268 "preceded_by_two_single_quotes": preceding_text(r"^.*''$"),
268 "preceded_by_two_single_quotes": preceding_text(r"^.*''$"),
269 "followed_by_closing_paren_or_end": following_text(r"[,)}\]]|$"),
269 "followed_by_closing_paren_or_end": following_text(r"[,)}\]]|$"),
270 "preceded_by_opening_round_paren": preceding_text(r".*\($"),
270 "preceded_by_opening_round_paren": preceding_text(r".*\($"),
271 "preceded_by_opening_bracket": preceding_text(r".*\[$"),
271 "preceded_by_opening_bracket": preceding_text(r".*\[$"),
272 "preceded_by_opening_brace": preceding_text(r".*\{$"),
272 "preceded_by_opening_brace": preceding_text(r".*\{$"),
273 "preceded_by_double_quote": preceding_text('.*"$'),
273 "preceded_by_double_quote": preceding_text('.*"$'),
274 "preceded_by_single_quote": preceding_text(r".*'$"),
274 "preceded_by_single_quote": preceding_text(r".*'$"),
275 "followed_by_closing_round_paren": following_text(r"^\)"),
275 "followed_by_closing_round_paren": following_text(r"^\)"),
276 "followed_by_closing_bracket": following_text(r"^\]"),
276 "followed_by_closing_bracket": following_text(r"^\]"),
277 "followed_by_closing_brace": following_text(r"^\}"),
277 "followed_by_closing_brace": following_text(r"^\}"),
278 "followed_by_double_quote": following_text('^"'),
278 "followed_by_double_quote": following_text('^"'),
279 "followed_by_single_quote": following_text("^'"),
279 "followed_by_single_quote": following_text("^'"),
280 "navigable_suggestions": navigable_suggestions,
280 "navigable_suggestions": navigable_suggestions,
281 "cursor_in_leading_ws": cursor_in_leading_ws,
281 "cursor_in_leading_ws": cursor_in_leading_ws,
282 "pass_through": pass_through,
282 "pass_through": pass_through,
283 }
283 }
284
284
285
285
286 def eval_node(node: Union[ast.AST, None]):
286 def eval_node(node: Union[ast.AST, None]):
287 if node is None:
287 if node is None:
288 return None
288 return None
289 if isinstance(node, ast.Expression):
289 if isinstance(node, ast.Expression):
290 return eval_node(node.body)
290 return eval_node(node.body)
291 if isinstance(node, ast.BinOp):
291 if isinstance(node, ast.BinOp):
292 left = eval_node(node.left)
292 left = eval_node(node.left)
293 right = eval_node(node.right)
293 right = eval_node(node.right)
294 dunders = _find_dunder(node.op, BINARY_OP_DUNDERS)
294 dunders = _find_dunder(node.op, BINARY_OP_DUNDERS)
295 if dunders:
295 if dunders:
296 return getattr(left, dunders[0])(right)
296 return getattr(left, dunders[0])(right)
297 raise ValueError(f"Unknown binary operation: {node.op}")
297 raise ValueError(f"Unknown binary operation: {node.op}")
298 if isinstance(node, ast.UnaryOp):
298 if isinstance(node, ast.UnaryOp):
299 value = eval_node(node.operand)
299 value = eval_node(node.operand)
300 dunders = _find_dunder(node.op, UNARY_OP_DUNDERS)
300 dunders = _find_dunder(node.op, UNARY_OP_DUNDERS)
301 if dunders:
301 if dunders:
302 return getattr(value, dunders[0])()
302 return getattr(value, dunders[0])()
303 raise ValueError(f"Unknown unary operation: {node.op}")
303 raise ValueError(f"Unknown unary operation: {node.op}")
304 if isinstance(node, ast.Name):
304 if isinstance(node, ast.Name):
305 if node.id in KEYBINDING_FILTERS:
305 if node.id in KEYBINDING_FILTERS:
306 return KEYBINDING_FILTERS[node.id]
306 return KEYBINDING_FILTERS[node.id]
307 else:
307 else:
308 sep = "\n - "
308 sep = "\n - "
309 known_filters = sep.join(sorted(KEYBINDING_FILTERS))
309 known_filters = sep.join(sorted(KEYBINDING_FILTERS))
310 raise NameError(
310 raise NameError(
311 f"{node.id} is not a known shortcut filter."
311 f"{node.id} is not a known shortcut filter."
312 f" Known filters are: {sep}{known_filters}."
312 f" Known filters are: {sep}{known_filters}."
313 )
313 )
314 raise ValueError("Unhandled node", ast.dump(node))
314 raise ValueError("Unhandled node", ast.dump(node))
315
315
316
316
317 def filter_from_string(code: str):
317 def filter_from_string(code: str):
318 expression = ast.parse(code, mode="eval")
318 expression = ast.parse(code, mode="eval")
319 return eval_node(expression)
319 return eval_node(expression)
320
320
321
321
322 __all__ = ["KEYBINDING_FILTERS", "filter_from_string"]
322 __all__ = ["KEYBINDING_FILTERS", "filter_from_string"]
General Comments 0
You need to be logged in to leave comments. Login now