From bcbf1e2522aed416d023ed849a3632c3c48620e9 Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Fri, 15 Nov 2024 00:18:12 -0500 Subject: [PATCH 01/17] API: Improve docstrings of SpyderShortcutsMixin The current docstrings were not easy to understand. --- spyder/api/shortcuts.py | 47 ++++++++++++++++++++++++----------------- 1 file changed, 28 insertions(+), 19 deletions(-) diff --git a/spyder/api/shortcuts.py b/spyder/api/shortcuts.py index 69480bcf288..882578d9641 100644 --- a/spyder/api/shortcuts.py +++ b/spyder/api/shortcuts.py @@ -35,11 +35,15 @@ def get_shortcut( Parameters ---------- name: str - Key identifier under which the shortcut is stored. - context: Optional[str] - Name of the shortcut context. - plugin: Optional[str] - Name of the plugin where the shortcut is defined. + The shortcut name (e.g. "run cell"). + context: str, optional + Name of the shortcut context, e.g. "editor" for shortcuts that have + effect when the Editor is focused or "_" for global shortcuts. If + not set, the widget's CONF_SECTION will be used as context. + plugin_name: str, optional + Name of the plugin where the shortcut is defined. This is necessary + for third-party plugins that have shortcuts with a context + different from the plugin name. Returns ------- @@ -49,7 +53,7 @@ def get_shortcut( Raises ------ configparser.NoOptionError - If the context does not exist in the configuration. + If the shortcut does not exist in the configuration. """ context = self.CONF_SECTION if context is None else context return CONF.get_shortcut(context, name, plugin_name) @@ -69,16 +73,20 @@ def set_shortcut( shortcut: str Key sequence of the shortcut. name: str - Key identifier under which the shortcut is stored. - context: Optional[str] - Name of the shortcut context. - plugin: Optional[str] - Name of the plugin where the shortcut is defined. + The shortcut name (e.g. "run cell"). + context: str, optional + Name of the shortcut context, e.g. "editor" for shortcuts that have + effect when the Editor is focused or "_" for global shortcuts. If + not set, the widget's CONF_SECTION will be used as context. + plugin_name: str, optional + Name of the plugin where the shortcut is defined. This is necessary + for third-party plugins that have shortcuts with a context + different from the plugin name. Raises ------ configparser.NoOptionError - If the context does not exist in the configuration. + If the shortcut does not exist in the configuration. """ context = self.CONF_SECTION if context is None else context return CONF.set_shortcut(context, name, shortcut, plugin_name) @@ -96,16 +104,17 @@ def register_shortcut_for_widget( Parameters ---------- name: str - Key identifier under which the shortcut is stored. + The shortcut name (e.g. "run cell"). triggered: Callable Callable (i.e. function or method) that will be triggered by the shortcut. - widget: Optional[QWidget] - Widget to which register this shortcut. By default we register it - to the one that calls this method. - context: Optional[str] - Name of the context (plugin) where the shortcut is defined. By - default we use the widget's CONF_SECTION. + widget: QWidget, optional + Widget to which this shortcut will be registered. If not set, the + widget that calls this method will be used. + context: str, optional + Name of the shortcut context, e.g. "editor" for shortcuts that have + effect when the Editor is focused or "_" for global shortcuts. If + not set, the widget's CONF_SECTION will be used as context. """ context = self.CONF_SECTION if context is None else context widget = self if widget is None else widget From 474c1855e3721dd8d74d336a5c56664c2b482615 Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Fri, 15 Nov 2024 00:42:25 -0500 Subject: [PATCH 02/17] Shortcuts: Use a dataclass to represent shortcut data - The previous solution, which used tuples to collect those data, was easy to break, because it required to put data in the right order; and undocumented, because it was unclear what kind of data had to be added in the tuple elements. - Those limitations made difficult to reason about shortcuts-related code. --- spyder/plugins/shortcuts/plugin.py | 54 ++++++++++++------- .../plugins/shortcuts/tests/test_shortcuts.py | 5 +- spyder/plugins/shortcuts/utils.py | 46 ++++++++++++++++ spyder/plugins/shortcuts/widgets/table.py | 23 +++++--- 4 files changed, 101 insertions(+), 27 deletions(-) create mode 100644 spyder/plugins/shortcuts/utils.py diff --git a/spyder/plugins/shortcuts/plugin.py b/spyder/plugins/shortcuts/plugin.py index 7f197beeef7..52009fe9bc9 100644 --- a/spyder/plugins/shortcuts/plugin.py +++ b/spyder/plugins/shortcuts/plugin.py @@ -12,6 +12,7 @@ # Standard library imports import configparser +from typing import List # Third party imports from qtpy.QtCore import Qt, Signal, Slot @@ -27,6 +28,7 @@ from spyder.api.translations import _ from spyder.plugins.mainmenu.api import ApplicationMenus, HelpMenuSections from spyder.plugins.shortcuts.confpage import ShortcutsConfigPage +from spyder.plugins.shortcuts.utils import ShortcutData from spyder.plugins.shortcuts.widgets.summary import ShortcutsSummaryDialog from spyder.utils.qthelpers import add_shortcut_to_tooltip, SpyderAction @@ -73,7 +75,7 @@ def get_icon(cls): return cls.create_icon('keyboard') def on_initialize(self): - self._shortcut_data = [] + self._shortcut_data: List[ShortcutData] = [] self._shortcut_sequences = set({}) self.create_action( ShortcutActions.ShortcutSummaryAction, @@ -143,16 +145,28 @@ def register_shortcut(self, qaction_or_qshortcut, context, name, Register QAction or QShortcut to Spyder main application, with shortcut (context, name, default) """ - self._shortcut_data.append((qaction_or_qshortcut, context, - name, add_shortcut_to_tip, plugin_name)) + self._shortcut_data.append( + ShortcutData( + qobject=qaction_or_qshortcut, + name=name, + context=context, + plugin_name=plugin_name, + add_shortcut_to_tip=add_shortcut_to_tip, + ) + ) def unregister_shortcut(self, qaction_or_qshortcut, context, name, add_shortcut_to_tip=True, plugin_name=None): """ Unregister QAction or QShortcut from Spyder main application. """ - data = (qaction_or_qshortcut, context, name, add_shortcut_to_tip, - plugin_name) + data = ShortcutData( + qobject=qaction_or_qshortcut, + name=name, + context=context, + plugin_name=plugin_name, + add_shortcut_to_tip=add_shortcut_to_tip, + ) if data in self._shortcut_data: self._shortcut_data.remove(data) @@ -166,24 +180,25 @@ def apply_shortcuts(self): # TODO: Check shortcut existence based on action existence, so that we # can update shortcut names without showing the old ones on the # preferences - for index, (qobject, context, name, add_shortcut_to_tip, - plugin_name) in enumerate(self._shortcut_data): + for index, data in enumerate(self._shortcut_data): try: shortcut_sequence = self.get_shortcut( - name, context, plugin_name + data.name, data.context, data.plugin_name ) except (configparser.NoSectionError, configparser.NoOptionError): # If shortcut does not exist, save it to CONF. This is an # action for which there is no shortcut assigned (yet) in # the configuration - self.set_shortcut('', name, context, plugin_name) + self.set_shortcut( + "", data.name, data.context, data.plugin_name + ) shortcut_sequence = '' if shortcut_sequence: if shortcut_sequence in self._shortcut_sequences: continue - self._shortcut_sequences |= {(context, shortcut_sequence)} + self._shortcut_sequences |= {(data.context, shortcut_sequence)} keyseq = QKeySequence(shortcut_sequence) else: # Needed to remove old sequences that were cleared. @@ -194,18 +209,21 @@ def apply_shortcuts(self): # The shortcut will be displayed only on the menus and handled by # about to show/hide signals. if ( - name.startswith('switch to') - and isinstance(qobject, SpyderAction) + data.name.startswith('switch to') + and isinstance(data.qobject, SpyderAction) ): keyseq = QKeySequence() + # Register shortcut for the associated qobject try: - if isinstance(qobject, QAction): - qobject.setShortcut(keyseq) - if add_shortcut_to_tip: - add_shortcut_to_tooltip(qobject, context, name) - elif isinstance(qobject, QShortcut): - qobject.setKey(keyseq) + if isinstance(data.qobject, QAction): + data.qobject.setShortcut(keyseq) + if data.add_shortcut_to_tip: + add_shortcut_to_tooltip( + data.qobject, data.context, data.name + ) + elif isinstance(data.qobject, QShortcut): + data.qobject.setKey(keyseq) except RuntimeError: # Object has been deleted toberemoved.append(index) diff --git a/spyder/plugins/shortcuts/tests/test_shortcuts.py b/spyder/plugins/shortcuts/tests/test_shortcuts.py index 232c2f810a0..6d2fa8bb752 100644 --- a/spyder/plugins/shortcuts/tests/test_shortcuts.py +++ b/spyder/plugins/shortcuts/tests/test_shortcuts.py @@ -19,6 +19,7 @@ # Local imports from spyder.config.base import running_in_ci from spyder.config.manager import CONF +from spyder.plugins.shortcuts.utils import ShortcutData from spyder.plugins.shortcuts.widgets.table import ( INVALID_KEY, NO_WARNING, SEQUENCE_CONFLICT, SEQUENCE_EMPTY, ShortcutEditor, ShortcutsTable, load_shortcuts) @@ -77,8 +78,8 @@ def test_shortcut_in_conf_is_filtered_with_shortcut_data(qtbot): shortcut_table_empty = ShortcutsTable() shortcut_table_empty.set_shortcut_data([ - (None, '_', 'switch to plots', None, None), - (None, '_', 'switch to editor', None, None), + ShortcutData(qobject=None, name='switch to plots', context='_'), + ShortcutData(qobject=None, name='switch to editor', context='_') ]) shortcut_table_empty.load_shortcuts() qtbot.addWidget(shortcut_table_empty) diff --git a/spyder/plugins/shortcuts/utils.py b/spyder/plugins/shortcuts/utils.py new file mode 100644 index 00000000000..120433b45e6 --- /dev/null +++ b/spyder/plugins/shortcuts/utils.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +"""Shortcuts utils.""" + +from dataclasses import dataclass +from typing import Optional + +from qtpy.QtCore import QObject + + +@dataclass(frozen=True) +class ShortcutData: + """Dataclass to represent shortcut data.""" + + qobject: QObject + """QObject to which the shortcut will be associated.""" + + name: str + """Shortcut name (e.g. "run cell").""" + + context: str + """ + Name of the shortcut context. + + Notes + ----- + This can be the plugin name (e.g. "editor" for shortcuts that have + effect when the Editor is focused) or "_" for global shortcuts. + """ + + plugin_name: Optional[str] = None + """ + Name of the plugin where the shortcut is defined. + + Notes + ----- + This is only necessary for third-party plugins that have shortcuts with + several contexts. + """ + + add_shortcut_to_tip: bool = False + """Whether to add the shortcut to the qobject's tooltip.""" diff --git a/spyder/plugins/shortcuts/widgets/table.py b/spyder/plugins/shortcuts/widgets/table.py index 1de08cf953c..24013837cf1 100644 --- a/spyder/plugins/shortcuts/widgets/table.py +++ b/spyder/plugins/shortcuts/widgets/table.py @@ -8,6 +8,7 @@ # Standard library importsimport re import re +from typing import List # Third party imports from qtawesome import IconWidget @@ -24,6 +25,7 @@ from spyder.api.shortcuts import SpyderShortcutsMixin from spyder.api.translations import _ from spyder.config.manager import CONF +from spyder.plugins.shortcuts.utils import ShortcutData from spyder.utils.icon_manager import ima from spyder.utils.palette import SpyderPalette from spyder.utils.qthelpers import create_toolbutton @@ -638,7 +640,7 @@ def __init__(self, parent=None): HoverRowsTableView.__init__(self, parent, custom_delegate=True) self._parent = parent self.finder = None - self.shortcut_data = None + self.shortcut_data: List[ShortcutData] = [] self.source_model = ShortcutsModel(self) self.proxy_model = ShortcutsSortFilterProxy(self) self.last_regex = '' @@ -701,11 +703,14 @@ def adjust_cells(self): def load_shortcuts(self): """Load shortcuts and assign to table model.""" - # item[1] -> context, item[2] -> name - # Data might be capitalized so we user lower() + # Data might be capitalized so we use lower() below. # See: spyder-ide/spyder/#12415 - shortcut_data = set([(item[1].lower(), item[2].lower()) for item - in self.shortcut_data]) + shortcut_data = set( + [ + (data.context.lower(), data.name.lower()) + for data in self.shortcut_data + ] + ) shortcut_data = list(sorted(set(shortcut_data))) shortcuts = [] @@ -746,8 +751,10 @@ def check_shortcuts(self): and (sh1.context == sh2.context or sh1.context == '_' or sh2.context == '_'): conflicts.append((sh1, sh2)) + if conflicts: - self.parent().show_this_page.emit() + if self.parent() is not None: + self.parent().show_this_page.emit() cstr = "\n".join(['%s <---> %s' % (sh1, sh2) for sh1, sh2 in conflicts]) QMessageBox.warning(self, _("Conflicts"), @@ -901,7 +908,9 @@ def load_shortcuts_data(): for context, name, __ in CONF.iter_shortcuts(): context = context.lower() name = name.lower() - shortcut_data.append((None, context, name, None, None)) + shortcut_data.append( + ShortcutData(qobject=None, name=name, context=context) + ) return shortcut_data From dd9d7e868f08bf798f7295293a2413bb476d2d4f Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Fri, 15 Nov 2024 12:55:47 -0500 Subject: [PATCH 03/17] Shortcuts: Restore showing shortcuts for widgets in Preferences --- spyder/api/shortcuts.py | 11 +++++++++++ spyder/plugins/shortcuts/plugin.py | 13 ++++++++----- spyder/plugins/shortcuts/utils.py | 17 ++++++++++++++--- 3 files changed, 33 insertions(+), 8 deletions(-) diff --git a/spyder/api/shortcuts.py b/spyder/api/shortcuts.py index 882578d9641..73467726ffb 100644 --- a/spyder/api/shortcuts.py +++ b/spyder/api/shortcuts.py @@ -18,6 +18,10 @@ # Local imports from spyder.config.manager import CONF +from spyder.plugins.shortcuts.utils import ( + ShortcutData, + SHORTCUTS_FOR_WIDGETS_DATA, +) class SpyderShortcutsMixin: @@ -119,7 +123,14 @@ def register_shortcut_for_widget( context = self.CONF_SECTION if context is None else context widget = self if widget is None else widget + # Register shortcut to widget keystr = self.get_shortcut(name, context) qsc = QShortcut(QKeySequence(keystr), widget) qsc.activated.connect(triggered) qsc.setContext(Qt.WidgetWithChildrenShortcut) + + # Keep track of all widget shortcuts. This is necessary to show them in + # Preferences. + data = ShortcutData(qobject=None, name=name, context=context) + if data not in SHORTCUTS_FOR_WIDGETS_DATA: + SHORTCUTS_FOR_WIDGETS_DATA.append(data) diff --git a/spyder/plugins/shortcuts/plugin.py b/spyder/plugins/shortcuts/plugin.py index 52009fe9bc9..554f9b1dfd0 100644 --- a/spyder/plugins/shortcuts/plugin.py +++ b/spyder/plugins/shortcuts/plugin.py @@ -28,7 +28,10 @@ from spyder.api.translations import _ from spyder.plugins.mainmenu.api import ApplicationMenus, HelpMenuSections from spyder.plugins.shortcuts.confpage import ShortcutsConfigPage -from spyder.plugins.shortcuts.utils import ShortcutData +from spyder.plugins.shortcuts.utils import ( + ShortcutData, + SHORTCUTS_FOR_WIDGETS_DATA, +) from spyder.plugins.shortcuts.widgets.summary import ShortcutsSummaryDialog from spyder.utils.qthelpers import add_shortcut_to_tooltip, SpyderAction @@ -123,10 +126,10 @@ def on_mainwindow_visible(self): # ---- Public API # ------------------------------------------------------------------------- def get_shortcut_data(self): - """ - Return the registered shortcut data from the main application window. - """ - return self._shortcut_data + """Return the registered shortcut data.""" + # We need to include the second list here so that those shortcuts are + # displayed in Preferences. + return self._shortcut_data + SHORTCUTS_FOR_WIDGETS_DATA def reset_shortcuts(self): """Reset shrotcuts.""" diff --git a/spyder/plugins/shortcuts/utils.py b/spyder/plugins/shortcuts/utils.py index 120433b45e6..ef4dada7faa 100644 --- a/spyder/plugins/shortcuts/utils.py +++ b/spyder/plugins/shortcuts/utils.py @@ -7,7 +7,7 @@ """Shortcuts utils.""" from dataclasses import dataclass -from typing import Optional +from typing import List, Optional from qtpy.QtCore import QObject @@ -16,8 +16,15 @@ class ShortcutData: """Dataclass to represent shortcut data.""" - qobject: QObject - """QObject to which the shortcut will be associated.""" + qobject: Optional[QObject] + """ + QObject to which the shortcut will be associated. + + Notes + ----- + This can be None when there's no need to register the shortcut to a + specific QObject. + """ name: str """Shortcut name (e.g. "run cell").""" @@ -44,3 +51,7 @@ class ShortcutData: add_shortcut_to_tip: bool = False """Whether to add the shortcut to the qobject's tooltip.""" + + +# List to save shortcut data registered for all widgets +SHORTCUTS_FOR_WIDGETS_DATA: List[ShortcutData] = [] From 4b73b75bb479becec49df03830eb66784df210d4 Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Mon, 18 Nov 2024 22:00:07 -0500 Subject: [PATCH 04/17] API: Allow to add callables to observe an option in our config system - Before we only allowed class methods decorated with on_conf_change to do that. - But that's too limited if we need to use regular functions to observe an option. And that's precisely what this new method allows us to do. --- changelogs/Spyder-6.md | 1 + spyder/api/config/mixins.py | 71 +++++++++++++++++++++++++++++++------ 2 files changed, 62 insertions(+), 10 deletions(-) diff --git a/changelogs/Spyder-6.md b/changelogs/Spyder-6.md index 142c6d6713f..14a985d4a01 100644 --- a/changelogs/Spyder-6.md +++ b/changelogs/Spyder-6.md @@ -4,6 +4,7 @@ ### API changes +* The `add_configuration_observer` method was added to `SpyderConfigurationObserver`. * Add `items_elide_mode` kwarg to the constructors of `SpyderComboBox` and `SpyderComboBoxWithIcons`. * The `sig_item_in_popup_changed` and `sig_popup_is_hidden` signals were added diff --git a/spyder/api/config/mixins.py b/spyder/api/config/mixins.py index 77f48984f85..e0eac09d2c4 100644 --- a/spyder/api/config/mixins.py +++ b/spyder/api/config/mixins.py @@ -10,7 +10,7 @@ # Standard library imports import logging -from typing import Any, Union, Optional +from typing import Any, Callable, Optional, Union import warnings # Local imports @@ -239,8 +239,10 @@ def __init__(self): section = self.CONF_SECTION if section is None else section observed_options = self._configuration_listeners[section] for option in observed_options: - logger.debug(f'{self} is observing {option} ' - f'in section {section}') + logger.debug( + f'{self} is observing option "{option}" in section ' + f'"{section}"' + ) CONF.observe_configuration(self, section, option) def __del__(self): @@ -257,12 +259,7 @@ def _gather_observers(self): self._multi_option_listeners |= {method_name} for section, option in info: - section_listeners = self._configuration_listeners.get( - section, {}) - option_listeners = section_listeners.get(option, []) - option_listeners.append(method_name) - section_listeners[option] = option_listeners - self._configuration_listeners[section] = section_listeners + self._add_listener(method_name, option, section) def _merge_none_observers(self): """Replace observers that declared section as None by CONF_SECTION.""" @@ -280,6 +277,27 @@ def _merge_none_observers(self): self._configuration_listeners[self.CONF_SECTION] = section_selectors self._configuration_listeners.pop(None, None) + def _add_listener( + self, func: Callable, option: ConfigurationKey, section: str + ): + """ + Add a callable as listener of the option `option` on section `section`. + + Parameters + ---------- + func: Callable + Function/method that will be called when `option` changes. + option: ConfigurationKey + Configuration option to observe. + section: str + Name of the section where `option` is contained. + """ + section_listeners = self._configuration_listeners.get(section, {}) + option_listeners = section_listeners.get(option, []) + option_listeners.append(func) + section_listeners[option] = option_listeners + self._configuration_listeners[section] = section_listeners + def on_configuration_change(self, option: ConfigurationKey, section: str, value: Any): """ @@ -298,8 +316,41 @@ def on_configuration_change(self, option: ConfigurationKey, section: str, section_receivers = self._configuration_listeners.get(section, {}) option_receivers = section_receivers.get(option, []) for receiver in option_receivers: - method = getattr(self, receiver) + method = ( + receiver if callable(receiver) else getattr(self, receiver) + ) if receiver in self._multi_option_listeners: method(option, value) else: method(value) + + def add_configuration_observer( + self, func: Callable, option: str, section: Optional[str] = None + ): + """ + Add a callable to observe the option `option` on section `section`. + + Parameters + ---------- + func: Callable + Function that will be called when `option` changes. + option: ConfigurationKey + Configuration option to observe. + section: str + Name of the section where `option` is contained. + + Notes + ----- + - This is only necessary if you need to add a callable that is not a + class method to observe an option. Otherwise, you simply need to + decorate your method with + :function:`spyder.api.config.decorators.on_conf_change`. + """ + if section is None: + section = self.CONF_SECTION + + logger.debug( + f'{self} is observing "{option}" option on section "{section}"' + ) + self._add_listener(func, option, section) + CONF.observe_configuration(self, section, option) From c308cb901cfc639959343b1f41ba3ef6940b1772 Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Tue, 19 Nov 2024 22:19:29 -0500 Subject: [PATCH 05/17] Config: Notify when a shortcut is changed in our config system This is necessary to add observers for specific shortcuts. --- spyder/config/manager.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/spyder/config/manager.py b/spyder/config/manager.py index 267f7c76134..6c26acfcdaa 100644 --- a/spyder/config/manager.py +++ b/spyder/config/manager.py @@ -703,7 +703,12 @@ def set_shortcut(self, context, name, keystr, plugin_name=None): Context must be either '_' for global or the name of a plugin. """ config = self._get_shortcut_config(context, plugin_name) - config.set('shortcuts', context + '/' + name, keystr) + option = context + "/" + name + current_shortcut = config.get("shortcuts", option, default="") + + if current_shortcut != keystr: + config.set('shortcuts', option, keystr) + self.notify_observers("shortcuts", option) def iter_shortcuts(self): """Iterate over keyboard shortcuts.""" From 011bd0e1dc5e370bfd02a7ce8c68c8d87d0c0672 Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Tue, 19 Nov 2024 22:25:13 -0500 Subject: [PATCH 06/17] API: Add an observer for each shortcut registered for a widget - That allows those shortcuts to be updated on the fly when they are changed in Preferences or directly with set_conf. - Also, fix inheritance of classes that inherit from SpyderShortcutsMixin to accomodate this change. --- spyder/api/shortcuts.py | 74 +++++++++++++++++-- spyder/api/widgets/mixins.py | 1 - spyder/app/mainwindow.py | 8 +- spyder/plugins/console/widgets/shell.py | 1 + spyder/plugins/editor/widgets/tabswitcher.py | 5 +- .../plugins/ipythonconsole/widgets/shell.py | 1 + spyder/plugins/shortcuts/plugin.py | 3 +- spyder/widgets/tabs.py | 3 + 8 files changed, 75 insertions(+), 21 deletions(-) diff --git a/spyder/api/shortcuts.py b/spyder/api/shortcuts.py index 73467726ffb..0acebc965ed 100644 --- a/spyder/api/shortcuts.py +++ b/spyder/api/shortcuts.py @@ -9,7 +9,8 @@ """ # Standard library imports -from typing import Callable, Optional +import functools +from typing import Callable, Dict, Optional # Third-party imports from qtpy.QtCore import Qt @@ -17,6 +18,7 @@ from qtpy.QtWidgets import QShortcut, QWidget # Local imports +from spyder.api.config.mixins import SpyderConfigurationObserver from spyder.config.manager import CONF from spyder.plugins.shortcuts.utils import ( ShortcutData, @@ -24,8 +26,14 @@ ) -class SpyderShortcutsMixin: - """Provide methods to get, set and register shortcuts.""" +class SpyderShortcutsMixin(SpyderConfigurationObserver): + """Provide methods to get, set and register shortcuts for widgets.""" + + def __init__(self): + super().__init__() + + # This is used to keep track of the widget shortcuts + self._shortcuts: Dict[(str, str), QShortcut] = {} def get_shortcut( self, @@ -123,14 +131,64 @@ def register_shortcut_for_widget( context = self.CONF_SECTION if context is None else context widget = self if widget is None else widget - # Register shortcut to widget - keystr = self.get_shortcut(name, context) - qsc = QShortcut(QKeySequence(keystr), widget) - qsc.activated.connect(triggered) - qsc.setContext(Qt.WidgetWithChildrenShortcut) + # Add observer to register shortcut when its associated option is + # broadcasted by CONF or updated in Preferences. + config_observer = functools.partial( + self._register_shortcut, + name=name, + triggered=triggered, + context=context, + widget=widget, + ) + + self.add_configuration_observer( + config_observer, option=f"{context}/{name}", section="shortcuts" + ) # Keep track of all widget shortcuts. This is necessary to show them in # Preferences. data = ShortcutData(qobject=None, name=name, context=context) if data not in SHORTCUTS_FOR_WIDGETS_DATA: SHORTCUTS_FOR_WIDGETS_DATA.append(data) + + def _register_shortcut( + self, + keystr: str, + name: str, + triggered: Callable, + context: str, + widget: QWidget, + ): + """ + Auxiliary function to register a shortcut for a widget. + + Parameters + ---------- + keystr: str + Key string for the shortcut (e.g. "Ctrl+Enter"). + name: str + The shortcut name (e.g. "run cell"). + triggered: Callable + Callable (i.e. function or method) that will be triggered by the + shortcut. + widget: QWidget, optional + Widget to which this shortcut will be registered. If not set, the + widget that calls this method will be used. + context: str, optional + Name of the shortcut context, e.g. "editor" for shortcuts that have + effect when the Editor is focused or "_" for global shortcuts. + """ + # Disable current shortcut, if available + current_shortcut = self._shortcuts.get((context, name)) + if current_shortcut: + current_shortcut.setEnabled(False) + current_shortcut.deleteLater() + self._shortcuts.pop((context, name)) + + # Create a new shortcut + new_shortcut = QShortcut(QKeySequence(keystr), widget) + new_shortcut.activated.connect(triggered) + new_shortcut.setContext(Qt.WidgetWithChildrenShortcut) + + # Save shortcut + self._shortcuts[(context, name)] = new_shortcut diff --git a/spyder/api/widgets/mixins.py b/spyder/api/widgets/mixins.py index c6bfbbe0844..36bd147a816 100644 --- a/spyder/api/widgets/mixins.py +++ b/spyder/api/widgets/mixins.py @@ -653,7 +653,6 @@ def update_actions(self, options): class SpyderWidgetMixin( SpyderActionMixin, - SpyderConfigurationObserver, SpyderMenuMixin, SpyderToolbarMixin, SpyderToolButtonMixin, diff --git a/spyder/app/mainwindow.py b/spyder/app/mainwindow.py index 83a03c4a7f2..4b8c302af8a 100644 --- a/spyder/app/mainwindow.py +++ b/spyder/app/mainwindow.py @@ -74,7 +74,6 @@ delete_debug_log_files, qt_message_handler, set_links_color, setup_logging, set_opengl_implementation) from spyder.api.plugin_registration.registry import PLUGIN_REGISTRY -from spyder.api.config.mixins import SpyderConfigurationAccessor from spyder.api.shortcuts import SpyderShortcutsMixin from spyder.api.widgets.mixins import SpyderMainWindowMixin from spyder.config.base import (_, DEV, get_conf_path, get_debug_level, @@ -122,12 +121,7 @@ #============================================================================== # Main Window #============================================================================== -class MainWindow( - QMainWindow, - SpyderMainWindowMixin, - SpyderConfigurationAccessor, - SpyderShortcutsMixin, -): +class MainWindow(QMainWindow, SpyderMainWindowMixin, SpyderShortcutsMixin): """Spyder main window""" CONF_SECTION = 'main' diff --git a/spyder/plugins/console/widgets/shell.py b/spyder/plugins/console/widgets/shell.py index c1288ddcfb6..dba67bef93c 100644 --- a/spyder/plugins/console/widgets/shell.py +++ b/spyder/plugins/console/widgets/shell.py @@ -679,6 +679,7 @@ def __init__( ) TracebackLinksMixin.__init__(self) GetHelpMixin.__init__(self) + SpyderShortcutsMixin.__init__(self) # Local shortcuts self.register_shortcuts() diff --git a/spyder/plugins/editor/widgets/tabswitcher.py b/spyder/plugins/editor/widgets/tabswitcher.py index 989507ba543..c8e3c7160e6 100644 --- a/spyder/plugins/editor/widgets/tabswitcher.py +++ b/spyder/plugins/editor/widgets/tabswitcher.py @@ -13,13 +13,10 @@ # Local imports from spyder.api.shortcuts import SpyderShortcutsMixin -from spyder.api.widgets.mixins import SpyderConfigurationAccessor from spyder.utils.icon_manager import ima -class TabSwitcherWidget( - QListWidget, SpyderConfigurationAccessor, SpyderShortcutsMixin -): +class TabSwitcherWidget(QListWidget, SpyderShortcutsMixin): """Show tabs in mru order and change between them.""" CONF_SECTION = "editor" diff --git a/spyder/plugins/ipythonconsole/widgets/shell.py b/spyder/plugins/ipythonconsole/widgets/shell.py index 79294458f06..653f23ea37b 100644 --- a/spyder/plugins/ipythonconsole/widgets/shell.py +++ b/spyder/plugins/ipythonconsole/widgets/shell.py @@ -172,6 +172,7 @@ def __init__( # Keyboard shortcuts # Registered here to use shellwidget as the parent + SpyderWidgetMixin.__init__(self) self.regiter_shortcuts() # Set the color of the matched parentheses here since the qtconsole diff --git a/spyder/plugins/shortcuts/plugin.py b/spyder/plugins/shortcuts/plugin.py index 554f9b1dfd0..f121e2dcd07 100644 --- a/spyder/plugins/shortcuts/plugin.py +++ b/spyder/plugins/shortcuts/plugin.py @@ -128,7 +128,8 @@ def on_mainwindow_visible(self): def get_shortcut_data(self): """Return the registered shortcut data.""" # We need to include the second list here so that those shortcuts are - # displayed in Preferences. + # displayed in Preferences. But they are updated using a different + # mechanism (see SpyderShortcutsMixin.register_shortcut_for_widget). return self._shortcut_data + SHORTCUTS_FOR_WIDGETS_DATA def reset_shortcuts(self): diff --git a/spyder/widgets/tabs.py b/spyder/widgets/tabs.py index 76809b7b061..a7f32dfb8dc 100644 --- a/spyder/widgets/tabs.py +++ b/spyder/widgets/tabs.py @@ -625,6 +625,9 @@ def refresh_style(self): class Tabs(BaseTabs, SpyderShortcutsMixin): """BaseTabs widget with movable tabs and tab navigation shortcuts.""" + # Dummy CONF_SECTION to avoid a warning + CONF_SECTION = "" + # Signals move_data = Signal(int, int) move_tab_finished = Signal() From 9a54ef2e489da709704a47206d34c8f2fc564384 Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Tue, 19 Nov 2024 22:27:12 -0500 Subject: [PATCH 07/17] Testing: Check that shortcuts in CodeEditor are updated on the fly --- .../widgets/codeeditor/tests/conftest.py | 5 +++ .../codeeditor/tests/test_codeeditor.py | 33 ++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/tests/conftest.py b/spyder/plugins/editor/widgets/codeeditor/tests/conftest.py index afcbdea99f9..17bb0fdc760 100644 --- a/spyder/plugins/editor/widgets/codeeditor/tests/conftest.py +++ b/spyder/plugins/editor/widgets/codeeditor/tests/conftest.py @@ -248,6 +248,11 @@ def codeeditor(qtbot): widget.setup_editor(language='Python') widget.resize(640, 480) widget.show() + + # Register shortcuts for CodeEditor + CONF.notify_section_all_observers("shortcuts") + qtbot.wait(300) + yield widget widget.close() diff --git a/spyder/plugins/editor/widgets/codeeditor/tests/test_codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/tests/test_codeeditor.py index b235436e96e..76a26ab9e9b 100644 --- a/spyder/plugins/editor/widgets/codeeditor/tests/test_codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/tests/test_codeeditor.py @@ -16,8 +16,8 @@ import pytest # Local imports +from spyder.config.base import running_in_ci from spyder.widgets.mixins import TIP_PARAMETER_HIGHLIGHT_COLOR -from spyder.py3compat import to_text_string HERE = osp.dirname(osp.abspath(__file__)) @@ -652,6 +652,7 @@ def test_cell_highlight(codeeditor, qtbot): editor = codeeditor text = ('\n\n\n#%%\n\n\n') editor.set_text(text) + # Set cursor to start of file cursor = editor.textCursor() cursor.setPosition(0) @@ -693,5 +694,35 @@ def test_cell_highlight(codeeditor, qtbot): assert editor.current_cell[0].selectionEnd() == 8 +@pytest.mark.skipif( + sys.platform.startswith("linux") and running_in_ci(), + reason="Fails on Linux and CI" +) +def test_shortcut_for_widget_is_updated(codeeditor, qtbot): + """Test shortcuts for codeeditor are updated on the fly.""" + editor = codeeditor + text = ('aa\nbb\ncc\ndd\n') + editor.set_text(text) + + # Check shortcuts were registered + assert editor._shortcuts != {} + + # Check "move line down" shortcut is working as expected + qtbot.keyClick(editor, Qt.Key_Down, modifier=Qt.AltModifier) + assert editor.toPlainText() == "bb\naa\ncc\ndd\n" + + # Change "move line down" to a different shortcut + editor.set_conf("editor/move line down", "Ctrl+B", section="shortcuts") + qtbot.wait(300) + + # Check new shortcut works + qtbot.keyClick(editor, Qt.Key_B, modifier=Qt.ControlModifier) + assert editor.toPlainText() == "bb\ncc\naa\ndd\n" + + # Check previous shortcut doesn't work + qtbot.keyClick(editor, Qt.Key_Down, modifier=Qt.AltModifier) + assert editor.toPlainText() == "bb\ncc\naa\ndd\n" + + if __name__ == '__main__': pytest.main(['test_codeeditor.py']) From 76a2d3bb89cdfdec6aff6b4ce55dc2474278a583 Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Wed, 20 Nov 2024 11:11:17 -0500 Subject: [PATCH 08/17] Testing: Remove setting CONF_SECTION for EditorStack/EditorSplitter That was introducing an error in test_shortcut_for_widget_is_updated when run on CI due to the incorrectly named section. --- spyder/plugins/editor/widgets/editorstack/editorstack.py | 6 ++++++ spyder/plugins/editor/widgets/editorstack/tests/conftest.py | 1 - .../editor/widgets/editorstack/tests/test_editorstack.py | 1 - .../editorstack/tests/test_editorstack_and_outline.py | 1 - .../plugins/editor/widgets/editorstack/tests/test_save.py | 2 -- .../editor/widgets/editorstack/tests/test_shortcuts.py | 1 - spyder/plugins/editor/widgets/tests/test_editorsplitter.py | 3 --- 7 files changed, 6 insertions(+), 9 deletions(-) diff --git a/spyder/plugins/editor/widgets/editorstack/editorstack.py b/spyder/plugins/editor/widgets/editorstack/editorstack.py index b30eb54a94f..e6bdf1e9e45 100644 --- a/spyder/plugins/editor/widgets/editorstack/editorstack.py +++ b/spyder/plugins/editor/widgets/editorstack/editorstack.py @@ -95,6 +95,12 @@ class EditorStackMenuSections: class EditorStack(QWidget, SpyderWidgetMixin): + + # This is necessary for the EditorStack tests to run independently of the + # Editor plugin. + CONF_SECTION = "editor" + + # Signals reset_statusbar = Signal() readonly_changed = Signal(bool) encoding_changed = Signal(str) diff --git a/spyder/plugins/editor/widgets/editorstack/tests/conftest.py b/spyder/plugins/editor/widgets/editorstack/tests/conftest.py index 5e70e6f650b..3f8f3c29175 100644 --- a/spyder/plugins/editor/widgets/editorstack/tests/conftest.py +++ b/spyder/plugins/editor/widgets/editorstack/tests/conftest.py @@ -34,7 +34,6 @@ def editor_factory(new_file=True, text=None): - EditorStack.CONF_SECTION = "Editor" editorstack = EditorStack(None, [], False) editorstack.set_find_widget(FindReplace(editorstack)) editorstack.set_io_actions(Mock(), Mock(), Mock(), Mock()) diff --git a/spyder/plugins/editor/widgets/editorstack/tests/test_editorstack.py b/spyder/plugins/editor/widgets/editorstack/tests/test_editorstack.py index 82784cb69a4..d142b006936 100644 --- a/spyder/plugins/editor/widgets/editorstack/tests/test_editorstack.py +++ b/spyder/plugins/editor/widgets/editorstack/tests/test_editorstack.py @@ -36,7 +36,6 @@ # ============================================================================= @pytest.fixture def base_editor_bot(qtbot): - EditorStack.CONF_SECTION = "Editor" editor_stack = EditorStack(None, [], False) editor_stack.set_find_widget(Mock()) editor_stack.set_io_actions(Mock(), Mock(), Mock(), Mock()) diff --git a/spyder/plugins/editor/widgets/editorstack/tests/test_editorstack_and_outline.py b/spyder/plugins/editor/widgets/editorstack/tests/test_editorstack_and_outline.py index 8be7a8c1b75..273f4f6a118 100644 --- a/spyder/plugins/editor/widgets/editorstack/tests/test_editorstack_and_outline.py +++ b/spyder/plugins/editor/widgets/editorstack/tests/test_editorstack_and_outline.py @@ -68,7 +68,6 @@ def test_files(tmpdir_factory): @pytest.fixture def editorstack(qtbot, outlineexplorer): def _create_editorstack(files): - EditorStack.CONF_SECTION = "Editor" editorstack = EditorStack(None, [], False) editorstack.set_find_widget(Mock()) editorstack.set_io_actions(Mock(), Mock(), Mock(), Mock()) diff --git a/spyder/plugins/editor/widgets/editorstack/tests/test_save.py b/spyder/plugins/editor/widgets/editorstack/tests/test_save.py index ec8217e639a..8315ee6a8e8 100644 --- a/spyder/plugins/editor/widgets/editorstack/tests/test_save.py +++ b/spyder/plugins/editor/widgets/editorstack/tests/test_save.py @@ -51,7 +51,6 @@ def add_files(editorstack): # ---- Qt Test Fixtures @pytest.fixture def base_editor_bot(qtbot): - EditorStack.CONF_SECTION = "Editor" editor_stack = EditorStack(None, [], False) editor_stack.set_find_widget(Mock()) editor_stack.set_io_actions(Mock(), Mock(), Mock(), Mock()) @@ -78,7 +77,6 @@ def editor_bot(base_editor_bot, request): @pytest.fixture def editor_splitter_bot(qtbot): """Create editor splitter.""" - EditorSplitter.CONF_SECTION = "Editor" main_widget = Mock(wraps=EditorMainWidgetExample()) es = EditorSplitter(None, main_widget, [], first=True) qtbot.addWidget(es) diff --git a/spyder/plugins/editor/widgets/editorstack/tests/test_shortcuts.py b/spyder/plugins/editor/widgets/editorstack/tests/test_shortcuts.py index cfa5ad6d125..7e91a4b6225 100644 --- a/spyder/plugins/editor/widgets/editorstack/tests/test_shortcuts.py +++ b/spyder/plugins/editor/widgets/editorstack/tests/test_shortcuts.py @@ -32,7 +32,6 @@ def editorstack(qtbot): Set up EditorStack with CodeEditors containing some Python code. The cursor is at the empty line below the code. """ - EditorStack.CONF_SECTION = "Editor" editorstack = EditorStack(None, [], False) editorstack.set_find_widget(Mock()) editorstack.set_io_actions(Mock(), Mock(), Mock(), Mock()) diff --git a/spyder/plugins/editor/widgets/tests/test_editorsplitter.py b/spyder/plugins/editor/widgets/tests/test_editorsplitter.py index 545c914249a..82e33d35a96 100644 --- a/spyder/plugins/editor/widgets/tests/test_editorsplitter.py +++ b/spyder/plugins/editor/widgets/tests/test_editorsplitter.py @@ -29,7 +29,6 @@ # ---- Qt Test Fixtures def editor_stack(): - EditorStack.CONF_SECTION = "Editor" editor_stack = EditorStack(None, [], False) editor_stack.set_find_widget(Mock()) editor_stack.set_io_actions(Mock(), Mock(), Mock(), Mock()) @@ -39,7 +38,6 @@ def editor_stack(): @pytest.fixture def editor_splitter_bot(qtbot): """Create editor splitter.""" - EditorSplitter.CONF_SECTION = "Editor" main_widget = Mock(wraps=EditorMainWidgetExample()) es = EditorSplitter(None, main_widget, [], first=True) qtbot.addWidget(es) @@ -83,7 +81,6 @@ def clone(editorstack, template=None): editorstack.new('test.py', 'utf-8', text) mock_main_widget = Mock(wraps=EditorMainWidgetExample()) - EditorSplitter.CONF_SECTION = "Editor" editorsplitter = EditorSplitter( None, mock_main_widget, From a34a26c7e728edb7656596efcb12df75964086c7 Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Wed, 20 Nov 2024 11:15:16 -0500 Subject: [PATCH 09/17] Testing: Fix tests that use keyboard shortcuts --- spyder/app/tests/test_mainwindow.py | 4 ++++ .../plugins/editor/widgets/codeeditor/tests/conftest.py | 8 ++++++++ .../editor/widgets/editorstack/tests/test_shortcuts.py | 6 +++++- 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/spyder/app/tests/test_mainwindow.py b/spyder/app/tests/test_mainwindow.py index b79f2fbc30a..5756dcf580b 100644 --- a/spyder/app/tests/test_mainwindow.py +++ b/spyder/app/tests/test_mainwindow.py @@ -5374,6 +5374,10 @@ def test_copy_paste(main_window, qtbot, tmpdir): code_editor = main_window.editor.get_focus_widget() code_editor.set_text(code) + # Register codeeditor shortcuts + CONF.notify_section_all_observers("shortcuts") + qtbot.wait(300) + # Test copy cursor = code_editor.textCursor() cursor.setPosition(69) diff --git a/spyder/plugins/editor/widgets/codeeditor/tests/conftest.py b/spyder/plugins/editor/widgets/codeeditor/tests/conftest.py index 17bb0fdc760..4879310d406 100644 --- a/spyder/plugins/editor/widgets/codeeditor/tests/conftest.py +++ b/spyder/plugins/editor/widgets/codeeditor/tests/conftest.py @@ -149,6 +149,10 @@ def mock_completions_codeeditor(qtbot_module, request): qtbot_module.addWidget(editor) editor.show() + # Register shortcuts for CodeEditor + CONF.notify_section_all_observers("shortcuts") + qtbot_module.wait(300) + mock_response = Mock() def perform_request(lang, method, params): @@ -182,6 +186,10 @@ def completions_codeeditor(completion_plugin_all_started, qtbot_module, completion_plugin, capabilities = completion_plugin_all_started completion_plugin.wait_for_ms = 2000 + # Register shortcuts for CodeEditor + CONF.notify_section_all_observers("shortcuts") + qtbot_module.wait(300) + CONF.set('completions', 'enable_code_snippets', False) completion_plugin.after_configuration_update([]) CONF.notify_section_all_observers('completions') diff --git a/spyder/plugins/editor/widgets/editorstack/tests/test_shortcuts.py b/spyder/plugins/editor/widgets/editorstack/tests/test_shortcuts.py index 7e91a4b6225..7a704ccf15e 100644 --- a/spyder/plugins/editor/widgets/editorstack/tests/test_shortcuts.py +++ b/spyder/plugins/editor/widgets/editorstack/tests/test_shortcuts.py @@ -20,9 +20,9 @@ # Local imports from spyder.config.base import running_in_ci +from spyder.config.manager import CONF from spyder.plugins.editor.widgets.gotoline import GoToLineDialog from spyder.plugins.editor.widgets.editorstack import EditorStack -from spyder.config.manager import CONF # ---- Qt Test Fixtures @@ -42,6 +42,10 @@ def editorstack(qtbot): editorstack.show() editorstack.go_to_line(1) + # Register shortcuts + CONF.notify_section_all_observers("shortcuts") + qtbot.wait(300) + return editorstack From bba018e5b1e4ab421b662a811c483c480de1679f Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Thu, 21 Nov 2024 12:12:12 -0500 Subject: [PATCH 10/17] API: Add plugin_name kwarg to register_shortcut_for_widget This is to have feature parity of kwargs with the other methods of SpyderShortcutsMixin. --- changelogs/Spyder-6.md | 2 ++ spyder/api/shortcuts.py | 21 +++++++++++++++++---- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/changelogs/Spyder-6.md b/changelogs/Spyder-6.md index 14a985d4a01..7d01999d7b8 100644 --- a/changelogs/Spyder-6.md +++ b/changelogs/Spyder-6.md @@ -4,6 +4,8 @@ ### API changes +* Add `plugin_name` kwarg to the `register_shortcut_for_widget` method of + `SpyderShortcutsMixin`. * The `add_configuration_observer` method was added to `SpyderConfigurationObserver`. * Add `items_elide_mode` kwarg to the constructors of `SpyderComboBox` and `SpyderComboBoxWithIcons`. diff --git a/spyder/api/shortcuts.py b/spyder/api/shortcuts.py index 0acebc965ed..44643209958 100644 --- a/spyder/api/shortcuts.py +++ b/spyder/api/shortcuts.py @@ -109,6 +109,7 @@ def register_shortcut_for_widget( triggered: Callable, widget: Optional[QWidget] = None, context: Optional[str] = None, + plugin_name: Optional[str] = None, ): """ Register a shortcut for a widget that inherits this mixin. @@ -127,6 +128,10 @@ def register_shortcut_for_widget( Name of the shortcut context, e.g. "editor" for shortcuts that have effect when the Editor is focused or "_" for global shortcuts. If not set, the widget's CONF_SECTION will be used as context. + plugin_name: str, optional + Name of the plugin where the shortcut is defined. This is necessary + for third-party plugins that have shortcuts with a context + different from the plugin name. """ context = self.CONF_SECTION if context is None else context widget = self if widget is None else widget @@ -139,6 +144,7 @@ def register_shortcut_for_widget( triggered=triggered, context=context, widget=widget, + plugin_name=plugin_name, ) self.add_configuration_observer( @@ -147,7 +153,9 @@ def register_shortcut_for_widget( # Keep track of all widget shortcuts. This is necessary to show them in # Preferences. - data = ShortcutData(qobject=None, name=name, context=context) + data = ShortcutData( + qobject=None, name=name, context=context, plugin_name=plugin_name + ) if data not in SHORTCUTS_FOR_WIDGETS_DATA: SHORTCUTS_FOR_WIDGETS_DATA.append(data) @@ -158,6 +166,7 @@ def _register_shortcut( triggered: Callable, context: str, widget: QWidget, + plugin_name: Optional[str] ): """ Auxiliary function to register a shortcut for a widget. @@ -177,13 +186,17 @@ def _register_shortcut( context: str, optional Name of the shortcut context, e.g. "editor" for shortcuts that have effect when the Editor is focused or "_" for global shortcuts. + plugin_name: str, optional + Name of the plugin where the shortcut is defined. This is necessary + for third-party plugins that have shortcuts with a context + different from the plugin name. """ # Disable current shortcut, if available - current_shortcut = self._shortcuts.get((context, name)) + current_shortcut = self._shortcuts.get((context, name, plugin_name)) if current_shortcut: current_shortcut.setEnabled(False) current_shortcut.deleteLater() - self._shortcuts.pop((context, name)) + self._shortcuts.pop((context, name, plugin_name)) # Create a new shortcut new_shortcut = QShortcut(QKeySequence(keystr), widget) @@ -191,4 +204,4 @@ def _register_shortcut( new_shortcut.setContext(Qt.WidgetWithChildrenShortcut) # Save shortcut - self._shortcuts[(context, name)] = new_shortcut + self._shortcuts[(context, name, plugin_name)] = new_shortcut From b8d84e8de4117b697966dc9933ce8e9de06757a6 Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Sun, 24 Nov 2024 21:17:20 -0500 Subject: [PATCH 11/17] Config: Update widget shortcuts on the fly after resetting all shortcuts Also, add inline typing for several attributes of ConfigurationManager and remove associated comments. --- spyder/config/manager.py | 51 +++++++++++++++++++++++++++++++--------- 1 file changed, 40 insertions(+), 11 deletions(-) diff --git a/spyder/config/manager.py b/spyder/config/manager.py index 6c26acfcdaa..bea4bb7b169 100644 --- a/spyder/config/manager.py +++ b/spyder/config/manager.py @@ -25,6 +25,7 @@ from spyder.config.main import CONF_VERSION, DEFAULTS, NAME_MAP from spyder.config.types import ConfigurationKey, ConfigurationObserver from spyder.config.user import UserConfig, MultiUserConfig, NoDefault, cp +from spyder.plugins.shortcuts.utils import SHORTCUTS_FOR_WIDGETS_DATA from spyder.utils.programs import check_version @@ -100,24 +101,25 @@ def __init__(self, parent=None, active_project_callback=None, # This dict maps from a configuration key (str/tuple) to a set # of objects that should be notified on changes to the corresponding # subscription key per section. The observer objects must be hashable. - # - # type: Dict[ConfigurationKey, Dict[str, Set[ConfigurationObserver]]] - self._observers = {} + self._observers: Dict[ + ConfigurationKey, Dict[str, Set[ConfigurationObserver]] + ] = {} # Set of suscription keys per observer object # This dict maps from a observer object to the set of configuration # keys that the object is subscribed to per section. - # - # type: Dict[ConfigurationObserver, Dict[str, Set[ConfigurationKey]]] - self._observer_map_keys = weakref.WeakKeyDictionary() + self._observer_map_keys: Dict[ + ConfigurationObserver, Dict[str, Set[ConfigurationKey]] + ] = weakref.WeakKeyDictionary() # List of options with disabled notifications. # This holds a list of (section, option) options that won't be notified # to observers. It can be used to temporarily disable notifications for # some options. - # - # type: List[Tuple(str, ConfigurationKey)] - self._disabled_options = [] + self._disabled_options: List[Tuple(str, ConfigurationKey)] = [] + + # Mapping for shortcuts that need to be notified + self._shortcuts_to_notify: Dict[(str, str), Optional[str]] = {} # Setup self.remove_deprecated_config_locations() @@ -361,8 +363,11 @@ def notify_observers( if option == '__section': self._notify_section(section) else: - value = self.get(section, option, secure=secure) - self._notify_option(section, option, value) + if section == "shortcuts": + self._notify_shortcut(option) + else: + value = self.get(section, option, secure=secure) + self._notify_option(section, option, value) def _notify_option(self, section: str, option: ConfigurationKey, value: Any): @@ -392,6 +397,27 @@ def _notify_section(self, section: str): section_values = dict(self.items(section) or []) self._notify_option(section, '__section', section_values) + def _notify_shortcut(self, option: str): + # We need this mapping for two reasons: + # 1. We don't need to notify changes for all shortcuts, only for + # widget shortcuts, which are the ones with associated observers + # (see SpyderShortcutsMixin.register_shortcut_for_widget). + # 2. Besides context and name, we need the plugin_name to correctly get + # the shortcut value to notify. That's not saved in our config + # system, but it is in SHORTCUTS_FOR_WIDGETS_DATA. + if not self._shortcuts_to_notify: + # Populate mapping only once + self._shortcuts_to_notify = { + (data.context, data.name): data.plugin_name + for data in SHORTCUTS_FOR_WIDGETS_DATA + } + + context, name = option.split("/") + if (context, name) in self._shortcuts_to_notify: + plugin_name = self._shortcuts_to_notify[(context, name)] + value = self.get_shortcut(context, name, plugin_name) + self._notify_option("shortcuts", option, value) + def notify_section_all_observers(self, section: str): """Notify all the observers subscribed to any option of a section.""" option_observers = self._observers[section] @@ -734,6 +760,9 @@ def reset_shortcuts(self): # TODO: check if the section exists? plugin_config.reset_to_defaults(section='shortcuts') + # This necessary to notify the observers of widget shortcuts + self.notify_section_all_observers(section="shortcuts") + try: CONF = ConfigurationManager() From b96896bac6b0462238006226070095e8ba7d9191 Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Mon, 25 Nov 2024 10:10:18 -0500 Subject: [PATCH 12/17] Shortcuts: Fix saving shortcuts that use plugin_name from Preferences --- spyder/plugins/shortcuts/widgets/table.py | 33 ++++++++++++----------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/spyder/plugins/shortcuts/widgets/table.py b/spyder/plugins/shortcuts/widgets/table.py index 24013837cf1..7309bc3d564 100644 --- a/spyder/plugins/shortcuts/widgets/table.py +++ b/spyder/plugins/shortcuts/widgets/table.py @@ -472,20 +472,21 @@ class Shortcut(SpyderShortcutsMixin): original ordering index, key sequence for the shortcut and localized text. """ - def __init__(self, context, name, key=None): + def __init__(self, context, name, key=None, plugin_name=None): self.index = 0 # Sorted index. Populated when loading shortcuts self.context = context self.name = name self.key = key + self.plugin_name = plugin_name def __str__(self): return "{0}/{1}: {2}".format(self.context, self.name, self.key) def load(self): - self.key = self.get_shortcut(self.name, self.context) + self.key = self.get_shortcut(self.name, self.context, self.plugin_name) def save(self): - self.set_shortcut(self.key, self.name, self.context) + self.set_shortcut(self.key, self.name, self.context, self.plugin_name) CONTEXT, NAME, SEQUENCE, SEARCH_SCORE = [0, 1, 2, 3] @@ -705,25 +706,27 @@ def load_shortcuts(self): """Load shortcuts and assign to table model.""" # Data might be capitalized so we use lower() below. # See: spyder-ide/spyder/#12415 - shortcut_data = set( - [ - (data.context.lower(), data.name.lower()) - for data in self.shortcut_data - ] - ) - shortcut_data = list(sorted(set(shortcut_data))) - shortcuts = [] + shortcut_data = { + (data.context.lower(), data.name.lower()): ( + data.plugin_name + if data.plugin_name is not None + else data.plugin_name + ) + for data in self.shortcut_data + } + shortcuts = [] for context, name, keystr in CONF.iter_shortcuts(): if (context, name) in shortcut_data: context = context.lower() name = name.lower() - # Only add to table actions that are registered from the main - # window - shortcut = Shortcut(context, name, keystr) + plugin_name = shortcut_data[(context, name)] + shortcut = Shortcut(context, name, keystr, plugin_name) shortcuts.append(shortcut) - shortcuts = sorted(shortcuts, key=lambda item: item.context+item.name) + shortcuts = sorted( + shortcuts, key=lambda item: item.context + item.name + ) # Store the original order of shortcuts for i, shortcut in enumerate(shortcuts): From 77a5024815e810b8d95ed28d865b5954043a232e Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Mon, 25 Nov 2024 11:17:22 -0500 Subject: [PATCH 13/17] Switcher: Fix shortcut context of its actions to be global Also, remove some extra valid contexts that are not necessary anymore. --- spyder/config/main.py | 6 +++--- spyder/config/manager.py | 8 +------- spyder/plugins/switcher/container.py | 6 ++++-- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/spyder/config/main.py b/spyder/config/main.py index b018c174a4f..ec6f7fe8df0 100644 --- a/spyder/config/main.py +++ b/spyder/config/main.py @@ -522,8 +522,8 @@ # -- Profiler -- 'profiler/run file in profiler': "F10", # -- Switcher -- - 'switcher/file switcher': 'Ctrl+P', - 'switcher/symbol finder': 'Ctrl+Alt+P', + '_/file switcher': 'Ctrl+P', + '_/symbol finder': 'Ctrl+Alt+P', # -- IPython console -- 'ipython_console/new tab': "Ctrl+T", 'ipython_console/reset namespace': "Ctrl+Alt+R", @@ -676,4 +676,4 @@ # or if you want to *rename* options, then you need to do a MAJOR update in # version, e.g. from 3.0.0 to 4.0.0 # 3. You don't need to touch this value if you're just adding a new option -CONF_VERSION = '84.3.0' +CONF_VERSION = '85.0.0' diff --git a/spyder/config/manager.py b/spyder/config/manager.py index bea4bb7b169..abbd4de1bfe 100644 --- a/spyder/config/manager.py +++ b/spyder/config/manager.py @@ -31,13 +31,7 @@ logger = logging.getLogger(__name__) -EXTRA_VALID_SHORTCUT_CONTEXTS = [ - '_', - 'array_builder', - 'console', - 'find_replace', - 'switcher' -] +EXTRA_VALID_SHORTCUT_CONTEXTS = ['_', 'find_replace'] class ConfigurationManager(object): diff --git a/spyder/plugins/switcher/container.py b/spyder/plugins/switcher/container.py index 6c323db0a5e..71d3552f355 100644 --- a/spyder/plugins/switcher/container.py +++ b/spyder/plugins/switcher/container.py @@ -35,7 +35,8 @@ def setup(self): tip=_('Fast switch between files'), triggered=self.open_switcher, register_shortcut=True, - context=Qt.ApplicationShortcut + context=Qt.ApplicationShortcut, + shortcut_context="_", ) self.create_action( @@ -45,7 +46,8 @@ def setup(self): tip=_('Fast symbol search in file'), triggered=self.open_symbolfinder, register_shortcut=True, - context=Qt.ApplicationShortcut + context=Qt.ApplicationShortcut, + shortcut_context="_", ) def update_actions(self): From a2b03a9fcf3e0197dde052f3c295c3a8196fadb8 Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Mon, 25 Nov 2024 12:58:54 -0500 Subject: [PATCH 14/17] API: Fix several block comments so they're displayed in Spyder's Outline [ci skip] --- spyder/api/plugins/new_api.py | 44 +++++++++++++++++------------------ 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/spyder/api/plugins/new_api.py b/spyder/api/plugins/new_api.py index f35916a5a50..47d19a53828 100644 --- a/spyder/api/plugins/new_api.py +++ b/spyder/api/plugins/new_api.py @@ -298,8 +298,8 @@ class SpyderPluginV2(QObject, SpyderActionMixin, SpyderConfigurationObserver, The window state. """ - # --- Private attributes ------------------------------------------------- - # ------------------------------------------------------------------------ + # ---- Private attributes + # ------------------------------------------------------------------------- # Define configuration name map for plugin to split configuration # among several files. See spyder/config/main.py _CONF_NAME_MAP = None @@ -361,8 +361,8 @@ def __init__(self, parent, configuration=None): plugin_path = osp.join(self.get_path(), self.IMG_PATH) IMAGE_PATH_MANAGER.add_image_path(plugin_path) - # --- Private methods ---------------------------------------------------- - # ------------------------------------------------------------------------ + # ---- Private methods + # ------------------------------------------------------------------------- def _register(self, omit_conf=False): """ Setup and register plugin in Spyder's main window and connect it to @@ -397,8 +397,8 @@ def _unregister(self): self.is_compatible = None self.is_registered = False - # --- API: available methods --------------------------------------------- - # ------------------------------------------------------------------------ + # ---- API: available methods + # ------------------------------------------------------------------------- def get_path(self): """ Return the plugin's system path. @@ -765,8 +765,8 @@ def get_command_line_options(self): sys_argv = [sys.argv[0]] # Avoid options passed to pytest return get_options(sys_argv)[0] - # --- API: Mandatory methods to define ----------------------------------- - # ------------------------------------------------------------------------ + # ---- API: Mandatory methods to define + # ------------------------------------------------------------------------- @staticmethod def get_name(): """ @@ -832,8 +832,8 @@ def on_initialize(self): f'The plugin {type(self)} is missing an implementation of ' 'on_initialize') - # --- API: Optional methods to override ---------------------------------- - # ------------------------------------------------------------------------ + # ---- API: Optional methods to override + # ------------------------------------------------------------------------- @staticmethod def check_compatibility(): """ @@ -952,14 +952,14 @@ class SpyderDockablePlugin(SpyderPluginV2): """ A Spyder plugin to enhance functionality with a dockable widget. """ - # --- API: Mandatory attributes ------------------------------------------ - # ------------------------------------------------------------------------ + # ---- API: Mandatory attributes + # ------------------------------------------------------------------------- # This is the main widget of the dockable plugin. # It needs to be a subclass of PluginMainWidget. WIDGET_CLASS = None - # --- API: Optional attributes ------------------------------------------- - # ------------------------------------------------------------------------ + # ---- API: Optional attributes + # ------------------------------------------------------------------------- # Define a list of plugins next to which we want to to tabify this plugin. # Example: ['Plugins.Editor'] TABIFY = [] @@ -972,8 +972,8 @@ class SpyderDockablePlugin(SpyderPluginV2): # the action to switch is called a second time. RAISE_AND_FOCUS = False - # --- API: Available signals --------------------------------------------- - # ------------------------------------------------------------------------ + # ---- API: Available signals + # ------------------------------------------------------------------------- sig_focus_changed = Signal() """ This signal is emitted to inform the focus of this plugin has changed. @@ -1010,8 +1010,8 @@ class SpyderDockablePlugin(SpyderPluginV2): needs its ancestor to be updated. """ - # --- Private methods ---------------------------------------------------- - # ------------------------------------------------------------------------ + # ---- Private methods + # ------------------------------------------------------------------------- def __init__(self, parent, configuration): if not issubclass(self.WIDGET_CLASS, PluginMainWidget): raise SpyderAPIError( @@ -1053,8 +1053,8 @@ def __init__(self, parent, configuration): widget.sig_update_ancestor_requested.connect( self.sig_update_ancestor_requested) - # --- API: available methods --------------------------------------------- - # ------------------------------------------------------------------------ + # ---- API: available methods + # ------------------------------------------------------------------------- def before_long_process(self, message): """ Show a message in main window's status bar, change the mouse pointer @@ -1116,8 +1116,8 @@ def set_ancestor(self, ancestor_widget): """ self.get_widget().set_ancestor(ancestor_widget) - # --- Convenience methods from the widget exposed on the plugin - # ------------------------------------------------------------------------ + # ---- Convenience methods from the widget exposed on the plugin + # ------------------------------------------------------------------------- @property def dockwidget(self): return self.get_widget().dockwidget From e7f07cd64aa4139e9d5b98bd78a623cf290c8c17 Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Tue, 26 Nov 2024 15:33:13 -0500 Subject: [PATCH 15/17] Testing: Check that resetting shortcuts restore widget ones on the fly --- .../codeeditor/tests/test_codeeditor.py | 34 +++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/spyder/plugins/editor/widgets/codeeditor/tests/test_codeeditor.py b/spyder/plugins/editor/widgets/codeeditor/tests/test_codeeditor.py index 76a26ab9e9b..03581054061 100644 --- a/spyder/plugins/editor/widgets/codeeditor/tests/test_codeeditor.py +++ b/spyder/plugins/editor/widgets/codeeditor/tests/test_codeeditor.py @@ -7,16 +7,19 @@ # Standard library imports import os.path as osp import sys +from unittest.mock import MagicMock # Third party imports from qtpy import QT_VERSION from qtpy.QtCore import Qt, QEvent, QPointF from qtpy.QtGui import QTextCursor, QMouseEvent -from qtpy.QtWidgets import QApplication, QTextEdit +from qtpy.QtWidgets import QApplication, QMainWindow, QTextEdit import pytest # Local imports from spyder.config.base import running_in_ci +from spyder.plugins.preferences.tests.conftest import config_dialog +from spyder.plugins.shortcuts.plugin import Shortcuts from spyder.widgets.mixins import TIP_PARAMETER_HIGHLIGHT_COLOR @@ -24,6 +27,14 @@ ASSETS = osp.join(HERE, 'assets') +class MainWindow(QMainWindow): + + _cli_options = MagicMock() + + def get_plugin(self, name, error=True): + return MagicMock() + + def test_editor_upper_to_lower(codeeditor): widget = codeeditor text = 'UPPERCASE' @@ -694,11 +705,17 @@ def test_cell_highlight(codeeditor, qtbot): assert editor.current_cell[0].selectionEnd() == 8 +@pytest.mark.parametrize( + 'config_dialog', + # [[MainWindowMock, [ConfigPlugins], [Plugins]]] + [[MainWindow, [], [Shortcuts]]], + indirect=True +) @pytest.mark.skipif( sys.platform.startswith("linux") and running_in_ci(), reason="Fails on Linux and CI" ) -def test_shortcut_for_widget_is_updated(codeeditor, qtbot): +def test_shortcut_for_widget_is_updated(config_dialog, codeeditor, qtbot): """Test shortcuts for codeeditor are updated on the fly.""" editor = codeeditor text = ('aa\nbb\ncc\ndd\n') @@ -723,6 +740,19 @@ def test_shortcut_for_widget_is_updated(codeeditor, qtbot): qtbot.keyClick(editor, Qt.Key_Down, modifier=Qt.AltModifier) assert editor.toPlainText() == "bb\ncc\naa\ndd\n" + # Reset all shortcuts to defaults (as users would do it) + configpage = config_dialog.get_page() + configpage.reset_to_default(force=True) + qtbot.wait(300) + + # Check default shortcut works + qtbot.keyClick(editor, Qt.Key_Down, modifier=Qt.AltModifier) + assert editor.toPlainText() == "bb\ncc\ndd\naa\n" + + # Check new shortcut doesn't work + qtbot.keyClick(editor, Qt.Key_B, modifier=Qt.ControlModifier) + assert editor.toPlainText() == "bb\ncc\ndd\naa\n" + if __name__ == '__main__': pytest.main(['test_codeeditor.py']) From 56bd1cc9fffd28e91dac8672941f3eed265cb975 Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Thu, 28 Nov 2024 20:51:46 -0500 Subject: [PATCH 16/17] Shortcuts: Fix getting/saving shortcuts with capitalized names --- spyder/api/shortcuts.py | 6 ++++++ spyder/config/user.py | 16 +++++++++++++--- spyder/plugins/shortcuts/plugin.py | 12 ++++++++++++ 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/spyder/api/shortcuts.py b/spyder/api/shortcuts.py index 44643209958..fa0005aa80a 100644 --- a/spyder/api/shortcuts.py +++ b/spyder/api/shortcuts.py @@ -136,6 +136,12 @@ def register_shortcut_for_widget( context = self.CONF_SECTION if context is None else context widget = self if widget is None else widget + # Name and context are saved in lowercase in our config system, so we + # need to use them like that here. + # Note: That's how the Python ConfigParser class saves options. + name = name.lower() + context = context.lower() + # Add observer to register shortcut when its associated option is # broadcasted by CONF or updated in Preferences. config_observer = functools.partial( diff --git a/spyder/config/user.py b/spyder/config/user.py index 9b4b38ae198..b6a54cf44e6 100644 --- a/spyder/config/user.py +++ b/spyder/config/user.py @@ -266,13 +266,23 @@ def _check_defaults(self, defaults): else: raise ValueError('`defaults` must be a dict or a list of tuples!') - # This attribute is overriding a method from cp.ConfigParser - self.defaults = defaults + # We need to transform default options to lowercase because + # ConfigParser saves options like that (see its optionxform method). + # Otherwise, resetting to defaults fails when option names are + # capitalized. + defaults_with_lowercase_options = [] + for sec, options in defaults: + defaults_with_lowercase_options.append( + (sec, {k.lower(): v for k, v in options.items()}) + ) + + # This attribute is overriding a method from ConfigParser + self.defaults = defaults_with_lowercase_options if defaults is not None: self.reset_to_defaults(save=False) - return defaults + return self.defaults @classmethod def _check_section_option(cls, section, option): diff --git a/spyder/plugins/shortcuts/plugin.py b/spyder/plugins/shortcuts/plugin.py index f121e2dcd07..f21f6cb952e 100644 --- a/spyder/plugins/shortcuts/plugin.py +++ b/spyder/plugins/shortcuts/plugin.py @@ -149,6 +149,12 @@ def register_shortcut(self, qaction_or_qshortcut, context, name, Register QAction or QShortcut to Spyder main application, with shortcut (context, name, default) """ + # Name and context are saved in lowercase in our config system, so we + # need to use them like that here. + # Note: That's how the Python ConfigParser class saves options. + name = name.lower() + context = context.lower() + self._shortcut_data.append( ShortcutData( qobject=qaction_or_qshortcut, @@ -164,6 +170,12 @@ def unregister_shortcut(self, qaction_or_qshortcut, context, name, """ Unregister QAction or QShortcut from Spyder main application. """ + # Name and context are saved in lowercase in our config system, so we + # need to use them like that here. + # Note: That's how the Python ConfigParser class saves options. + name = name.lower() + context = context.lower() + data = ShortcutData( qobject=qaction_or_qshortcut, name=name, From b0d9c04a83ebea6d565b60926e6c0561875ec864 Mon Sep 17 00:00:00 2001 From: Carlos Cordoba Date: Thu, 28 Nov 2024 21:00:00 -0500 Subject: [PATCH 17/17] Testing: Check registering widget shortcuts for external plugins --- spyder/app/tests/spyder-boilerplate/setup.py | 2 +- .../spyder_boilerplate/spyder/plugin.py | 42 +++++++-- spyder/app/tests/test_mainwindow.py | 86 +++++++++++++++++++ 3 files changed, 124 insertions(+), 6 deletions(-) diff --git a/spyder/app/tests/spyder-boilerplate/setup.py b/spyder/app/tests/spyder-boilerplate/setup.py index 8e420f28359..8d9c1ce9639 100644 --- a/spyder/app/tests/spyder-boilerplate/setup.py +++ b/spyder/app/tests/spyder-boilerplate/setup.py @@ -23,7 +23,7 @@ install_requires=[ "qtpy", "qtawesome", - "spyder>=5.1.1", + "spyder>=6", ], packages=find_packages(), entry_points={ diff --git a/spyder/app/tests/spyder-boilerplate/spyder_boilerplate/spyder/plugin.py b/spyder/app/tests/spyder-boilerplate/spyder_boilerplate/spyder/plugin.py index a08de26c308..27b2c21dc04 100644 --- a/spyder/app/tests/spyder-boilerplate/spyder_boilerplate/spyder/plugin.py +++ b/spyder/app/tests/spyder-boilerplate/spyder_boilerplate/spyder/plugin.py @@ -10,7 +10,7 @@ # Third party imports import qtawesome as qta -from qtpy.QtWidgets import QHBoxLayout, QLabel +from qtpy.QtWidgets import QHBoxLayout, QTextEdit # Spyder imports from spyder.api.config.decorators import on_conf_change @@ -50,12 +50,13 @@ class SpyderBoilerplateWidget(PluginMainWidget): def __init__(self, name=None, plugin=None, parent=None): super().__init__(name, plugin, parent) - # Create an example label - self._example_label = QLabel("Example Label") + # Create example widgets + self._example_widget = QTextEdit(self) + self._example_widget.setText("Example text") # Add example label to layout layout = QHBoxLayout() - layout.addWidget(self._example_label) + layout.addWidget(self._example_widget) self.setLayout(layout) # --- PluginMainWidget API @@ -72,7 +73,7 @@ def setup(self): name=SpyderBoilerplateActions.ExampleAction, text="Example action", tip="Example hover hint", - icon=self.create_icon("spyder"), + icon=self.create_icon("python"), triggered=lambda: print("Example action triggered!"), ) @@ -92,6 +93,19 @@ def setup(self): SpyderBoilerplateOptionsMenuSections.ExampleSection, ) + # Shortcuts + self.register_shortcut_for_widget( + "Change text", + self.change_text, + ) + + self.register_shortcut_for_widget( + "new text", + self.new_text, + context="editor", + plugin_name=self._plugin.NAME, + ) + def update_actions(self): pass @@ -101,6 +115,15 @@ def on_section_conf_change(self, section): # --- Public API # ------------------------------------------------------------------------ + def change_text(self): + if self._example_widget.toPlainText() == "": + self._example_widget.setText("Example text") + else: + self._example_widget.setText("") + + def new_text(self): + if self._example_widget.toPlainText() != "Another text": + self._example_widget.setText("Another text") class SpyderBoilerplate(SpyderDockablePlugin): @@ -115,6 +138,15 @@ class SpyderBoilerplate(SpyderDockablePlugin): CONF_SECTION = NAME CONF_WIDGET_CLASS = SpyderBoilerplateConfigPage CUSTOM_LAYOUTS = [VerticalSplitLayout2] + CONF_DEFAULTS = [ + (CONF_SECTION, {}), + ( + "shortcuts", + # Note: These shortcut names are capitalized to check we can + # set/get/reset them correctly. + {f"{NAME}/Change text": "Ctrl+B", "editor/New text": "Ctrl+H"}, + ), + ] # --- Signals diff --git a/spyder/app/tests/test_mainwindow.py b/spyder/app/tests/test_mainwindow.py index 5756dcf580b..cb4b77f2f22 100644 --- a/spyder/app/tests/test_mainwindow.py +++ b/spyder/app/tests/test_mainwindow.py @@ -83,6 +83,7 @@ from spyder.plugins.run.api import ( RunExecutionParameters, ExtendedRunExecutionParameters, WorkingDirOpts, WorkingDirSource, RunContext) +from spyder.plugins.shortcuts.widgets.table import SEQUENCE from spyder.py3compat import qbytearray_to_str, to_text_string from spyder.utils.environ import set_user_env from spyder.utils.conda import get_list_conda_envs @@ -5434,6 +5435,91 @@ def test_add_external_plugins_to_dependencies(main_window, qtbot): assert 'spyder-boilerplate' in external_names +@pytest.mark.skipif( + sys.platform.startswith("linux") and running_in_ci(), + reason="Fails on Linux and CI" +) +@pytest.mark.skipif(not running_in_ci(), reason="Only works in CIs") +def test_shortcuts_in_external_plugins(main_window, qtbot): + """Test that keyboard shortcuts for widgets work in external plugins.""" + # Wait until the window is fully up + shell = main_window.ipyconsole.get_current_shellwidget() + qtbot.waitUntil( + lambda: shell.spyder_kernel_ready and shell._prompt_html is not None, + timeout=SHELL_TIMEOUT) + + # Show plugin + main_widget = main_window.get_plugin('spyder_boilerplate').get_widget() + main_widget.toggle_view_action.setChecked(True) + + # Give focus to text edit area + example_widget = main_widget._example_widget + example_widget.setFocus() + + # Check first shortcut is working + qtbot.keyClick(example_widget, Qt.Key_B, modifier=Qt.ControlModifier) + assert example_widget.toPlainText() == "" + qtbot.keyClick(example_widget, Qt.Key_B, modifier=Qt.ControlModifier) + assert example_widget.toPlainText() == "Example text" + + # Check second shortcut is working + qtbot.keyClick(example_widget, Qt.Key_H, modifier=Qt.ControlModifier) + assert example_widget.toPlainText() == "Another text" + qtbot.keyClick(example_widget, Qt.Key_H, modifier=Qt.ControlModifier) + assert example_widget.toPlainText() == "Another text" + + # Open Preferences and select shortcuts table + dlg, index, page = preferences_dialog_helper( + qtbot, main_window, 'shortcuts' + ) + table = page.table + + # Change shortcuts in table + new_shortcuts = [("change text", "Ctrl+J"), ("new text", "Alt+K")] + for name, sequence in new_shortcuts: + table.finder.setFocus() + table.finder.clear() + qtbot.keyClicks(table.finder, name) + index = table.proxy_model.mapToSource(table.currentIndex()) + row = index.row() + sequence_index = table.source_model.index(row, SEQUENCE) + table.source_model.setData(sequence_index, sequence) + + # Save new shortcuts + dlg.ok_btn.animateClick() + qtbot.wait(1000) + + # Check new shortcuts are working + example_widget.setFocus() + qtbot.keyClick(example_widget, Qt.Key_J, modifier=Qt.ControlModifier) + assert example_widget.toPlainText() == "" + qtbot.keyClick(example_widget, Qt.Key_J, modifier=Qt.ControlModifier) + assert example_widget.toPlainText() == "Example text" + + qtbot.keyClick(example_widget, Qt.Key_K, modifier=Qt.AltModifier) + assert example_widget.toPlainText() == "Another text" + + # Open Preferences again and reset shortcuts + dlg, index, page = preferences_dialog_helper( + qtbot, main_window, 'shortcuts' + ) + page.reset_to_default(force=True) + + # Close preferences + dlg.ok_btn.animateClick() + qtbot.wait(1000) + + # Check default shortcuts are working again + example_widget.setFocus() + qtbot.keyClick(example_widget, Qt.Key_B, modifier=Qt.ControlModifier) + assert example_widget.toPlainText() == "" + qtbot.keyClick(example_widget, Qt.Key_B, modifier=Qt.ControlModifier) + assert example_widget.toPlainText() == "Example text" + + qtbot.keyClick(example_widget, Qt.Key_H, modifier=Qt.ControlModifier) + assert example_widget.toPlainText() == "Another text" + + def test_locals_globals_var_debug(main_window, qtbot, tmpdir): """Test that the debugger can handle variables named globals and locals.""" ipyconsole = main_window.ipyconsole