From 442c33cf881bc5691c2061ddce4d5b80263ee27c 2023-02-13 09:28:54 From: Matthias Bussonnier Date: 2023-02-13 09:28:54 Subject: [PATCH] Allow to customise shortcuts using a traitlet (#13928) This is a refactor of keybindings code aiming to enable users to modify, disable, and add new shortcuts. Closes #13878, relates to #13879. ## Code changes - The filters are no longer defined as Python condition expression but as strings. This ensures that all shortcuts that we define can be unambiguously overridden by users from JSON config files. - All filters were moved to a new `filters.py` module - All commands previously defined in closure of `create_ipython_shortcuts(shell)` were moved to globals (which ensures nice identifier names and makes unit-testing easier) - All bindings are now collected in `KEY_BINDINGS` global variable; in future one could consider further splitting them up and moving bindings definition to respective modules (e.g. `AUTO_MATCH_BINDINGS` to `auto_match.py`). ## User-facing changes - New configuration traitlet: `c.TerminalInteractiveShell.shortcuts` - Accept single character in autosuggestion shortcut now uses alt + right instead of right (which is accepting the entire suggestion as in versions 8.8 and before). After a few iterations I arrived to a specification that separates the existing key/filter from the new key/filter and has a separate "create" flag used to indicate that a new shortcut should be created (rather than modifying an existing one): > 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 overridden/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. --- 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