diff --git a/IPython/terminal/interactiveshell.py b/IPython/terminal/interactiveshell.py index b5fc148..41a3321 100644 --- a/IPython/terminal/interactiveshell.py +++ b/IPython/terminal/interactiveshell.py @@ -16,6 +16,7 @@ from traitlets import ( Unicode, Dict, Integer, + List, observe, Instance, Type, @@ -29,7 +30,7 @@ from traitlets import ( from prompt_toolkit.auto_suggest import AutoSuggestFromHistory from prompt_toolkit.enums import DEFAULT_BUFFER, EditingMode -from prompt_toolkit.filters import (HasFocus, Condition, IsDone) +from prompt_toolkit.filters import HasFocus, Condition, IsDone from prompt_toolkit.formatted_text import PygmentsTokens from prompt_toolkit.history import History from prompt_toolkit.layout.processors import ConditionalProcessor, HighlightMatchingBracketProcessor @@ -49,7 +50,13 @@ from .magics import TerminalMagics from .pt_inputhooks import get_inputhook_name_and_func from .prompts import Prompts, ClassicPrompts, RichPromptDisplayHook from .ptutils import IPythonPTCompleter, IPythonPTLexer -from .shortcuts import create_ipython_shortcuts +from .shortcuts import ( + create_ipython_shortcuts, + create_identifier, + RuntimeBinding, + add_binding, +) +from .shortcuts.filters import KEYBINDING_FILTERS, filter_from_string from .shortcuts.auto_suggest import ( NavigableAutoSuggestFromHistory, AppendAutoSuggestionInAnyLine, @@ -415,6 +422,165 @@ class TerminalInteractiveShell(InteractiveShell): provider = change.new self._set_autosuggestions(provider) + shortcuts = List( + trait=Dict( + key_trait=Enum( + [ + "command", + "match_keys", + "match_filter", + "new_keys", + "new_filter", + "create", + ] + ), + per_key_traits={ + "command": Unicode(), + "match_keys": List(Unicode()), + "match_filter": Unicode(), + "new_keys": List(Unicode()), + "new_filter": Unicode(), + "create": Bool(False), + }, + ), + help="""Add, disable or modifying shortcuts. + + Each entry on the list should be a dictionary with ``command`` key + identifying the target function executed by the shortcut and at least + one of the following: + + - ``match_keys``: list of keys used to match an existing shortcut, + - ``match_filter``: shortcut filter used to match an existing shortcut, + - ``new_keys``: list of keys to set, + - ``new_filter``: a new shortcut filter to set + + The filters have to be composed of pre-defined verbs and joined by one + of the following conjunctions: ``&`` (and), ``|`` (or), ``~`` (not). + The pre-defined verbs are: + + {} + + + To disable a shortcut set ``new_keys`` to an empty list. + To add a shortcut add key ``create`` with value ``True``. + + When modifying/disabling shortcuts, ``match_keys``/``match_filter`` can + be omitted if the provided specification uniquely identifies a shortcut + to be modified/disabled. When modifying a shortcut ``new_filter`` or + ``new_keys`` can be omitted which will result in reuse of the existing + filter/keys. + + Only shortcuts defined in IPython (and not default prompt-toolkit + shortcuts) can be modified or disabled. The full list of shortcuts, + command identifiers and filters is available under + :ref:`terminal-shortcuts-list`. + """.format( + "\n ".join([f"- `{k}`" for k in KEYBINDING_FILTERS]) + ), + ).tag(config=True) + + @observe("shortcuts") + def _shortcuts_changed(self, change): + if self.pt_app: + self.pt_app.key_bindings = self._merge_shortcuts(user_shortcuts=change.new) + + def _merge_shortcuts(self, user_shortcuts): + # rebuild the bindings list from scratch + key_bindings = create_ipython_shortcuts(self) + + # for now we only allow adding shortcuts for commands which are already + # registered; this is a security precaution. + known_commands = { + create_identifier(binding.handler): binding.handler + for binding in key_bindings.bindings + } + shortcuts_to_skip = [] + shortcuts_to_add = [] + + for shortcut in user_shortcuts: + command_id = shortcut["command"] + if command_id not in known_commands: + allowed_commands = "\n - ".join(known_commands) + raise ValueError( + f"{command_id} is not a known shortcut command." + f" Allowed commands are: \n - {allowed_commands}" + ) + old_keys = shortcut.get("match_keys", None) + old_filter = ( + filter_from_string(shortcut["match_filter"]) + if "match_filter" in shortcut + else None + ) + matching = [ + binding + for binding in key_bindings.bindings + if ( + (old_filter is None or binding.filter == old_filter) + and (old_keys is None or [k for k in binding.keys] == old_keys) + and create_identifier(binding.handler) == command_id + ) + ] + + new_keys = shortcut.get("new_keys", None) + new_filter = shortcut.get("new_filter", None) + + command = known_commands[command_id] + + creating_new = shortcut.get("create", False) + modifying_existing = not creating_new and ( + new_keys is not None or new_filter + ) + + if creating_new and new_keys == []: + raise ValueError("Cannot add a shortcut without keys") + + if modifying_existing: + specification = { + key: shortcut[key] + for key in ["command", "filter"] + if key in shortcut + } + if len(matching) == 0: + raise ValueError(f"No shortcuts matching {specification} found") + elif len(matching) > 1: + raise ValueError( + f"Multiple shortcuts matching {specification} found," + f" please add keys/filter to select one of: {matching}" + ) + + matched = matching[0] + old_filter = matched.filter + old_keys = list(matched.keys) + shortcuts_to_skip.append( + RuntimeBinding( + command, + keys=old_keys, + filter=old_filter, + ) + ) + + if new_keys != []: + shortcuts_to_add.append( + RuntimeBinding( + command, + keys=new_keys or old_keys, + filter=filter_from_string(new_filter) + if new_filter is not None + else ( + old_filter + if old_filter is not None + else filter_from_string("always") + ), + ) + ) + + # rebuild the bindings list from scratch + key_bindings = create_ipython_shortcuts(self, skip=shortcuts_to_skip) + for binding in shortcuts_to_add: + add_binding(key_bindings, binding) + + return key_bindings + prompt_includes_vi_mode = Bool(True, help="Display the current vi mode (when using vi editing mode)." ).tag(config=True) @@ -452,8 +618,7 @@ class TerminalInteractiveShell(InteractiveShell): return # Set up keyboard shortcuts - key_bindings = create_ipython_shortcuts(self) - + key_bindings = self._merge_shortcuts(user_shortcuts=self.shortcuts) # Pre-populate history from IPython's history database history = PtkHistoryAdapter(self) @@ -477,7 +642,7 @@ class TerminalInteractiveShell(InteractiveShell): enable_open_in_editor=self.extra_open_editor_shortcuts, color_depth=self.color_depth, tempfile_suffix=".py", - **self._extra_prompt_options() + **self._extra_prompt_options(), ) if isinstance(self.auto_suggest, NavigableAutoSuggestFromHistory): self.auto_suggest.connect(self.pt_app) diff --git a/IPython/terminal/shortcuts/__init__.py b/IPython/terminal/shortcuts/__init__.py index 7ec9a28..9711c81 100644 --- a/IPython/terminal/shortcuts/__init__.py +++ b/IPython/terminal/shortcuts/__init__.py @@ -7,414 +7,269 @@ Module to define and register Terminal IPython shortcuts with # Distributed under the terms of the Modified BSD License. import os -import re import signal import sys import warnings -from typing import Callable, Dict, Union +from dataclasses import dataclass +from typing import Callable, Any, Optional, List from prompt_toolkit.application.current import get_app -from prompt_toolkit.enums import DEFAULT_BUFFER, SEARCH_BUFFER -from prompt_toolkit.filters import Condition, emacs_insert_mode, has_completions -from prompt_toolkit.filters import has_focus as has_focus_impl -from prompt_toolkit.filters import ( - has_selection, - has_suggestion, - vi_insert_mode, - vi_mode, -) from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.key_binding.key_processor import KeyPressEvent from prompt_toolkit.key_binding.bindings import named_commands as nc from prompt_toolkit.key_binding.bindings.completion import ( display_completions_like_readline, ) from prompt_toolkit.key_binding.vi_state import InputMode, ViState -from prompt_toolkit.layout.layout import FocusableElement +from prompt_toolkit.filters import Condition +from IPython.core.getipython import get_ipython from IPython.terminal.shortcuts import auto_match as match from IPython.terminal.shortcuts import auto_suggest +from IPython.terminal.shortcuts.filters import filter_from_string from IPython.utils.decorators import undoc __all__ = ["create_ipython_shortcuts"] -@undoc -@Condition -def cursor_in_leading_ws(): - before = get_app().current_buffer.document.current_line_before_cursor - return (not before) or before.isspace() - - -def has_focus(value: FocusableElement): - """Wrapper around has_focus adding a nice `__name__` to tester function""" - tester = has_focus_impl(value).func - tester.__name__ = f"is_focused({value})" - return Condition(tester) - - -@undoc -@Condition -def has_line_below() -> bool: - document = get_app().current_buffer.document - return document.cursor_position_row < len(document.lines) - 1 - - -@undoc -@Condition -def has_line_above() -> bool: - document = get_app().current_buffer.document - return document.cursor_position_row != 0 - - -def create_ipython_shortcuts(shell, for_all_platforms: bool = False) -> KeyBindings: - """Set up the prompt_toolkit keyboard shortcuts for IPython. - - Parameters - ---------- - shell: InteractiveShell - The current IPython shell Instance - for_all_platforms: bool (default false) - This parameter is mostly used in generating the documentation - to create the shortcut binding for all the platforms, and export - them. - - Returns - ------- - KeyBindings - the keybinding instance for prompt toolkit. +@dataclass +class BaseBinding: + command: Callable[[KeyPressEvent], Any] + keys: List[str] - """ - # Warning: if possible, do NOT define handler functions in the locals - # scope of this function, instead define functions in the global - # scope, or a separate module, and include a user-friendly docstring - # describing the action. - kb = KeyBindings() - insert_mode = vi_insert_mode | emacs_insert_mode +@dataclass +class RuntimeBinding(BaseBinding): + filter: Condition - if getattr(shell, "handle_return", None): - return_handler = shell.handle_return(shell) - else: - return_handler = newline_or_execute_outer(shell) - kb.add("enter", filter=(has_focus(DEFAULT_BUFFER) & ~has_selection & insert_mode))( - return_handler - ) - - @Condition - def ebivim(): - return shell.emacs_bindings_in_vi_insert_mode - - @kb.add( - "escape", - "enter", - filter=(has_focus(DEFAULT_BUFFER) & ~has_selection & insert_mode & ebivim), - ) - def reformat_and_execute(event): - """Reformat code and execute it""" - reformat_text_before_cursor( - event.current_buffer, event.current_buffer.document, shell - ) - event.current_buffer.validate_and_handle() - - kb.add("c-\\")(quit) - - kb.add("c-p", filter=(vi_insert_mode & has_focus(DEFAULT_BUFFER)))( - previous_history_or_previous_completion - ) - - kb.add("c-n", filter=(vi_insert_mode & has_focus(DEFAULT_BUFFER)))( - next_history_or_next_completion - ) - - kb.add("c-g", filter=(has_focus(DEFAULT_BUFFER) & has_completions))( - dismiss_completion - ) - - kb.add("c-c", filter=has_focus(DEFAULT_BUFFER))(reset_buffer) - - kb.add("c-c", filter=has_focus(SEARCH_BUFFER))(reset_search_buffer) - - supports_suspend = Condition(lambda: hasattr(signal, "SIGTSTP")) - kb.add("c-z", filter=supports_suspend)(suspend_to_bg) - - # Ctrl+I == Tab - kb.add( - "tab", - filter=( - has_focus(DEFAULT_BUFFER) - & ~has_selection - & insert_mode - & cursor_in_leading_ws - ), - )(indent_buffer) - kb.add("c-o", filter=(has_focus(DEFAULT_BUFFER) & emacs_insert_mode))( - newline_autoindent_outer(shell.input_transformer_manager) - ) - - kb.add("f2", filter=has_focus(DEFAULT_BUFFER))(open_input_in_editor) - - @Condition - def auto_match(): - return shell.auto_match - - def all_quotes_paired(quote, buf): - paired = True - i = 0 - while i < len(buf): - c = buf[i] - if c == quote: - paired = not paired - elif c == "\\": - i += 1 - i += 1 - return paired - - focused_insert = (vi_insert_mode | emacs_insert_mode) & has_focus(DEFAULT_BUFFER) - _preceding_text_cache: Dict[Union[str, Callable], Condition] = {} - _following_text_cache: Dict[Union[str, Callable], Condition] = {} - - def preceding_text(pattern: Union[str, Callable]): - if pattern in _preceding_text_cache: - return _preceding_text_cache[pattern] - - if callable(pattern): - - def _preceding_text(): - app = get_app() - before_cursor = app.current_buffer.document.current_line_before_cursor - # mypy can't infer if(callable): https://github.com/python/mypy/issues/3603 - return bool(pattern(before_cursor)) # type: ignore[operator] +@dataclass +class Binding(BaseBinding): + # while filter could be created by referencing variables directly (rather + # than created from strings), by using strings we ensure that users will + # be able to create filters in configuration (e.g. JSON) files too, which + # also benefits the documentation by enforcing human-readable filter names. + condition: Optional[str] = None + def __post_init__(self): + if self.condition: + self.filter = filter_from_string(self.condition) else: - m = re.compile(pattern) - - def _preceding_text(): - app = get_app() - before_cursor = app.current_buffer.document.current_line_before_cursor - return bool(m.match(before_cursor)) - - _preceding_text.__name__ = f"preceding_text({pattern!r})" - - condition = Condition(_preceding_text) - _preceding_text_cache[pattern] = condition - return condition - - def following_text(pattern): - try: - return _following_text_cache[pattern] - except KeyError: - pass - m = re.compile(pattern) - - def _following_text(): - app = get_app() - return bool(m.match(app.current_buffer.document.current_line_after_cursor)) - - _following_text.__name__ = f"following_text({pattern!r})" + self.filter = None - condition = Condition(_following_text) - _following_text_cache[pattern] = condition - return condition - @Condition - def not_inside_unclosed_string(): - app = get_app() - s = app.current_buffer.document.text_before_cursor - # remove escaped quotes - s = s.replace('\\"', "").replace("\\'", "") - # remove triple-quoted string literals - s = re.sub(r"(?:\"\"\"[\s\S]*\"\"\"|'''[\s\S]*''')", "", s) - # remove single-quoted string literals - s = re.sub(r"""(?:"[^"]*["\n]|'[^']*['\n])""", "", s) - return not ('"' in s or "'" in s) - - # auto match - for key, cmd in match.auto_match_parens.items(): - kb.add(key, filter=focused_insert & auto_match & following_text(r"[,)}\]]|$"))( - cmd - ) +def create_identifier(handler: Callable): + parts = handler.__module__.split(".") + name = handler.__name__ + package = parts[0] + if len(parts) > 1: + final_module = parts[-1] + return f"{package}:{final_module}.{name}" + else: + return f"{package}:{name}" - # raw string - for key, cmd in match.auto_match_parens_raw_string.items(): - kb.add( - key, - filter=focused_insert & auto_match & preceding_text(r".*(r|R)[\"'](-*)$"), - )(cmd) - - kb.add( - '"', - filter=focused_insert - & auto_match - & not_inside_unclosed_string - & preceding_text(lambda line: all_quotes_paired('"', line)) - & following_text(r"[,)}\]]|$"), - )(match.double_quote) - - kb.add( - "'", - filter=focused_insert - & auto_match - & not_inside_unclosed_string - & preceding_text(lambda line: all_quotes_paired("'", line)) - & following_text(r"[,)}\]]|$"), - )(match.single_quote) - - kb.add( - '"', - filter=focused_insert - & auto_match - & not_inside_unclosed_string - & preceding_text(r'^.*""$'), - )(match.docstring_double_quotes) - - kb.add( - "'", - filter=focused_insert - & auto_match - & not_inside_unclosed_string - & preceding_text(r"^.*''$"), - )(match.docstring_single_quotes) - - # just move cursor - kb.add(")", filter=focused_insert & auto_match & following_text(r"^\)"))( - match.skip_over - ) - kb.add("]", filter=focused_insert & auto_match & following_text(r"^\]"))( - match.skip_over - ) - kb.add("}", filter=focused_insert & auto_match & following_text(r"^\}"))( - match.skip_over - ) - kb.add('"', filter=focused_insert & auto_match & following_text('^"'))( - match.skip_over - ) - kb.add("'", filter=focused_insert & auto_match & following_text("^'"))( - match.skip_over - ) - kb.add( - "backspace", - filter=focused_insert - & preceding_text(r".*\($") - & auto_match - & following_text(r"^\)"), - )(match.delete_pair) - kb.add( - "backspace", - filter=focused_insert - & preceding_text(r".*\[$") - & auto_match - & following_text(r"^\]"), - )(match.delete_pair) - kb.add( - "backspace", - filter=focused_insert - & preceding_text(r".*\{$") - & auto_match - & following_text(r"^\}"), - )(match.delete_pair) - kb.add( - "backspace", - filter=focused_insert - & preceding_text('.*"$') - & auto_match - & following_text('^"'), - )(match.delete_pair) - kb.add( - "backspace", - filter=focused_insert - & preceding_text(r".*'$") - & auto_match - & following_text(r"^'"), - )(match.delete_pair) - - if shell.display_completions == "readlinelike": - kb.add( - "c-i", - filter=( - has_focus(DEFAULT_BUFFER) - & ~has_selection - & insert_mode - & ~cursor_in_leading_ws - ), - )(display_completions_like_readline) - - if sys.platform == "win32" or for_all_platforms: - kb.add("c-v", filter=(has_focus(DEFAULT_BUFFER) & ~vi_mode))(win_paste) - - focused_insert_vi = has_focus(DEFAULT_BUFFER) & vi_insert_mode - - # autosuggestions - @Condition - def navigable_suggestions(): - return isinstance( - shell.auto_suggest, auto_suggest.NavigableAutoSuggestFromHistory +AUTO_MATCH_BINDINGS = [ + *[ + Binding( + cmd, [key], "focused_insert & auto_match & followed_by_closing_paren_or_end" ) - - kb.add("end", filter=has_focus(DEFAULT_BUFFER) & (ebivim | ~vi_insert_mode))( - auto_suggest.accept_in_vi_insert_mode - ) - kb.add("c-e", filter=focused_insert_vi & ebivim)( - auto_suggest.accept_in_vi_insert_mode - ) - kb.add("c-f", filter=focused_insert_vi)(auto_suggest.accept) - kb.add("escape", "f", filter=focused_insert_vi & ebivim)(auto_suggest.accept_word) - kb.add("c-right", filter=has_suggestion & has_focus(DEFAULT_BUFFER))( - auto_suggest.accept_token - ) - kb.add( - "escape", filter=has_suggestion & has_focus(DEFAULT_BUFFER) & emacs_insert_mode - )(auto_suggest.discard) - kb.add( - "up", - filter=navigable_suggestions - & ~has_line_above - & has_suggestion - & has_focus(DEFAULT_BUFFER), - )(auto_suggest.swap_autosuggestion_up(shell.auto_suggest)) - kb.add( - "down", - filter=navigable_suggestions - & ~has_line_below - & has_suggestion - & has_focus(DEFAULT_BUFFER), - )(auto_suggest.swap_autosuggestion_down(shell.auto_suggest)) - kb.add( - "up", filter=has_line_above & navigable_suggestions & has_focus(DEFAULT_BUFFER) - )(auto_suggest.up_and_update_hint) - kb.add( - "down", - filter=has_line_below & navigable_suggestions & has_focus(DEFAULT_BUFFER), - )(auto_suggest.down_and_update_hint) - kb.add("right", filter=has_suggestion & has_focus(DEFAULT_BUFFER))( - auto_suggest.accept_character - ) - kb.add("c-left", filter=has_suggestion & has_focus(DEFAULT_BUFFER))( - auto_suggest.accept_and_move_cursor_left - ) - kb.add("c-down", filter=has_suggestion & has_focus(DEFAULT_BUFFER))( - auto_suggest.accept_and_keep_cursor - ) - kb.add("backspace", filter=has_suggestion & has_focus(DEFAULT_BUFFER))( - auto_suggest.backspace_and_resume_hint - ) - - # Simple Control keybindings - key_cmd_dict = { + for key, cmd in match.auto_match_parens.items() + ], + *[ + # raw string + Binding(cmd, [key], "focused_insert & auto_match & preceded_by_raw_str_prefix") + for key, cmd in match.auto_match_parens_raw_string.items() + ], + Binding( + match.double_quote, + ['"'], + "focused_insert" + " & auto_match" + " & not_inside_unclosed_string" + " & preceded_by_paired_double_quotes" + " & followed_by_closing_paren_or_end", + ), + Binding( + match.single_quote, + ["'"], + "focused_insert" + " & auto_match" + " & not_inside_unclosed_string" + " & preceded_by_paired_single_quotes" + " & followed_by_closing_paren_or_end", + ), + Binding( + match.docstring_double_quotes, + ['"'], + "focused_insert" + " & auto_match" + " & not_inside_unclosed_string" + " & preceded_by_two_double_quotes", + ), + Binding( + match.docstring_single_quotes, + ["'"], + "focused_insert" + " & auto_match" + " & not_inside_unclosed_string" + " & preceded_by_two_single_quotes", + ), + Binding( + match.skip_over, + [")"], + "focused_insert & auto_match & followed_by_closing_round_paren", + ), + Binding( + match.skip_over, + ["]"], + "focused_insert & auto_match & followed_by_closing_bracket", + ), + Binding( + match.skip_over, + ["}"], + "focused_insert & auto_match & followed_by_closing_brace", + ), + Binding( + match.skip_over, ['"'], "focused_insert & auto_match & followed_by_double_quote" + ), + Binding( + match.skip_over, ["'"], "focused_insert & auto_match & followed_by_single_quote" + ), + Binding( + match.delete_pair, + ["backspace"], + "focused_insert" + " & preceded_by_opening_round_paren" + " & auto_match" + " & followed_by_closing_round_paren", + ), + Binding( + match.delete_pair, + ["backspace"], + "focused_insert" + " & preceded_by_opening_bracket" + " & auto_match" + " & followed_by_closing_bracket", + ), + Binding( + match.delete_pair, + ["backspace"], + "focused_insert" + " & preceded_by_opening_brace" + " & auto_match" + " & followed_by_closing_brace", + ), + Binding( + match.delete_pair, + ["backspace"], + "focused_insert" + " & preceded_by_double_quote" + " & auto_match" + " & followed_by_double_quote", + ), + Binding( + match.delete_pair, + ["backspace"], + "focused_insert" + " & preceded_by_single_quote" + " & auto_match" + " & followed_by_single_quote", + ), +] + +AUTO_SUGGEST_BINDINGS = [ + Binding( + auto_suggest.accept_in_vi_insert_mode, + ["end"], + "default_buffer_focused & (ebivim | ~vi_insert_mode)", + ), + Binding( + auto_suggest.accept_in_vi_insert_mode, + ["c-e"], + "vi_insert_mode & default_buffer_focused & ebivim", + ), + Binding(auto_suggest.accept, ["c-f"], "vi_insert_mode & default_buffer_focused"), + Binding( + auto_suggest.accept_word, + ["escape", "f"], + "vi_insert_mode & default_buffer_focused & ebivim", + ), + Binding( + auto_suggest.accept_token, + ["c-right"], + "has_suggestion & default_buffer_focused", + ), + Binding( + auto_suggest.discard, + ["escape"], + "has_suggestion & default_buffer_focused & emacs_insert_mode", + ), + Binding( + auto_suggest.swap_autosuggestion_up, + ["up"], + "navigable_suggestions" + " & ~has_line_above" + " & has_suggestion" + " & default_buffer_focused", + ), + Binding( + auto_suggest.swap_autosuggestion_down, + ["down"], + "navigable_suggestions" + " & ~has_line_below" + " & has_suggestion" + " & default_buffer_focused", + ), + Binding( + auto_suggest.up_and_update_hint, + ["up"], + "has_line_above & navigable_suggestions & default_buffer_focused", + ), + Binding( + auto_suggest.down_and_update_hint, + ["down"], + "has_line_below & navigable_suggestions & default_buffer_focused", + ), + Binding( + auto_suggest.accept_character, + ["escape", "right"], + "has_suggestion & default_buffer_focused", + ), + Binding( + auto_suggest.accept_and_move_cursor_left, + ["c-left"], + "has_suggestion & default_buffer_focused", + ), + Binding( + auto_suggest.accept_and_keep_cursor, + ["c-down"], + "has_suggestion & default_buffer_focused", + ), + Binding( + auto_suggest.backspace_and_resume_hint, + ["backspace"], + "has_suggestion & default_buffer_focused", + ), +] + + +SIMPLE_CONTROL_BINDINGS = [ + Binding(cmd, [key], "vi_insert_mode & default_buffer_focused & ebivim") + for key, cmd in { "c-a": nc.beginning_of_line, "c-b": nc.backward_char, "c-k": nc.kill_line, "c-w": nc.backward_kill_word, "c-y": nc.yank, "c-_": nc.undo, - } + }.items() +] - for key, cmd in key_cmd_dict.items(): - kb.add(key, filter=focused_insert_vi & ebivim)(cmd) - # Alt and Combo Control keybindings - keys_cmd_dict = { +ALT_AND_COMOBO_CONTROL_BINDINGS = [ + Binding(cmd, list(keys), "vi_insert_mode & default_buffer_focused & ebivim") + for keys, cmd in { # Control Combos ("c-x", "c-e"): nc.edit_and_execute, ("c-x", "e"): nc.edit_and_execute, @@ -427,10 +282,48 @@ def create_ipython_shortcuts(shell, for_all_platforms: bool = False) -> KeyBindi ("escape", "u"): nc.uppercase_word, ("escape", "y"): nc.yank_pop, ("escape", "."): nc.yank_last_arg, - } + }.items() +] + + +def add_binding(bindings: KeyBindings, binding: Binding): + bindings.add( + *binding.keys, + **({"filter": binding.filter} if binding.filter is not None else {}), + )(binding.command) + + +def create_ipython_shortcuts(shell, skip=None) -> KeyBindings: + """Set up the prompt_toolkit keyboard shortcuts for IPython. - for keys, cmd in keys_cmd_dict.items(): - kb.add(*keys, filter=focused_insert_vi & ebivim)(cmd) + Parameters + ---------- + shell: InteractiveShell + The current IPython shell Instance + skip: List[Binding] + Bindings to skip. + + Returns + ------- + KeyBindings + the keybinding instance for prompt toolkit. + + """ + kb = KeyBindings() + skip = skip or [] + for binding in KEY_BINDINGS: + skip_this_one = False + for to_skip in skip: + if ( + to_skip.command == binding.command + and to_skip.filter == binding.filter + and to_skip.keys == binding.keys + ): + skip_this_one = True + break + if skip_this_one: + continue + add_binding(kb, binding) def get_input_mode(self): app = get_app() @@ -451,9 +344,19 @@ def create_ipython_shortcuts(shell, for_all_platforms: bool = False) -> KeyBindi if shell.editing_mode == "vi" and shell.modal_cursor: ViState._input_mode = InputMode.INSERT # type: ignore ViState.input_mode = property(get_input_mode, set_input_mode) # type: ignore + return kb +def reformat_and_execute(event): + """Reformat code and execute it""" + shell = get_ipython() + reformat_text_before_cursor( + event.current_buffer, event.current_buffer.document, shell + ) + event.current_buffer.validate_and_handle() + + def reformat_text_before_cursor(buffer, document, shell): text = buffer.delete_before_cursor(len(document.text[: document.cursor_position])) try: @@ -463,6 +366,14 @@ def reformat_text_before_cursor(buffer, document, shell): buffer.insert_text(text) +def handle_return_or_newline_or_execute(event): + shell = get_ipython() + if getattr(shell, "handle_return", None): + return shell.handle_return(shell)(event) + else: + return newline_or_execute_outer(shell)(event) + + def newline_or_execute_outer(shell): def newline_or_execute(event): """When the user presses return, insert a newline or execute the code.""" @@ -512,8 +423,6 @@ def newline_or_execute_outer(shell): else: b.insert_text("\n") - newline_or_execute.__qualname__ = "newline_or_execute" - return newline_or_execute @@ -610,30 +519,24 @@ def newline_with_copy_margin(event): b.cursor_right(count=pos_diff) -def newline_autoindent_outer(inputsplitter) -> Callable[..., None]: - """ - Return a function suitable for inserting a indented newline after the cursor. +def newline_autoindent(event): + """Insert a newline after the cursor indented appropriately. Fancier version of deprecated ``newline_with_copy_margin`` which should compute the correct indentation of the inserted line. That is to say, indent by 4 extra space after a function definition, class definition, context manager... And dedent by 4 space after ``pass``, ``return``, ``raise ...``. """ + shell = get_ipython() + inputsplitter = shell.input_transformer_manager + b = event.current_buffer + d = b.document - def newline_autoindent(event): - """Insert a newline after the cursor indented appropriately.""" - b = event.current_buffer - d = b.document - - if b.complete_state: - b.cancel_completion() - text = d.text[: d.cursor_position] + "\n" - _, indent = inputsplitter.check_complete(text) - b.insert_text("\n" + (" " * (indent or 0)), move_cursor=False) - - newline_autoindent.__qualname__ = "newline_autoindent" - - return newline_autoindent + if b.complete_state: + b.cancel_completion() + text = d.text[: d.cursor_position] + "\n" + _, indent = inputsplitter.check_complete(text) + b.insert_text("\n" + (" " * (indent or 0)), move_cursor=False) def open_input_in_editor(event): @@ -666,5 +569,58 @@ else: @undoc def win_paste(event): - """Stub used when auto-generating shortcuts for documentation""" + """Stub used on other platforms""" pass + + +KEY_BINDINGS = [ + Binding( + handle_return_or_newline_or_execute, + ["enter"], + "default_buffer_focused & ~has_selection & insert_mode", + ), + Binding( + reformat_and_execute, + ["escape", "enter"], + "default_buffer_focused & ~has_selection & insert_mode & ebivim", + ), + Binding(quit, ["c-\\"]), + Binding( + previous_history_or_previous_completion, + ["c-p"], + "vi_insert_mode & default_buffer_focused", + ), + Binding( + next_history_or_next_completion, + ["c-n"], + "vi_insert_mode & default_buffer_focused", + ), + Binding(dismiss_completion, ["c-g"], "default_buffer_focused & has_completions"), + Binding(reset_buffer, ["c-c"], "default_buffer_focused"), + Binding(reset_search_buffer, ["c-c"], "search_buffer_focused"), + Binding(suspend_to_bg, ["c-z"], "supports_suspend"), + Binding( + indent_buffer, + ["tab"], # Ctrl+I == Tab + "default_buffer_focused" + " & ~has_selection" + " & insert_mode" + " & cursor_in_leading_ws", + ), + Binding(newline_autoindent, ["c-o"], "default_buffer_focused & emacs_insert_mode"), + Binding(open_input_in_editor, ["f2"], "default_buffer_focused"), + *AUTO_MATCH_BINDINGS, + *AUTO_SUGGEST_BINDINGS, + Binding( + display_completions_like_readline, + ["c-i"], + "readline_like_completions" + " & default_buffer_focused" + " & ~has_selection" + " & insert_mode" + " & ~cursor_in_leading_ws", + ), + Binding(win_paste, ["c-v"], "default_buffer_focused & ~vi_mode & is_windows_os"), + *SIMPLE_CONTROL_BINDINGS, + *ALT_AND_COMOBO_CONTROL_BINDINGS, +] diff --git a/IPython/terminal/shortcuts/auto_match.py b/IPython/terminal/shortcuts/auto_match.py index 46cb1bd..6c2b1ef 100644 --- a/IPython/terminal/shortcuts/auto_match.py +++ b/IPython/terminal/shortcuts/auto_match.py @@ -1,7 +1,7 @@ """ -Utilities function for keybinding with prompt toolkit. +Utilities function for keybinding with prompt toolkit. -This will be bound to specific key press and filter modes, +This will be bound to specific key press and filter modes, like whether we are in edit mode, and whether the completer is open. """ import re @@ -84,9 +84,9 @@ def raw_string_braces(event: KeyPressEvent): def skip_over(event: KeyPressEvent): - """Skip over automatically added parenthesis. + """Skip over automatically added parenthesis/quote. - (rather than adding another parenthesis)""" + (rather than adding another parenthesis/quote)""" event.current_buffer.cursor_right() diff --git a/IPython/terminal/shortcuts/auto_suggest.py b/IPython/terminal/shortcuts/auto_suggest.py index 3bfd6d5..0c193d9 100644 --- a/IPython/terminal/shortcuts/auto_suggest.py +++ b/IPython/terminal/shortcuts/auto_suggest.py @@ -1,7 +1,7 @@ import re import tokenize from io import StringIO -from typing import Callable, List, Optional, Union, Generator, Tuple, Sequence +from typing import Callable, List, Optional, Union, Generator, Tuple from prompt_toolkit.buffer import Buffer from prompt_toolkit.key_binding import KeyPressEvent @@ -16,6 +16,7 @@ from prompt_toolkit.layout.processors import ( TransformationInput, ) +from IPython.core.getipython import get_ipython from IPython.utils.tokenutil import generate_tokens @@ -346,33 +347,29 @@ def _swap_autosuggestion( buffer.suggestion = new_suggestion -def swap_autosuggestion_up(provider: Provider): - def swap_autosuggestion_up(event: KeyPressEvent): - """Get next autosuggestion from history.""" - if not isinstance(provider, NavigableAutoSuggestFromHistory): - return +def swap_autosuggestion_up(event: KeyPressEvent): + """Get next autosuggestion from history.""" + shell = get_ipython() + provider = shell.auto_suggest - return _swap_autosuggestion( - buffer=event.current_buffer, provider=provider, direction_method=provider.up - ) + if not isinstance(provider, NavigableAutoSuggestFromHistory): + return - swap_autosuggestion_up.__name__ = "swap_autosuggestion_up" - return swap_autosuggestion_up + return _swap_autosuggestion( + buffer=event.current_buffer, provider=provider, direction_method=provider.up + ) -def swap_autosuggestion_down( - provider: Union[AutoSuggestFromHistory, NavigableAutoSuggestFromHistory, None] -): - def swap_autosuggestion_down(event: KeyPressEvent): - """Get previous autosuggestion from history.""" - if not isinstance(provider, NavigableAutoSuggestFromHistory): - return +def swap_autosuggestion_down(event: KeyPressEvent): + """Get previous autosuggestion from history.""" + shell = get_ipython() + provider = shell.auto_suggest - return _swap_autosuggestion( - buffer=event.current_buffer, - provider=provider, - direction_method=provider.down, - ) + if not isinstance(provider, NavigableAutoSuggestFromHistory): + return - swap_autosuggestion_down.__name__ = "swap_autosuggestion_down" - return swap_autosuggestion_down + return _swap_autosuggestion( + buffer=event.current_buffer, + provider=provider, + direction_method=provider.down, + ) diff --git a/IPython/terminal/shortcuts/filters.py b/IPython/terminal/shortcuts/filters.py new file mode 100644 index 0000000..a4a6213 --- /dev/null +++ b/IPython/terminal/shortcuts/filters.py @@ -0,0 +1,256 @@ +""" +Filters restricting scope of IPython Terminal shortcuts. +""" + +# Copyright (c) IPython Development Team. +# Distributed under the terms of the Modified BSD License. + +import ast +import re +import signal +import sys +from typing import Callable, Dict, Union + +from prompt_toolkit.application.current import get_app +from prompt_toolkit.enums import DEFAULT_BUFFER, SEARCH_BUFFER +from prompt_toolkit.filters import Condition, emacs_insert_mode, has_completions +from prompt_toolkit.filters import has_focus as has_focus_impl +from prompt_toolkit.filters import ( + Always, + has_selection, + has_suggestion, + vi_insert_mode, + vi_mode, +) +from prompt_toolkit.layout.layout import FocusableElement + +from IPython.core.getipython import get_ipython +from IPython.core.guarded_eval import _find_dunder, BINARY_OP_DUNDERS, UNARY_OP_DUNDERS +from IPython.terminal.shortcuts import auto_suggest +from IPython.utils.decorators import undoc + + +@undoc +@Condition +def cursor_in_leading_ws(): + before = get_app().current_buffer.document.current_line_before_cursor + return (not before) or before.isspace() + + +def has_focus(value: FocusableElement): + """Wrapper around has_focus adding a nice `__name__` to tester function""" + tester = has_focus_impl(value).func + tester.__name__ = f"is_focused({value})" + return Condition(tester) + + +@undoc +@Condition +def has_line_below() -> bool: + document = get_app().current_buffer.document + return document.cursor_position_row < len(document.lines) - 1 + + +@undoc +@Condition +def has_line_above() -> bool: + document = get_app().current_buffer.document + return document.cursor_position_row != 0 + + +@Condition +def ebivim(): + shell = get_ipython() + return shell.emacs_bindings_in_vi_insert_mode + + +@Condition +def supports_suspend(): + return hasattr(signal, "SIGTSTP") + + +@Condition +def auto_match(): + shell = get_ipython() + return shell.auto_match + + +def all_quotes_paired(quote, buf): + paired = True + i = 0 + while i < len(buf): + c = buf[i] + if c == quote: + paired = not paired + elif c == "\\": + i += 1 + i += 1 + return paired + + +_preceding_text_cache: Dict[Union[str, Callable], Condition] = {} +_following_text_cache: Dict[Union[str, Callable], Condition] = {} + + +def preceding_text(pattern: Union[str, Callable]): + if pattern in _preceding_text_cache: + return _preceding_text_cache[pattern] + + if callable(pattern): + + def _preceding_text(): + app = get_app() + before_cursor = app.current_buffer.document.current_line_before_cursor + # mypy can't infer if(callable): https://github.com/python/mypy/issues/3603 + return bool(pattern(before_cursor)) # type: ignore[operator] + + else: + m = re.compile(pattern) + + def _preceding_text(): + app = get_app() + before_cursor = app.current_buffer.document.current_line_before_cursor + return bool(m.match(before_cursor)) + + _preceding_text.__name__ = f"preceding_text({pattern!r})" + + condition = Condition(_preceding_text) + _preceding_text_cache[pattern] = condition + return condition + + +def following_text(pattern): + try: + return _following_text_cache[pattern] + except KeyError: + pass + m = re.compile(pattern) + + def _following_text(): + app = get_app() + return bool(m.match(app.current_buffer.document.current_line_after_cursor)) + + _following_text.__name__ = f"following_text({pattern!r})" + + condition = Condition(_following_text) + _following_text_cache[pattern] = condition + return condition + + +@Condition +def not_inside_unclosed_string(): + app = get_app() + s = app.current_buffer.document.text_before_cursor + # remove escaped quotes + s = s.replace('\\"', "").replace("\\'", "") + # remove triple-quoted string literals + s = re.sub(r"(?:\"\"\"[\s\S]*\"\"\"|'''[\s\S]*''')", "", s) + # remove single-quoted string literals + s = re.sub(r"""(?:"[^"]*["\n]|'[^']*['\n])""", "", s) + return not ('"' in s or "'" in s) + + +@Condition +def navigable_suggestions(): + shell = get_ipython() + return isinstance(shell.auto_suggest, auto_suggest.NavigableAutoSuggestFromHistory) + + +@Condition +def readline_like_completions(): + shell = get_ipython() + return shell.display_completions == "readlinelike" + + +@Condition +def is_windows_os(): + return sys.platform == "win32" + + +# these one is callable and re-used multiple times hence needs to be +# only defined once beforhand so that transforming back to human-readable +# names works well in the documentation. +default_buffer_focused = has_focus(DEFAULT_BUFFER) + +KEYBINDING_FILTERS = { + "always": Always(), + "has_line_below": has_line_below, + "has_line_above": has_line_above, + "has_selection": has_selection, + "has_suggestion": has_suggestion, + "vi_mode": vi_mode, + "vi_insert_mode": vi_insert_mode, + "emacs_insert_mode": emacs_insert_mode, + "has_completions": has_completions, + "insert_mode": vi_insert_mode | emacs_insert_mode, + "default_buffer_focused": default_buffer_focused, + "search_buffer_focused": has_focus(SEARCH_BUFFER), + "ebivim": ebivim, + "supports_suspend": supports_suspend, + "is_windows_os": is_windows_os, + "auto_match": auto_match, + "focused_insert": (vi_insert_mode | emacs_insert_mode) & default_buffer_focused, + "not_inside_unclosed_string": not_inside_unclosed_string, + "readline_like_completions": readline_like_completions, + "preceded_by_paired_double_quotes": preceding_text( + lambda line: all_quotes_paired('"', line) + ), + "preceded_by_paired_single_quotes": preceding_text( + lambda line: all_quotes_paired("'", line) + ), + "preceded_by_raw_str_prefix": preceding_text(r".*(r|R)[\"'](-*)$"), + "preceded_by_two_double_quotes": preceding_text(r'^.*""$'), + "preceded_by_two_single_quotes": preceding_text(r"^.*''$"), + "followed_by_closing_paren_or_end": following_text(r"[,)}\]]|$"), + "preceded_by_opening_round_paren": preceding_text(r".*\($"), + "preceded_by_opening_bracket": preceding_text(r".*\[$"), + "preceded_by_opening_brace": preceding_text(r".*\{$"), + "preceded_by_double_quote": preceding_text('.*"$'), + "preceded_by_single_quote": preceding_text(r".*'$"), + "followed_by_closing_round_paren": following_text(r"^\)"), + "followed_by_closing_bracket": following_text(r"^\]"), + "followed_by_closing_brace": following_text(r"^\}"), + "followed_by_double_quote": following_text('^"'), + "followed_by_single_quote": following_text("^'"), + "navigable_suggestions": navigable_suggestions, + "cursor_in_leading_ws": cursor_in_leading_ws, +} + + +def eval_node(node: Union[ast.AST, None]): + if node is None: + return None + if isinstance(node, ast.Expression): + return eval_node(node.body) + if isinstance(node, ast.BinOp): + left = eval_node(node.left) + right = eval_node(node.right) + dunders = _find_dunder(node.op, BINARY_OP_DUNDERS) + if dunders: + return getattr(left, dunders[0])(right) + raise ValueError(f"Unknown binary operation: {node.op}") + if isinstance(node, ast.UnaryOp): + value = eval_node(node.operand) + dunders = _find_dunder(node.op, UNARY_OP_DUNDERS) + if dunders: + return getattr(value, dunders[0])() + raise ValueError(f"Unknown unary operation: {node.op}") + if isinstance(node, ast.Name): + if node.id in KEYBINDING_FILTERS: + return KEYBINDING_FILTERS[node.id] + else: + sep = "\n - " + known_filters = sep.join(sorted(KEYBINDING_FILTERS)) + raise NameError( + f"{node.id} is not a known shortcut filter." + f" Known filters are: {sep}{known_filters}." + ) + raise ValueError("Unhandled node", ast.dump(node)) + + +def filter_from_string(code: str): + expression = ast.parse(code, mode="eval") + return eval_node(expression) + + +__all__ = ["KEYBINDING_FILTERS", "filter_from_string"] diff --git a/IPython/terminal/tests/test_interactivshell.py b/IPython/terminal/tests/test_interactivshell.py index 01008d7..ae7da21 100644 --- a/IPython/terminal/tests/test_interactivshell.py +++ b/IPython/terminal/tests/test_interactivshell.py @@ -9,9 +9,8 @@ import os from prompt_toolkit.auto_suggest import AutoSuggestFromHistory -from IPython.core.inputtransformer import InputTransformer + from IPython.testing import tools as tt -from IPython.utils.capture import capture_output from IPython.terminal.ptutils import _elide, _adjust_completion_text_based_on_context from IPython.terminal.shortcuts.auto_suggest import NavigableAutoSuggestFromHistory diff --git a/IPython/terminal/tests/test_shortcuts.py b/IPython/terminal/tests/test_shortcuts.py index 309205d..18c9dab 100644 --- a/IPython/terminal/tests/test_shortcuts.py +++ b/IPython/terminal/tests/test_shortcuts.py @@ -11,6 +11,8 @@ from IPython.terminal.shortcuts.auto_suggest import ( swap_autosuggestion_up, swap_autosuggestion_down, ) +from IPython.terminal.shortcuts.auto_match import skip_over +from IPython.terminal.shortcuts import create_ipython_shortcuts from prompt_toolkit.history import InMemoryHistory from prompt_toolkit.buffer import Buffer @@ -192,18 +194,20 @@ def test_autosuggest_token_empty(): def test_other_providers(): """Ensure that swapping autosuggestions does not break with other providers""" provider = AutoSuggestFromHistory() - up = swap_autosuggestion_up(provider) - down = swap_autosuggestion_down(provider) + ip = get_ipython() + ip.auto_suggest = provider event = Mock() event.current_buffer = Buffer() - assert up(event) is None - assert down(event) is None + assert swap_autosuggestion_up(event) is None + assert swap_autosuggestion_down(event) is None async def test_navigable_provider(): provider = NavigableAutoSuggestFromHistory() history = InMemoryHistory(history_strings=["very_a", "very", "very_b", "very_c"]) buffer = Buffer(history=history) + ip = get_ipython() + ip.auto_suggest = provider async for _ in history.load(): pass @@ -211,8 +215,8 @@ async def test_navigable_provider(): buffer.cursor_position = 5 buffer.text = "very" - up = swap_autosuggestion_up(provider) - down = swap_autosuggestion_down(provider) + up = swap_autosuggestion_up + down = swap_autosuggestion_down event = Mock() event.current_buffer = buffer @@ -254,14 +258,16 @@ async def test_navigable_provider_multiline_entries(): provider = NavigableAutoSuggestFromHistory() history = InMemoryHistory(history_strings=["very_a\nvery_b", "very_c"]) buffer = Buffer(history=history) + ip = get_ipython() + ip.auto_suggest = provider async for _ in history.load(): pass buffer.cursor_position = 5 buffer.text = "very" - up = swap_autosuggestion_up(provider) - down = swap_autosuggestion_down(provider) + up = swap_autosuggestion_up + down = swap_autosuggestion_down event = Mock() event.current_buffer = buffer @@ -316,3 +322,140 @@ def test_navigable_provider_connection(): session_1.default_buffer.on_text_insert.fire() session_2.default_buffer.on_text_insert.fire() assert provider.skip_lines == 3 + + +@pytest.fixture +def ipython_with_prompt(): + ip = get_ipython() + ip.pt_app = Mock() + ip.pt_app.key_bindings = create_ipython_shortcuts(ip) + try: + yield ip + finally: + ip.pt_app = None + + +def find_bindings_by_command(command): + ip = get_ipython() + return [ + binding + for binding in ip.pt_app.key_bindings.bindings + if binding.handler == command + ] + + +def test_modify_unique_shortcut(ipython_with_prompt): + original = find_bindings_by_command(accept_token) + assert len(original) == 1 + + ipython_with_prompt.shortcuts = [ + {"command": "IPython:auto_suggest.accept_token", "new_keys": ["a", "b", "c"]} + ] + matched = find_bindings_by_command(accept_token) + assert len(matched) == 1 + assert list(matched[0].keys) == ["a", "b", "c"] + assert list(matched[0].keys) != list(original[0].keys) + assert matched[0].filter == original[0].filter + + ipython_with_prompt.shortcuts = [ + {"command": "IPython:auto_suggest.accept_token", "new_filter": "always"} + ] + matched = find_bindings_by_command(accept_token) + assert len(matched) == 1 + assert list(matched[0].keys) != ["a", "b", "c"] + assert list(matched[0].keys) == list(original[0].keys) + assert matched[0].filter != original[0].filter + + +def test_disable_shortcut(ipython_with_prompt): + matched = find_bindings_by_command(accept_token) + assert len(matched) == 1 + + ipython_with_prompt.shortcuts = [ + {"command": "IPython:auto_suggest.accept_token", "new_keys": []} + ] + matched = find_bindings_by_command(accept_token) + assert len(matched) == 0 + + ipython_with_prompt.shortcuts = [] + matched = find_bindings_by_command(accept_token) + assert len(matched) == 1 + + +def test_modify_shortcut_with_filters(ipython_with_prompt): + matched = find_bindings_by_command(skip_over) + matched_keys = {m.keys[0] for m in matched} + assert matched_keys == {")", "]", "}", "'", '"'} + + with pytest.raises(ValueError, match="Multiple shortcuts matching"): + ipython_with_prompt.shortcuts = [ + {"command": "IPython:auto_match.skip_over", "new_keys": ["x"]} + ] + + ipython_with_prompt.shortcuts = [ + { + "command": "IPython:auto_match.skip_over", + "new_keys": ["x"], + "match_filter": "focused_insert & auto_match & followed_by_single_quote", + } + ] + matched = find_bindings_by_command(skip_over) + matched_keys = {m.keys[0] for m in matched} + assert matched_keys == {")", "]", "}", "x", '"'} + + +def example_command(): + pass + + +def test_add_shortcut_for_new_command(ipython_with_prompt): + matched = find_bindings_by_command(example_command) + assert len(matched) == 0 + + with pytest.raises(ValueError, match="example_command is not a known"): + ipython_with_prompt.shortcuts = [ + {"command": "example_command", "new_keys": ["x"]} + ] + matched = find_bindings_by_command(example_command) + assert len(matched) == 0 + + +def test_modify_shortcut_failure(ipython_with_prompt): + with pytest.raises(ValueError, match="No shortcuts matching"): + ipython_with_prompt.shortcuts = [ + { + "command": "IPython:auto_match.skip_over", + "match_keys": ["x"], + "new_keys": ["y"], + } + ] + + +def test_add_shortcut_for_existing_command(ipython_with_prompt): + matched = find_bindings_by_command(skip_over) + assert len(matched) == 5 + + with pytest.raises(ValueError, match="Cannot add a shortcut without keys"): + ipython_with_prompt.shortcuts = [ + {"command": "IPython:auto_match.skip_over", "new_keys": [], "create": True} + ] + + ipython_with_prompt.shortcuts = [ + {"command": "IPython:auto_match.skip_over", "new_keys": ["x"], "create": True} + ] + matched = find_bindings_by_command(skip_over) + assert len(matched) == 6 + + ipython_with_prompt.shortcuts = [] + matched = find_bindings_by_command(skip_over) + assert len(matched) == 5 + + +def test_setting_shortcuts_before_pt_app_init(): + ipython = get_ipython() + assert ipython.pt_app is None + shortcuts = [ + {"command": "IPython:auto_match.skip_over", "new_keys": ["x"], "create": True} + ] + ipython.shortcuts = shortcuts + assert ipython.shortcuts == shortcuts diff --git a/docs/autogen_shortcuts.py b/docs/autogen_shortcuts.py index f8fd17b..15bb0ee 100755 --- a/docs/autogen_shortcuts.py +++ b/docs/autogen_shortcuts.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from inspect import getsource from pathlib import Path -from typing import cast, Callable, List, Union +from typing import cast, List, Union from html import escape as html_escape import re @@ -10,7 +10,8 @@ from prompt_toolkit.key_binding import KeyBindingsBase from prompt_toolkit.filters import Filter, Condition from prompt_toolkit.shortcuts import PromptSession -from IPython.terminal.shortcuts import create_ipython_shortcuts +from IPython.terminal.shortcuts import create_ipython_shortcuts, create_identifier +from IPython.terminal.shortcuts.filters import KEYBINDING_FILTERS @dataclass @@ -44,11 +45,16 @@ class _Invert(Filter): filter: Filter -conjunctions_labels = {"_AndList": "and", "_OrList": "or"} +conjunctions_labels = {"_AndList": "&", "_OrList": "|"} ATOMIC_CLASSES = {"Never", "Always", "Condition"} +HUMAN_NAMES_FOR_FILTERS = { + filter_: name for name, filter_ in KEYBINDING_FILTERS.items() +} + + def format_filter( filter_: Union[Filter, _NestedFilter, Condition, _Invert], is_top_level=True, @@ -58,6 +64,8 @@ def format_filter( s = filter_.__class__.__name__ if s == "Condition": func = cast(Condition, filter_).func + if filter_ in HUMAN_NAMES_FOR_FILTERS: + return HUMAN_NAMES_FOR_FILTERS[filter_] name = func.__name__ if name == "": source = getsource(func) @@ -66,10 +74,12 @@ def format_filter( elif s == "_Invert": operand = cast(_Invert, filter_).filter if operand.__class__.__name__ in ATOMIC_CLASSES: - return f"not {format_filter(operand, is_top_level=False)}" - return f"not ({format_filter(operand, is_top_level=False)})" + return f"~{format_filter(operand, is_top_level=False)}" + return f"~({format_filter(operand, is_top_level=False)})" elif s in conjunctions_labels: filters = cast(_NestedFilter, filter_).filters + if filter_ in HUMAN_NAMES_FOR_FILTERS: + return HUMAN_NAMES_FOR_FILTERS[filter_] conjunction = conjunctions_labels[s] glue = f" {conjunction} " result = glue.join(format_filter(x, is_top_level=False) for x in filters) @@ -104,17 +114,6 @@ class _DummyTerminal: auto_suggest = None -def create_identifier(handler: Callable): - parts = handler.__module__.split(".") - name = handler.__name__ - package = parts[0] - if len(parts) > 1: - final_module = parts[-1] - return f"{package}:{final_module}.{name}" - else: - return f"{package}:{name}" - - def bindings_from_prompt_toolkit(prompt_bindings: KeyBindingsBase) -> List[Binding]: """Collect bindings to a simple format that does not depend on prompt-toolkit internals""" bindings: List[Binding] = [] @@ -178,11 +177,12 @@ def format_prompt_keys(keys: str, add_alternatives=True) -> str: return result + if __name__ == '__main__': here = Path(__file__).parent dest = here / "source" / "config" / "shortcuts" - ipy_bindings = create_ipython_shortcuts(_DummyTerminal(), for_all_platforms=True) + ipy_bindings = create_ipython_shortcuts(_DummyTerminal()) session = PromptSession(key_bindings=ipy_bindings) prompt_bindings = session.app.key_bindings diff --git a/docs/environment.yml b/docs/environment.yml index 9961253..9fe7d4a 100644 --- a/docs/environment.yml +++ b/docs/environment.yml @@ -16,3 +16,4 @@ dependencies: - prompt_toolkit - ipykernel - stack_data + - -e .. diff --git a/docs/source/config/details.rst b/docs/source/config/details.rst index 3cc310a..c002d9a 100644 --- a/docs/source/config/details.rst +++ b/docs/source/config/details.rst @@ -200,10 +200,20 @@ With (X)EMacs >= 24, You can enable IPython in python-mode with: Keyboard Shortcuts ================== +.. versionadded:: 8.11 + +You can modify, disable or modify keyboard shortcuts for IPython Terminal using +:std:configtrait:`TerminalInteractiveShell.shortcuts` traitlet. + +The list of shortcuts is available in the Configuring IPython :ref:`terminal-shortcuts-list` section. + +Advanced configuration +---------------------- + .. versionchanged:: 5.0 -You can customise keyboard shortcuts for terminal IPython. Put code like this in -a :ref:`startup file `:: +Creating custom commands requires adding custom code to a +:ref:`startup file `:: from IPython import get_ipython from prompt_toolkit.enums import DEFAULT_BUFFER diff --git a/docs/source/config/shortcuts/index.rst b/docs/source/config/shortcuts/index.rst index e361ec2..42fd6ac 100755 --- a/docs/source/config/shortcuts/index.rst +++ b/docs/source/config/shortcuts/index.rst @@ -1,8 +1,10 @@ +.. _terminal-shortcuts-list: + ================= IPython shortcuts ================= -Available shortcuts in an IPython terminal. +Shortcuts available in an IPython terminal. .. note:: @@ -12,7 +14,10 @@ Available shortcuts in an IPython terminal. * Comma-separated keys, e.g. :kbd:`Esc`, :kbd:`f`, indicate a sequence which can be activated by pressing the listed keys in succession. * Plus-separated keys, e.g. :kbd:`Esc` + :kbd:`f` indicate a combination which requires pressing all keys simultaneously. -* Hover over the ⓘ icon in the filter column to see when the shortcut is active.g +* Hover over the ⓘ icon in the filter column to see when the shortcut is active. + +You can use :std:configtrait:`TerminalInteractiveShell.shortcuts` configuration +to modify, disable or add shortcuts. .. role:: raw-html(raw) :format: html diff --git a/docs/source/whatsnew/pr/shortcuts-config-traitlet.rst b/docs/source/whatsnew/pr/shortcuts-config-traitlet.rst new file mode 100644 index 0000000..8d3ce11 --- /dev/null +++ b/docs/source/whatsnew/pr/shortcuts-config-traitlet.rst @@ -0,0 +1,21 @@ +Terminal shortcuts customization +================================ + +Previously modifying shortcuts was only possible by hooking into startup files +and practically limited to adding new shortcuts or removing all shortcuts bound +to a specific key. This release enables users to override existing terminal +shortcuts, disable them or add new keybindings. + +For example, to set the :kbd:`right` to accept a single character of auto-suggestion +you could use:: + + my_shortcuts = [ + { + "command": "IPython:auto_suggest.accept_character", + "new_keys": ["right"] + } + ] + %config TerminalInteractiveShell.shortcuts = my_shortcuts + +You can learn more in :std:configtrait:`TerminalInteractiveShell.shortcuts` +configuration reference. \ No newline at end of file