diff --git a/binder/environment.yml b/binder/environment.yml index f7944e627df..a9da95c7761 100644 --- a/binder/environment.yml +++ b/binder/environment.yml @@ -47,6 +47,7 @@ dependencies: - setuptools >=49.6.0 - sphinx >=0.6.6 - spyder-kernels >=3.0.0b2,<3.0.0b3 +- superqt >=0.6.1,<1.0.0 - textdistance >=4.2.0 - three-merge >=0.1.1 - watchdog >=0.10.3 diff --git a/requirements/main.yml b/requirements/main.yml index 5531a68113a..ad22d827519 100644 --- a/requirements/main.yml +++ b/requirements/main.yml @@ -43,6 +43,7 @@ dependencies: - setuptools >=49.6.0 - sphinx >=0.6.6 - spyder-kernels >=3.0.0b2,<3.0.0b3 + - superqt >=0.6.1,<1.0.0 - textdistance >=4.2.0 - three-merge >=0.1.1 - watchdog >=0.10.3 diff --git a/setup.py b/setup.py index 37c2b88c4b7..b86a15432b9 100644 --- a/setup.py +++ b/setup.py @@ -244,6 +244,7 @@ def run(self): 'setuptools>=49.6.0', 'sphinx>=0.6.6', 'spyder-kernels>=3.0.0b2,<3.0.0b3', + 'superqt>=0.6.1,<1.0.0', 'textdistance>=4.2.0', 'three-merge>=0.1.1', 'watchdog>=0.10.3' diff --git a/spyder/api/preferences.py b/spyder/api/preferences.py index 83c1a0a8213..00571841f31 100644 --- a/spyder/api/preferences.py +++ b/spyder/api/preferences.py @@ -16,7 +16,10 @@ from spyder.config.manager import CONF from spyder.config.types import ConfigurationKey from spyder.api.utils import PrefixedTuple -from spyder.plugins.preferences.api import SpyderConfigPage, BaseConfigTab +from spyder.plugins.preferences.widgets.config_widgets import ( + SpyderConfigPage, + BaseConfigTab +) OptionSet = Set[ConfigurationKey] @@ -65,6 +68,11 @@ def __getattr__(self, attr): else: return super().__getattr__(attr) + def setLayout(self, layout): + """Remove default margins by default.""" + layout.setContentsMargins(0, 0, 0, 0) + super().setLayout(layout) + class PluginConfigPage(SpyderConfigPage): """ diff --git a/spyder/app/mainwindow.py b/spyder/app/mainwindow.py index 4469f0670f1..ec463eadc4d 100644 --- a/spyder/app/mainwindow.py +++ b/spyder/app/mainwindow.py @@ -228,7 +228,6 @@ def signal_handler(signum, frame=None): self.thirdparty_plugins = [] # Preferences - self.prefs_dialog_size = None self.prefs_dialog_instance = None # Actions @@ -1310,11 +1309,7 @@ def apply_panes_settings(self): @Slot() def show_preferences(self): """Edit Spyder preferences.""" - self.preferences.open_dialog(self.prefs_dialog_size) - - def set_prefs_size(self, size): - """Save preferences dialog size.""" - self.prefs_dialog_size = size + self.preferences.open_dialog() # ---- Open files server # ------------------------------------------------------------------------- diff --git a/spyder/app/tests/conftest.py b/spyder/app/tests/conftest.py index a73f377906d..8ca00b58d80 100755 --- a/spyder/app/tests/conftest.py +++ b/spyder/app/tests/conftest.py @@ -224,14 +224,14 @@ def preferences_dialog_helper(qtbot, main_window, section): shell = main_window.ipyconsole.get_current_shellwidget() qtbot.waitUntil( lambda: shell.spyder_kernel_ready and shell._prompt_html is not None, - timeout=SHELL_TIMEOUT) + timeout=SHELL_TIMEOUT + ) main_window.show_preferences() preferences = main_window.preferences container = preferences.get_container() - qtbot.waitUntil(lambda: container.dialog is not None, - timeout=5000) + qtbot.waitUntil(lambda: container.dialog is not None, timeout=5000) dlg = container.dialog index = dlg.get_index_by_name(section) page = dlg.get_page(index) diff --git a/spyder/config/main.py b/spyder/config/main.py index 2f9c49e390d..5cd419c7016 100644 --- a/spyder/config/main.py +++ b/spyder/config/main.py @@ -74,7 +74,6 @@ 'window/position': (10, 10), 'window/is_maximized': True, 'window/is_fullscreen': False, - 'window/prefs_dialog_size': (1050, 530), 'use_custom_margin': True, 'custom_margin': 0, 'use_custom_cursor_blinking': False, @@ -292,6 +291,13 @@ 'follow_cursor': True, 'display_variables': False }), + ('preferences', + { + 'enable': True, + 'dialog_size': ( + (1010, 725) if MAC else ((900, 670) if WIN else (950, 690)) + ), + }), ('project_explorer', { 'name_filters': NAME_FILTERS, @@ -567,7 +573,6 @@ 'current_version', 'historylog_filename', 'window/position', - 'window/prefs_dialog_size', 'window/size', 'window/state', ] @@ -611,6 +616,10 @@ 'scrollbar_position', ], ), + ('preferences', [ + 'dialog_size', + ], + ), ('project_explorer', [ 'current_project_path', 'expanded_state', @@ -653,4 +662,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 = '79.0.0' +CONF_VERSION = '80.0.0' diff --git a/spyder/dependencies.py b/spyder/dependencies.py index 0c54708e610..c29fb42f20d 100644 --- a/spyder/dependencies.py +++ b/spyder/dependencies.py @@ -71,11 +71,11 @@ SETUPTOOLS_REQVER = '>=49.6.0' SPHINX_REQVER = '>=0.6.6' SPYDER_KERNELS_REQVER = '>=3.0.0b2,<3.0.0b3' +SUPERQT_REQVER = '>=0.6.1,<1.0.0' TEXTDISTANCE_REQVER = '>=4.2.0' THREE_MERGE_REQVER = '>=0.1.1' WATCHDOG_REQVER = '>=0.10.3' - # Optional dependencies CYTHON_REQVER = '>=0.21' MATPLOTLIB_REQVER = '>=3.0.0' @@ -234,11 +234,11 @@ 'required_version': QTPY_REQVER}, {'modname': "rtree", 'package_name': "rtree", - 'features': _("Fast access to code snippets regions"), + 'features': _("Fast access to code snippet regions"), 'required_version': RTREE_REQVER}, {'modname': "setuptools", 'package_name': "setuptools", - 'features': _("Determine package version"), + 'features': _("Determine package versions"), 'required_version': SETUPTOOLS_REQVER}, {'modname': "sphinx", 'package_name': "sphinx", @@ -248,6 +248,10 @@ 'package_name': "spyder-kernels", 'features': _("Jupyter kernels for the Spyder console"), 'required_version': SPYDER_KERNELS_REQVER}, + {'modname': "superqt", + 'package_name': "superqt", + 'features': _("Special widgets and utilities for PyQt applications"), + 'required_version': SUPERQT_REQVER}, {'modname': 'textdistance', 'package_name': "textdistance", 'features': _('Compute distances between strings'), diff --git a/spyder/plugins/appearance/confpage.py b/spyder/plugins/appearance/confpage.py index 4c3943ddf99..e3ba9918526 100644 --- a/spyder/plugins/appearance/confpage.py +++ b/spyder/plugins/appearance/confpage.py @@ -20,6 +20,7 @@ from spyder.plugins.appearance.widgets import SchemeEditor from spyder.utils import syntaxhighlighters from spyder.utils.palette import QStylePalette +from spyder.utils.stylesheet import AppStyle from spyder.widgets.simplecodeeditor import SimpleCodeEditor @@ -77,6 +78,7 @@ def setup_page(self): self.reset_button = QPushButton(_("Reset to defaults")) self.preview_editor = SimpleCodeEditor(self) + self.preview_editor.setMinimumWidth(210) self.stacked_widget = QStackedWidget(self) self.scheme_editor_dialog = SchemeEditor(parent=self, stack=self.stacked_widget) @@ -132,12 +134,10 @@ def setup_page(self): fonts_grid_layout = QGridLayout() fonts_grid_layout.addWidget(self.plain_text_font.fontlabel, 0, 0) fonts_grid_layout.addWidget(self.plain_text_font.fontbox, 0, 1) - fonts_grid_layout.addWidget(self.plain_text_font.sizelabel, 0, 2) - fonts_grid_layout.addWidget(self.plain_text_font.sizebox, 0, 3) + fonts_grid_layout.addWidget(self.plain_text_font.sizebox, 0, 2) fonts_grid_layout.addWidget(self.app_font.fontlabel, 2, 0) fonts_grid_layout.addWidget(self.app_font.fontbox, 2, 1) - fonts_grid_layout.addWidget(self.app_font.sizelabel, 2, 2) - fonts_grid_layout.addWidget(self.app_font.sizebox, 2, 3) + fonts_grid_layout.addWidget(self.app_font.sizebox, 2, 2) fonts_grid_layout.setRowStretch(fonts_grid_layout.rowCount(), 1) fonts_layout = QVBoxLayout() @@ -161,8 +161,7 @@ def setup_page(self): # Combined layout combined_layout = QGridLayout() - combined_layout.setRowStretch(0, 1) - combined_layout.setColumnStretch(1, 100) + combined_layout.setHorizontalSpacing(AppStyle.MarginSize * 5) combined_layout.addLayout(options_layout, 0, 0) combined_layout.addWidget(preview_group, 0, 1) @@ -349,7 +348,7 @@ def update_preview(self, index=None, scheme_name=None): def update_app_font_group(self, state): """Update app font group enabled state.""" - subwidgets = ['fontlabel', 'sizelabel', 'fontbox', 'sizebox'] + subwidgets = ['fontlabel', 'fontbox', 'sizebox'] if state: for widget in subwidgets: diff --git a/spyder/plugins/appearance/tests/test_confpage.py b/spyder/plugins/appearance/tests/test_confpage.py index d74a2c4e4ff..7da0f208087 100644 --- a/spyder/plugins/appearance/tests/test_confpage.py +++ b/spyder/plugins/appearance/tests/test_confpage.py @@ -12,7 +12,7 @@ # Local imports from spyder.config.manager import CONF from spyder.plugins.appearance.plugin import Appearance -from spyder.plugins.preferences.api import SpyderConfigPage +from spyder.plugins.preferences.widgets.config_widgets import SpyderConfigPage from spyder.plugins.preferences.tests.conftest import ( config_dialog, MainWindowMock) diff --git a/spyder/plugins/application/confpage.py b/spyder/plugins/application/confpage.py index 11981e1ff43..b55a2083f58 100644 --- a/spyder/plugins/application/confpage.py +++ b/spyder/plugins/application/confpage.py @@ -18,8 +18,8 @@ from qtpy.compat import from_qvariant from qtpy.QtCore import Qt from qtpy.QtWidgets import (QApplication, QButtonGroup, QGridLayout, QGroupBox, - QHBoxLayout, QLabel, QMessageBox, QTabWidget, - QVBoxLayout, QWidget) + QHBoxLayout, QLabel, QMessageBox, QVBoxLayout, + QWidget) from spyder.config.base import (_, DISABLED_LANGUAGES, LANGUAGE_CODES, is_conda_based_app, save_lang_conf) @@ -236,21 +236,19 @@ def set_open_file(state): screen_resolution_layout.addLayout(screen_resolution_inner_layout) screen_resolution_group.setLayout(screen_resolution_layout) + if sys.platform == "darwin" and not is_conda_based_app(): - interface_tab = self.create_tab(screen_resolution_group, - interface_group, macOS_group) + self.create_tab( + _("Interface"), + [screen_resolution_group, interface_group, macOS_group] + ) else: - interface_tab = self.create_tab(screen_resolution_group, - interface_group) - - self.tabs = QTabWidget() - self.tabs.addTab(interface_tab, _("Interface")) - self.tabs.addTab(self.create_tab(advanced_widget), - _("Advanced settings")) + self.create_tab( + _("Interface"), + [screen_resolution_group, interface_group] + ) - vlayout = QVBoxLayout() - vlayout.addWidget(self.tabs) - self.setLayout(vlayout) + self.create_tab(_("Advanced settings"), advanced_widget) def apply_settings(self, options): if 'high_dpi_custom_scale_factors' in options: diff --git a/spyder/plugins/completion/confpage.py b/spyder/plugins/completion/confpage.py index 1ff6ff239c2..09265a6fbcc 100644 --- a/spyder/plugins/completion/confpage.py +++ b/spyder/plugins/completion/confpage.py @@ -87,6 +87,7 @@ def disable_completion_after_characters(state): disable_completion_after_characters) layout = QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(self.completions_group) layout.addWidget(self.providers_group) layout.addStretch(1) diff --git a/spyder/plugins/completion/providers/languageserver/conftabs/otherlanguages.py b/spyder/plugins/completion/providers/languageserver/conftabs/otherlanguages.py index 32b74a65088..514adb05394 100644 --- a/spyder/plugins/completion/providers/languageserver/conftabs/otherlanguages.py +++ b/spyder/plugins/completion/providers/languageserver/conftabs/otherlanguages.py @@ -73,10 +73,10 @@ def __init__(self, parent): # Combined layout servers_layout = QVBoxLayout() - servers_layout.addSpacing(-10) servers_layout.addWidget(servers_label) + servers_layout.addSpacing(9) servers_layout.addWidget(table_group) - servers_layout.addSpacing(10) + servers_layout.addSpacing(9) servers_layout.addLayout(buttons_layout) self.setLayout(servers_layout) diff --git a/spyder/plugins/completion/providers/snippets/conftabs.py b/spyder/plugins/completion/providers/snippets/conftabs.py index 1e42c573d90..2f4ed2d7c4d 100644 --- a/spyder/plugins/completion/providers/snippets/conftabs.py +++ b/spyder/plugins/completion/providers/snippets/conftabs.py @@ -14,10 +14,9 @@ # Third party imports from qtpy.compat import getsavefilename, getopenfilename -from qtpy.QtCore import Qt, Slot +from qtpy.QtCore import Qt from qtpy.QtWidgets import (QComboBox, QGroupBox, QGridLayout, QLabel, - QMessageBox, QPushButton, QTabWidget, QVBoxLayout, - QWidget, QFileDialog) + QMessageBox, QPushButton, QVBoxLayout, QFileDialog) # Local imports from spyder.config.base import _ @@ -57,6 +56,7 @@ def __init__(self, parent): self.change_language_snippets) snippet_lang_group = QGroupBox(_('Language')) + snippet_lang_group.setStyleSheet('margin-bottom: 3px') snippet_lang_layout = QVBoxLayout() snippet_lang_layout.addWidget(self.snippets_language_cb) snippet_lang_group.setLayout(snippet_lang_layout) @@ -103,6 +103,7 @@ def __init__(self, parent): # Snippets layout snippets_layout = QVBoxLayout() snippets_layout.addWidget(snippets_info_label) + snippets_layout.addSpacing(9) snippets_layout.addWidget(snippet_lang_group) snippets_layout.addWidget(snippet_table_group) snippets_layout.addLayout(sn_buttons_layout) diff --git a/spyder/plugins/completion/tests/conftest.py b/spyder/plugins/completion/tests/conftest.py index c617e6d16b0..30936d70460 100644 --- a/spyder/plugins/completion/tests/conftest.py +++ b/spyder/plugins/completion/tests/conftest.py @@ -61,9 +61,6 @@ def get_plugin(self, plugin_name, error=True): if plugin_name in PLUGIN_REGISTRY: return PLUGIN_REGISTRY.get_plugin(plugin_name) - def set_prefs_size(self, size): - pass - @pytest.fixture(scope="module") def qtbot_module(qapp, request): diff --git a/spyder/plugins/editor/confpage.py b/spyder/plugins/editor/confpage.py index 9497d33b5ff..387d932dece 100644 --- a/spyder/plugins/editor/confpage.py +++ b/spyder/plugins/editor/confpage.py @@ -6,8 +6,11 @@ """Editor config page.""" +import os +import sys + from qtpy.QtWidgets import (QGridLayout, QGroupBox, QHBoxLayout, QLabel, - QTabWidget, QVBoxLayout, QWidget) + QVBoxLayout) from spyder.api.config.decorators import on_conf_change from spyder.api.config.mixins import SpyderConfigurationObserver @@ -32,10 +35,24 @@ class EditorConfigPage(PluginConfigPage, SpyderConfigurationObserver): def __init__(self, plugin, parent): PluginConfigPage.__init__(self, plugin, parent) SpyderConfigurationObserver.__init__(self) + self.removetrail_box = None self.add_newline_box = None self.remove_trail_newline_box = None + # *********************** IMPORTANT NOTES ***************************** + # * This value needs to be ajusted if we add new options to the + # "Advanced settings" tab. + # * We need to do this so that the text of some options is not clipped. + if os.name == "nt": + min_height = 620 + elif sys.platform == "darwin": + min_height = 760 + else: + min_height = 670 + + self.setMinimumHeight(min_height) + def get_name(self): return _("Editor") @@ -80,15 +97,16 @@ def setup_page(self): occurrence_spin.slabel.setEnabled( self.get_option('occurrence_highlighting')) - display_g_layout = QGridLayout() - display_g_layout.addWidget(occurrence_box, 0, 0) - display_g_layout.addWidget(occurrence_spin.spinbox, 0, 1) - display_g_layout.addWidget(occurrence_spin.slabel, 0, 2) + occurrence_glayout = QGridLayout() + occurrence_glayout.addWidget(occurrence_box, 0, 0) + occurrence_glayout.addWidget(occurrence_spin.spinbox, 0, 1) + occurrence_glayout.addWidget(occurrence_spin.slabel, 0, 2) - display_h_layout = QHBoxLayout() - display_h_layout.addLayout(display_g_layout) - display_h_layout.addStretch(1) + occurrence_layout = QHBoxLayout() + occurrence_layout.addLayout(occurrence_glayout) + occurrence_layout.addStretch(1) + display_group = QGroupBox(_("Display")) display_layout = QVBoxLayout() display_layout.addWidget(showtabbar_box) display_layout.addWidget(showclassfuncdropdown_box) @@ -97,14 +115,20 @@ def setup_page(self): display_layout.addWidget(linenumbers_box) display_layout.addWidget(breakpoints_box) display_layout.addWidget(blanks_box) - display_layout.addWidget(currentline_box) - display_layout.addWidget(currentcell_box) - display_layout.addWidget(wrap_mode_box) - display_layout.addWidget(scroll_past_end_box) - display_layout.addLayout(display_h_layout) + display_group.setLayout(display_layout) + + highlight_group = QGroupBox(_("Highlight")) + highlight_layout = QVBoxLayout() + highlight_layout.addWidget(currentline_box) + highlight_layout.addWidget(currentcell_box) + highlight_layout.addLayout(occurrence_layout) + highlight_group.setLayout(highlight_layout) - display_widget = QWidget() - display_widget.setLayout(display_layout) + other_group = QGroupBox(_("Other")) + other_layout = QVBoxLayout() + other_layout.addWidget(wrap_mode_box) + other_layout.addWidget(scroll_past_end_box) + other_group.setLayout(other_layout) # --- Source code tab --- closepar_box = newcb( @@ -128,7 +152,7 @@ def setup_page(self): "completion may be triggered using the alternate\n" "shortcut: Ctrl+Space)")) strip_mode_box = newcb( - _("Automatically strip trailing spaces on changed lines"), + _("Automatic stripping of trailing spaces on changed lines"), 'strip_trailing_spaces_on_modify', default=True, tip=_("If enabled, modified lines of code (excluding strings)\n" "will have their trailing whitespace stripped when leaving them.\n" @@ -136,9 +160,11 @@ def setup_page(self): ibackspace_box = newcb( _("Intelligent backspace"), 'intelligent_backspace', + tip=_("Make the backspace key automatically remove the amount of " + "indentation characters set above."), default=True) self.removetrail_box = newcb( - _("Automatically remove trailing spaces when saving files"), + _("Automatic removal of trailing spaces when saving files"), 'always_remove_trailing_spaces', default=False) self.add_newline_box = newcb( @@ -197,27 +223,36 @@ def enable_tabwidth_spin(index): indent_tab_layout.addLayout(indent_tab_grid_layout) indent_tab_layout.addStretch(1) - sourcecode_layout = QVBoxLayout() - sourcecode_layout.addWidget(closepar_box) - sourcecode_layout.addWidget(autounindent_box) - sourcecode_layout.addWidget(add_colons_box) - sourcecode_layout.addWidget(close_quotes_box) - sourcecode_layout.addWidget(tab_mode_box) - sourcecode_layout.addWidget(ibackspace_box) - sourcecode_layout.addWidget(self.removetrail_box) - sourcecode_layout.addWidget(self.add_newline_box) - sourcecode_layout.addWidget(self.remove_trail_newline_box) - sourcecode_layout.addWidget(strip_mode_box) - sourcecode_layout.addLayout(indent_tab_layout) - - sourcecode_widget = QWidget() - sourcecode_widget.setLayout(sourcecode_layout) + automatic_group = QGroupBox(_("Automatic changes")) + automatic_layout = QVBoxLayout() + automatic_layout.addWidget(closepar_box) + automatic_layout.addWidget(autounindent_box) + automatic_layout.addWidget(add_colons_box) + automatic_layout.addWidget(close_quotes_box) + automatic_layout.addWidget(self.removetrail_box) + automatic_layout.addWidget(strip_mode_box) + automatic_layout.addWidget(self.add_newline_box) + automatic_layout.addWidget(self.remove_trail_newline_box) + automatic_group.setLayout(automatic_layout) + + indentation_group = QGroupBox(_("Indentation")) + indentation_layout = QVBoxLayout() + indentation_layout.addLayout(indent_tab_layout) + indentation_layout.addWidget(ibackspace_box) + indentation_layout.addWidget(tab_mode_box) + indentation_group.setLayout(indentation_layout) # --- Advanced tab --- # -- Templates + templates_group = QGroupBox(_('Templates')) template_btn = self.create_button(_("Edit template for new files"), self.plugin.edit_template) + templates_layout = QVBoxLayout() + templates_layout.addSpacing(3) + templates_layout.addWidget(template_btn) + templates_group.setLayout(templates_layout) + # -- Autosave autosave_group = QGroupBox(_('Autosave')) autosave_checkbox = newcb( @@ -324,17 +359,18 @@ def enable_tabwidth_spin(index): eol_group.setLayout(eol_layout) # --- Tabs --- - self.tabs = QTabWidget() - self.tabs.addTab(self.create_tab(display_widget), _("Display")) - self.tabs.addTab(self.create_tab(sourcecode_widget), _("Source code")) - self.tabs.addTab(self.create_tab(template_btn, autosave_group, - docstring_group, annotations_group, - eol_group), - _("Advanced settings")) - - vlayout = QVBoxLayout() - vlayout.addWidget(self.tabs) - self.setLayout(vlayout) + self.create_tab( + _("Interface"), + [display_group, highlight_group, other_group] + ) + + self.create_tab(_("Source code"), [automatic_group, indentation_group]) + + self.create_tab( + _("Advanced settings"), + [templates_group, autosave_group, docstring_group, + annotations_group, eol_group] + ) @on_conf_change( option=('provider_configuration', 'lsp', 'values', 'format_on_save'), diff --git a/spyder/plugins/explorer/confpage.py b/spyder/plugins/explorer/confpage.py index e52a1e5994c..5e9bd9cb969 100644 --- a/spyder/plugins/explorer/confpage.py +++ b/spyder/plugins/explorer/confpage.py @@ -7,8 +7,8 @@ """File explorer configuration page.""" # Third party imports -from qtpy.QtWidgets import (QTabWidget, QVBoxLayout, QWidget, QGroupBox, - QLabel, QPushButton) +from qtpy.QtWidgets import (QGroupBox, QLabel, QPushButton, QVBoxLayout, + QWidget) # Local imports from spyder.api.preferences import PluginConfigPage @@ -86,15 +86,8 @@ def setup_page(self): layout_file.addWidget(self.edit_file_associations) associations_widget.setLayout(layout_file) - self.tabs = QTabWidget() - self.tabs.addTab(self.create_tab(general_widget), _("General")) - self.tabs.addTab(self.create_tab(associations_widget), - _("File associations")) - - tab_layout = QVBoxLayout() - tab_layout.addWidget(self.tabs) - - self.setLayout(tab_layout) + self.create_tab(_("General"), general_widget) + self.create_tab(_("File associations"), associations_widget) # Signals file_associations.sig_data_changed.connect(self.update_associations) diff --git a/spyder/plugins/ipythonconsole/confpage.py b/spyder/plugins/ipythonconsole/confpage.py index 19f37b029e3..e0f70b95305 100644 --- a/spyder/plugins/ipythonconsole/confpage.py +++ b/spyder/plugins/ipythonconsole/confpage.py @@ -12,7 +12,7 @@ # Third party imports from qtpy.QtCore import Qt from qtpy.QtWidgets import (QGridLayout, QGroupBox, QHBoxLayout, QLabel, - QTabWidget, QVBoxLayout) + QVBoxLayout) # Local imports from spyder.api.translations import _ @@ -24,36 +24,45 @@ class IPythonConsoleConfigPage(PluginConfigPage): def setup_page(self): newcb = self.create_checkbox - # Interface Group - interface_group = QGroupBox(_("Interface")) + # Display group + display_group = QGroupBox(_("Display")) banner_box = newcb(_("Display initial banner"), 'show_banner', tip=_("This option lets you hide the message " "shown at\nthe top of the console when " "it's opened.")) calltips_box = newcb(_("Show calltips"), 'show_calltips') - ask_box = newcb(_("Ask for confirmation before closing"), - 'ask_before_closing') - reset_namespace_box = newcb( - _("Ask for confirmation before removing all user-defined " - "variables"), - 'show_reset_namespace_warning', - tip=_("This option lets you hide the warning message shown\n" - "when resetting the namespace from Spyder.")) show_time_box = newcb(_("Show elapsed time"), 'show_elapsed_time') + + display_layout = QVBoxLayout() + display_layout .addWidget(banner_box) + display_layout .addWidget(calltips_box) + display_layout.addWidget(show_time_box) + display_group.setLayout(display_layout) + + # Confirmations group + confirmations_group = QGroupBox(_("Confirmations")) + ask_box = newcb( + _("Ask for confirmation before closing"), 'ask_before_closing' + ) + reset_namespace_box = newcb( + _("Ask for confirmation before removing all user-defined " + "variables"), + 'show_reset_namespace_warning', + tip=_("This option lets you hide the warning message shown\n" + "when resetting the namespace from Spyder.") + ) ask_restart_box = newcb( - _("Ask for confirmation before restarting"), - 'ask_before_restart', - tip=_("This option lets you hide the warning message shown\n" - "when restarting the kernel.")) - - interface_layout = QVBoxLayout() - interface_layout.addWidget(banner_box) - interface_layout.addWidget(calltips_box) - interface_layout.addWidget(ask_box) - interface_layout.addWidget(reset_namespace_box) - interface_layout.addWidget(show_time_box) - interface_layout.addWidget(ask_restart_box) - interface_group.setLayout(interface_layout) + _("Ask for confirmation before restarting"), + 'ask_before_restart', + tip=_("This option lets you hide the warning message shown\n" + "when restarting the kernel.") + ) + + confirmations_layout = QVBoxLayout() + confirmations_layout.addWidget(ask_box) + confirmations_layout.addWidget(reset_namespace_box) + confirmations_layout.addWidget(ask_restart_box) + confirmations_group.setLayout(confirmations_layout) comp_group = QGroupBox(_("Completion type")) comp_label = QLabel(_("Decide what type of completion to use")) @@ -61,6 +70,7 @@ def setup_page(self): completers = [(_("Graphical"), 0), (_("Terminal"), 1), (_("Plain"), 2)] comp_box = self.create_combobox(_("Completion:")+" ", completers, 'completion_type') + comp_layout = QVBoxLayout() comp_layout.addWidget(comp_label) comp_layout.addWidget(comp_box) @@ -78,6 +88,7 @@ def setup_page(self): tip=_("Set the maximum number of lines of text shown in the\n" "console before truncation. Specifying -1 disables it\n" "(not recommended!)")) + source_code_layout = QVBoxLayout() source_code_layout.addWidget(buffer_spin) source_code_group.setLayout(source_code_layout) @@ -257,7 +268,7 @@ def setup_page(self): "<Space> for some completions; " "e.g. np.sin(<Space>np.<Tab>" " works while np.sin(np.<Tab> " - " doesn't.")) + " doesn't.

")) greedy_label.setWordWrap(True) greedy_box = newcb(_("Use greedy completion in the IPython console"), "greedy_completer", @@ -333,8 +344,6 @@ def setup_page(self): '%i</span>]:'), alignment=Qt.Horizontal) - prompts_layout = QVBoxLayout() - prompts_layout.addWidget(prompts_label) prompts_g_layout = QGridLayout() prompts_g_layout.addWidget(in_prompt_edit.label, 0, 0) prompts_g_layout.addWidget(in_prompt_edit.textbox, 0, 1) @@ -342,6 +351,9 @@ def setup_page(self): prompts_g_layout.addWidget(out_prompt_edit.label, 1, 0) prompts_g_layout.addWidget(out_prompt_edit.textbox, 1, 1) prompts_g_layout.addWidget(out_prompt_edit.help_label, 1, 2) + + prompts_layout = QVBoxLayout() + prompts_layout.addWidget(prompts_label) prompts_layout.addLayout(prompts_g_layout) prompts_group.setLayout(prompts_layout) @@ -356,18 +368,23 @@ def setup_page(self): windows_group.setLayout(windows_layout) # --- Tabs organization --- - self.tabs = QTabWidget() - self.tabs.addTab(self.create_tab(interface_group, comp_group, - source_code_group), _("Display")) - self.tabs.addTab(self.create_tab( - pylab_group, backend_group, inline_group), _("Graphics")) - self.tabs.addTab(self.create_tab( - run_lines_group, run_file_group), _("Startup")) - self.tabs.addTab(self.create_tab( - jedi_group, greedy_group, autocall_group, - sympy_group, prompts_group, - windows_group), _("Advanced settings")) - - vlayout = QVBoxLayout() - vlayout.addWidget(self.tabs) - self.setLayout(vlayout) + self.create_tab( + _("Interface"), + [display_group, confirmations_group, comp_group, source_code_group] + ) + + self.create_tab( + _("Graphics"), + [pylab_group, backend_group, inline_group] + ) + + self.create_tab( + _("Startup"), + [run_lines_group, run_file_group] + ) + + self.create_tab( + _("Advanced settings"), + [jedi_group, greedy_group, autocall_group, sympy_group, + prompts_group, windows_group] + ) diff --git a/spyder/plugins/layout/plugin.py b/spyder/plugins/layout/plugin.py index ca0423563be..ddb7ec942b6 100644 --- a/spyder/plugins/layout/plugin.py +++ b/spyder/plugins/layout/plugin.py @@ -33,7 +33,7 @@ HorizontalSplitLayout, MatlabLayout, RLayout, SpyderLayout, VerticalSplitLayout) -from spyder.plugins.preferences.widgets.container import PreferencesActions +from spyder.plugins.preferences.api import PreferencesActions from spyder.plugins.toolbar.api import ( ApplicationToolbars, MainToolbarSections) from spyder.py3compat import qbytearray_to_str # FIXME: @@ -416,9 +416,9 @@ def quick_layout_switch(self, index_or_layout_id): container = self.get_container() try: settings = self.load_window_settings( - 'layout_{}/'.format(index_or_layout_id), section=section) - (hexstate, window_size, prefs_dialog_size, pos, is_maximized, - is_fullscreen) = settings + 'layout_{}/'.format(index_or_layout_id), section=section + ) + hexstate, window_size, pos, is_maximized, is_fullscreen = settings # The defaults layouts will always be regenerated unless there was # an overwrite, either by rewriting with same name, or by deleting @@ -474,8 +474,6 @@ def load_window_settings(self, prefix, default=False, section='main'): """ get_func = self.get_conf_default if default else self.get_conf window_size = get_func(prefix + 'size', section=section) - prefs_dialog_size = get_func( - prefix + 'prefs_dialog_size', section=section) if default: hexstate = None @@ -499,8 +497,7 @@ def load_window_settings(self, prefix, default=False, section='main'): is_maximized = get_func(prefix + 'is_maximized', section=section) is_fullscreen = get_func(prefix + 'is_fullscreen', section=section) - return (hexstate, window_size, prefs_dialog_size, pos, is_maximized, - is_fullscreen) + return (hexstate, window_size, pos, is_maximized, is_fullscreen) def get_window_settings(self): """ @@ -518,25 +515,19 @@ def get_window_settings(self): is_maximized = self.main.isMaximized() pos = (self.window_position.x(), self.window_position.y()) - prefs_dialog_size = (self.prefs_dialog_size.width(), - self.prefs_dialog_size.height()) hexstate = qbytearray_to_str( self.main.saveState(version=WINDOW_STATE_VERSION) ) - return (hexstate, window_size, prefs_dialog_size, pos, is_maximized, - is_fullscreen) + return (hexstate, window_size, pos, is_maximized, is_fullscreen) - def set_window_settings(self, hexstate, window_size, prefs_dialog_size, - pos, is_maximized, is_fullscreen): + def set_window_settings(self, hexstate, window_size, pos, is_maximized, + is_fullscreen): """ Set window settings Symetric to the 'get_window_settings' accessor. """ main = self.main main.setUpdatesEnabled(False) - self.prefs_dialog_size = QSize(prefs_dialog_size[0], - prefs_dialog_size[1]) # width,height - main.set_prefs_size(self.prefs_dialog_size) self.window_size = QSize(window_size[0], window_size[1]) # width, height self.window_position = QPoint(pos[0], pos[1]) # x,y @@ -585,18 +576,12 @@ def save_current_window_settings(self, prefix, section='main', # Fixes spyder-ide/spyder#13882 win_size = self.main.size() pos = self.main.pos() - prefs_size = self.prefs_dialog_size self.set_conf( prefix + 'size', (win_size.width(), win_size.height()), section=section, ) - self.set_conf( - prefix + 'prefs_dialog_size', - (prefs_size.width(), prefs_size.height()), - section=section, - ) self.set_conf( prefix + 'is_maximized', self.main.isMaximized(), diff --git a/spyder/plugins/maininterpreter/tests/test_confpage.py b/spyder/plugins/maininterpreter/tests/test_confpage.py index 57f5012b502..38653648b60 100644 --- a/spyder/plugins/maininterpreter/tests/test_confpage.py +++ b/spyder/plugins/maininterpreter/tests/test_confpage.py @@ -46,7 +46,7 @@ def test_load_time(qtbot): # Create page and measure time to do it t0 = time.time() - preferences.open_dialog(None) + preferences.open_dialog() load_time = time.time() - t0 container = preferences.get_container() diff --git a/spyder/plugins/preferences/__init__.py b/spyder/plugins/preferences/__init__.py index 88a2b96a750..66c374cd4d8 100644 --- a/spyder/plugins/preferences/__init__.py +++ b/spyder/plugins/preferences/__init__.py @@ -10,3 +10,15 @@ Preferences plugin """ + +from spyder.api.plugins import Plugins + + +# We consider these to be the plugins with the most important pages. So, we'll +# show those pages as the first entries in the config dialog +MOST_IMPORTANT_PAGES = [ + Plugins.Appearance, + Plugins.Application, + Plugins.MainInterpreter, + Plugins.Shortcuts, +] diff --git a/spyder/plugins/preferences/api.py b/spyder/plugins/preferences/api.py index f0626125476..2d68fb98494 100644 --- a/spyder/plugins/preferences/api.py +++ b/spyder/plugins/preferences/api.py @@ -5,960 +5,10 @@ # (see spyder/__init__.py for details) """ -Preferences plugin public facing API +Preferences Plugin API. """ -# Standard library imports -import ast -import os.path as osp -# Third party imports -from qtpy import API -from qtpy.compat import (getexistingdirectory, getopenfilename, from_qvariant, - to_qvariant) -from qtpy.QtCore import Qt, Signal, Slot, QRegExp, QSize -from qtpy.QtGui import QColor, QRegExpValidator, QTextOption, QPixmap -from qtpy.QtWidgets import (QButtonGroup, QCheckBox, QComboBox, QDoubleSpinBox, - QFileDialog, QFontComboBox, QGridLayout, QGroupBox, - QHBoxLayout, QLabel, QLineEdit, QMessageBox, - QPlainTextEdit, QPushButton, QRadioButton, - QSpinBox, QTabWidget, QVBoxLayout, QWidget, QSizePolicy) - -# Local imports -from spyder.config.base import _ -from spyder.config.manager import CONF -from spyder.config.user import NoDefault -from spyder.py3compat import to_text_string -from spyder.utils.icon_manager import ima -from spyder.utils.image_path_manager import get_image_path -from spyder.utils.misc import getcwd_or_home -from spyder.widgets.colors import ColorLayout -from spyder.widgets.comboboxes import FileComboBox - - -class BaseConfigTab(QWidget): - """Stub class to declare a config tab.""" - pass - - -class ConfigAccessMixin(object): - """Namespace for methods that access config storage""" - CONF_SECTION = None - - def set_option(self, option, value, section=None, - recursive_notification=False): - section = self.CONF_SECTION if section is None else section - CONF.set(section, option, value, - recursive_notification=recursive_notification) - - def get_option(self, option, default=NoDefault, section=None): - section = self.CONF_SECTION if section is None else section - return CONF.get(section, option, default) - - def remove_option(self, option, section=None): - section = self.CONF_SECTION if section is None else section - CONF.remove_option(section, option) - - -class ConfigPage(QWidget): - """Base class for configuration page in Preferences""" - - # Signals - apply_button_enabled = Signal(bool) - show_this_page = Signal() - - def __init__(self, parent, apply_callback=None): - QWidget.__init__(self, parent) - - # Callback to call before saving settings to disk - self.pre_apply_callback = None - - # Callback to call after saving settings to disk - self.apply_callback = apply_callback - - self.is_modified = False - - def initialize(self): - """ - Initialize configuration page: - * setup GUI widgets - * load settings and change widgets accordingly - """ - self.setup_page() - self.load_from_conf() - - def get_name(self): - """Return configuration page name""" - raise NotImplementedError - - def get_icon(self): - """Return configuration page icon (24x24)""" - raise NotImplementedError - - def setup_page(self): - """Setup configuration page widget""" - raise NotImplementedError - - def set_modified(self, state): - self.is_modified = state - self.apply_button_enabled.emit(state) - - def is_valid(self): - """Return True if all widget contents are valid""" - raise NotImplementedError - - def apply_changes(self): - """Apply changes callback""" - if self.is_modified: - if self.pre_apply_callback is not None: - self.pre_apply_callback() - - self.save_to_conf() - - if self.apply_callback is not None: - self.apply_callback() - - # Since the language cannot be retrieved by CONF and the language - # is needed before loading CONF, this is an extra method needed to - # ensure that when changes are applied, they are copied to a - # specific file storing the language value. This only applies to - # the main section config. - if self.CONF_SECTION == u'main': - self._save_lang() - - for restart_option in self.restart_options: - if restart_option in self.changed_options: - self.prompt_restart_required() - break # Ensure a single popup is displayed - self.set_modified(False) - - def load_from_conf(self): - """Load settings from configuration file""" - raise NotImplementedError - - def save_to_conf(self): - """Save settings to configuration file""" - raise NotImplementedError - - -class SpyderConfigPage(ConfigPage, ConfigAccessMixin): - """Plugin configuration dialog box page widget""" - CONF_SECTION = None - - def __init__(self, parent): - ConfigPage.__init__(self, parent, - apply_callback=lambda: - self._apply_settings_tabs(self.changed_options)) - self.checkboxes = {} - self.radiobuttons = {} - self.lineedits = {} - self.textedits = {} - self.validate_data = {} - self.spinboxes = {} - self.comboboxes = {} - self.fontboxes = {} - self.coloredits = {} - self.scedits = {} - self.cross_section_options = {} - self.changed_options = set() - self.restart_options = dict() # Dict to store name and localized text - self.default_button_group = None - self.main = parent.main - self.tabs = None - - def _apply_settings_tabs(self, options): - if self.tabs is not None: - for i in range(self.tabs.count()): - tab = self.tabs.widget(i) - layout = tab.layout() - for i in range(layout.count()): - widget = layout.itemAt(i).widget() - if hasattr(widget, 'apply_settings'): - if issubclass(type(widget), BaseConfigTab): - options |= widget.apply_settings() - self.apply_settings(options) - - def apply_settings(self, options): - raise NotImplementedError - - def check_settings(self): - """This method is called to check settings after configuration - dialog has been shown""" - pass - - def set_modified(self, state): - ConfigPage.set_modified(self, state) - if not state: - self.changed_options = set() - - def is_valid(self): - """Return True if all widget contents are valid""" - status = True - for lineedit in self.lineedits: - if lineedit in self.validate_data and lineedit.isEnabled(): - validator, invalid_msg = self.validate_data[lineedit] - text = to_text_string(lineedit.text()) - if not validator(text): - QMessageBox.critical(self, self.get_name(), - f"{invalid_msg}:
{text}", - QMessageBox.Ok) - return False - - if self.tabs is not None and status: - for i in range(self.tabs.count()): - tab = self.tabs.widget(i) - layout = tab.layout() - for i in range(layout.count()): - widget = layout.itemAt(i).widget() - if issubclass(type(widget), BaseConfigTab): - status &= widget.is_valid() - if not status: - return status - return status - - def load_from_conf(self): - """Load settings from configuration file.""" - for checkbox, (sec, option, default) in list(self.checkboxes.items()): - checkbox.setChecked(self.get_option(option, default, section=sec)) - checkbox.clicked[bool].connect(lambda _, opt=option, sect=sec: - self.has_been_modified(sect, opt)) - if checkbox.restart_required: - if sec is None: - self.restart_options[option] = checkbox.text() - else: - self.restart_options[(sec, option)] = checkbox.text() - - for radiobutton, (sec, option, default) in list( - self.radiobuttons.items()): - radiobutton.setChecked(self.get_option(option, default, - section=sec)) - radiobutton.toggled.connect(lambda _foo, opt=option, sect=sec: - self.has_been_modified(sect, opt)) - if radiobutton.restart_required: - if sec is None: - self.restart_options[option] = radiobutton.label_text - else: - self.restart_options[(sec, option)] = radiobutton.label_text - - for lineedit, (sec, option, default) in list(self.lineedits.items()): - data = self.get_option(option, default, section=sec) - if getattr(lineedit, 'content_type', None) == list: - data = ', '.join(data) - else: - # Make option value a string to prevent errors when using it - # as widget text. - # See spyder-ide/spyder#18929 - data = str(data) - lineedit.setText(data) - lineedit.textChanged.connect(lambda _, opt=option, sect=sec: - self.has_been_modified(sect, opt)) - if lineedit.restart_required: - if sec is None: - self.restart_options[option] = lineedit.label_text - else: - self.restart_options[(sec, option)] = lineedit.label_text - - for textedit, (sec, option, default) in list(self.textedits.items()): - data = self.get_option(option, default, section=sec) - if getattr(textedit, 'content_type', None) == list: - data = ', '.join(data) - elif getattr(textedit, 'content_type', None) == dict: - data = to_text_string(data) - textedit.setPlainText(data) - textedit.textChanged.connect(lambda opt=option, sect=sec: - self.has_been_modified(sect, opt)) - if textedit.restart_required: - if sec is None: - self.restart_options[option] = textedit.label_text - else: - self.restart_options[(sec, option)] = textedit.label_text - - for spinbox, (sec, option, default) in list(self.spinboxes.items()): - spinbox.setValue(self.get_option(option, default, section=sec)) - spinbox.valueChanged.connect(lambda _foo, opt=option, sect=sec: - self.has_been_modified(sect, opt)) - - for combobox, (sec, option, default) in list(self.comboboxes.items()): - value = self.get_option(option, default, section=sec) - for index in range(combobox.count()): - data = from_qvariant(combobox.itemData(index), to_text_string) - # For PyQt API v2, it is necessary to convert `data` to - # unicode in case the original type was not a string, like an - # integer for example (see qtpy.compat.from_qvariant): - if to_text_string(data) == to_text_string(value): - break - else: - if combobox.count() == 0: - index = None - if index: - combobox.setCurrentIndex(index) - combobox.currentIndexChanged.connect( - lambda _foo, opt=option, sect=sec: - self.has_been_modified(sect, opt)) - if combobox.restart_required: - if sec is None: - self.restart_options[option] = combobox.label_text - else: - self.restart_options[(sec, option)] = combobox.label_text - - for (fontbox, sizebox), option in list(self.fontboxes.items()): - font = self.get_font(option) - fontbox.setCurrentFont(font) - sizebox.setValue(font.pointSize()) - - fontbox.currentIndexChanged.connect( - lambda _foo, opt=option: self.has_been_modified(None, opt)) - sizebox.valueChanged.connect( - lambda _foo, opt=option: self.has_been_modified(None, opt)) - - if fontbox.restart_required: - self.restart_options[option] = fontbox.label_text - - if sizebox.restart_required: - self.restart_options[option] = sizebox.label_text - - for clayout, (sec, option, default) in list(self.coloredits.items()): - edit = clayout.lineedit - btn = clayout.colorbtn - edit.setText(self.get_option(option, default, section=sec)) - # QAbstractButton works differently for PySide and PyQt - if not API == 'pyside': - btn.clicked.connect(lambda _foo, opt=option, sect=sec: - self.has_been_modified(sect, opt)) - else: - btn.clicked.connect(lambda opt=option, sect=sec: - self.has_been_modified(sect, opt)) - edit.textChanged.connect(lambda _foo, opt=option, sect=sec: - self.has_been_modified(sect, opt)) - - for (clayout, cb_bold, cb_italic - ), (sec, option, default) in list(self.scedits.items()): - edit = clayout.lineedit - btn = clayout.colorbtn - options = self.get_option(option, default, section=sec) - if options: - color, bold, italic = options - edit.setText(color) - cb_bold.setChecked(bold) - cb_italic.setChecked(italic) - - edit.textChanged.connect(lambda _foo, opt=option, sect=sec: - self.has_been_modified(sect, opt)) - btn.clicked[bool].connect(lambda _foo, opt=option, sect=sec: - self.has_been_modified(sect, opt)) - cb_bold.clicked[bool].connect(lambda _foo, opt=option, sect=sec: - self.has_been_modified(sect, opt)) - cb_italic.clicked[bool].connect(lambda _foo, opt=option, sect=sec: - self.has_been_modified(sect, opt)) - - def save_to_conf(self): - """Save settings to configuration file""" - for checkbox, (sec, option, _default) in list( - self.checkboxes.items()): - if (option in self.changed_options or - (sec, option) in self.changed_options): - value = checkbox.isChecked() - self.set_option(option, value, section=sec, - recursive_notification=False) - - for radiobutton, (sec, option, _default) in list( - self.radiobuttons.items()): - if (option in self.changed_options or - (sec, option) in self.changed_options): - self.set_option(option, radiobutton.isChecked(), section=sec, - recursive_notification=False) - - for lineedit, (sec, option, _default) in list(self.lineedits.items()): - if (option in self.changed_options or - (sec, option) in self.changed_options): - data = lineedit.text() - content_type = getattr(lineedit, 'content_type', None) - if content_type == list: - data = [item.strip() for item in data.split(',')] - else: - data = to_text_string(data) - self.set_option(option, data, section=sec, - recursive_notification=False) - - for textedit, (sec, option, _default) in list(self.textedits.items()): - if (option in self.changed_options or - (sec, option) in self.changed_options): - data = textedit.toPlainText() - content_type = getattr(textedit, 'content_type', None) - if content_type == dict: - if data: - data = ast.literal_eval(data) - else: - data = textedit.content_type() - elif content_type in (tuple, list): - data = [item.strip() for item in data.split(',')] - else: - data = to_text_string(data) - self.set_option(option, data, section=sec, - recursive_notification=False) - - for spinbox, (sec, option, _default) in list(self.spinboxes.items()): - if (option in self.changed_options or - (sec, option) in self.changed_options): - self.set_option(option, spinbox.value(), section=sec, - recursive_notification=False) - - for combobox, (sec, option, _default) in list(self.comboboxes.items()): - if (option in self.changed_options or - (sec, option) in self.changed_options): - data = combobox.itemData(combobox.currentIndex()) - self.set_option(option, from_qvariant(data, to_text_string), - section=sec, recursive_notification=False) - - for (fontbox, sizebox), option in list(self.fontboxes.items()): - if option in self.changed_options: - font = fontbox.currentFont() - font.setPointSize(sizebox.value()) - self.set_font(font, option) - - for clayout, (sec, option, _default) in list(self.coloredits.items()): - if (option in self.changed_options or - (sec, option) in self.changed_options): - self.set_option(option, - to_text_string(clayout.lineedit.text()), - section=sec, recursive_notification=False) - - for (clayout, cb_bold, cb_italic), (sec, option, _default) in list( - self.scedits.items()): - if (option in self.changed_options or - (sec, option) in self.changed_options): - color = to_text_string(clayout.lineedit.text()) - bold = cb_bold.isChecked() - italic = cb_italic.isChecked() - self.set_option(option, (color, bold, italic), section=sec, - recursive_notification=False) - - @Slot(str) - def has_been_modified(self, section, option): - self.set_modified(True) - if section is None: - self.changed_options.add(option) - else: - self.changed_options.add((section, option)) - - def add_help_info_label(self, layout, tip_text): - help_label = QLabel() - image = ima.icon('help_gray').pixmap(QSize(20, 20)) - help_label.setPixmap(image) - help_label.setFixedWidth(23) - help_label.setFixedHeight(23) - help_label.setToolTip(tip_text) - layout.addWidget(help_label) - layout.addStretch(100) - - return layout, help_label - - def create_checkbox(self, text, option, default=NoDefault, - tip=None, msg_warning=None, msg_info=None, - msg_if_enabled=False, section=None, restart=False): - layout = QHBoxLayout() - layout.setContentsMargins(0, 0, 0, 0) - checkbox = QCheckBox(text) - layout.addWidget(checkbox) - - self.checkboxes[checkbox] = (section, option, default) - if section is not None and section != self.CONF_SECTION: - self.cross_section_options[option] = section - if msg_warning is not None or msg_info is not None: - def show_message(is_checked=False): - if is_checked or not msg_if_enabled: - if msg_warning is not None: - QMessageBox.warning(self, self.get_name(), - msg_warning, QMessageBox.Ok) - if msg_info is not None: - QMessageBox.information(self, self.get_name(), - msg_info, QMessageBox.Ok) - checkbox.clicked.connect(show_message) - checkbox.restart_required = restart - - widget = QWidget(self) - widget.checkbox = checkbox - if tip is not None: - layout, help_label = self.add_help_info_label(layout, tip) - widget.help_label = help_label - widget.setLayout(layout) - return widget - - def create_radiobutton(self, text, option, default=NoDefault, - tip=None, msg_warning=None, msg_info=None, - msg_if_enabled=False, button_group=None, - restart=False, section=None): - layout = QHBoxLayout() - layout.setContentsMargins(0, 0, 0, 0) - radiobutton = QRadioButton(text) - layout.addWidget(radiobutton) - - if section is not None and section != self.CONF_SECTION: - self.cross_section_options[option] = section - if button_group is None: - if self.default_button_group is None: - self.default_button_group = QButtonGroup(self) - button_group = self.default_button_group - button_group.addButton(radiobutton) - self.radiobuttons[radiobutton] = (section, option, default) - if msg_warning is not None or msg_info is not None: - def show_message(is_checked): - if is_checked or not msg_if_enabled: - if msg_warning is not None: - QMessageBox.warning(self, self.get_name(), - msg_warning, QMessageBox.Ok) - if msg_info is not None: - QMessageBox.information(self, self.get_name(), - msg_info, QMessageBox.Ok) - radiobutton.toggled.connect(show_message) - radiobutton.restart_required = restart - radiobutton.label_text = text - - if tip is not None: - layout, help_label = self.add_help_info_label(layout, tip) - radiobutton.help_label = help_label - widget = QWidget(self) - widget.radiobutton = radiobutton - widget.setLayout(layout) - return widget - - def create_lineedit(self, text, option, default=NoDefault, - tip=None, alignment=Qt.Vertical, regex=None, - restart=False, word_wrap=True, placeholder=None, - content_type=None, section=None): - if section is not None and section != self.CONF_SECTION: - self.cross_section_options[option] = section - label = QLabel(text) - label.setWordWrap(word_wrap) - edit = QLineEdit() - edit.content_type = content_type - layout = QVBoxLayout() if alignment == Qt.Vertical else QHBoxLayout() - layout.addWidget(label) - layout.addWidget(edit) - layout.setContentsMargins(0, 0, 0, 0) - if regex: - edit.setValidator(QRegExpValidator(QRegExp(regex))) - if placeholder: - edit.setPlaceholderText(placeholder) - self.lineedits[edit] = (section, option, default) - - widget = QWidget(self) - widget.label = label - widget.textbox = edit - if tip is not None: - layout, help_label = self.add_help_info_label(layout, tip) - widget.help_label = help_label - widget.setLayout(layout) - edit.restart_required = restart - edit.label_text = text - return widget - - def create_textedit(self, text, option, default=NoDefault, - tip=None, restart=False, content_type=None, - section=None): - if section is not None and section != self.CONF_SECTION: - self.cross_section_options[option] = section - label = QLabel(text) - label.setWordWrap(True) - edit = QPlainTextEdit() - edit.content_type = content_type - edit.setWordWrapMode(QTextOption.WordWrap) - layout = QVBoxLayout() - layout.addWidget(label) - layout.addWidget(edit) - layout.setContentsMargins(0, 0, 0, 0) - self.textedits[edit] = (section, option, default) - - widget = QWidget(self) - widget.label = label - widget.textbox = edit - if tip is not None: - layout, help_label = self.add_help_info_label(layout, tip) - widget.help_label = help_label - widget.setLayout(layout) - edit.restart_required = restart - edit.label_text = text - return widget - - def create_browsedir(self, text, option, default=NoDefault, tip=None, - section=None): - widget = self.create_lineedit(text, option, default, section=section, - alignment=Qt.Horizontal) - for edit in self.lineedits: - if widget.isAncestorOf(edit): - break - msg = _("Invalid directory path") - self.validate_data[edit] = (osp.isdir, msg) - browse_btn = QPushButton(ima.icon('DirOpenIcon'), '', self) - browse_btn.setToolTip(_("Select directory")) - browse_btn.clicked.connect(lambda: self.select_directory(edit)) - layout = QHBoxLayout() - layout.addWidget(widget) - layout.addWidget(browse_btn) - layout.setContentsMargins(0, 0, 0, 0) - if tip is not None: - layout, help_label = self.add_help_info_label(layout, tip) - browsedir = QWidget(self) - browsedir.setLayout(layout) - return browsedir - - def select_directory(self, edit): - """Select directory""" - basedir = to_text_string(edit.text()) - if not osp.isdir(basedir): - basedir = getcwd_or_home() - title = _("Select directory") - directory = getexistingdirectory(self, title, basedir) - if directory: - edit.setText(directory) - - def create_browsefile(self, text, option, default=NoDefault, tip=None, - filters=None, section=None): - widget = self.create_lineedit(text, option, default, section=section, - alignment=Qt.Horizontal) - for edit in self.lineedits: - if widget.isAncestorOf(edit): - break - msg = _('Invalid file path') - self.validate_data[edit] = (osp.isfile, msg) - browse_btn = QPushButton(ima.icon('FileIcon'), '', self) - browse_btn.setToolTip(_("Select file")) - browse_btn.clicked.connect(lambda: self.select_file(edit, filters)) - layout = QHBoxLayout() - layout.addWidget(widget) - layout.addWidget(browse_btn) - layout.setContentsMargins(0, 0, 0, 0) - if tip is not None: - layout, help_label = self.add_help_info_label(layout, tip) - browsedir = QWidget(self) - browsedir.setLayout(layout) - return browsedir - - def select_file(self, edit, filters=None, **kwargs): - """Select File""" - basedir = osp.dirname(to_text_string(edit.text())) - if not osp.isdir(basedir): - basedir = getcwd_or_home() - if filters is None: - filters = _("All files (*)") - title = _("Select file") - filename, _selfilter = getopenfilename(self, title, basedir, filters, - **kwargs) - if filename: - edit.setText(filename) - - def create_spinbox(self, prefix, suffix, option, default=NoDefault, - min_=None, max_=None, step=None, tip=None, - section=None): - if section is not None and section != self.CONF_SECTION: - self.cross_section_options[option] = section - widget = QWidget(self) - if prefix: - plabel = QLabel(prefix) - widget.plabel = plabel - else: - plabel = None - if suffix: - slabel = QLabel(suffix) - widget.slabel = slabel - else: - slabel = None - if step is not None: - if type(step) is int: - spinbox = QSpinBox() - else: - spinbox = QDoubleSpinBox() - spinbox.setDecimals(1) - spinbox.setSingleStep(step) - else: - spinbox = QSpinBox() - if min_ is not None: - spinbox.setMinimum(min_) - if max_ is not None: - spinbox.setMaximum(max_) - self.spinboxes[spinbox] = (section, option, default) - layout = QHBoxLayout() - for subwidget in (plabel, spinbox, slabel): - if subwidget is not None: - layout.addWidget(subwidget) - layout.addStretch(1) - layout.setContentsMargins(0, 0, 0, 0) - widget.spinbox = spinbox - if tip is not None: - layout, help_label = self.add_help_info_label(layout, tip) - widget.help_label = help_label - widget.setLayout(layout) - return widget - - def create_coloredit(self, text, option, default=NoDefault, tip=None, - without_layout=False, section=None): - if section is not None and section != self.CONF_SECTION: - self.cross_section_options[option] = section - label = QLabel(text) - clayout = ColorLayout(QColor(Qt.black), self) - clayout.lineedit.setMaximumWidth(80) - self.coloredits[clayout] = (section, option, default) - if without_layout: - return label, clayout - layout = QHBoxLayout() - layout.addWidget(label) - layout.addLayout(clayout) - layout.addStretch(1) - layout.setContentsMargins(0, 0, 0, 0) - if tip is not None: - layout, help_label = self.add_help_info_label(layout, tip) - - widget = QWidget(self) - widget.setLayout(layout) - return widget - - def create_scedit(self, text, option, default=NoDefault, tip=None, - without_layout=False, section=None): - if section is not None and section != self.CONF_SECTION: - self.cross_section_options[option] = section - label = QLabel(text) - clayout = ColorLayout(QColor(Qt.black), self) - clayout.lineedit.setMaximumWidth(80) - cb_bold = QCheckBox() - cb_bold.setIcon(ima.icon('bold')) - cb_bold.setToolTip(_("Bold")) - cb_italic = QCheckBox() - cb_italic.setIcon(ima.icon('italic')) - cb_italic.setToolTip(_("Italic")) - self.scedits[(clayout, cb_bold, cb_italic)] = (section, option, - default) - if without_layout: - return label, clayout, cb_bold, cb_italic - layout = QHBoxLayout() - layout.addWidget(label) - layout.addLayout(clayout) - layout.addSpacing(10) - layout.addWidget(cb_bold) - layout.addWidget(cb_italic) - layout.addStretch(1) - layout.setContentsMargins(0, 0, 0, 0) - if tip is not None: - layout, help_label = self.add_help_info_label(layout, tip) - widget = QWidget(self) - widget.setLayout(layout) - return widget - - def create_combobox(self, text, choices, option, default=NoDefault, - tip=None, restart=False, section=None): - """choices: couples (name, key)""" - if section is not None and section != self.CONF_SECTION: - self.cross_section_options[option] = section - label = QLabel(text) - combobox = QComboBox() - for name, key in choices: - if not (name is None and key is None): - combobox.addItem(name, to_qvariant(key)) - # Insert separators - count = 0 - for index, item in enumerate(choices): - name, key = item - if name is None and key is None: - combobox.insertSeparator(index + count) - count += 1 - self.comboboxes[combobox] = (section, option, default) - layout = QHBoxLayout() - layout.addWidget(label) - layout.addWidget(combobox) - layout.addStretch(1) - layout.setContentsMargins(0, 0, 0, 0) - widget = QWidget(self) - widget.label = label - widget.combobox = combobox - if tip is not None: - layout, help_label = self.add_help_info_label(layout, tip) - widget.help_label = help_label - widget.setLayout(layout) - combobox.restart_required = restart - combobox.label_text = text - return widget - - def create_file_combobox(self, text, choices, option, default=NoDefault, - tip=None, restart=False, filters=None, - adjust_to_contents=False, - default_line_edit=False, section=None, - validate_callback=None): - """choices: couples (name, key)""" - if section is not None and section != self.CONF_SECTION: - self.cross_section_options[option] = section - combobox = FileComboBox(self, adjust_to_contents=adjust_to_contents, - default_line_edit=default_line_edit) - combobox.restart_required = restart - combobox.label_text = text - edit = combobox.lineEdit() - edit.label_text = text - edit.restart_required = restart - self.lineedits[edit] = (section, option, default) - combobox.addItems(choices) - combobox.choices = choices - - msg = _('Invalid file path') - self.validate_data[edit] = ( - validate_callback if validate_callback else osp.isfile, - msg) - browse_btn = QPushButton(ima.icon('FileIcon'), '', self) - browse_btn.setToolTip(_("Select file")) - options = QFileDialog.DontResolveSymlinks - browse_btn.clicked.connect( - lambda: self.select_file(edit, filters, options=options)) - - layout = QGridLayout() - layout.addWidget(combobox, 0, 0, 0, 9) - layout.addWidget(browse_btn, 0, 10) - layout.setContentsMargins(0, 0, 0, 0) - - widget = QWidget(self) - widget.combobox = combobox - widget.browse_btn = browse_btn - if tip is not None: - layout, help_label = self.add_help_info_label(layout, tip) - widget.help_label = help_label - widget.setLayout(layout) - - return widget - - def create_fontgroup(self, option=None, text=None, title=None, - tip=None, fontfilters=None, without_group=False, - restart=False): - """Option=None -> setting plugin font""" - - if title: - fontlabel = QLabel(title) - else: - fontlabel = QLabel(_("Font")) - - fontbox = QFontComboBox() - fontbox.restart_required = restart - fontbox.label_text = _("{} font").format(title) - - if fontfilters is not None: - fontbox.setFontFilters(fontfilters) - - sizelabel = QLabel(" " + _("Size")) - sizebox = QSpinBox() - sizebox.setRange(7, 100) - sizebox.restart_required = restart - sizebox.label_text = _("{} font size").format(title) - - self.fontboxes[(fontbox, sizebox)] = option - - layout = QHBoxLayout() - for subwidget in (fontlabel, fontbox, sizelabel, sizebox): - layout.addWidget(subwidget) - layout.addStretch(1) - - if not without_group: - if text is None: - text = _("Font style") - - group = QGroupBox(text) - group.setLayout(layout) - - if tip is not None: - layout, help_label = self.add_help_info_label(layout, tip) - - return group - else: - widget = QWidget(self) - widget.fontlabel = fontlabel - widget.sizelabel = sizelabel - widget.fontbox = fontbox - widget.sizebox = sizebox - widget.setLayout(layout) - - return widget - - def create_button(self, text, callback): - btn = QPushButton(text) - btn.clicked.connect(callback) - btn.clicked.connect( - lambda checked=False, opt='': self.has_been_modified( - self.CONF_SECTION, opt)) - return btn - - def create_tab(self, *widgets): - """Create simple tab widget page: widgets added in a vertical layout""" - widget = QWidget() - layout = QVBoxLayout() - for widg in widgets: - layout.addWidget(widg) - layout.addStretch(1) - widget.setLayout(layout) - return widget - - def prompt_restart_required(self): - """Prompt the user with a request to restart.""" - message = _( - "One or more of the settings you changed requires a restart to be " - "applied.

" - "Do you wish to restart now?" - ) - - answer = QMessageBox.information( - self, - _("Information"), - message, - QMessageBox.Yes | QMessageBox.No - ) - - if answer == QMessageBox.Yes: - self.restart() - - def restart(self): - """Restart Spyder.""" - self.main.restart(close_immediately=True) - - def add_tab(self, Widget): - widget = Widget(self) - if self.tabs is None: - # In case a preference page does not have any tabs, we need to - # add a tab with the widgets that already exist and then add the - # new tab. - self.tabs = QTabWidget() - layout = self.layout() - main_widget = QWidget() - main_widget.setLayout(layout) - self.tabs.addTab(self.create_tab(main_widget), - _('General')) - self.tabs.addTab(self.create_tab(widget), - Widget.TITLE) - vlayout = QVBoxLayout() - vlayout.addWidget(self.tabs) - self.setLayout(vlayout) - else: - self.tabs.addTab(self.create_tab(widget), - Widget.TITLE) - self.load_from_conf() - - -class GeneralConfigPage(SpyderConfigPage): - """Config page that maintains reference to main Spyder window - and allows to specify page name and icon declaratively - """ - CONF_SECTION = None - - NAME = None # configuration page name, e.g. _("General") - ICON = None # name of icon resource (24x24) - - def __init__(self, parent, main): - SpyderConfigPage.__init__(self, parent) - self.main = main - - def get_name(self): - """Configuration page name""" - return self.NAME - - def get_icon(self): - """Loads page icon named by self.ICON""" - return self.ICON - - def apply_settings(self, options): - raise NotImplementedError - - -class PreferencePages: - General = 'main' +class PreferencesActions: + Show = 'show_action' + Reset = 'reset_action' diff --git a/spyder/plugins/preferences/plugin.py b/spyder/plugins/preferences/plugin.py index f6f4e8c32f0..80d7f459c3d 100644 --- a/spyder/plugins/preferences/plugin.py +++ b/spyder/plugins/preferences/plugin.py @@ -18,6 +18,7 @@ # Third-party imports from packaging.version import parse, Version +from pyuca import Collator from qtpy.QtGui import QIcon from qtpy.QtCore import Slot from qtpy.QtWidgets import QMessageBox @@ -26,17 +27,22 @@ from spyder.api.plugins import Plugins, SpyderPluginV2, SpyderPlugin from spyder.api.plugin_registration.decorators import ( on_plugin_available, on_plugin_teardown) -from spyder.config.base import _ +from spyder.api.plugin_registration.registry import PreferencesAdapter +from spyder.config.base import _, running_under_pytest from spyder.config.main import CONF_VERSION from spyder.config.user import NoDefault from spyder.plugins.mainmenu.api import ApplicationMenus, ToolsMenuSections +from spyder.plugins.preferences import MOST_IMPORTANT_PAGES from spyder.plugins.preferences.widgets.container import ( PreferencesActions, PreferencesContainer) from spyder.plugins.pythonpath.api import PythonpathActions from spyder.plugins.toolbar.api import ApplicationToolbars, MainToolbarSections + +# Logger logger = logging.getLogger(__name__) +# Types BaseType = Union[int, float, bool, complex, str, bytes] IterableType = Union[list, tuple] BasicType = Union[BaseType, IterableType] @@ -65,11 +71,18 @@ def __init__(self, parent, configuration=None): super().__init__(parent, configuration) self.config_pages = {} self.config_tabs = {} + self._config_pages_ordered = False + # ---- Public API + # ------------------------------------------------------------------------- def register_plugin_preferences( - self, plugin: Union[SpyderPluginV2, SpyderPlugin]) -> None: - if (hasattr(plugin, 'CONF_WIDGET_CLASS') and - plugin.CONF_WIDGET_CLASS is not None): + self, + plugin: Union[SpyderPluginV2, SpyderPlugin] + ) -> None: + if ( + hasattr(plugin, 'CONF_WIDGET_CLASS') + and plugin.CONF_WIDGET_CLASS is not None + ): # New API Widget = plugin.CONF_WIDGET_CLASS @@ -96,9 +109,10 @@ def register_plugin_preferences( plugin_tabs = self.config_tabs.get(plugin_name, []) plugin_tabs += tabs_to_add self.config_tabs[plugin_name] = plugin_tabs - - elif (hasattr(plugin, 'CONFIGWIDGET_CLASS') and - plugin.CONFIGWIDGET_CLASS is not None): + elif ( + hasattr(plugin, 'CONFIGWIDGET_CLASS') + and plugin.CONFIGWIDGET_CLASS is not None + ): # Old API Widget = plugin.CONFIGWIDGET_CLASS @@ -106,7 +120,9 @@ def register_plugin_preferences( self.OLD_API, Widget, plugin) def deregister_plugin_preferences( - self, plugin: Union[SpyderPluginV2, SpyderPlugin]): + self, + plugin: Union[SpyderPluginV2, SpyderPlugin] + ) -> None: """Remove a plugin preference page and additional configuration tabs.""" name = (getattr(plugin, 'NAME', None) or getattr(plugin, 'CONF_SECTION', None)) @@ -179,11 +195,13 @@ def check_version_and_merge( self.set_conf( conf_key, new_value, section=conf_section) - - def merge_defaults(self, prev_default: BasicType, - new_default: BasicType, - allow_replacement: bool = False, - allow_deletions: bool = False) -> BasicType: + def merge_defaults( + self, + prev_default: BasicType, + new_default: BasicType, + allow_replacement: bool = False, + allow_deletions: bool = False + ) -> BasicType: """Compare and merge two versioned values.""" prev_type = type(prev_default) new_type = type(new_default) @@ -216,7 +234,10 @@ def merge_defaults(self, prev_default: BasicType, return prev_default def merge_configurations( - self, current_value: BasicType, new_value: BasicType) -> BasicType: + self, + current_value: BasicType, + new_value: BasicType + ) -> BasicType: """ Recursively match and merge a new configuration value into a previous one. @@ -255,13 +276,37 @@ def merge_configurations( f'by {new_value}') return current_value - def open_dialog(self, prefs_dialog_size): + def open_dialog(self): container = self.get_container() + + self.before_long_process('') + + if not running_under_pytest(): + self._reorder_config_pages() + container.create_dialog( - self.config_pages, self.config_tabs, prefs_dialog_size, - self.get_main()) + self.config_pages, self.config_tabs, self.get_main() + ) + + self.after_long_process() + + @Slot() + def reset(self): + answer = QMessageBox.warning( + self.main, + _("Warning"), + _("Spyder will restart and reset to default settings:" + "

" + "Do you want to continue?"), + QMessageBox.Yes | QMessageBox.No + ) + + if answer == QMessageBox.Yes: + os.environ['SPYDER_RESET'] = 'True' + self.sig_restart_requested.emit() - # ---------------- Public Spyder API required methods --------------------- + # ---- SpyderPluginV2 API + # ------------------------------------------------------------------------- @staticmethod def get_name() -> str: return _('Preferences') @@ -276,10 +321,7 @@ def get_icon(cls) -> QIcon: def on_initialize(self): container = self.get_container() - main = self.get_main() - - container.sig_show_preferences_requested.connect( - lambda: self.open_dialog(main.prefs_dialog_size)) + container.sig_show_preferences_requested.connect(self.open_dialog) container.sig_reset_preferences_requested.connect(self.reset) @on_plugin_available(plugin=Plugins.MainMenu) @@ -332,18 +374,38 @@ def on_toolbar_teardown(self): toolbar_id=ApplicationToolbars.Main ) - @Slot() - def reset(self): - answer = QMessageBox.warning(self.main, _("Warning"), - _("Spyder will restart and reset to default settings:

" - "Do you want to continue?"), - QMessageBox.Yes | QMessageBox.No) - if answer == QMessageBox.Yes: - os.environ['SPYDER_RESET'] = 'True' - self.sig_restart_requested.emit() - def on_close(self, cancelable=False): container = self.get_container() if container.is_preferences_open(): container.close_preferences() return True + + # ---- Private API + # ------------------------------------------------------------------------- + def _reorder_config_pages(self): + if self._config_pages_ordered: + return + + plugins_page = [PreferencesAdapter.NAME] + + # Order pages alphabetically by plugin name + pages = [] + for k, v in self.config_pages.items(): + pages.append((k, v[2].get_name())) + + collator = Collator() + pages.sort(key=lambda p: collator.sort_key(p[1])) + + # Get pages from the previous list without including the most important + # ones and the plugins page because they'll be added in a different + # order. + other_pages = [ + page[0] for page in pages + if page[0] not in (MOST_IMPORTANT_PAGES + plugins_page) + ] + + # Show most important pages first and the Plugins page last + ordering = MOST_IMPORTANT_PAGES + other_pages + plugins_page + self.config_pages = {k: self.config_pages[k] for k in ordering} + + self._config_pages_ordered = True diff --git a/spyder/plugins/preferences/tests/conftest.py b/spyder/plugins/preferences/tests/conftest.py index bc140e604f9..b3333176c45 100644 --- a/spyder/plugins/preferences/tests/conftest.py +++ b/spyder/plugins/preferences/tests/conftest.py @@ -69,9 +69,6 @@ def get_plugin(self, plugin_name, error=True): if plugin_name in PLUGIN_REGISTRY: return PLUGIN_REGISTRY.get_plugin(plugin_name) - def set_prefs_size(self, size): - pass - class ConfigDialogTester(QWidget): def __init__(self, parent, main_class, @@ -81,9 +78,6 @@ def __init__(self, parent, main_class, if self._main is None: self._main = MainWindowMock(self) - def set_prefs_size(self, size): - pass - def register_plugin(self, plugin_name, external=False): plugin = PLUGIN_REGISTRY.get_plugin(plugin_name) plugin._register() @@ -97,8 +91,6 @@ def get_plugin(self, plugin_name, error=True): types.MethodType(register_plugin, self._main)) setattr(self._main, 'get_plugin', types.MethodType(get_plugin, self._main)) - setattr(self._main, 'set_prefs_size', - types.MethodType(set_prefs_size, self._main)) PLUGIN_REGISTRY.reset() PLUGIN_REGISTRY.sig_plugin_ready.connect(self._main.register_plugin) @@ -129,7 +121,7 @@ def global_config_dialog(qtbot): qtbot.addWidget(mainwindow) preferences = Preferences(mainwindow, CONF) - preferences.open_dialog(None) + preferences.open_dialog() container = preferences.get_container() dlg = container.dialog @@ -148,7 +140,7 @@ def config_dialog(qtbot, request, mocker): qtbot.addWidget(main_ref) preferences = main_ref._main.get_plugin(Plugins.Preferences) - preferences.open_dialog(None) + preferences.open_dialog() container = preferences.get_container() dlg = container.dialog diff --git a/spyder/plugins/preferences/widgets/config_widgets.py b/spyder/plugins/preferences/widgets/config_widgets.py new file mode 100644 index 00000000000..77335122af0 --- /dev/null +++ b/spyder/plugins/preferences/widgets/config_widgets.py @@ -0,0 +1,970 @@ +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +""" +Preferences plugin public facing API +""" + +# Standard library imports +import ast +import os.path as osp + +# Third party imports +from qtpy import API +from qtpy.compat import (getexistingdirectory, getopenfilename, from_qvariant, + to_qvariant) +from qtpy.QtCore import Qt, Signal, Slot, QRegExp, QSize +from qtpy.QtGui import QColor, QRegExpValidator, QTextOption +from qtpy.QtWidgets import (QButtonGroup, QCheckBox, QComboBox, QDoubleSpinBox, + QFileDialog, QFontComboBox, QGridLayout, QGroupBox, + QHBoxLayout, QLabel, QLineEdit, QMessageBox, + QPlainTextEdit, QPushButton, QRadioButton, + QSpinBox, QTabWidget, QVBoxLayout, QWidget) + +# Local imports +from spyder.config.base import _ +from spyder.config.manager import CONF +from spyder.config.user import NoDefault +from spyder.py3compat import to_text_string +from spyder.utils.icon_manager import ima +from spyder.utils.misc import getcwd_or_home +from spyder.widgets.colors import ColorLayout +from spyder.widgets.comboboxes import FileComboBox + + +class BaseConfigTab(QWidget): + """Stub class to declare a config tab.""" + pass + + +class ConfigAccessMixin(object): + """Namespace for methods that access config storage""" + CONF_SECTION = None + + def set_option(self, option, value, section=None, + recursive_notification=False): + section = self.CONF_SECTION if section is None else section + CONF.set(section, option, value, + recursive_notification=recursive_notification) + + def get_option(self, option, default=NoDefault, section=None): + section = self.CONF_SECTION if section is None else section + return CONF.get(section, option, default) + + def remove_option(self, option, section=None): + section = self.CONF_SECTION if section is None else section + CONF.remove_option(section, option) + + +class ConfigPage(QWidget): + """Base class for configuration page in Preferences""" + + # Signals + apply_button_enabled = Signal(bool) + show_this_page = Signal() + + def __init__(self, parent, apply_callback=None): + QWidget.__init__(self, parent) + + # Callback to call before saving settings to disk + self.pre_apply_callback = None + + # Callback to call after saving settings to disk + self.apply_callback = apply_callback + + self.is_modified = False + + def initialize(self): + """ + Initialize configuration page: + * setup GUI widgets + * load settings and change widgets accordingly + """ + self.setup_page() + self.load_from_conf() + + def get_name(self): + """Return configuration page name""" + raise NotImplementedError + + def get_icon(self): + """Return configuration page icon (24x24)""" + raise NotImplementedError + + def setup_page(self): + """Setup configuration page widget""" + raise NotImplementedError + + def set_modified(self, state): + self.is_modified = state + self.apply_button_enabled.emit(state) + + def is_valid(self): + """Return True if all widget contents are valid""" + raise NotImplementedError + + def apply_changes(self): + """Apply changes callback""" + if self.is_modified: + if self.pre_apply_callback is not None: + self.pre_apply_callback() + + self.save_to_conf() + + if self.apply_callback is not None: + self.apply_callback() + + # Since the language cannot be retrieved by CONF and the language + # is needed before loading CONF, this is an extra method needed to + # ensure that when changes are applied, they are copied to a + # specific file storing the language value. This only applies to + # the main section config. + if self.CONF_SECTION == u'main': + self._save_lang() + + for restart_option in self.restart_options: + if restart_option in self.changed_options: + self.prompt_restart_required() + break # Ensure a single popup is displayed + self.set_modified(False) + + def load_from_conf(self): + """Load settings from configuration file""" + raise NotImplementedError + + def save_to_conf(self): + """Save settings to configuration file""" + raise NotImplementedError + + +class SpyderConfigPage(ConfigPage, ConfigAccessMixin): + """Plugin configuration dialog box page widget""" + CONF_SECTION = None + MAX_WIDTH = 620 + MIN_HEIGHT = 550 + + def __init__(self, parent): + ConfigPage.__init__( + self, + parent, + apply_callback=lambda: self._apply_settings_tabs( + self.changed_options) + ) + + self.checkboxes = {} + self.radiobuttons = {} + self.lineedits = {} + self.textedits = {} + self.validate_data = {} + self.spinboxes = {} + self.comboboxes = {} + self.fontboxes = {} + self.coloredits = {} + self.scedits = {} + self.cross_section_options = {} + self.changed_options = set() + self.restart_options = dict() # Dict to store name and localized text + self.default_button_group = None + self.main = parent.main + self.tabs = None + + # Set dimensions + self.setMaximumWidth(self.MAX_WIDTH) + self.setMinimumHeight(self.MIN_HEIGHT) + + def sizeHint(self): + """Default page size.""" + return QSize(self.MAX_WIDTH, self.MIN_HEIGHT) + + def _apply_settings_tabs(self, options): + if self.tabs is not None: + for i in range(self.tabs.count()): + tab = self.tabs.widget(i) + layout = tab.layout() + for i in range(layout.count()): + widget = layout.itemAt(i).widget() + if hasattr(widget, 'apply_settings'): + if issubclass(type(widget), BaseConfigTab): + options |= widget.apply_settings() + self.apply_settings(options) + + def apply_settings(self, options): + raise NotImplementedError + + def check_settings(self): + """This method is called to check settings after configuration + dialog has been shown""" + pass + + def set_modified(self, state): + ConfigPage.set_modified(self, state) + if not state: + self.changed_options = set() + + def is_valid(self): + """Return True if all widget contents are valid""" + status = True + for lineedit in self.lineedits: + if lineedit in self.validate_data and lineedit.isEnabled(): + validator, invalid_msg = self.validate_data[lineedit] + text = to_text_string(lineedit.text()) + if not validator(text): + QMessageBox.critical(self, self.get_name(), + f"{invalid_msg}:
{text}", + QMessageBox.Ok) + return False + + if self.tabs is not None and status: + for i in range(self.tabs.count()): + tab = self.tabs.widget(i) + layout = tab.layout() + for i in range(layout.count()): + widget = layout.itemAt(i).widget() + if issubclass(type(widget), BaseConfigTab): + status &= widget.is_valid() + if not status: + return status + return status + + def load_from_conf(self): + """Load settings from configuration file.""" + for checkbox, (sec, option, default) in list(self.checkboxes.items()): + checkbox.setChecked(self.get_option(option, default, section=sec)) + checkbox.clicked[bool].connect(lambda _, opt=option, sect=sec: + self.has_been_modified(sect, opt)) + if checkbox.restart_required: + if sec is None: + self.restart_options[option] = checkbox.text() + else: + self.restart_options[(sec, option)] = checkbox.text() + + for radiobutton, (sec, option, default) in list( + self.radiobuttons.items()): + radiobutton.setChecked(self.get_option(option, default, + section=sec)) + radiobutton.toggled.connect(lambda _foo, opt=option, sect=sec: + self.has_been_modified(sect, opt)) + if radiobutton.restart_required: + if sec is None: + self.restart_options[option] = radiobutton.label_text + else: + self.restart_options[(sec, option)] = radiobutton.label_text + + for lineedit, (sec, option, default) in list(self.lineedits.items()): + data = self.get_option(option, default, section=sec) + if getattr(lineedit, 'content_type', None) == list: + data = ', '.join(data) + else: + # Make option value a string to prevent errors when using it + # as widget text. + # See spyder-ide/spyder#18929 + data = str(data) + lineedit.setText(data) + lineedit.textChanged.connect(lambda _, opt=option, sect=sec: + self.has_been_modified(sect, opt)) + if lineedit.restart_required: + if sec is None: + self.restart_options[option] = lineedit.label_text + else: + self.restart_options[(sec, option)] = lineedit.label_text + + for textedit, (sec, option, default) in list(self.textedits.items()): + data = self.get_option(option, default, section=sec) + if getattr(textedit, 'content_type', None) == list: + data = ', '.join(data) + elif getattr(textedit, 'content_type', None) == dict: + data = to_text_string(data) + textedit.setPlainText(data) + textedit.textChanged.connect(lambda opt=option, sect=sec: + self.has_been_modified(sect, opt)) + if textedit.restart_required: + if sec is None: + self.restart_options[option] = textedit.label_text + else: + self.restart_options[(sec, option)] = textedit.label_text + + for spinbox, (sec, option, default) in list(self.spinboxes.items()): + spinbox.setValue(self.get_option(option, default, section=sec)) + spinbox.valueChanged.connect(lambda _foo, opt=option, sect=sec: + self.has_been_modified(sect, opt)) + + for combobox, (sec, option, default) in list(self.comboboxes.items()): + value = self.get_option(option, default, section=sec) + for index in range(combobox.count()): + data = from_qvariant(combobox.itemData(index), to_text_string) + # For PyQt API v2, it is necessary to convert `data` to + # unicode in case the original type was not a string, like an + # integer for example (see qtpy.compat.from_qvariant): + if to_text_string(data) == to_text_string(value): + break + else: + if combobox.count() == 0: + index = None + if index: + combobox.setCurrentIndex(index) + combobox.currentIndexChanged.connect( + lambda _foo, opt=option, sect=sec: + self.has_been_modified(sect, opt)) + if combobox.restart_required: + if sec is None: + self.restart_options[option] = combobox.label_text + else: + self.restart_options[(sec, option)] = combobox.label_text + + for (fontbox, sizebox), option in list(self.fontboxes.items()): + font = self.get_font(option) + fontbox.setCurrentFont(font) + sizebox.setValue(font.pointSize()) + + fontbox.currentIndexChanged.connect( + lambda _foo, opt=option: self.has_been_modified(None, opt)) + sizebox.valueChanged.connect( + lambda _foo, opt=option: self.has_been_modified(None, opt)) + + if fontbox.restart_required: + self.restart_options[option] = fontbox.label_text + + if sizebox.restart_required: + self.restart_options[option] = sizebox.label_text + + for clayout, (sec, option, default) in list(self.coloredits.items()): + edit = clayout.lineedit + btn = clayout.colorbtn + edit.setText(self.get_option(option, default, section=sec)) + # QAbstractButton works differently for PySide and PyQt + if not API == 'pyside': + btn.clicked.connect(lambda _foo, opt=option, sect=sec: + self.has_been_modified(sect, opt)) + else: + btn.clicked.connect(lambda opt=option, sect=sec: + self.has_been_modified(sect, opt)) + edit.textChanged.connect(lambda _foo, opt=option, sect=sec: + self.has_been_modified(sect, opt)) + + for (clayout, cb_bold, cb_italic + ), (sec, option, default) in list(self.scedits.items()): + edit = clayout.lineedit + btn = clayout.colorbtn + options = self.get_option(option, default, section=sec) + if options: + color, bold, italic = options + edit.setText(color) + cb_bold.setChecked(bold) + cb_italic.setChecked(italic) + + edit.textChanged.connect(lambda _foo, opt=option, sect=sec: + self.has_been_modified(sect, opt)) + btn.clicked[bool].connect(lambda _foo, opt=option, sect=sec: + self.has_been_modified(sect, opt)) + cb_bold.clicked[bool].connect(lambda _foo, opt=option, sect=sec: + self.has_been_modified(sect, opt)) + cb_italic.clicked[bool].connect(lambda _foo, opt=option, sect=sec: + self.has_been_modified(sect, opt)) + + def save_to_conf(self): + """Save settings to configuration file""" + for checkbox, (sec, option, _default) in list( + self.checkboxes.items()): + if (option in self.changed_options or + (sec, option) in self.changed_options): + value = checkbox.isChecked() + self.set_option(option, value, section=sec, + recursive_notification=False) + + for radiobutton, (sec, option, _default) in list( + self.radiobuttons.items()): + if (option in self.changed_options or + (sec, option) in self.changed_options): + self.set_option(option, radiobutton.isChecked(), section=sec, + recursive_notification=False) + + for lineedit, (sec, option, _default) in list(self.lineedits.items()): + if (option in self.changed_options or + (sec, option) in self.changed_options): + data = lineedit.text() + content_type = getattr(lineedit, 'content_type', None) + if content_type == list: + data = [item.strip() for item in data.split(',')] + else: + data = to_text_string(data) + self.set_option(option, data, section=sec, + recursive_notification=False) + + for textedit, (sec, option, _default) in list(self.textedits.items()): + if (option in self.changed_options or + (sec, option) in self.changed_options): + data = textedit.toPlainText() + content_type = getattr(textedit, 'content_type', None) + if content_type == dict: + if data: + data = ast.literal_eval(data) + else: + data = textedit.content_type() + elif content_type in (tuple, list): + data = [item.strip() for item in data.split(',')] + else: + data = to_text_string(data) + self.set_option(option, data, section=sec, + recursive_notification=False) + + for spinbox, (sec, option, _default) in list(self.spinboxes.items()): + if (option in self.changed_options or + (sec, option) in self.changed_options): + self.set_option(option, spinbox.value(), section=sec, + recursive_notification=False) + + for combobox, (sec, option, _default) in list(self.comboboxes.items()): + if (option in self.changed_options or + (sec, option) in self.changed_options): + data = combobox.itemData(combobox.currentIndex()) + self.set_option(option, from_qvariant(data, to_text_string), + section=sec, recursive_notification=False) + + for (fontbox, sizebox), option in list(self.fontboxes.items()): + if option in self.changed_options: + font = fontbox.currentFont() + font.setPointSize(sizebox.value()) + self.set_font(font, option) + + for clayout, (sec, option, _default) in list(self.coloredits.items()): + if (option in self.changed_options or + (sec, option) in self.changed_options): + self.set_option(option, + to_text_string(clayout.lineedit.text()), + section=sec, recursive_notification=False) + + for (clayout, cb_bold, cb_italic), (sec, option, _default) in list( + self.scedits.items()): + if (option in self.changed_options or + (sec, option) in self.changed_options): + color = to_text_string(clayout.lineedit.text()) + bold = cb_bold.isChecked() + italic = cb_italic.isChecked() + self.set_option(option, (color, bold, italic), section=sec, + recursive_notification=False) + + @Slot(str) + def has_been_modified(self, section, option): + self.set_modified(True) + if section is None: + self.changed_options.add(option) + else: + self.changed_options.add((section, option)) + + def add_help_info_label(self, layout, tip_text): + help_label = QLabel() + image = ima.icon('help_gray').pixmap(QSize(20, 20)) + help_label.setPixmap(image) + help_label.setFixedWidth(23) + help_label.setFixedHeight(23) + help_label.setToolTip(tip_text) + layout.addWidget(help_label) + layout.addStretch(100) + + return layout, help_label + + def create_checkbox(self, text, option, default=NoDefault, + tip=None, msg_warning=None, msg_info=None, + msg_if_enabled=False, section=None, restart=False): + layout = QHBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + checkbox = QCheckBox(text) + layout.addWidget(checkbox) + + self.checkboxes[checkbox] = (section, option, default) + if section is not None and section != self.CONF_SECTION: + self.cross_section_options[option] = section + if msg_warning is not None or msg_info is not None: + def show_message(is_checked=False): + if is_checked or not msg_if_enabled: + if msg_warning is not None: + QMessageBox.warning(self, self.get_name(), + msg_warning, QMessageBox.Ok) + if msg_info is not None: + QMessageBox.information(self, self.get_name(), + msg_info, QMessageBox.Ok) + checkbox.clicked.connect(show_message) + checkbox.restart_required = restart + + widget = QWidget(self) + widget.checkbox = checkbox + if tip is not None: + layout, help_label = self.add_help_info_label(layout, tip) + widget.help_label = help_label + widget.setLayout(layout) + return widget + + def create_radiobutton(self, text, option, default=NoDefault, + tip=None, msg_warning=None, msg_info=None, + msg_if_enabled=False, button_group=None, + restart=False, section=None): + layout = QHBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + radiobutton = QRadioButton(text) + layout.addWidget(radiobutton) + + if section is not None and section != self.CONF_SECTION: + self.cross_section_options[option] = section + if button_group is None: + if self.default_button_group is None: + self.default_button_group = QButtonGroup(self) + button_group = self.default_button_group + button_group.addButton(radiobutton) + self.radiobuttons[radiobutton] = (section, option, default) + if msg_warning is not None or msg_info is not None: + def show_message(is_checked): + if is_checked or not msg_if_enabled: + if msg_warning is not None: + QMessageBox.warning(self, self.get_name(), + msg_warning, QMessageBox.Ok) + if msg_info is not None: + QMessageBox.information(self, self.get_name(), + msg_info, QMessageBox.Ok) + radiobutton.toggled.connect(show_message) + radiobutton.restart_required = restart + radiobutton.label_text = text + + if tip is not None: + layout, help_label = self.add_help_info_label(layout, tip) + radiobutton.help_label = help_label + widget = QWidget(self) + widget.radiobutton = radiobutton + widget.setLayout(layout) + return widget + + def create_lineedit(self, text, option, default=NoDefault, + tip=None, alignment=Qt.Vertical, regex=None, + restart=False, word_wrap=True, placeholder=None, + content_type=None, section=None): + if section is not None and section != self.CONF_SECTION: + self.cross_section_options[option] = section + label = QLabel(text) + label.setWordWrap(word_wrap) + edit = QLineEdit() + edit.content_type = content_type + layout = QVBoxLayout() if alignment == Qt.Vertical else QHBoxLayout() + layout.addWidget(label) + layout.addWidget(edit) + layout.setContentsMargins(0, 0, 0, 0) + if regex: + edit.setValidator(QRegExpValidator(QRegExp(regex))) + if placeholder: + edit.setPlaceholderText(placeholder) + self.lineedits[edit] = (section, option, default) + + widget = QWidget(self) + widget.label = label + widget.textbox = edit + if tip is not None: + layout, help_label = self.add_help_info_label(layout, tip) + widget.help_label = help_label + widget.setLayout(layout) + edit.restart_required = restart + edit.label_text = text + return widget + + def create_textedit(self, text, option, default=NoDefault, + tip=None, restart=False, content_type=None, + section=None): + if section is not None and section != self.CONF_SECTION: + self.cross_section_options[option] = section + label = QLabel(text) + label.setWordWrap(True) + edit = QPlainTextEdit() + edit.content_type = content_type + edit.setWordWrapMode(QTextOption.WordWrap) + layout = QVBoxLayout() + layout.addWidget(label) + layout.addWidget(edit) + layout.setContentsMargins(0, 0, 0, 0) + self.textedits[edit] = (section, option, default) + + widget = QWidget(self) + widget.label = label + widget.textbox = edit + if tip is not None: + layout, help_label = self.add_help_info_label(layout, tip) + widget.help_label = help_label + widget.setLayout(layout) + edit.restart_required = restart + edit.label_text = text + return widget + + def create_browsedir(self, text, option, default=NoDefault, tip=None, + section=None): + widget = self.create_lineedit(text, option, default, section=section, + alignment=Qt.Horizontal) + for edit in self.lineedits: + if widget.isAncestorOf(edit): + break + msg = _("Invalid directory path") + self.validate_data[edit] = (osp.isdir, msg) + browse_btn = QPushButton(ima.icon('DirOpenIcon'), '', self) + browse_btn.setToolTip(_("Select directory")) + browse_btn.clicked.connect(lambda: self.select_directory(edit)) + layout = QHBoxLayout() + layout.addWidget(widget) + layout.addWidget(browse_btn) + layout.setContentsMargins(0, 0, 0, 0) + if tip is not None: + layout, help_label = self.add_help_info_label(layout, tip) + browsedir = QWidget(self) + browsedir.setLayout(layout) + return browsedir + + def select_directory(self, edit): + """Select directory""" + basedir = to_text_string(edit.text()) + if not osp.isdir(basedir): + basedir = getcwd_or_home() + title = _("Select directory") + directory = getexistingdirectory(self, title, basedir) + if directory: + edit.setText(directory) + + def create_browsefile(self, text, option, default=NoDefault, tip=None, + filters=None, section=None): + widget = self.create_lineedit(text, option, default, section=section, + alignment=Qt.Horizontal) + for edit in self.lineedits: + if widget.isAncestorOf(edit): + break + msg = _('Invalid file path') + self.validate_data[edit] = (osp.isfile, msg) + browse_btn = QPushButton(ima.icon('FileIcon'), '', self) + browse_btn.setToolTip(_("Select file")) + browse_btn.clicked.connect(lambda: self.select_file(edit, filters)) + layout = QHBoxLayout() + layout.addWidget(widget) + layout.addWidget(browse_btn) + layout.setContentsMargins(0, 0, 0, 0) + if tip is not None: + layout, help_label = self.add_help_info_label(layout, tip) + browsedir = QWidget(self) + browsedir.setLayout(layout) + return browsedir + + def select_file(self, edit, filters=None, **kwargs): + """Select File""" + basedir = osp.dirname(to_text_string(edit.text())) + if not osp.isdir(basedir): + basedir = getcwd_or_home() + if filters is None: + filters = _("All files (*)") + title = _("Select file") + filename, _selfilter = getopenfilename(self, title, basedir, filters, + **kwargs) + if filename: + edit.setText(filename) + + def create_spinbox(self, prefix, suffix, option, default=NoDefault, + min_=None, max_=None, step=None, tip=None, + section=None): + if section is not None and section != self.CONF_SECTION: + self.cross_section_options[option] = section + widget = QWidget(self) + if prefix: + plabel = QLabel(prefix) + widget.plabel = plabel + else: + plabel = None + if suffix: + slabel = QLabel(suffix) + widget.slabel = slabel + else: + slabel = None + if step is not None: + if type(step) is int: + spinbox = QSpinBox() + else: + spinbox = QDoubleSpinBox() + spinbox.setDecimals(1) + spinbox.setSingleStep(step) + else: + spinbox = QSpinBox() + if min_ is not None: + spinbox.setMinimum(min_) + if max_ is not None: + spinbox.setMaximum(max_) + self.spinboxes[spinbox] = (section, option, default) + layout = QHBoxLayout() + for subwidget in (plabel, spinbox, slabel): + if subwidget is not None: + layout.addWidget(subwidget) + layout.addStretch(1) + layout.setContentsMargins(0, 0, 0, 0) + widget.spinbox = spinbox + if tip is not None: + layout, help_label = self.add_help_info_label(layout, tip) + widget.help_label = help_label + widget.setLayout(layout) + return widget + + def create_coloredit(self, text, option, default=NoDefault, tip=None, + without_layout=False, section=None): + if section is not None and section != self.CONF_SECTION: + self.cross_section_options[option] = section + label = QLabel(text) + clayout = ColorLayout(QColor(Qt.black), self) + clayout.lineedit.setMaximumWidth(80) + self.coloredits[clayout] = (section, option, default) + if without_layout: + return label, clayout + layout = QHBoxLayout() + layout.addWidget(label) + layout.addLayout(clayout) + layout.addStretch(1) + layout.setContentsMargins(0, 0, 0, 0) + if tip is not None: + layout, help_label = self.add_help_info_label(layout, tip) + + widget = QWidget(self) + widget.setLayout(layout) + return widget + + def create_scedit(self, text, option, default=NoDefault, tip=None, + without_layout=False, section=None): + if section is not None and section != self.CONF_SECTION: + self.cross_section_options[option] = section + label = QLabel(text) + clayout = ColorLayout(QColor(Qt.black), self) + clayout.lineedit.setMaximumWidth(80) + cb_bold = QCheckBox() + cb_bold.setIcon(ima.icon('bold')) + cb_bold.setToolTip(_("Bold")) + cb_italic = QCheckBox() + cb_italic.setIcon(ima.icon('italic')) + cb_italic.setToolTip(_("Italic")) + self.scedits[(clayout, cb_bold, cb_italic)] = (section, option, + default) + if without_layout: + return label, clayout, cb_bold, cb_italic + layout = QHBoxLayout() + layout.addWidget(label) + layout.addLayout(clayout) + layout.addSpacing(10) + layout.addWidget(cb_bold) + layout.addWidget(cb_italic) + layout.addStretch(1) + layout.setContentsMargins(0, 0, 0, 0) + if tip is not None: + layout, help_label = self.add_help_info_label(layout, tip) + widget = QWidget(self) + widget.setLayout(layout) + return widget + + def create_combobox(self, text, choices, option, default=NoDefault, + tip=None, restart=False, section=None): + """choices: couples (name, key)""" + if section is not None and section != self.CONF_SECTION: + self.cross_section_options[option] = section + label = QLabel(text) + combobox = QComboBox() + for name, key in choices: + if not (name is None and key is None): + combobox.addItem(name, to_qvariant(key)) + # Insert separators + count = 0 + for index, item in enumerate(choices): + name, key = item + if name is None and key is None: + combobox.insertSeparator(index + count) + count += 1 + self.comboboxes[combobox] = (section, option, default) + layout = QHBoxLayout() + layout.addWidget(label) + layout.addWidget(combobox) + layout.addStretch(1) + layout.setContentsMargins(0, 0, 0, 0) + widget = QWidget(self) + widget.label = label + widget.combobox = combobox + if tip is not None: + layout, help_label = self.add_help_info_label(layout, tip) + widget.help_label = help_label + widget.setLayout(layout) + combobox.restart_required = restart + combobox.label_text = text + return widget + + def create_file_combobox(self, text, choices, option, default=NoDefault, + tip=None, restart=False, filters=None, + adjust_to_contents=False, + default_line_edit=False, section=None, + validate_callback=None): + """choices: couples (name, key)""" + if section is not None and section != self.CONF_SECTION: + self.cross_section_options[option] = section + combobox = FileComboBox(self, adjust_to_contents=adjust_to_contents, + default_line_edit=default_line_edit) + combobox.restart_required = restart + combobox.label_text = text + edit = combobox.lineEdit() + edit.label_text = text + edit.restart_required = restart + self.lineedits[edit] = (section, option, default) + combobox.addItems(choices) + combobox.choices = choices + + msg = _('Invalid file path') + self.validate_data[edit] = ( + validate_callback if validate_callback else osp.isfile, + msg) + browse_btn = QPushButton(ima.icon('FileIcon'), '', self) + browse_btn.setToolTip(_("Select file")) + options = QFileDialog.DontResolveSymlinks + browse_btn.clicked.connect( + lambda: self.select_file(edit, filters, options=options)) + + layout = QGridLayout() + layout.addWidget(combobox, 0, 0, 0, 9) + layout.addWidget(browse_btn, 0, 10) + layout.setContentsMargins(0, 0, 0, 0) + + widget = QWidget(self) + widget.combobox = combobox + widget.browse_btn = browse_btn + if tip is not None: + layout, help_label = self.add_help_info_label(layout, tip) + widget.help_label = help_label + widget.setLayout(layout) + + return widget + + def create_fontgroup(self, option=None, text=None, title=None, + tip=None, fontfilters=None, without_group=False, + restart=False): + """Option=None -> setting plugin font""" + + if title: + fontlabel = QLabel(title) + else: + fontlabel = QLabel(_("Font")) + + fontbox = QFontComboBox() + fontbox.restart_required = restart + fontbox.label_text = _("{} font").format(title) + + if fontfilters is not None: + fontbox.setFontFilters(fontfilters) + + sizebox = QSpinBox() + sizebox.setRange(7, 100) + sizebox.restart_required = restart + sizebox.label_text = _("{} font size").format(title) + + self.fontboxes[(fontbox, sizebox)] = option + + layout = QHBoxLayout() + for subwidget in (fontlabel, fontbox, sizebox): + layout.addWidget(subwidget) + layout.addStretch(1) + + if not without_group: + if text is None: + text = _("Font style") + + group = QGroupBox(text) + group.setLayout(layout) + + if tip is not None: + layout, help_label = self.add_help_info_label(layout, tip) + + return group + else: + widget = QWidget(self) + widget.fontlabel = fontlabel + widget.fontbox = fontbox + widget.sizebox = sizebox + widget.setLayout(layout) + + return widget + + def create_button(self, text, callback): + btn = QPushButton(text) + btn.clicked.connect(callback) + btn.clicked.connect( + lambda checked=False, opt='': self.has_been_modified( + self.CONF_SECTION, opt)) + return btn + + def create_tab(self, name, widgets): + """ + Create a tab widget page. + + Parameters + ---------- + name: str + Name of the tab + widgets: list or QWidget + List of widgets to add to the tab. This can be also a single + widget. + + Notes + ----- + * Widgets are added in a vertical layout. + """ + if self.tabs is None: + self.tabs = QTabWidget(self) + self.tabs.setUsesScrollButtons(True) + self.tabs.setElideMode(Qt.ElideNone) + + vlayout = QVBoxLayout() + vlayout.addWidget(self.tabs) + self.setLayout(vlayout) + + if not isinstance(widgets, list): + widgets = [widgets] + + tab = QWidget(self) + layout = QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + for w in widgets: + layout.addWidget(w) + layout.addStretch(1) + tab.setLayout(layout) + + self.tabs.addTab(tab, name) + + def prompt_restart_required(self): + """Prompt the user with a request to restart.""" + message = _( + "One or more of the settings you changed requires a restart to be " + "applied.

" + "Do you wish to restart now?" + ) + + answer = QMessageBox.information( + self, + _("Information"), + message, + QMessageBox.Yes | QMessageBox.No + ) + + if answer == QMessageBox.Yes: + self.restart() + + def restart(self): + """Restart Spyder.""" + self.main.restart(close_immediately=True) + + def _add_tab(self, Widget): + widget = Widget(self) + + if self.tabs is None: + # In case a preference page does not have any tabs, we need to + # add a tab with the widgets that already exist and then add the + # new tab. + layout = self.layout() + main_widget = QWidget(self) + main_widget.setLayout(layout) + + self.create_tab(_('General'), main_widget) + self.create_tab(Widget.TITLE, widget) + else: + self.create_tab(Widget.TITLE, widget) + + self.load_from_conf() diff --git a/spyder/plugins/preferences/widgets/configdialog.py b/spyder/plugins/preferences/widgets/configdialog.py index c2d8e2db660..255336b1229 100644 --- a/spyder/plugins/preferences/widgets/configdialog.py +++ b/spyder/plugins/preferences/widgets/configdialog.py @@ -5,36 +5,68 @@ # (see spyder/__init__.py for details) # Third party imports -import qstylizer.style from qtpy.QtCore import QSize, Qt, Signal, Slot -from qtpy.QtWidgets import (QDialog, QDialogButtonBox, QHBoxLayout, - QListView, QListWidget, QListWidgetItem, - QPushButton, QScrollArea, QSplitter, - QStackedWidget, QVBoxLayout) +from qtpy.QtGui import QFontMetricsF +from qtpy.QtWidgets import ( + QDialog, QDialogButtonBox, QFrame, QGridLayout, QHBoxLayout, QListView, + QListWidget, QListWidgetItem, QPushButton, QScrollArea, QStackedWidget, + QVBoxLayout, QWidget) +from superqt.utils import qdebounced, signals_blocked # Local imports +from spyder.api.config.fonts import SpyderFontType, SpyderFontsMixin from spyder.config.base import _, load_lang_conf from spyder.config.manager import CONF from spyder.utils.icon_manager import ima +from spyder.utils.palette import QStylePalette +from spyder.utils.stylesheet import ( + AppStyle, MAC, PREFERENCES_TABBAR_STYLESHEET, WIN) -class ConfigDialog(QDialog): - """Spyder configuration ('Preferences') dialog box""" +class PageScrollArea(QScrollArea): + """Scroll area for preference pages.""" + + def widget(self): + """Return the page widget inside the scroll area.""" + return super().widget().page + + +class ConfigDialog(QDialog, SpyderFontsMixin): + """Preferences dialog.""" # Signals check_settings = Signal() - size_change = Signal(QSize) + sig_size_changed = Signal(QSize) sig_reset_preferences_requested = Signal() + # Constants + ITEMS_MARGIN = 2 * AppStyle.MarginSize + ITEMS_PADDING = ( + AppStyle.MarginSize if (MAC or WIN) else 2 * AppStyle.MarginSize + ) + CONTENTS_WIDTH = 230 if MAC else (200 if WIN else 240) + ICON_SIZE = 20 + MIN_WIDTH = 940 if MAC else (875 if WIN else 920) + MIN_HEIGHT = 700 if MAC else (660 if WIN else 670) + def __init__(self, parent=None): QDialog.__init__(self, parent) + # Attributes self.main = parent + self.items_font = self.get_font( + SpyderFontType.Interface, font_size_delta=1 + ) + self._is_shown = False + self._separators = [] + + # Size + self.setMinimumWidth(self.MIN_WIDTH) + self.setMinimumHeight(self.MIN_HEIGHT) # Widgets - self.pages_widget = QStackedWidget() - self.pages_widget.setMinimumWidth(600) - self.contents_widget = QListWidget() + self.pages_widget = QStackedWidget(self) + self.contents_widget = QListWidget(self) self.button_reset = QPushButton(_('Reset to defaults')) bbox = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Apply | @@ -42,7 +74,6 @@ def __init__(self, parent=None): self.apply_btn = bbox.button(QDialogButtonBox.Apply) self.ok_btn = bbox.button(QDialogButtonBox.Ok) - # Widgets setup # Destroying the C++ object right after closing the dialog box, # otherwise it may be garbage-collected in another QThread # (e.g. the editor's analysis thread in Spyder), thus leading to @@ -50,38 +81,52 @@ def __init__(self, parent=None): self.setAttribute(Qt.WA_DeleteOnClose) self.setWindowTitle(_('Preferences')) self.setWindowIcon(ima.icon('configure')) + + # Widgets setup + self.pages_widget.setMinimumWidth(600) + self.contents_widget.setMovement(QListView.Static) - self.contents_widget.setSpacing(1) + self.contents_widget.setSpacing(3) self.contents_widget.setCurrentRow(0) - self.contents_widget.setMinimumWidth(220) - self.contents_widget.setMinimumHeight(400) + self.contents_widget.setObjectName('configdialog-contents') + self.contents_widget.setIconSize(QSize(self.ICON_SIZE, self.ICON_SIZE)) + self.contents_widget.setFixedWidth(self.CONTENTS_WIDTH) + + # Don't show horizontal scrollbar because it doesn't look good. Instead + # we show tooltips if the text doesn't fit in contents_widget width. + self.contents_widget.setHorizontalScrollBarPolicy( + Qt.ScrollBarAlwaysOff) # Layout - hsplitter = QSplitter() - hsplitter.addWidget(self.contents_widget) - hsplitter.addWidget(self.pages_widget) - hsplitter.setStretchFactor(0, 1) - hsplitter.setStretchFactor(1, 2) + contents_and_pages_layout = QGridLayout() + contents_and_pages_layout.addWidget(self.contents_widget, 0, 0) + contents_and_pages_layout.addWidget(self.pages_widget, 0, 1) + contents_and_pages_layout.setContentsMargins(0, 0, 0, 0) + contents_and_pages_layout.setColumnStretch(0, 1) + contents_and_pages_layout.setColumnStretch(1, 3) + contents_and_pages_layout.setHorizontalSpacing(0) btnlayout = QHBoxLayout() btnlayout.addWidget(self.button_reset) btnlayout.addStretch(1) btnlayout.addWidget(bbox) - vlayout = QVBoxLayout() - vlayout.addWidget(hsplitter) - vlayout.addLayout(btnlayout) + layout = QVBoxLayout() + layout.addLayout(contents_and_pages_layout) + layout.addSpacing(3) + layout.addLayout(btnlayout) - self.setLayout(vlayout) + self.setLayout(layout) # Stylesheet - self.setStyleSheet(self._stylesheet) + self._css = self._generate_stylesheet() + self.setStyleSheet(self._css.toString()) # Signals and slots self.button_reset.clicked.connect(self.sig_reset_preferences_requested) self.pages_widget.currentChanged.connect(self.current_page_changed) self.contents_widget.currentRowChanged.connect( - self.pages_widget.setCurrentIndex) + self.pages_widget.setCurrentIndex) bbox.accepted.connect(self.accept) bbox.rejected.connect(self.reject) bbox.clicked.connect(self.button_clicked) @@ -89,6 +134,8 @@ def __init__(self, parent=None): # Ensures that the config is present on spyder first run CONF.set('main', 'interface_language', load_lang_conf()) + # ---- Public API + # ------------------------------------------------------------------------- def get_current_index(self): """Return current page index""" return self.contents_widget.currentRow() @@ -100,39 +147,35 @@ def set_current_index(self, index): def get_page(self, index=None): """Return page widget""" if index is None: - widget = self.pages_widget.currentWidget() + page = self.pages_widget.currentWidget() else: - widget = self.pages_widget.widget(index) + page = self.pages_widget.widget(index) - if widget: - return widget.widget() + # Not all pages are config pages (e.g. separators have a simple QWidget + # as their config page). So, we need to check for this. + if page and hasattr(page, 'widget'): + return page.widget() def get_index_by_name(self, name): """Return page index by CONF_SECTION name.""" for idx in range(self.pages_widget.count()): - widget = self.pages_widget.widget(idx) - widget = widget.widget() + page = self.get_page(idx) + + # This is the case for separators + if page is None: + continue + try: # New API - section = widget.plugin.NAME + section = page.plugin.NAME except AttributeError: - section = widget.CONF_SECTION + section = page.CONF_SECTION if section == name: return idx else: return None - @Slot() - def accept(self): - """Reimplement Qt method""" - for index in range(self.pages_widget.count()): - configpage = self.get_page(index) - if not configpage.is_valid(): - return - configpage.apply_changes() - QDialog.accept(self) - def button_clicked(self, button): if button is self.apply_btn: # Apply button was clicked @@ -146,44 +189,259 @@ def current_page_changed(self, index): self.apply_btn.setVisible(widget.apply_callback is not None) self.apply_btn.setEnabled(widget.is_modified) - def add_page(self, widget): - self.check_settings.connect(widget.check_settings) - widget.show_this_page.connect(lambda row=self.contents_widget.count(): - self.contents_widget.setCurrentRow(row)) - widget.apply_button_enabled.connect(self.apply_btn.setEnabled) - scrollarea = QScrollArea(self) + def add_separator(self): + """Add a horizontal line to separate different sections.""" + # Solution taken from https://stackoverflow.com/a/24819554/438386 + item = QListWidgetItem(self.contents_widget) + item.setFlags(Qt.NoItemFlags) + + size = ( + AppStyle.MarginSize * 3 if (MAC or WIN) + else AppStyle.MarginSize * 5 + ) + item.setSizeHint(QSize(size, size)) + + hline = QFrame(self.contents_widget) + hline.setFrameShape(QFrame.HLine) + self.contents_widget.setItemWidget(item, hline) + + # This is necessary to keep in sync the contents_widget and + # pages_widget indexes. + self.pages_widget.addWidget(QWidget(self)) + + # Save separators to perform certain operations only on them + self._separators.append(hline) + + def add_page(self, page): + # Signals + self.check_settings.connect(page.check_settings) + page.show_this_page.connect(lambda row=self.contents_widget.count(): + self.contents_widget.setCurrentRow(row)) + page.apply_button_enabled.connect(self.apply_btn.setEnabled) + + # Container widget so that we can center the page + layout = QHBoxLayout() + layout.addWidget(page) + layout.setAlignment(Qt.AlignHCenter) + + # The smaller margin to the right is necessary to compensate for the + # space added by the vertical scrollbar + layout.setContentsMargins(27, 27, 15, 27) + + container = QWidget(self) + container.setLayout(layout) + container.page = page + + # Add container to a scroll area in case the page contents don't fit + # in the dialog + scrollarea = PageScrollArea(self) + scrollarea.setObjectName('configdialog-scrollarea') scrollarea.setWidgetResizable(True) - scrollarea.setWidget(widget) + scrollarea.setWidget(container) self.pages_widget.addWidget(scrollarea) + + # Add plugin entry item to contents widget item = QListWidgetItem(self.contents_widget) + item.setText(page.get_name()) + item.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) + + # In case a plugin doesn't have an icon try: - item.setIcon(widget.get_icon()) + item.setIcon(page.get_icon()) except TypeError: pass - item.setText(widget.get_name()) - item.setFlags(Qt.ItemIsSelectable|Qt.ItemIsEnabled) - item.setSizeHint(QSize(0, 25)) + + # Set font for items + item.setFont(self.items_font) def check_all_settings(self): """This method is called to check all configuration page settings after configuration dialog has been shown""" self.check_settings.emit() + # ---- Qt methods + # ------------------------------------------------------------------------- + @Slot() + def accept(self): + """Reimplement Qt method""" + for index in range(self.pages_widget.count()): + configpage = self.get_page(index) + + # This can be the case for separators, which doesn't have a config + # page. + if configpage is None: + continue + + if not configpage.is_valid(): + return + + configpage.apply_changes() + + QDialog.accept(self) + + def showEvent(self, event): + """Adjustments when the widget is shown.""" + if not self._is_shown: + self._add_tooltips() + self._adjust_items_margin() + + self._is_shown = True + + super().showEvent(event) + + # This is necessary to paint the separators as expected when there + # are elided items in contents_widget. + with signals_blocked(self): + height = self.height() + self.resize(self.width(), height + 1) + self.resize(self.width(), height - 1) + def resizeEvent(self, event): """ - Reimplement Qt method to be able to save the widget's size from the - main application + Reimplement Qt method to perform several operations when resizing. """ QDialog.resizeEvent(self, event) - self.size_change.emit(self.size()) + self._on_resize_event() - @property - def _stylesheet(self): - css = qstylizer.style.StyleSheet() + # ---- Private API + # ------------------------------------------------------------------------- + def _add_tooltips(self): + """ + Check if it's necessary to add tooltips to the contents_widget items. + """ + contents_width = self.contents_widget.width() + metrics = QFontMetricsF(self.items_font) + + for i in range(self.contents_widget.count()): + item = self.contents_widget.item(i) + + # Item width + item_width = self.contents_widget.visualItemRect(item).width() + + # Set tooltip + if item_width >= contents_width: + item.setToolTip(item.text()) + else: + # This covers the case when item_width is too close to + # contents_width without the scrollbar being visible, which + # can't be detected by Qt with the check above. + scrollbar = self.contents_widget.verticalScrollBar() + + if scrollbar.isVisible(): + if MAC: + # This is a crude heuristic to detect if we need to add + # tooltips on Mac. However, it's the best we can do + # (the approach for other OSes below ends up adding + # tooltips to all items) and it works for all our + # localized languages. + text_width = metrics.boundingRect(item.text()).width() + if text_width + 70 > item_width - 5: + item.setToolTip(item.text()) + else: + if item_width > (contents_width - scrollbar.width()): + item.setToolTip(item.text()) + + def _adjust_items_margin(self): + """ + Adjust margins of contents_widget items depending on if its vertical + scrollbar is visible. + + Notes + ----- + We need to do this only in Mac because Qt doesn't account for the + scrollbar width in most widgets. + """ + if MAC: + scrollbar = self.contents_widget.verticalScrollBar() + extra_margin = ( + AppStyle.MacScrollBarWidth if scrollbar.isVisible() else 0 + ) + item_margin = ( + f'0px {self.ITEMS_MARGIN + extra_margin}px ' + f'0px {self.ITEMS_MARGIN}px' + ) + + self._css['QListView#configdialog-contents::item'].setValues( + margin=item_margin + ) + + self.setStyleSheet(self._css.toString()) + + def _adjust_separators_width(self): + """ + Adjust the width of separators present in contents_widget depending on + if its vertical scrollbar is visible. + + Notes + ----- + We need to do this only in Mac because Qt doesn't set the widths + correctly when there are elided items. + """ + if MAC: + scrollbar = self.contents_widget.verticalScrollBar() + for sep in self._separators: + if self.CONTENTS_WIDTH != 230: + raise ValueError( + "The values used here for the separators' width were " + "the ones reported by Qt for a contents_widget width " + "of 230px. Since this value changed, you need to " + "update them." + ) + + # These are the values reported by Qt when CONTENTS_WIDTH = 230 + # and the interface language is English. + if scrollbar.isVisible(): + sep.setFixedWidth(188) + else: + sep.setFixedWidth(204) + + def _generate_stylesheet(self): + """Generate stylesheet for this widget as a qstylizer object.""" + # Use the tabbar stylesheet as the base one and extend it. + tabs_stylesheet = PREFERENCES_TABBAR_STYLESHEET.get_copy() + css = tabs_stylesheet.get_stylesheet() + + # Set style of contents area + css['QListView#configdialog-contents'].setValues( + padding=f'{self.ITEMS_MARGIN}px 0px', + backgroundColor=QStylePalette.COLOR_BACKGROUND_2, + border=f'1px solid {QStylePalette.COLOR_BACKGROUND_2}', + ) - # Show tabs aligned to the left - css['QTabWidget::tab-bar'].setValues( - alignment='left' + # Remove border color on focus of contents area + css['QListView#configdialog-contents:focus'].setValues( + border=f'1px solid {QStylePalette.COLOR_BACKGROUND_2}', ) - return css.toString() + # Add margin and padding for items in contents area + css['QListView#configdialog-contents::item'].setValues( + padding=f'{self.ITEMS_PADDING}px', + margin=f'0px {self.ITEMS_MARGIN}px' + ) + + # Set border radius and background color for hover, active and inactive + # states of items + css['QListView#configdialog-contents::item:hover'].setValues( + borderRadius=f'{QStylePalette.SIZE_BORDER_RADIUS}', + ) + + for state in ['item:selected:active', 'item:selected:!active']: + css[f'QListView#configdialog-contents::{state}'].setValues( + borderRadius=f'{QStylePalette.SIZE_BORDER_RADIUS}', + backgroundColor=QStylePalette.COLOR_BACKGROUND_4 + ) + + # Remove border of all scroll areas for pages + css['QScrollArea#configdialog-scrollarea'].setValues( + border='0px', + ) + + return css + + @qdebounced(timeout=40) + def _on_resize_event(self): + """Method to run when Qt emits a resize event.""" + self._add_tooltips() + self._adjust_items_margin() + self._adjust_separators_width() + self.sig_size_changed.emit(self.size()) diff --git a/spyder/plugins/preferences/widgets/container.py b/spyder/plugins/preferences/widgets/container.py index ded205f17b2..1aa46fc168b 100644 --- a/spyder/plugins/preferences/widgets/container.py +++ b/spyder/plugins/preferences/widgets/container.py @@ -10,16 +10,14 @@ from qtpy import PYSIDE2 # Local imports +from spyder.api.plugin_registration.registry import PreferencesAdapter from spyder.api.translations import _ from spyder.api.widgets.main_container import PluginMainContainer +from spyder.plugins.preferences import MOST_IMPORTANT_PAGES +from spyder.plugins.preferences.api import PreferencesActions from spyder.plugins.preferences.widgets.configdialog import ConfigDialog -class PreferencesActions: - Show = 'show_action' - Reset = 'reset_action' - - class PreferencesContainer(PluginMainContainer): sig_reset_preferences_requested = Signal() """Request a reset of preferences.""" @@ -30,10 +28,10 @@ class PreferencesContainer(PluginMainContainer): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.dialog = None - self.dialog_index = None + self.dialog_index = 0 + self._dialog_size = None - def create_dialog(self, config_pages, config_tabs, prefs_dialog_size, - main_window): + def create_dialog(self, config_pages, config_tabs, main_window): def _dialog_finished(result_code): """Restore preferences dialog instance variable.""" @@ -41,6 +39,7 @@ def _dialog_finished(result_code): self.dialog.disconnect(None, None, None) else: self.dialog.disconnect() + self.dialog = None if self.dialog is None: @@ -48,45 +47,59 @@ def _dialog_finished(result_code): dlg = ConfigDialog(main_window) self.dialog = dlg - if prefs_dialog_size is not None: - dlg.resize(prefs_dialog_size) + if self._dialog_size is None: + self._dialog_size = self.get_conf('dialog_size') + dlg.resize(*self._dialog_size) for page_name in config_pages: + # Add separator before the Plugins page + if page_name == PreferencesAdapter.NAME: + dlg.add_separator() + (api, ConfigPage, plugin) = config_pages[page_name] if api == 'new': page = ConfigPage(plugin, dlg) page.initialize() for Tab in config_tabs.get(page_name, []): - page.add_tab(Tab) + page._add_tab(Tab) dlg.add_page(page) else: page = plugin._create_configwidget(dlg, main_window) for Tab in config_tabs.get(page_name, []): - page.add_tab(Tab) + page._add_tab(Tab) dlg.add_page(page) - if self.dialog_index is not None: - dlg.set_current_index(self.dialog_index) + # Add separator after the last element of the most important + # pages + if page_name == MOST_IMPORTANT_PAGES[-1]: + dlg.add_separator() + dlg.set_current_index(self.dialog_index) dlg.show() dlg.check_all_settings() dlg.finished.connect(_dialog_finished) dlg.pages_widget.currentChanged.connect( self.__preference_page_changed) - dlg.size_change.connect(main_window.set_prefs_size) + dlg.sig_size_changed.connect(self._set_dialog_size) dlg.sig_reset_preferences_requested.connect( self.sig_reset_preferences_requested) else: + self.dialog.resize(*self._dialog_size) self.dialog.show() self.dialog.activateWindow() self.dialog.raise_() self.dialog.setFocus() + # ---- Private API def __preference_page_changed(self, index): """Preference page index has changed.""" self.dialog_index = index + def _set_dialog_size(self, size): + self._dialog_size = (size.width(), size.height()) + + # ---- Public API def is_preferences_open(self): """Check if preferences is open.""" return self.dialog is not None and self.dialog.isVisible() @@ -119,3 +132,8 @@ def setup(self): def update_actions(self): pass + + def on_close(self): + # Save dialog size to use it in the next Spyder session + if isinstance(self._dialog_size, tuple): + self.set_conf('dialog_size', self._dialog_size) diff --git a/spyder/plugins/run/confpage.py b/spyder/plugins/run/confpage.py index b0ae2157851..c661c4fcf4d 100644 --- a/spyder/plugins/run/confpage.py +++ b/spyder/plugins/run/confpage.py @@ -16,7 +16,7 @@ from qtpy.QtCore import Qt from qtpy.QtWidgets import (QGroupBox, QLabel, QVBoxLayout, QComboBox, QTableView, QAbstractItemView, QPushButton, - QGridLayout, QHeaderView, QTabWidget, QWidget) + QGridLayout, QHeaderView, QWidget) # Local imports from spyder.api.preferences import PluginConfigPage @@ -230,21 +230,17 @@ def setup_page(self): vlayout = QVBoxLayout() vlayout.addWidget(about_label) - vlayout.addSpacing(10) + vlayout.addSpacing(9) vlayout.addWidget(self.executor_combo) + vlayout.addSpacing(9) vlayout.addWidget(params_group) vlayout.addLayout(sn_buttons_layout) vlayout.addStretch(1) executor_widget = QWidget() executor_widget.setLayout(vlayout) - self.tabs = QTabWidget() - self.tabs.addTab(self.create_tab(executor_widget), _("Run executors")) - self.tabs.addTab( - self.create_tab(run_widget), _("Editor interactions")) - main_layout = QVBoxLayout() - main_layout.addWidget(self.tabs) - self.setLayout(main_layout) + self.create_tab(_("Run executors"), executor_widget) + self.create_tab(_("Editor interactions"), run_widget) def executor_index_changed(self, index: int): # Save previous executor configuration diff --git a/spyder/utils/stylesheet.py b/spyder/utils/stylesheet.py index 4ddbfa18471..4ca254ad190 100644 --- a/spyder/utils/stylesheet.py +++ b/spyder/utils/stylesheet.py @@ -47,6 +47,9 @@ class AppStyle: FindMinWidth = 400 FindHeight = 26 + # To have it for quick access because it's needed a lot in Mac + MacScrollBarWidth = 16 + # ============================================================================= # ---- Base stylesheet class @@ -54,15 +57,27 @@ class AppStyle: class SpyderStyleSheet: """Base class for Spyder stylesheets.""" - def __init__(self, set_stylesheet=True): + SET_STYLESHEET_AT_INIT = True + """ + Decide if the stylesheet must be set when the class is initialized. + + Notes + ----- + There are some stylesheets for which this is not possible (e.g. the ones + that need to access our fonts). + """ + + def __init__(self): self._stylesheet = qstylizer.style.StyleSheet() - if set_stylesheet: + if self.SET_STYLESHEET_AT_INIT: self.set_stylesheet() def get_stylesheet(self): return self._stylesheet def to_string(self): + if self._stylesheet.toString() == "": + self.set_stylesheet() return self._stylesheet.toString() def get_copy(self): @@ -71,6 +86,8 @@ def get_copy(self): This allows it to be modified for specific widgets. """ + if self._stylesheet.toString() == "": + self.set_stylesheet() return copy.deepcopy(self) def set_stylesheet(self): @@ -96,17 +113,19 @@ class AppStylesheet(SpyderStyleSheet, SpyderConfigurationAccessor): application. """ + # Don't create the stylesheet here so that Spyder gets the app font from + # the system when it starts for the first time. This also allows us to + # display the splash screen more quickly because the stylesheet is then + # computed only when it's going to be applied to the app, not when this + # object is imported. + SET_STYLESHEET_AT_INIT = False + def __init__(self): - # Don't create the stylesheet here so that Spyder gets the app font - # from the system when it starts for the first time. This also allows - # us to display the splash screen more quickly because the stylesheet - # is then computed only when it's going to be applied to the app, not - # when this object is imported. - super().__init__(set_stylesheet=False) + super().__init__() self._stylesheet_as_string = None def to_string(self): - "Save stylesheet as a string for quick access." + """Save stylesheet as a string for quick access.""" if self._stylesheet_as_string is None: self.set_stylesheet() self._stylesheet_as_string = self._stylesheet.toString() @@ -254,6 +273,19 @@ def _customize_stylesheet(self): minHeight=f'{combobox_min_height - 0.2}em' ) + # Change QGroupBox style to avoid the "boxes within boxes" antipattern + # in Preferences + css.QGroupBox.setValues( + border='0px', + marginBottom='15px', + fontSize=f'{font_size + 1}pt', + ) + + css['QGroupBox::title'].setValues( + paddingTop='-0.3em', + left='0px', + ) + APP_STYLESHEET = AppStylesheet() @@ -269,7 +301,7 @@ class ApplicationToolbarStylesheet(SpyderStyleSheet): BUTTON_MARGIN_RIGHT = '3px' def set_stylesheet(self): - css = self._stylesheet + css = self.get_stylesheet() # Main background color css.QToolBar.setValues( @@ -309,7 +341,7 @@ class PanesToolbarStyleSheet(SpyderStyleSheet): BUTTON_HEIGHT = '37px' def set_stylesheet(self): - css = self._stylesheet + css = self.get_stylesheet() css.QToolBar.setValues( spacing='4px' @@ -471,7 +503,7 @@ def set_stylesheet(self): # Make scroll button icons smaller on Windows and Mac if WIN or MAC: css[f'QTabBar{self.OBJECT_NAME} QToolButton'].setValues( - padding='7px', + padding=f'{5 if WIN else 7}px', ) @@ -479,6 +511,7 @@ class BaseDockTabBarStyleSheet(BaseTabBarStyleSheet): """Base style for dockwidget tabbars.""" SCROLL_BUTTONS_BORDER_WIDTH = '2px' + SCROLL_BUTTONS_PADDING = 7 if WIN else 9 def set_stylesheet(self): super().set_stylesheet() @@ -498,6 +531,10 @@ def set_stylesheet(self): alignment='center' ) + css['QTabWidget::tab-bar'].setValues( + alignment='center' + ) + # Style for selected tabs css['QTabBar::tab:selected'].setValues( color=( @@ -510,14 +547,18 @@ def set_stylesheet(self): # Make scroll button icons smaller on Windows and Mac if WIN or MAC: css['QTabBar QToolButton'].setValues( - padding='5px', + padding=f'{self.SCROLL_BUTTONS_PADDING}px', ) -class HorizontalDockTabBarStyleSheet(BaseDockTabBarStyleSheet): +class SpecialTabBarStyleSheet(BaseDockTabBarStyleSheet): """ - This implements the design for dockwidget tabs discussed on issue - spyder-ide/ux-improvements#4. + Style for special tab bars. + + Notes + ----- + This is the base class for horizontal tab bars that follow the design + discussed on issue spyder-ide/ux-improvements#4. """ SCROLL_BUTTONS_BORDER_POS = 'right' @@ -531,18 +572,12 @@ def set_stylesheet(self): # Basic style css['QTabBar::tab'].setValues( - # No margins to left/right but top/bottom to separate tabbar from - # the dockwidget areas. - # Notes: - # * Top margin is half the one at the bottom so that we can show - # a bottom margin on dockwidgets that are not tabified. - # * The other half is added through the _margin_bottom attribute of - # PluginMainWidget. - margin=f'{margin_size}px 0px {2 * margin_size}px 0px', + # Only add margin to the bottom + margin=f'0px 0px {2 * margin_size}px 0px', # Border radius is added for specific tabs (see below) borderRadius='0px', # Remove a colored border added by QDarkStyle - borderTop='0px', + borderBottom='0px', # Add right border to make it work as our tabs separator borderRight=f'1px solid {self.color_tabs_separator}', # Padding for text inside tabs @@ -568,18 +603,15 @@ def set_stylesheet(self): borderLeft=f'1px solid {self.color_tabs_separator}', ) - # First and last tabs have rounded borders. Also, add margin to avoid - # them touch the left and right areas, respectively. + # First and last tabs have rounded borders css['QTabBar::tab:first'].setValues( borderTopLeftRadius='4px', borderBottomLeftRadius='4px', - marginLeft=f'{2 * margin_size}px', ) css['QTabBar::tab:last'].setValues( borderTopRightRadius='4px', borderBottomRightRadius='4px', - marginRight=f'{2 * margin_size}px', ) # Last tab doesn't need to show the separator @@ -589,6 +621,83 @@ def set_stylesheet(self): borderRightColor=f'{QStylePalette.COLOR_BACKGROUND_4}' ) + # Set bottom margin for scroll buttons. + css['QTabBar QToolButton'].setValues( + marginBottom=f'{2 * margin_size}px', + ) + + +class PreferencesTabBarStyleSheet(SpecialTabBarStyleSheet, SpyderFontsMixin): + """Style for tab bars in our Preferences dialog.""" + + # This is necessary because this class needs to access fonts + SET_STYLESHEET_AT_INIT = False + + def set_stylesheet(self): + super().set_stylesheet() + + # Main constants + css = self.get_stylesheet() + font = self.get_font(SpyderFontType.Interface, font_size_delta=1) + + # Set font size to be one point bigger than the regular text. + css.QTabBar.setValues( + fontSize=f'{font.pointSize()}pt', + ) + + # Make scroll buttons a bit bigger on Windows and Mac (this has no + # effect on Linux). + if WIN or MAC: + css['QTabBar QToolButton'].setValues( + padding=f'{self.SCROLL_BUTTONS_PADDING - 1}px', + ) + + # Increase padding around text because we're using a larger font. + css['QTabBar::tab'].setValues( + padding='6px 10px', + ) + + # Remove border and add padding for content inside tabs + css['QTabWidget::pane'].setValues( + border='0px', + padding='15px', + ) + + +class HorizontalDockTabBarStyleSheet(SpecialTabBarStyleSheet): + """Style for horizontal dockwidget tab bars.""" + + def set_stylesheet(self): + super().set_stylesheet() + + # Main constants + css = self.get_stylesheet() + margin_size = AppStyle.MarginSize + + # Tabs style + css['QTabBar::tab'].setValues( + # No margins to left/right but top/bottom to separate tabbar from + # the dockwidget areas. + # Notes: + # * Top margin is half the one at the bottom so that we can show + # a bottom margin on dockwidgets that are not tabified. + # * The other half is added through the _margin_bottom attribute of + # PluginMainWidget. + margin=f'{margin_size}px 0px {2 * margin_size}px 0px', + # Remove a colored border added by QDarkStyle + borderTop='0px', + ) + + # Add margin to first and last tabs to avoid them touching the left and + # right dockwidget areas, respectively. + css['QTabBar::tab:first'].setValues( + marginLeft=f'{2 * margin_size}px', + ) + + css['QTabBar::tab:last'].setValues( + marginRight=f'{2 * margin_size}px', + ) + # Make top and bottom margins for scroll buttons even. # This is necessary since the tabbar top margin is half the one at the # bottom (see the notes in the 'QTabBar::tab' style above). @@ -599,9 +708,7 @@ def set_stylesheet(self): class VerticalDockTabBarStyleSheet(BaseDockTabBarStyleSheet): - """ - Vertical implementation for the design on spyder-ide/ux-improvements#4. - """ + """Style for vertical dockwidget tab bars.""" SCROLL_BUTTONS_BORDER_POS = 'bottom' @@ -676,6 +783,7 @@ def set_stylesheet(self): PANES_TABBAR_STYLESHEET = PanesTabBarStyleSheet() HORIZONTAL_DOCK_TABBAR_STYLESHEET = HorizontalDockTabBarStyleSheet() VERTICAL_DOCK_TABBAR_STYLESHEET = VerticalDockTabBarStyleSheet() +PREFERENCES_TABBAR_STYLESHEET = PreferencesTabBarStyleSheet() # ============================================================================= diff --git a/spyder/widgets/dock.py b/spyder/widgets/dock.py index 181c7982542..8a53c0e9c6b 100644 --- a/spyder/widgets/dock.py +++ b/spyder/widgets/dock.py @@ -38,6 +38,7 @@ def __init__(self, dock_tabbar, main): self._set_tabbar_stylesheet() self.dock_tabbar.setElideMode(Qt.ElideNone) + self.dock_tabbar.setUsesScrollButtons(True) def eventFilter(self, obj, event): """Filter mouse press events. diff --git a/spyder/widgets/elementstable.py b/spyder/widgets/elementstable.py index f9caf7c819a..fa80115d67d 100644 --- a/spyder/widgets/elementstable.py +++ b/spyder/widgets/elementstable.py @@ -10,18 +10,21 @@ """ # Standard library imports +import sys from typing import List, Optional, TypedDict # Third-party imports import qstylizer.style -from qtpy.QtCore import QAbstractTableModel, QEvent, QModelIndex, QSize, Qt +from qtpy.QtCore import QAbstractTableModel, QModelIndex, QSize, Qt from qtpy.QtGui import QIcon from qtpy.QtWidgets import QAbstractItemView, QCheckBox, QHBoxLayout, QWidget +from superqt.utils import qdebounced # 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.utils.stylesheet import AppStyle from spyder.widgets.helperwidgets import HoverRowsTableView, HTMLDelegate @@ -155,16 +158,16 @@ def __init__(self, parent: Optional[QWidget], elements: List[Element]): 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') + self._with_icons = self._with_feature('icon') + self._with_addtional_info = self._with_feature('additional_info') + self._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 + # To make adjustments when the widget is shown self._is_shown = False # This is used to paint the entire row's background color when its @@ -173,7 +176,11 @@ def __init__(self, parent: Optional[QWidget], elements: List[Element]): # Set model self.model = ElementsModel( - self, self.elements, with_icons, with_addtional_info, with_widgets + self, + self.elements, + self._with_icons, + self._with_addtional_info, + self._with_widgets ) self.setModel(self.model) @@ -186,7 +193,7 @@ def __init__(self, parent: Optional[QWidget], elements: List[Element]): # Adjustments for the additional info column self._info_column_width = 0 - if with_addtional_info: + if self._with_addtional_info: info_delegate = HTMLDelegate(self, margin=10, align_vcenter=True) self.setItemDelegateForColumn( self.model.columns['additional_info'], info_delegate) @@ -201,7 +208,7 @@ def __init__(self, parent: Optional[QWidget], elements: List[Element]): # Adjustments for the widgets column self._widgets_column_width = 0 - if with_widgets: + if self._with_widgets: widgets_delegate = HTMLDelegate(self, margin=0) self.setItemDelegateForColumn( self.model.columns['widgets'], widgets_delegate) @@ -244,7 +251,7 @@ def __init__(self, parent: Optional[QWidget], elements: List[Element]): self.verticalHeader().hide() # Set icons size - if with_icons: + if self._with_icons: self.setIconSize(QSize(32, 32)) # Hide grid to only paint horizontal lines with css @@ -265,18 +272,19 @@ def _on_hover_index_changed(self, index): 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("") + if self._with_widgets: + # 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 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 + # 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.""" @@ -297,12 +305,25 @@ def _set_layout(self): This is necessary to make the table look good at different sizes. """ + # We need to make these extra adjustments for Mac so that the last + # column is not too close to the right border + extra_width = 0 + if sys.platform == 'darwin': + if self.verticalScrollBar().isVisible(): + extra_width = ( + AppStyle.MacScrollBarWidth + + (15 if self._with_widgets else 5) + ) + else: + extra_width = 10 if self._with_widgets else 5 + # 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._info_column_width + self._widgets_column_width + + extra_width) ) self.horizontalHeader().resizeSection( @@ -313,6 +334,18 @@ def _set_layout(self): # changes row heights in unpredictable ways. self.resizeRowsToContents() + _set_layout_debounced = qdebounced(_set_layout, timeout=40) + """ + Debounced version of _set_layout. + + Notes + ----- + * We need a different version of _set_layout so that we can use the regular + one in showEvent. That way users won't experience a visual glitch when + the widget is rendered for the first time. + * We use this version in resizeEvent, where that is not a problem. + """ + 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 @@ -349,16 +382,9 @@ def enterEvent(self, event): def resizeEvent(self, event): # This is necessary to readjust the layout when the parent widget is # resized. - self._set_layout() + self._set_layout_debounced() 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