diff --git a/binder/environment.yml b/binder/environment.yml index 3d92f93adda..c4668fea0b0 100644 --- a/binder/environment.yml +++ b/binder/environment.yml @@ -33,6 +33,7 @@ dependencies: - pyqtwebengine <5.16 - python-lsp-black >=1.2.0,<3.0.0 - python-lsp-server >=1.7.4,<1.8.0 +- pyuca >=1.2 - pyxdg >=0.26 - pyzmq >=22.1.0 - qdarkstyle >=3.0.2,<3.2.0 diff --git a/changelogs/Spyder-6.md b/changelogs/Spyder-6.md index 86c4cc13b72..c7604919e3b 100644 --- a/changelogs/Spyder-6.md +++ b/changelogs/Spyder-6.md @@ -1,6 +1,6 @@ # History of changes for Spyder 6 -## Version 6.0alpha1 (2023-06-19) +## Version 6.0.0 (unreleased) ### New features @@ -17,17 +17,35 @@ ### Important fixes +* Environment variables declared in `~/.bashrc` or `~/.zhrc` are detected and + passed to the IPython console. * Restore ability to load Hdf5 and Dicom files through the Variable Explorer (this was working in Spyder 4 and before). +### UX/UI improvements + +* The file switcher can open files present in the current project. +* The interface font used by the entire application can be configured in + `Preferences > Appearance` +* Files can be opened in the editor by pasting their path in the Working + Directory toolbar. + ### New API features -* Generalize Run plugin to support generic inputs and executors. This allows +* `SpyderPluginV2.get_description` must be a static method now and + `SpyderPluginV2.get_icon` a class or static method. This is necessary to + display the list of available plugins in Preferences in a more user-friendly + way (see PR spyder-ide/spyder#21101). +* Generalize the Run plugin to support generic inputs and executors. This allows plugins to declare what kind of inputs (i.e. file, cell or selection) they can execute and how they will display the result. -* Add a new plugin for the files and symbols switcher. +* Add a new plugin called `Switcher` for the files and symbols switcher. * Declare a proper API for the Projects plugin. +---- + +## Version 6.0alpha1 (2023-06-19) + ### Issues Closed * [Issue 20885](https://github.com/spyder-ide/spyder/issues/20885) - Error when opening files due to a corrupted config file ([PR 20886](https://github.com/spyder-ide/spyder/pull/20886) by [@dalthviz](https://github.com/dalthviz)) diff --git a/requirements/main.yml b/requirements/main.yml index 8e015098f7a..311f9aa56d9 100644 --- a/requirements/main.yml +++ b/requirements/main.yml @@ -31,6 +31,7 @@ dependencies: - pyqtwebengine <5.16 - python-lsp-black >=1.2.0,<3.0.0 - python-lsp-server >=1.7.4,<1.8.0 + - pyuca >=1.2 - pyzmq >=22.1.0 - qdarkstyle >=3.0.2,<3.2.0 - qstylizer >=0.2.2 diff --git a/setup.py b/setup.py index 61be0c3fec8..881dad503a6 100644 --- a/setup.py +++ b/setup.py @@ -224,11 +224,12 @@ def run(self): 'pygments>=2.0', 'pylint>=2.5.0,<3.0', 'pylint-venv>=3.0.2', - 'python-lsp-black>=1.2.0,<3.0.0', 'pyls-spyder>=0.4.0', 'pyqt5<5.16', 'pyqtwebengine<5.16', + 'python-lsp-black>=1.2.0,<3.0.0', 'python-lsp-server[all]>=1.7.4,<1.8.0', + 'pyuca>=1.2', 'pyxdg>=0.26;platform_system=="Linux"', 'pyzmq>=22.1.0', 'qdarkstyle>=3.0.2,<3.2.0', diff --git a/spyder/api/_version.py b/spyder/api/_version.py index d4c5fbfcf05..9e275cbcd63 100644 --- a/spyder/api/_version.py +++ b/spyder/api/_version.py @@ -22,5 +22,5 @@ updated. """ -VERSION_INFO = (0, 9, 1) +VERSION_INFO = (0, 10, 0) __version__ = '.'.join(map(str, VERSION_INFO)) diff --git a/spyder/api/plugin_registration/_confpage.py b/spyder/api/plugin_registration/_confpage.py index f5d1a2d977c..fc0e4253ea1 100644 --- a/spyder/api/plugin_registration/_confpage.py +++ b/spyder/api/plugin_registration/_confpage.py @@ -7,33 +7,33 @@ """Plugin registry configuration page.""" # Third party imports -from qtpy.QtWidgets import (QGroupBox, QVBoxLayout, QCheckBox, - QGridLayout, QLabel) +from pyuca import Collator +from qtpy.QtWidgets import QVBoxLayout, QLabel # Local imports -from spyder.api.plugins import SpyderPlugin from spyder.api.preferences import PluginConfigPage from spyder.config.base import _ -from spyder.config.manager import CONF +from spyder.widgets.elementstable import ElementsTable class PluginsConfigPage(PluginConfigPage): + def setup_page(self): newcb = self.create_checkbox self.plugins_checkboxes = {} header_label = QLabel( - _("Here you can turn on/off any internal or external Spyder plugin " - "to disable functionality that is not desired or to have a lighter " - "experience. Unchecked plugins in this page will be unloaded " - "immediately and will not be loaded the next time Spyder starts.")) + _("Disable a Spyder plugin (external or built-in) to prevent it " + "from loading until re-enabled here, to simplify the interface " + "or in case it causes problems.") + ) header_label.setWordWrap(True) - # ------------------ Internal plugin status group --------------------- - internal_layout = QGridLayout() - self.internal_plugins_group = QGroupBox(_("Internal plugins")) + # To save the plugin elements + internal_elements = [] + external_elements = [] - i = 0 + # ------------------ Internal plugins --------------------------------- for plugin_name in self.plugin.all_internal_plugins: (conf_section_name, PluginClass) = self.plugin.all_internal_plugins[plugin_name] @@ -42,30 +42,25 @@ def setup_page(self): # Do not list core plugins that can not be disabled continue - plugin_loc_name = None - if hasattr(PluginClass, 'get_name'): - plugin_loc_name = PluginClass.get_name() - elif hasattr(PluginClass, 'get_plugin_title'): - plugin_loc_name = PluginClass.get_plugin_title() + plugin_state = self.get_option( + 'enable', section=conf_section_name, default=True) + cb = newcb('', 'enable', default=True, section=conf_section_name, + restart=True) + + internal_elements.append( + dict( + title=PluginClass.get_name(), + description=PluginClass.get_description(), + icon=PluginClass.get_icon(), + widget=cb, + additional_info=_("Built-in") + ) + ) - plugin_state = CONF.get(conf_section_name, 'enable', True) - cb = newcb(plugin_loc_name, 'enable', default=True, - section=conf_section_name, restart=True) - internal_layout.addWidget(cb, i // 2, i % 2) self.plugins_checkboxes[plugin_name] = (cb, plugin_state) - i += 1 - - self.internal_plugins_group.setLayout(internal_layout) - # ------------------ External plugin status group --------------------- - external_layout = QGridLayout() - self.external_plugins_group = QGroupBox(_("External plugins")) - - i = 0 - # Temporal fix to avoid disabling external plugins. - # for more info see spyder#17464 - show_external_plugins_group = False - for i, plugin_name in enumerate(self.plugin.all_external_plugins): + # ------------------ External plugins --------------------------------- + for plugin_name in self.plugin.all_external_plugins: (conf_section_name, PluginClass) = self.plugin.all_external_plugins[plugin_name] @@ -73,27 +68,42 @@ def setup_page(self): # Do not list external plugins that can not be disabled continue - plugin_loc_name = None - if hasattr(PluginClass, 'get_name'): - plugin_loc_name = PluginClass.get_name() - elif hasattr(PluginClass, 'get_plugin_title'): - plugin_loc_name = PluginClass.get_plugin_title() + plugin_state = self.get_option( + f'{conf_section_name}/enable', + section=self.plugin._external_plugins_conf_section, + default=True + ) + cb = newcb('', f'{conf_section_name}/enable', default=True, + section=self.plugin._external_plugins_conf_section, + restart=True) + + external_elements.append( + dict( + title=PluginClass.get_name(), + description=PluginClass.get_description(), + icon=PluginClass.get_icon(), + widget=cb + ) + ) - cb = newcb(plugin_loc_name, 'enable', default=True, - section=conf_section_name, restart=True) - external_layout.addWidget(cb, i // 2, i % 2) - plugin_state = CONF.get(conf_section_name, 'enable', True) self.plugins_checkboxes[plugin_name] = (cb, plugin_state) - i += 1 - self.external_plugins_group.setLayout(external_layout) + # Sort elements by title for easy searching + collator = Collator() + internal_elements.sort(key=lambda e: collator.sort_key(e['title'])) + external_elements.sort(key=lambda e: collator.sort_key(e['title'])) + + # Build plugins table, showing external plugins first. + plugins_table = ElementsTable( + self, external_elements + internal_elements + ) + # Layout layout = QVBoxLayout() layout.addWidget(header_label) - layout.addWidget(self.internal_plugins_group) - if show_external_plugins_group: - layout.addWidget(self.external_plugins_group) - layout.addStretch(1) + layout.addSpacing(15) + layout.addWidget(plugins_table) + layout.addSpacing(15) self.setLayout(layout) def apply_settings(self): @@ -109,7 +119,7 @@ def apply_settings(self): elif plugin_name in self.plugin.all_external_plugins: (__, PluginClass) = self.plugin.all_external_plugins[plugin_name] - external = True + external = True # noqa # TODO: Once we can test that all plugins can be restarted # without problems during runtime, we can enable the diff --git a/spyder/api/plugin_registration/registry.py b/spyder/api/plugin_registration/registry.py index 8699b735951..ce1094ad612 100644 --- a/spyder/api/plugin_registration/registry.py +++ b/spyder/api/plugin_registration/registry.py @@ -8,7 +8,7 @@ # Standard library imports import logging -from typing import Dict, List, Union, Type, Any, Set, Optional, Tuple +from typing import Dict, List, Union, Type, Any, Set, Optional # Third-party library imports from qtpy.QtCore import QObject, Signal @@ -124,6 +124,9 @@ def __init__(self): # Dictionary that contains all the external plugins (enabled or not) self.all_external_plugins = {} # type: Dict[str, Tuple[str, Type[SpyderPluginClass]]] + # This is used to allow disabling external plugins through Preferences + self._external_plugins_conf_section = "external_plugins" + # ------------------------- PRIVATE API ----------------------------------- def _update_dependents(self, plugin: str, dependent_plugin: str, key: str): """Add `dependent_plugin` to the list of dependents of `plugin`.""" diff --git a/spyder/api/plugins/new_api.py b/spyder/api/plugins/new_api.py index 0183fed74ea..b62a41bce7a 100644 --- a/spyder/api/plugins/new_api.py +++ b/spyder/api/plugins/new_api.py @@ -750,11 +750,12 @@ def get_name(): Notes ----- - This is a method to be able to update localization without a restart. + This method needs to be decorated with `staticmethod`. """ raise NotImplementedError('A plugin name must be defined!') - def get_description(self): + @staticmethod + def get_description(): """ Return the plugin localized description. @@ -765,11 +766,12 @@ def get_description(self): Notes ----- - This is a method to be able to update localization without a restart. + This method needs to be decorated with `staticmethod`. """ raise NotImplementedError('A plugin description must be defined!') - def get_icon(self): + @classmethod + def get_icon(cls): """ Return the plugin associated icon. @@ -777,6 +779,10 @@ def get_icon(self): ------- QIcon QIcon instance + + Notes + ----- + This method needs to be decorated with `classmethod` or `staticmethod`. """ raise NotImplementedError('A plugin icon must be defined!') diff --git a/spyder/app/mainwindow.py b/spyder/app/mainwindow.py index 7c11ff8a2ac..dd103b6a511 100644 --- a/spyder/app/mainwindow.py +++ b/spyder/app/mainwindow.py @@ -717,30 +717,47 @@ def setup(self): all_plugins = external_plugins.copy() all_plugins.update(internal_plugins.copy()) - # Determine 'enable' config for the plugins that have it + # Determine 'enable' config for plugins that have it. enabled_plugins = {} registry_internal_plugins = {} registry_external_plugins = {} + for plugin in all_plugins.values(): plugin_name = plugin.NAME - # Disable panes that use web widgets (currently Help and Online + # Disable plugins that use web widgets (currently Help and Online # Help) if the user asks for it. # See spyder-ide/spyder#16518 if self._cli_options.no_web_widgets: if "help" in plugin_name: continue + plugin_main_attribute_name = ( self._INTERNAL_PLUGINS_MAPPING[plugin_name] if plugin_name in self._INTERNAL_PLUGINS_MAPPING else plugin_name) + if plugin_name in internal_plugins: registry_internal_plugins[plugin_name] = ( plugin_main_attribute_name, plugin) + enable_option = "enable" + enable_section = plugin_main_attribute_name else: registry_external_plugins[plugin_name] = ( plugin_main_attribute_name, plugin) + + # This is a workaround to allow disabling external plugins. + # Because of the way the current config implementation works, + # an external plugin config option (e.g. 'enable') can only be + # read after the plugin is loaded. But here we're trying to + # decide if the plugin should be loaded if it's enabled. So, + # for now we read (and save, see the config page associated to + # PLUGIN_REGISTRY) that option in our internal config options. + # See spyder-ide/spyder#17464 for more details. + enable_option = f"{plugin_main_attribute_name}/enable" + enable_section = PLUGIN_REGISTRY._external_plugins_conf_section + try: - if self.get_conf("enable", section=plugin_main_attribute_name): + if self.get_conf(enable_option, section=enable_section): enabled_plugins[plugin_name] = plugin PLUGIN_REGISTRY.set_plugin_enabled(plugin_name) except (cp.NoOptionError, cp.NoSectionError): 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 5212ed1848b..b1653a5299c 100644 --- a/spyder/app/tests/spyder-boilerplate/spyder_boilerplate/spyder/plugin.py +++ b/spyder/app/tests/spyder-boilerplate/spyder_boilerplate/spyder/plugin.py @@ -9,15 +9,16 @@ """ # Third party imports -from qtpy.QtGui import QIcon +import qtawesome as qta from qtpy.QtWidgets import QHBoxLayout, QLabel # Spyder imports from spyder.api.config.decorators import on_conf_change -from spyder.api.plugins import Plugins, SpyderDockablePlugin +from spyder.api.plugins import SpyderDockablePlugin from spyder.api.preferences import PluginConfigPage from spyder.api.widgets.main_widget import PluginMainWidget from spyder.plugins.layout.layouts import VerticalSplitLayout2 +from spyder.utils.palette import SpyderPalette class SpyderBoilerplateConfigPage(PluginConfigPage): @@ -60,7 +61,7 @@ def __init__(self, name=None, plugin=None, parent=None): # --- PluginMainWidget API # ------------------------------------------------------------------------ def get_title(self): - return "Spyder Boilerplate" + return "Spyder boilerplate plugin" def get_focus_widget(self): pass @@ -121,13 +122,15 @@ class SpyderBoilerplate(SpyderDockablePlugin): # ------------------------------------------------------------------------ @staticmethod def get_name(): - return "Spyder Boilerplate" + return "Spyder boilerplate plugin" - def get_description(self): - return "Boilerplate needed to create a Spyder Plugin." + @staticmethod + def get_description(): + return "A boilerplate plugin for testing." - def get_icon(self): - return QIcon() + @staticmethod + def get_icon(): + return qta.icon('mdi6.alpha-b-box', color=SpyderPalette.ICON_1) def on_initialize(self): pass diff --git a/spyder/dependencies.py b/spyder/dependencies.py index c156867d3e7..d0d413650ec 100644 --- a/spyder/dependencies.py +++ b/spyder/dependencies.py @@ -55,6 +55,7 @@ PYLSP_REQVER = '>=1.7.4,<1.8.0' PYLSP_BLACK_REQVER = '>=1.2.0,<3.0.0' PYLS_SPYDER_REQVER = '>=0.4.0' +PYUCA_REQVER = '>=1.2' PYXDG_REQVER = '>=0.26' PYZMQ_REQVER = '>=22.1.0' QDARKSTYLE_REQVER = '>=3.0.2,<3.2.0' @@ -194,6 +195,10 @@ 'package_name': 'pyls-spyder', 'features': _('Spyder plugin for the Python LSP Server'), 'required_version': PYLS_SPYDER_REQVER}, + {'modname': 'pyuca', + 'package_name': 'pyuca', + 'features': _('Properly sort lists of non-English strings'), + 'required_version': PYUCA_REQVER}, {'modname': "xdg", 'package_name': "pyxdg", 'features': _("Parse desktop files on Linux"), diff --git a/spyder/plugins/appearance/plugin.py b/spyder/plugins/appearance/plugin.py index 0be93ae42a0..a0a7448f149 100644 --- a/spyder/plugins/appearance/plugin.py +++ b/spyder/plugins/appearance/plugin.py @@ -40,11 +40,13 @@ class Appearance(SpyderPluginV2): def get_name(): return _("Appearance") - def get_description(self): + @staticmethod + def get_description(): return _("Manage application appearance and themes.") - def get_icon(self): - return self.create_icon('eyedropper') + @classmethod + def get_icon(cls): + return cls.create_icon('eyedropper') def on_initialize(self): # NOTES: diff --git a/spyder/plugins/application/plugin.py b/spyder/plugins/application/plugin.py index 42bcbd1fa4a..6f0589a2f80 100644 --- a/spyder/plugins/application/plugin.py +++ b/spyder/plugins/application/plugin.py @@ -50,10 +50,12 @@ class Application(SpyderPluginV2): def get_name(): return _('Application') - def get_icon(self): - return self.create_icon('genprefs') + @classmethod + def get_icon(cls): + return cls.create_icon('genprefs') - def get_description(self): + @staticmethod + def get_description(): return _('Provide main application base actions.') def on_initialize(self): diff --git a/spyder/plugins/breakpoints/plugin.py b/spyder/plugins/breakpoints/plugin.py index 702b0269c3e..a1d0c428f91 100644 --- a/spyder/plugins/breakpoints/plugin.py +++ b/spyder/plugins/breakpoints/plugin.py @@ -90,11 +90,13 @@ class Breakpoints(SpyderDockablePlugin): def get_name(): return _("Breakpoints") - def get_description(self): - return _("Manage code breakpoints in a unified pane.") + @staticmethod + def get_description(): + return _("Manage debugger breakpoints in a unified pane.") - def get_icon(self): - return self.create_icon('breakpoints') + @classmethod + def get_icon(cls): + return cls.create_icon('breakpoints') def on_initialize(self): widget = self.get_widget() diff --git a/spyder/plugins/completion/plugin.py b/spyder/plugins/completion/plugin.py index 09c14ca88e3..b76e7a51a28 100644 --- a/spyder/plugins/completion/plugin.py +++ b/spyder/plugins/completion/plugin.py @@ -264,13 +264,15 @@ def __init__(self, parent, configuration=None): def get_name() -> str: return _('Completion and linting') - def get_description(self) -> str: - return _('This plugin is in charge of handling and dispatching, as ' - 'well as of receiving the responses of completion and ' - 'linting requests sent to multiple providers.') - - def get_icon(self): - return self.create_icon('completions') + @staticmethod + def get_description() -> str: + return _('Handle code completion, analysis, formatting, introspection, ' + 'folding and more via the Language Server Protocol and other ' + 'providers.') + + @classmethod + def get_icon(cls): + return cls.create_icon('completions') def on_initialize(self): self.sig_interpreter_changed.connect(self.update_completion_status) diff --git a/spyder/plugins/console/plugin.py b/spyder/plugins/console/plugin.py index 0eb1d020ec9..a7ad274a16b 100644 --- a/spyder/plugins/console/plugin.py +++ b/spyder/plugins/console/plugin.py @@ -82,11 +82,13 @@ class Console(SpyderDockablePlugin): def get_name(): return _('Internal console') - def get_icon(self): + @classmethod + def get_icon(cls): return QIcon() - def get_description(self): - return _('Internal console running Spyder.') + @staticmethod + def get_description(): + return _('An internal Python console running Spyder itself.') def on_initialize(self): widget = self.get_widget() diff --git a/spyder/plugins/debugger/plugin.py b/spyder/plugins/debugger/plugin.py index fe469cc6693..08af2562d21 100644 --- a/spyder/plugins/debugger/plugin.py +++ b/spyder/plugins/debugger/plugin.py @@ -56,11 +56,13 @@ class Debugger(SpyderDockablePlugin, ShellConnectMixin, RunExecutor): def get_name(): return _('Debugger') - def get_description(self): - return _('Display and explore frames while debugging.') + @staticmethod + def get_description(): + return _('View, explore and navigate stack frames while debugging.') - def get_icon(self): - return self.create_icon('debug') + @classmethod + def get_icon(cls): + return cls.create_icon('debug') def on_initialize(self): widget = self.get_widget() diff --git a/spyder/plugins/editor/plugin.py b/spyder/plugins/editor/plugin.py index 733038a85d7..256af69803c 100644 --- a/spyder/plugins/editor/plugin.py +++ b/spyder/plugins/editor/plugin.py @@ -652,10 +652,23 @@ def get_plugin_title(): title = _('Editor') return title - def get_plugin_icon(self): + # TODO: Remove when the editor is migrated to the new API + get_name = get_plugin_title + + @staticmethod + def get_description(): + return _( + "Edit Python, Markdown, Cython and many other types of text files." + ) + + @classmethod + def get_plugin_icon(cls): """Return widget icon.""" return ima.icon('edit') + # TODO: Remove when the editor is migrated to the new API + get_icon = get_plugin_icon + def get_focus_widget(self): """ Return the widget to give focus to. diff --git a/spyder/plugins/explorer/plugin.py b/spyder/plugins/explorer/plugin.py index c6134682357..6dfe9de5db1 100644 --- a/spyder/plugins/explorer/plugin.py +++ b/spyder/plugins/explorer/plugin.py @@ -157,14 +157,15 @@ def get_name(): """Return widget title""" return _("Files") - def get_description(self): + @staticmethod + def get_description(): """Return the description of the explorer widget.""" - return _("Explore files in the computer with a tree view.") + return _("Explore your filesystem in a tree view.") - def get_icon(self): + @classmethod + def get_icon(cls): """Return the explorer icon.""" - # TODO: Find a decent icon for the explorer - return self.create_icon('outline_explorer') + return cls.create_icon('files') def on_initialize(self): widget = self.get_widget() diff --git a/spyder/plugins/externalconsole/plugin.py b/spyder/plugins/externalconsole/plugin.py index dd8cf899e45..23e386ee0e3 100644 --- a/spyder/plugins/externalconsole/plugin.py +++ b/spyder/plugins/externalconsole/plugin.py @@ -49,11 +49,13 @@ class ExternalConsole(SpyderPluginV2, RunExecutor): def get_name(): return _("External console") - def get_description(self): - return _("Manage run configuration.") + @staticmethod + def get_description(): + return _("Run scripts in an external system terminal.") - def get_icon(self): - return self.create_icon('copywop') + @classmethod + def get_icon(cls): + return cls.create_icon('DollarFileIcon') def on_initialize(self): self.editor_configurations = [ diff --git a/spyder/plugins/findinfiles/plugin.py b/spyder/plugins/findinfiles/plugin.py index cbac5f6fdf5..971ac475f25 100644 --- a/spyder/plugins/findinfiles/plugin.py +++ b/spyder/plugins/findinfiles/plugin.py @@ -48,11 +48,13 @@ class FindInFiles(SpyderDockablePlugin): def get_name(): return _("Find") - def get_description(self): - return _("Search for strings of text in files.") + @staticmethod + def get_description(): + return _("Search for text patterns in files.") - def get_icon(self): - return self.create_icon('findf') + @classmethod + def get_icon(cls): + return cls.create_icon('findf') def on_initialize(self): self.create_action( diff --git a/spyder/plugins/help/plugin.py b/spyder/plugins/help/plugin.py index 64f9740bc42..07a84fb92aa 100644 --- a/spyder/plugins/help/plugin.py +++ b/spyder/plugins/help/plugin.py @@ -61,12 +61,15 @@ class Help(SpyderDockablePlugin): def get_name(): return _('Help') - def get_description(self): + @staticmethod + def get_description(): return _( - 'Get rich text documentation from the editor and the console') + "Get documentation for objects in the Editor and IPython console." + ) - def get_icon(self): - return self.create_icon('help') + @classmethod + def get_icon(cls): + return cls.create_icon('help') def on_initialize(self): widget = self.get_widget() diff --git a/spyder/plugins/history/plugin.py b/spyder/plugins/history/plugin.py index b07e08fd543..607c600f20f 100644 --- a/spyder/plugins/history/plugin.py +++ b/spyder/plugins/history/plugin.py @@ -55,11 +55,13 @@ def __init__(self, parent=None, configuration=None): def get_name(): return _('History') - def get_description(self): - return _('Provide command history for IPython Consoles') + @staticmethod + def get_description(): + return _('View command history for the IPython console.') - def get_icon(self): - return self.create_icon('history') + @classmethod + def get_icon(cls): + return cls.create_icon('history') def on_initialize(self): widget = self.get_widget() diff --git a/spyder/plugins/ipythonconsole/plugin.py b/spyder/plugins/ipythonconsole/plugin.py index d8b5c9f1ac7..d6da46de3fe 100644 --- a/spyder/plugins/ipythonconsole/plugin.py +++ b/spyder/plugins/ipythonconsole/plugin.py @@ -191,11 +191,15 @@ class IPythonConsole(SpyderDockablePlugin, RunExecutor): def get_name(): return _('IPython console') - def get_description(self): - return _('IPython console') + @staticmethod + def get_description(): + return _( + "Run Python files, cells, code and commands interactively." + ) - def get_icon(self): - return self.create_icon('ipython_console') + @classmethod + def get_icon(cls): + return cls.create_icon('ipython_console') def on_initialize(self): widget = self.get_widget() diff --git a/spyder/plugins/layout/plugin.py b/spyder/plugins/layout/plugin.py index b53111fdcb6..cf106349663 100644 --- a/spyder/plugins/layout/plugin.py +++ b/spyder/plugins/layout/plugin.py @@ -14,6 +14,7 @@ # Third party imports from qtpy.QtCore import Qt, QByteArray, QSize, QPoint, Slot +from qtpy.QtGui import QIcon from qtpy.QtWidgets import QApplication, QDesktopWidget # Local imports @@ -80,11 +81,13 @@ class Layout(SpyderPluginV2): def get_name(): return _("Layout") - def get_description(self): + @staticmethod + def get_description(): return _("Layout manager") - def get_icon(self): - return self.create_icon("history") # FIXME: + @classmethod + def get_icon(cls): + return QIcon() def on_initialize(self): self._last_plugin = None diff --git a/spyder/plugins/maininterpreter/plugin.py b/spyder/plugins/maininterpreter/plugin.py index 907a93120bb..abf1e4f69ef 100644 --- a/spyder/plugins/maininterpreter/plugin.py +++ b/spyder/plugins/maininterpreter/plugin.py @@ -44,11 +44,16 @@ class MainInterpreter(SpyderPluginV2): def get_name(): return _("Python interpreter") - def get_description(self): - return _("Main Python interpreter to open consoles.") + @staticmethod + def get_description(): + return _( + "Manage the default Python interpreter used to run, analyze and " + "profile your code in Spyder." + ) - def get_icon(self): - return self.create_icon('python') + @classmethod + def get_icon(cls): + return cls.create_icon('python') def on_initialize(self): container = self.get_container() diff --git a/spyder/plugins/mainmenu/plugin.py b/spyder/plugins/mainmenu/plugin.py index d49856a0f11..67e02d32cfc 100644 --- a/spyder/plugins/mainmenu/plugin.py +++ b/spyder/plugins/mainmenu/plugin.py @@ -44,10 +44,12 @@ class MainMenu(SpyderPluginV2): def get_name(): return _('Main menus') - def get_icon(self): - return self.create_icon('genprefs') + @classmethod + def get_icon(cls): + return cls.create_icon('genprefs') - def get_description(self): + @staticmethod + def get_description(): return _('Provide main application menu management.') def on_initialize(self): diff --git a/spyder/plugins/onlinehelp/plugin.py b/spyder/plugins/onlinehelp/plugin.py index 4f33b21738b..976ec160cab 100644 --- a/spyder/plugins/onlinehelp/plugin.py +++ b/spyder/plugins/onlinehelp/plugin.py @@ -46,12 +46,16 @@ class OnlineHelp(SpyderDockablePlugin): def get_name(): return _('Online help') - def get_description(self): + @staticmethod + def get_description(): return _( - 'Browse and search the currently installed modules interactively.') + "Browse and search documentation for installed Python modules " + "interactively." + ) - def get_icon(self): - return self.create_icon('help') + @classmethod + def get_icon(cls): + return cls.create_icon('online_help') def on_close(self, cancelable=False): self.save_history() diff --git a/spyder/plugins/outlineexplorer/plugin.py b/spyder/plugins/outlineexplorer/plugin.py index 814f58192d2..eb9c31bab9c 100644 --- a/spyder/plugins/outlineexplorer/plugin.py +++ b/spyder/plugins/outlineexplorer/plugin.py @@ -33,13 +33,17 @@ def get_name() -> str: """Return widget title.""" return _('Outline Explorer') - def get_description(self) -> str: + @staticmethod + def get_description() -> str: """Return the description of the outline explorer widget.""" - return _("Explore a file's functions, classes and methods") + return _("Explore functions, classes and methods in open files. Note " + "that if you disable the 'Completion and linting' plugin, " + "this one won't work.") - def get_icon(self): + @classmethod + def get_icon(cls): """Return the outline explorer icon.""" - return self.create_icon('outline_explorer') + return cls.create_icon('outline_explorer') def on_initialize(self): if self.main: diff --git a/spyder/plugins/plots/plugin.py b/spyder/plugins/plots/plugin.py index 845cf2975a9..22e721e4532 100644 --- a/spyder/plugins/plots/plugin.py +++ b/spyder/plugins/plots/plugin.py @@ -33,11 +33,13 @@ class Plots(SpyderDockablePlugin, ShellConnectMixin): def get_name(): return _('Plots') - def get_description(self): - return _('Display, explore and save console generated plots.') + @staticmethod + def get_description(): + return _('View, browse and save generated figures.') - def get_icon(self): - return self.create_icon('plot') + @classmethod + def get_icon(cls): + return cls.create_icon('plot') def on_initialize(self): # If a figure is loaded, raise the dockwidget the first time diff --git a/spyder/plugins/preferences/plugin.py b/spyder/plugins/preferences/plugin.py index b5f8873043e..f6f4e8c32f0 100644 --- a/spyder/plugins/preferences/plugin.py +++ b/spyder/plugins/preferences/plugin.py @@ -266,11 +266,13 @@ def open_dialog(self, prefs_dialog_size): def get_name() -> str: return _('Preferences') - def get_description(self) -> str: - return _('This plugin provides access to Spyder preferences page') + @staticmethod + def get_description() -> str: + return _("Manage Spyder's preferences.") - def get_icon(self) -> QIcon: - return self.create_icon('configure') + @classmethod + def get_icon(cls) -> QIcon: + return cls.create_icon('configure') def on_initialize(self): container = self.get_container() diff --git a/spyder/plugins/profiler/plugin.py b/spyder/plugins/profiler/plugin.py index 553d40248c7..32c3e9cd866 100644 --- a/spyder/plugins/profiler/plugin.py +++ b/spyder/plugins/profiler/plugin.py @@ -60,11 +60,13 @@ class Profiler(SpyderDockablePlugin, RunExecutor): def get_name(): return _("Profiler") - def get_description(self): - return _("Profile your scripts and find bottlenecks.") + @staticmethod + def get_description(): + return _("Profile Python files to find execution bottlenecks.") - def get_icon(self): - return self.create_icon('profiler') + @classmethod + def get_icon(cls): + return cls.create_icon('profiler') def on_initialize(self): widget = self.get_widget() diff --git a/spyder/plugins/projects/plugin.py b/spyder/plugins/projects/plugin.py index 9fcb350071a..92144612ce6 100644 --- a/spyder/plugins/projects/plugin.py +++ b/spyder/plugins/projects/plugin.py @@ -89,11 +89,13 @@ class Projects(SpyderDockablePlugin): def get_name(): return _("Projects") - def get_description(self): + @staticmethod + def get_description(): return _("Create Spyder projects and manage their files.") - def get_icon(self): - return self.create_icon('project') + @classmethod + def get_icon(cls): + return cls.create_icon('project') def on_initialize(self): """Register plugin in Spyder's main window""" diff --git a/spyder/plugins/pylint/plugin.py b/spyder/plugins/pylint/plugin.py index 1ecd2004459..d15c5033577 100644 --- a/spyder/plugins/pylint/plugin.py +++ b/spyder/plugins/pylint/plugin.py @@ -67,11 +67,14 @@ class Pylint(SpyderDockablePlugin, RunExecutor): def get_name(): return _("Code Analysis") - def get_description(self): - return _("Run Code Analysis.") + @staticmethod + def get_description(): + return _("Analyze code and view the results from both static and " + "real-time analysis.") - def get_icon(self): - return self.create_icon("pylint") + @classmethod + def get_icon(cls): + return cls.create_icon("pylint") def on_initialize(self): widget = self.get_widget() diff --git a/spyder/plugins/pythonpath/plugin.py b/spyder/plugins/pythonpath/plugin.py index ae2c2416204..6b33eb69276 100644 --- a/spyder/plugins/pythonpath/plugin.py +++ b/spyder/plugins/pythonpath/plugin.py @@ -33,7 +33,6 @@ class PythonpathManager(SpyderPluginV2): CONTAINER_CLASS = PythonpathContainer CONF_SECTION = NAME CONF_FILE = False - CAN_BE_DISABLED = False sig_pythonpath_changed = Signal(object, object) """ @@ -66,12 +65,13 @@ class PythonpathManager(SpyderPluginV2): def get_name(): return _("PYTHONPATH manager") - def get_description(self): - return _("Manager of additional locations to search for Python " - "modules.") + @staticmethod + def get_description(): + return _("Manage additional locations to search for Python modules.") - def get_icon(self): - return self.create_icon('python') + @classmethod + def get_icon(cls): + return cls.create_icon('python') def on_initialize(self): container = self.get_container() diff --git a/spyder/plugins/run/plugin.py b/spyder/plugins/run/plugin.py index c70fe90b08a..d5fc445421a 100644 --- a/spyder/plugins/run/plugin.py +++ b/spyder/plugins/run/plugin.py @@ -68,11 +68,13 @@ class Run(SpyderPluginV2): def get_name(): return _("Run") - def get_description(self): - return _("Manage run configuration.") + @staticmethod + def get_description(): + return _("Manage run configuration for executing files.") - def get_icon(self): - return self.create_icon('run') + @classmethod + def get_icon(cls): + return cls.create_icon('run') def on_initialize(self): self.pending_toolbar_actions = [] diff --git a/spyder/plugins/shortcuts/plugin.py b/spyder/plugins/shortcuts/plugin.py index 9387e5a6bd1..53b5fe537af 100644 --- a/spyder/plugins/shortcuts/plugin.py +++ b/spyder/plugins/shortcuts/plugin.py @@ -63,11 +63,13 @@ class Shortcuts(SpyderPluginV2): def get_name(): return _("Keyboard shortcuts") - def get_description(self): - return _("Manage application, widget and actions shortcuts.") + @staticmethod + def get_description(): + return _("Manage application, pane and actions shortcuts.") - def get_icon(self): - return self.create_icon('keyboard') + @classmethod + def get_icon(cls): + return cls.create_icon('keyboard') def on_initialize(self): self._shortcut_data = [] diff --git a/spyder/plugins/shortcuts/widgets/table.py b/spyder/plugins/shortcuts/widgets/table.py index 55c59df3a7b..7371e6111e8 100644 --- a/spyder/plugins/shortcuts/widgets/table.py +++ b/spyder/plugins/shortcuts/widgets/table.py @@ -17,7 +17,7 @@ from qtpy.QtWidgets import (QAbstractItemView, QApplication, QDialog, QGridLayout, QHBoxLayout, QKeySequenceEdit, QLabel, QLineEdit, QMessageBox, QPushButton, - QSpacerItem, QTableView, QVBoxLayout) + QSpacerItem, QVBoxLayout) # Local imports from spyder.api.translations import _ @@ -25,10 +25,8 @@ from spyder.utils.icon_manager import ima from spyder.utils.qthelpers import create_toolbutton from spyder.utils.stringmatching import get_search_regex, get_search_scores -from spyder.widgets.helperwidgets import (VALID_FINDER_CHARS, - CustomSortFilterProxy, - HelperToolButton, - HTMLDelegate) +from spyder.widgets.helperwidgets import ( + HelperToolButton, HTMLDelegate, HoverRowsTableView, VALID_FINDER_CHARS) # Valid shortcut keys @@ -653,10 +651,10 @@ def reset(self): self.endResetModel() -class ShortcutsTable(QTableView): - def __init__(self, - parent=None, text_color=None, text_color_highlight=None): - QTableView.__init__(self, parent) +class ShortcutsTable(HoverRowsTableView): + def __init__(self, parent=None, text_color=None, + text_color_highlight=None): + HoverRowsTableView.__init__(self, parent) self._parent = parent self.finder = None self.shortcut_data = None @@ -675,8 +673,7 @@ def __init__(self, self.setModel(self.proxy_model) self.hideColumn(SEARCH_SCORE) - self.setItemDelegateForColumn(NAME, HTMLDelegate(self, margin=9)) - self.setItemDelegateForColumn(CONTEXT, HTMLDelegate(self, margin=9)) + self.setItemDelegate(HTMLDelegate(self, margin=9)) self.setSelectionBehavior(QAbstractItemView.SelectRows) self.setSelectionMode(QAbstractItemView.SingleSelection) self.setSortingEnabled(True) @@ -685,6 +682,10 @@ def __init__(self, self.verticalHeader().hide() + self.sig_hover_index_changed.connect( + self.itemDelegate().on_hover_index_changed + ) + def set_shortcut_data(self, shortcut_data): """ Shortcut data comes from the registration of actions on the main @@ -783,7 +784,7 @@ def save_shortcuts(self): def show_editor(self): """Create, setup and display the shortcut editor dialog.""" index = self.proxy_model.mapToSource(self.currentIndex()) - row, column = index.row(), index.column() + row = index.row() shortcuts = self.source_model.shortcuts context = shortcuts[row].context name = shortcuts[row].name diff --git a/spyder/plugins/statusbar/plugin.py b/spyder/plugins/statusbar/plugin.py index a5944ffd773..247a93da454 100644 --- a/spyder/plugins/statusbar/plugin.py +++ b/spyder/plugins/statusbar/plugin.py @@ -53,11 +53,13 @@ class StatusBar(SpyderPluginV2): def get_name(): return _('Status bar') - def get_icon(self): - return self.create_icon('statusbar') + @classmethod + def get_icon(cls): + return cls.create_icon('statusbar') - def get_description(self): - return _('Provide Core user interface management') + @staticmethod + def get_description(): + return _("Display the main window status bar.") def on_initialize(self): # --- Status widgets diff --git a/spyder/plugins/switcher/plugin.py b/spyder/plugins/switcher/plugin.py index b06d2b874f7..160a68fedde 100644 --- a/spyder/plugins/switcher/plugin.py +++ b/spyder/plugins/switcher/plugin.py @@ -108,11 +108,13 @@ class Switcher(SpyderPluginV2): def get_name(): return _("Switcher") - def get_description(self): - return _("A multi-purpose switcher.") + @staticmethod + def get_description(): + return _("Quickly switch between files and other items.") - def get_icon(self): - return self.create_icon('filelist') + @classmethod + def get_icon(cls): + return cls.create_icon('switcher') def on_initialize(self): container = self.get_container() diff --git a/spyder/plugins/toolbar/plugin.py b/spyder/plugins/toolbar/plugin.py index 2b85a62845f..65f4e5dbe59 100644 --- a/spyder/plugins/toolbar/plugin.py +++ b/spyder/plugins/toolbar/plugin.py @@ -9,9 +9,12 @@ """ # Standard library imports -from spyder.utils.qthelpers import SpyderAction from typing import Union, Optional +# Third-party imports +from qtpy.QtGui import QIcon +from qtpy.QtWidgets import QWidget + # Local imports from spyder.api.plugins import SpyderPluginV2, Plugins from spyder.api.plugin_registration.decorators import ( @@ -21,9 +24,7 @@ from spyder.plugins.toolbar.api import ApplicationToolbars from spyder.plugins.toolbar.container import ( ToolbarContainer, ToolbarMenus, ToolbarActions) - -# Third-party imports -from qtpy.QtWidgets import QWidget +from spyder.utils.qthelpers import SpyderAction class Toolbar(SpyderPluginV2): @@ -43,11 +44,13 @@ class Toolbar(SpyderPluginV2): def get_name(): return _('Toolbar') - def get_description(self): - return _('Application toolbars management.') + @staticmethod + def get_description(): + return _('Manage application toolbars.') - def get_icon(self): - return self.create_icon('help') + @classmethod + def get_icon(cls): + return QIcon() def on_initialize(self): create_app_toolbar = self.create_application_toolbar diff --git a/spyder/plugins/tours/plugin.py b/spyder/plugins/tours/plugin.py index dfd70ff1146..31bac84ac8c 100644 --- a/spyder/plugins/tours/plugin.py +++ b/spyder/plugins/tours/plugin.py @@ -40,11 +40,13 @@ class Tours(SpyderPluginV2): def get_name(): return _("Interactive tours") - def get_description(self): - return _("Provide interactive tours.") + @staticmethod + def get_description(): + return _("Provide interactive tours of the Spyder interface.") - def get_icon(self): - return self.create_icon('tour') + @classmethod + def get_icon(cls): + return cls.create_icon('tour') def on_initialize(self): self.register_tour( diff --git a/spyder/plugins/variableexplorer/plugin.py b/spyder/plugins/variableexplorer/plugin.py index ea6e4ed87ac..279a5db216e 100644 --- a/spyder/plugins/variableexplorer/plugin.py +++ b/spyder/plugins/variableexplorer/plugin.py @@ -39,12 +39,14 @@ class VariableExplorer(SpyderDockablePlugin, ShellConnectMixin): def get_name(): return _('Variable explorer') - def get_description(self): - return _('Display, explore load and save variables in the current ' - 'namespace.') + @staticmethod + def get_description(): + return _("Explore, edit, save and load the lists, arrays, dataframes " + "and other global variables generated by running your code.") - def get_icon(self): - return self.create_icon('dictedit') + @classmethod + def get_icon(cls): + return cls.create_icon('variable_explorer') def on_initialize(self): widget = self.get_widget() diff --git a/spyder/plugins/workingdirectory/plugin.py b/spyder/plugins/workingdirectory/plugin.py index cd73269c919..4a7f11dd1fe 100644 --- a/spyder/plugins/workingdirectory/plugin.py +++ b/spyder/plugins/workingdirectory/plugin.py @@ -61,11 +61,13 @@ class WorkingDirectory(SpyderPluginV2): def get_name(): return _('Working directory') - def get_description(self): - return _('Set the current working directory for various plugins.') + @staticmethod + def get_description(): + return _("Manage the current working directory used in Spyder.") - def get_icon(self): - return self.create_icon('DirOpenIcon') + @classmethod + def get_icon(cls): + return cls.create_icon('DirOpenIcon') def on_initialize(self): container = self.get_container() diff --git a/spyder/utils/icon_manager.py b/spyder/utils/icon_manager.py index 8ef8eeaa4b1..55c08e85327 100644 --- a/spyder/utils/icon_manager.py +++ b/spyder/utils/icon_manager.py @@ -165,14 +165,18 @@ def __init__(self): 'ipython_console': [('mdi.console',), {'color': self.MAIN_FG_COLOR}], 'python': [('spyder.python-logo-up', 'spyder.python-logo-down'), {'options': [{'color': SpyderPalette.PYTHON_LOGO_UP}, {'color': SpyderPalette.PYTHON_LOGO_DOWN}]}], 'pythonpath': [('spyder.python-logo-up', 'spyder.python-logo-down'), {'options': [{'color': SpyderPalette.PYTHON_LOGO_UP}, {'color': SpyderPalette.PYTHON_LOGO_DOWN}]}], - 'findf': [('mdi.file-find',), {'color': self.MAIN_FG_COLOR}], + 'findf': [('mdi.file-find-outline',), {'color': self.MAIN_FG_COLOR}], 'history': [('mdi.history',), {'color': self.MAIN_FG_COLOR}], + 'files': [('mdi.file-multiple',), {'color': self.MAIN_FG_COLOR}], 'help_gray': [('mdi.help-circle-outline',), {'color': SpyderPalette.COLOR_OCCURRENCE_4}], 'help': [('mdi.help-circle',), {'color': self.MAIN_FG_COLOR}], + 'online_help': [('mdi.help-rhombus-outline',), {'color': self.MAIN_FG_COLOR}], 'lock': [('mdi.lock',), {'color': self.MAIN_FG_COLOR}], 'lock_open': [('mdi.lock-open',), {'color': self.MAIN_FG_COLOR}], 'outline_explorer': [('mdi.file-tree',), {'color': self.MAIN_FG_COLOR}], - 'dictedit': [('mdi.view-list',), {'color': self.MAIN_FG_COLOR}], + 'switcher': [('mdi.arrow-left-right-bold',), {'color': self.MAIN_FG_COLOR}], + 'variable_explorer': [('mdi.telescope',), {'color': self.MAIN_FG_COLOR}], + 'dictedit': [('mdi.view-list-outline',), {'color': self.MAIN_FG_COLOR}], 'previous': [('mdi.arrow-left-bold',), {'color': self.MAIN_FG_COLOR}], 'next': [('mdi.arrow-right-bold',), {'color': self.MAIN_FG_COLOR}], 'up': [('mdi.arrow-up-bold',), {'color': self.MAIN_FG_COLOR}], @@ -320,7 +324,7 @@ def __init__(self): 'no_match': [('mdi.checkbox-blank-circle',), {'color': SpyderPalette.GROUP_3, 'scale_factor': self.SMALL_ATTR_FACTOR}], 'github': [('mdi.github',), {'color': self.MAIN_FG_COLOR}], # --- Spyder Tour -------------------------------------------------------- - 'tour': [('mdi.map-outline',), {'color': self.MAIN_FG_COLOR}], + 'tour': [('mdi.compass',), {'color': self.MAIN_FG_COLOR}], 'tour.close': [('mdi.close',), {'color': self.MAIN_FG_COLOR}], 'tour.home': [('mdi.skip-backward',), {'color': self.MAIN_FG_COLOR}], 'tour.previous': [('mdi.skip-previous',), {'color': self.MAIN_FG_COLOR}], diff --git a/spyder/widgets/elementstable.py b/spyder/widgets/elementstable.py new file mode 100644 index 00000000000..f9caf7c819a --- /dev/null +++ b/spyder/widgets/elementstable.py @@ -0,0 +1,410 @@ +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Table widget to display a set of elements with title, description, icon and an +associated widget. +""" + +# Standard library imports +from typing import List, Optional, TypedDict + +# Third-party imports +import qstylizer.style +from qtpy.QtCore import QAbstractTableModel, QEvent, QModelIndex, QSize, Qt +from qtpy.QtGui import QIcon +from qtpy.QtWidgets import QAbstractItemView, QCheckBox, QHBoxLayout, QWidget + +# Local imports +from spyder.api.config.fonts import SpyderFontsMixin, SpyderFontType +from spyder.utils.icon_manager import ima +from spyder.utils.palette import QStylePalette +from spyder.widgets.helperwidgets import HoverRowsTableView, HTMLDelegate + + +class Element(TypedDict): + """Spec for elements that can be displayed in ElementsTable.""" + + title: str + """Element title""" + + description: str + """Element description""" + + additional_info: Optional[str] + """ + Additional info that needs to be displayed in a separate column (optional) + """ + + icon: Optional[QIcon] + """Element icon (optional)""" + + widget: Optional[QWidget] + """ + Element widget, e.g. a checkbox or radio button associated to the element + (optional) + """ + + +class ElementsModel(QAbstractTableModel, SpyderFontsMixin): + + def __init__( + self, + parent: QWidget, + elements: List[Element], + with_icons: bool, + with_addtional_info: bool, + with_widgets: bool, + ): + QAbstractTableModel.__init__(self) + + self.elements = elements + self.with_icons = with_icons + + # Number of columns + self.n_columns = 1 + + # Index corresponding to columns. The 'title' column is always expected + self.columns = {'title': 0} + + # Extra columns + if with_addtional_info: + self.n_columns += 1 + self.columns['additional_info'] = 1 + + if with_widgets: + self.n_columns += 1 + + if self.n_columns == 3: + self.columns['widgets'] = 2 + else: + self.columns['widgets'] = 1 + + # Text styles + text_color = QStylePalette.COLOR_TEXT_1 + title_font_size = self.get_font( + SpyderFontType.Interface, font_size_delta=1).pointSize() + + self.title_style = f'color:{text_color}; font-size:{title_font_size}pt' + self.additional_info_style = f'color:{QStylePalette.COLOR_TEXT_4}' + self.description_style = f'color:{text_color}' + + # ---- Qt overrides + # ------------------------------------------------------------------------- + def data(self, index, role=Qt.DisplayRole): + + element = self.elements[index.row()] + + if role == Qt.DisplayRole: + if index.column() == self.columns['title']: + return self.get_title_repr(element) + elif index.column() == self.columns.get('additional_info'): + return self.get_info_repr(element) + else: + return None + elif role == Qt.DecorationRole and self.with_icons: + if index.column() == self.columns['title']: + return element['icon'] + else: + return None + + return None + + def rowCount(self, index=QModelIndex()): + return len(self.elements) + + def columnCount(self, index=QModelIndex()): + return self.n_columns + + # ---- Own methods + # ------------------------------------------------------------------------- + def get_title_repr(self, element: Element) -> str: + return ( + f'' + # Title + f'' + # Description + f'' + f'
' + f'{element["title"]}' + f'
' + f'{element["description"]}' + f'
' + ) + + def get_info_repr(self, element: Element) -> str: + if element.get('additional_info'): + additional_info = f' {element["additional_info"]}' + else: + return '' + + return ( + f'' + f'{additional_info}' + f'' + ) + + +class ElementsTable(HoverRowsTableView): + + def __init__(self, parent: Optional[QWidget], elements: List[Element]): + HoverRowsTableView.__init__(self, parent) + self.elements = elements + + # Check for additional features + with_icons = self._with_feature('icon') + with_addtional_info = self._with_feature('additional_info') + with_widgets = self._with_feature('widget') + + # To keep track of the current row widget (e.g. a checkbox) in order to + # change its background color when its row is hovered. + self._current_row = -1 + self._current_row_widget = None + + # To do adjustments when the widget is shown only once + self._is_shown = False + + # This is used to paint the entire row's background color when its + # hovered. + self.sig_hover_index_changed.connect(self._on_hover_index_changed) + + # Set model + self.model = ElementsModel( + self, self.elements, with_icons, with_addtional_info, with_widgets + ) + self.setModel(self.model) + + # Adjustments for the title column + title_delegate = HTMLDelegate(self, margin=9, wrap_text=True) + self.setItemDelegateForColumn( + self.model.columns['title'], title_delegate) + self.sig_hover_index_changed.connect( + title_delegate.on_hover_index_changed) + + # Adjustments for the additional info column + self._info_column_width = 0 + if with_addtional_info: + info_delegate = HTMLDelegate(self, margin=10, align_vcenter=True) + self.setItemDelegateForColumn( + self.model.columns['additional_info'], info_delegate) + self.sig_hover_index_changed.connect( + info_delegate.on_hover_index_changed) + + # This is necessary to get this column's width below + self.resizeColumnsToContents() + + self._info_column_width = self.horizontalHeader().sectionSize( + self.model.columns['additional_info']) + + # Adjustments for the widgets column + self._widgets_column_width = 0 + if with_widgets: + widgets_delegate = HTMLDelegate(self, margin=0) + self.setItemDelegateForColumn( + self.model.columns['widgets'], widgets_delegate) + self.sig_hover_index_changed.connect( + widgets_delegate.on_hover_index_changed) + + # This is necessary to get this column's width below + self.resizeColumnsToContents() + + # Note: We add 15 pixels to the Qt width so that the widgets are + # not so close to the right border of the table, which doesn't look + # good. + self._widgets_column_width = self.horizontalHeader().sectionSize( + self.model.columns['widgets']) + 15 + + # Add widgets + for i in range(len(self.elements)): + layout = QHBoxLayout() + layout.addWidget(self.elements[i]['widget']) + layout.setAlignment(Qt.AlignHCenter) + + container_widget = QWidget(self) + container_widget.setLayout(layout) + + # This key is not accounted for in Element because it's only + # used internally, so it doesn't need to provided in a list of + # Element's. + self.elements[i]['row_widget'] = container_widget + + self.setIndexWidget( + self.model.index(i, self.model.columns['widgets']), + container_widget + ) + + # Make last column take the available space to the right + self.horizontalHeader().setStretchLastSection(True) + + # Hide headers + self.horizontalHeader().hide() + self.verticalHeader().hide() + + # Set icons size + if with_icons: + self.setIconSize(QSize(32, 32)) + + # Hide grid to only paint horizontal lines with css + self.setShowGrid(False) + + # Set selection behavior + self.setSelectionMode(QAbstractItemView.NoSelection) + + # Set stylesheet + self._set_stylesheet() + + # ---- Private API + # ------------------------------------------------------------------------- + def _on_hover_index_changed(self, index): + """Actions to take when the index that is hovered has changed.""" + row = index.row() + + if row != self._current_row: + self._current_row = row + + # Remove background color of previous row widget + if self._current_row_widget is not None: + self._current_row_widget.setStyleSheet("") + + # Set background for the new row widget + new_row_widget = self.elements[row]["row_widget"] + new_row_widget.setStyleSheet( + f"background-color: {QStylePalette.COLOR_BACKGROUND_3}" + ) + + # Set new current row widget + self._current_row_widget = new_row_widget + + def _set_stylesheet(self, leave=False): + """Set stylesheet when entering or leaving the widget.""" + css = qstylizer.style.StyleSheet() + bgcolor = QStylePalette.COLOR_BACKGROUND_1 if leave else "transparent" + + css["QTableView::item"].setValues( + borderBottom=f"1px solid {QStylePalette.COLOR_BACKGROUND_4}", + paddingLeft="5px", + backgroundColor=bgcolor + ) + + self.setStyleSheet(css.toString()) + + def _set_layout(self): + """ + Set rows and columns layout. + + This is necessary to make the table look good at different sizes. + """ + # Resize title column so that the table fits into the available + # horizontal space. + if self._info_column_width > 0 or self._widgets_column_width > 0: + title_column_width = ( + self.horizontalHeader().size().width() - + (self._info_column_width + self._widgets_column_width) + ) + + self.horizontalHeader().resizeSection( + self.model.columns['title'], title_column_width + ) + + # Resize rows. This is done because wrapping text in HTMLDelegate's + # changes row heights in unpredictable ways. + self.resizeRowsToContents() + + def _with_feature(self, feature_name: str) -> bool: + """Check if it's necessary to build the table with `feature_name`.""" + return len([e for e in self.elements if e.get(feature_name)]) > 0 + + # ---- Qt methods + # ------------------------------------------------------------------------- + def showEvent(self, event): + if not self._is_shown: + self._set_layout() + + # To not run the adjustments above every time the widget is shown + self._is_shown = True + + super().showEvent(event) + + def leaveEvent(self, event): + super().leaveEvent(event) + + # Clear background color painted on hovered row widget + if self._current_row_widget is not None: + self._current_row_widget.setStyleSheet('') + self._set_stylesheet(leave=True) + + def enterEvent(self, event): + super().enterEvent(event) + + # Restore background color that's going to be painted on hovered row + if self._current_row_widget is not None: + self._current_row_widget.setStyleSheet( + f"background-color: {QStylePalette.COLOR_BACKGROUND_3}" + ) + self._set_stylesheet() + + def resizeEvent(self, event): + # This is necessary to readjust the layout when the parent widget is + # resized. + self._set_layout() + super().resizeEvent(event) + + def event(self, event): + # This is necessary to readjust the layout when the parent widget is + # maximized. + if event.type() == QEvent.LayoutRequest: + self._set_layout() + return super().event(event) + + +def test_elements_table(): + from spyder.utils.qthelpers import qapplication + app = qapplication() # noqa + + elements_with_title = [ + {'title': 'IPython console', 'description': 'Execute code'}, + {'title': 'Help', 'description': 'Look for help'} + ] + + table = ElementsTable(None, elements_with_title) + table.show() + + elements_with_icons = [ + {'title': 'IPython console', 'description': 'Execute code', + 'icon': ima.icon('ipython_console')}, + {'title': 'Help', 'description': 'Look for help', + 'icon': ima.icon('help')} + ] + + table_with_icons = ElementsTable(None, elements_with_icons) + table_with_icons.show() + + elements_with_widgets = [ + {'title': 'IPython console', 'description': 'Execute code', + 'icon': ima.icon('ipython_console'), 'widget': QCheckBox()}, + {'title': 'Help', 'description': 'Look for help', + 'icon': ima.icon('help'), 'widget': QCheckBox()} + ] + + table_with_widgets = ElementsTable(None, elements_with_widgets) + table_with_widgets.show() + + elements_with_info = [ + {'title': 'IPython console', 'description': 'Execute code', + 'icon': ima.icon('ipython_console'), 'widget': QCheckBox(), + 'additional_info': 'Core plugin'}, + {'title': 'Help', 'description': 'Look for help', + 'icon': ima.icon('help'), 'widget': QCheckBox()} + ] + + table_with_widgets_and_icons = ElementsTable(None, elements_with_info) + table_with_widgets_and_icons.show() + + app.exec_() + + +if __name__ == '__main__': + test_elements_table() diff --git a/spyder/widgets/helperwidgets.py b/spyder/widgets/helperwidgets.py index 91e6fbd3731..d48f27e9faa 100644 --- a/spyder/widgets/helperwidgets.py +++ b/spyder/widgets/helperwidgets.py @@ -21,7 +21,7 @@ from qtpy.QtWidgets import (QApplication, QCheckBox, QLineEdit, QMessageBox, QSpacerItem, QStyle, QStyledItemDelegate, QStyleOptionFrame, QStyleOptionViewItem, - QToolButton, QToolTip, QVBoxLayout, + QTableView, QToolButton, QToolTip, QVBoxLayout, QWidget, QHBoxLayout, QLabel, QFrame) # Local imports @@ -32,6 +32,7 @@ from spyder.utils.image_path_manager import get_image_path from spyder.utils.stylesheet import DialogStyle + # Valid finder chars. To be improved VALID_ACCENT_CHARS = "ÁÉÍOÚáéíúóàèìòùÀÈÌÒÙâêîôûÂÊÎÔÛäëïöüÄËÏÖÜñÑ" VALID_FINDER_CHARS = r"[A-Za-z\s{0}]".format(VALID_ACCENT_CHARS) @@ -112,14 +113,18 @@ def set_checkbox_text(self, text): class HTMLDelegate(QStyledItemDelegate): - """With this delegate, a QListWidgetItem or a QTableItem can render HTML. + """ + With this delegate, a QListWidgetItem or a QTableItem can render HTML. Taken from https://stackoverflow.com/a/5443112/2399799 """ - def __init__(self, parent, margin=0): + def __init__(self, parent, margin=0, wrap_text=False, align_vcenter=False): super(HTMLDelegate, self).__init__(parent) self._margin = margin + self._wrap_text = wrap_text + self._hovered_row = -1 + self._align_vcenter = align_vcenter def _prepare_text_document(self, option, index): # This logic must be shared between paint and sizeHint for consistency @@ -129,8 +134,22 @@ def _prepare_text_document(self, option, index): doc = QTextDocument() doc.setDocumentMargin(self._margin) doc.setHtml(options.text) + if self._wrap_text: + # The -25 here is used to avoid the text to go totally up to the + # right border of the widget that contains the delegate, which + # doesn't look good. + doc.setTextWidth(option.rect.width() - 25) + return options, doc + def on_hover_index_changed(self, index): + """ + This can be used by a widget that inherits from HoverRowsTableView to + connect its sig_hover_index_changed signal to this method to paint an + entire row when it's hovered. + """ + self._hovered_row = index.row() + def paint(self, painter, option, index): options, doc = self._prepare_text_document(option, index) @@ -138,6 +157,14 @@ def paint(self, painter, option, index): else options.widget.style()) options.text = "" + # This paints the entire row associated to the delegate when it's + # hovered and the table that holds it informs it what's the current + # row (see HoverRowsTableView for an example). + if index.row() == self._hovered_row: + painter.fillRect( + options.rect, QColor(QStylePalette.COLOR_BACKGROUND_3) + ) + # Note: We need to pass the options widget as an argument of # drawCrontol to make sure the delegate is painted with a style # consistent with the widget in which it is used. @@ -153,18 +180,21 @@ def paint(self, painter, option, index): # Adjustments for the file switcher if hasattr(options.widget, 'files_list'): - if style.objectName() in ['oxygen', 'qtcurve', 'breeze']: - if options.widget.files_list: - painter.translate(textRect.topLeft() + QPoint(4, -9)) - else: - painter.translate(textRect.topLeft()) + if options.widget.files_list: + painter.translate(textRect.topLeft() + QPoint(4, 4)) else: - if options.widget.files_list: - painter.translate(textRect.topLeft() + QPoint(4, 4)) - else: - painter.translate(textRect.topLeft() + QPoint(2, 4)) + painter.translate(textRect.topLeft() + QPoint(2, 4)) else: - painter.translate(textRect.topLeft() + QPoint(0, -3)) + if not self._align_vcenter: + painter.translate(textRect.topLeft() + QPoint(0, -3)) + + # Center text vertically if requested. + # Take from https://stackoverflow.com/a/32911270/438386 + if self._align_vcenter: + doc.setTextWidth(option.rect.width()) + offset_y = (option.rect.height() - doc.size().height()) / 2 + painter.translate(options.rect.x(), options.rect.y() + offset_y) + doc.drawContents(painter) # Type check: Prevent error in PySide where using # doc.documentLayout().draw() may fail because doc.documentLayout() @@ -529,9 +559,67 @@ def _apply_stylesheet(self, focus): self.setStyleSheet(qss.toString()) +class HoverRowsTableView(QTableView): + """ + QTableView subclass that can highlight an entire row when hovered. + + Notes + ----- + * Classes that inherit from this one need to connect a slot to + sig_hover_index_changed that handles how the row is painted. + """ + + sig_hover_index_changed = Signal(object) + """ + This is emitted when the index that is currently hovered has changed. + + Parameters + ---------- + index: object + QModelIndex that has changed on hover. + """ + + def __init__(self, parent): + QTableView.__init__(self, parent) + + # For mouseMoveEvent + self.setMouseTracking(True) + + # To remove background color for the hovered row when the mouse is not + # over the widget. + css = qstylizer.style.StyleSheet() + css["QTableView::item"].setValues( + backgroundColor=f"{QStylePalette.COLOR_BACKGROUND_1}" + ) + self._stylesheet = css.toString() + + # ---- Qt methods + def mouseMoveEvent(self, event): + self._inform_hover_index_changed(event) + + def wheelEvent(self, event): + super().wheelEvent(event) + self._inform_hover_index_changed(event) + + def leaveEvent(self, event): + super().leaveEvent(event) + self.setStyleSheet(self._stylesheet) + + def enterEvent(self, event): + super().enterEvent(event) + self.setStyleSheet("") + + # ---- Private methods + def _inform_hover_index_changed(self, event): + index = self.indexAt(event.pos()) + if index.isValid(): + self.sig_hover_index_changed.emit(index) + self.viewport().update() + + def test_msgcheckbox(): from spyder.utils.qthelpers import qapplication - app = qapplication() + app = qapplication() # noqa box = MessageCheckBox() box.setWindowTitle(_("Spyder updates")) box.setText("Testing checkbox")