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