diff --git a/spyder/config/appearance.py b/spyder/config/appearance.py index 4fd74cf711b..37889a3d4aa 100644 --- a/spyder/config/appearance.py +++ b/spyder/config/appearance.py @@ -11,6 +11,7 @@ import os import sys +from spyder.config.base import running_under_pytest from spyder.config.fonts import MEDIUM, MONOSPACE from spyder.plugins.help.utils.sphinxify import CSS_PATH @@ -28,8 +29,11 @@ 'font/bold': False, # We set the app font used in the system when Spyder starts, so we don't # need to do it here. - 'app_font/family': '', - 'app_font/size': 0, + 'app_font/family': 'Arial' if running_under_pytest() else '', + # This default value helps to do visual checks in our tests when run + # independently and avoids Qt warnings related to a null font size. It can + # also be useful in case we fail to detect the interface font. + 'app_font/size': 10, 'app_font/italic': False, 'app_font/bold': False, 'use_system_font': True, diff --git a/spyder/config/main.py b/spyder/config/main.py index 7270e4d294e..d7f79ed0a82 100644 --- a/spyder/config/main.py +++ b/spyder/config/main.py @@ -661,7 +661,7 @@ # ============================================================================= -# Config instance +# Config version # ============================================================================= # IMPORTANT NOTES: # 1. If you want to *change* the default value of a current option, you need to @@ -670,4 +670,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 = '82.0.0' +CONF_VERSION = '82.1.0' diff --git a/spyder/plugins/preferences/widgets/config_widgets.py b/spyder/plugins/preferences/widgets/config_widgets.py index 0d7e04a0501..49e3280b720 100644 --- a/spyder/plugins/preferences/widgets/config_widgets.py +++ b/spyder/plugins/preferences/widgets/config_widgets.py @@ -34,6 +34,7 @@ from spyder.utils.misc import getcwd_or_home from spyder.widgets.colors import ColorLayout from spyder.widgets.comboboxes import FileComboBox +from spyder.widgets.sidebardialog import SidebarPage class BaseConfigTab(QWidget): @@ -60,99 +61,26 @@ def remove_option(self, option, section=None): CONF.remove_option(section, option) -class ConfigPage(QWidget): - """Base class for configuration page in Preferences""" +class SpyderConfigPage(SidebarPage, ConfigAccessMixin): + """ + Page that can display graphical elements connected to our config system. + """ # Signals apply_button_enabled = Signal(bool) - show_this_page = Signal() - def __init__(self, parent, apply_callback=None): - QWidget.__init__(self, parent) + # Constants + CONF_SECTION = None + + def __init__(self, parent): + SidebarPage.__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.apply_callback = lambda: self._apply_settings_tabs( + self.changed_options ) self.checkboxes = {} @@ -171,14 +99,12 @@ def __init__(self, parent): self.default_button_group = None self.main = parent.main self.tabs = None + self.is_modified = False - # 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 initialize(self): + """Initialize configuration page.""" + self.setup_page() + self.load_from_conf() def _apply_settings_tabs(self, options): if self.tabs is not None: @@ -195,13 +121,39 @@ def _apply_settings_tabs(self, options): def apply_settings(self, options): 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 == '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 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) + self.is_modified = state + self.apply_button_enabled.emit(state) if not state: self.changed_options = set() diff --git a/spyder/plugins/preferences/widgets/configdialog.py b/spyder/plugins/preferences/widgets/configdialog.py index 1b1289270d0..b93d2ff66a3 100644 --- a/spyder/plugins/preferences/widgets/configdialog.py +++ b/spyder/plugins/preferences/widgets/configdialog.py @@ -5,34 +5,19 @@ # (see spyder/__init__.py for details) # Third party imports -import qstylizer.style -from qtpy.QtCore import QSize, Qt, Signal, Slot -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 +from qtpy.QtCore import QSize, Signal, Slot +from qtpy.QtWidgets import QDialog, QDialogButtonBox, QHBoxLayout, QPushButton +from superqt.utils import qdebounced # 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) +from spyder.utils.stylesheet import MAC, WIN +from spyder.widgets.sidebardialog import SidebarDialog -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): +class ConfigDialog(SidebarDialog): """Preferences dialog.""" # Signals @@ -41,128 +26,22 @@ class ConfigDialog(QDialog, SpyderFontsMixin): 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 + TITLE = _("Preferences") + ICON = ima.icon('configure') 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) + SidebarDialog.__init__(self, parent) - # ---- Attributes + # 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) - self.contents_widget = QListWidget(self) - self.button_reset = QPushButton(_('Reset to defaults')) - - bbox = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Apply | - QDialogButtonBox.Cancel) - self.apply_btn = bbox.button(QDialogButtonBox.Apply) - self.ok_btn = bbox.button(QDialogButtonBox.Ok) - - # 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 - # a segmentation fault on UNIX or an application crash on Windows - 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(3) - self.contents_widget.setCurrentRow(0) - 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 - 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) - - layout = QVBoxLayout() - layout.addLayout(contents_and_pages_layout) - layout.addSpacing(3) - layout.addLayout(btnlayout) - - self.setLayout(layout) - - # ---- Stylesheet - self.setStyleSheet(self._main_stylesheet) - - self._contents_css = self._generate_contents_stylesheet() - self.contents_widget.setStyleSheet(self._contents_css.toString()) - - self.contents_widget.verticalScrollBar().setStyleSheet( - self._contents_scrollbar_stylesheet - ) - - # ---- 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) - bbox.accepted.connect(self.accept) - bbox.rejected.connect(self.reject) - bbox.clicked.connect(self.button_clicked) # 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() - - def set_current_index(self, index): - """Set current page index""" - self.contents_widget.setCurrentRow(index) - - def get_page(self, index=None): - """Return page widget""" - if index is None: - page = self.pages_widget.currentWidget() - else: - page = self.pages_widget.widget(index) - - # 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()): @@ -183,6 +62,15 @@ def get_index_by_name(self, name): else: return None + 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() + + # ---- SidebarDialog API + # ------------------------------------------------------------------------- def button_clicked(self, button): if button is self.apply_btn: # Apply button was clicked @@ -196,82 +84,34 @@ 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_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) - hline.setStyleSheet(self._separators_stylesheet) - 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) + super().add_page(page) - # 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(container) - self.pages_widget.addWidget(scrollarea) + def create_buttons(self): + bbox = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Apply | + QDialogButtonBox.Cancel) + self.apply_btn = bbox.button(QDialogButtonBox.Apply) - # Add plugin entry item to contents widget - item = QListWidgetItem(self.contents_widget) - item.setText(page.get_name()) - item.setFlags(Qt.ItemIsSelectable | Qt.ItemIsEnabled) + # This is needed for our tests + self.ok_btn = bbox.button(QDialogButtonBox.Ok) - # In case a plugin doesn't have an icon - try: - item.setIcon(page.get_icon()) - except TypeError: - pass + button_reset = QPushButton(_('Reset to defaults')) + button_reset.clicked.connect(self.sig_reset_preferences_requested) - # Set font for items - item.setFont(self.items_font) + layout = QHBoxLayout() + layout.addWidget(button_reset) + layout.addStretch(1) + layout.addWidget(bbox) - 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() + return bbox, layout # ---- Qt methods # ------------------------------------------------------------------------- @Slot() def accept(self): - """Reimplement Qt method""" for index in range(self.pages_widget.count()): configpage = self.get_page(index) @@ -287,203 +127,16 @@ def accept(self): 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 perform several operations when resizing. - """ - QDialog.resizeEvent(self, event) - self._on_resize_event() + super().resizeEvent(event) + self._on_resize() # ---- 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._contents_css['QListView::item'].setValues( - margin=item_margin - ) - - self.contents_widget.setStyleSheet(self._contents_css.toString()) - - def _adjust_separators_width(self): + @qdebounced(timeout=40) + def _on_resize(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. + We name this method differently from SidebarDialog._on_resize_event + because we want to debounce this as well. """ - 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) - - @property - def _main_stylesheet(self): - """Main style for this widget.""" - # Use the preferences tabbar stylesheet as the base one and extend it. - tabs_stylesheet = PREFERENCES_TABBAR_STYLESHEET.get_copy() - css = tabs_stylesheet.get_stylesheet() - - # Remove border of all scroll areas for pages - css['QScrollArea#configdialog-scrollarea'].setValues( - border='0px', - ) - - return css.toString() - - def _generate_contents_stylesheet(self): - """Generate stylesheet for the contents widget""" - css = qstylizer.style.StyleSheet() - - # This also sets the background color of the vertical scrollbar - # associated to this widget - css.setValues( - backgroundColor=QStylePalette.COLOR_BACKGROUND_2 - ) - - # Main style - css.QListView.setValues( - padding=f'{self.ITEMS_MARGIN}px 0px', - border=f'1px solid {QStylePalette.COLOR_BACKGROUND_2}', - ) - - # Remove border color on focus - css['QListView:focus'].setValues( - border=f'1px solid {QStylePalette.COLOR_BACKGROUND_2}', - ) - - # Add margin and padding for items - css['QListView::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::item:hover'].setValues( - borderRadius=QStylePalette.SIZE_BORDER_RADIUS, - ) - - for state in ['item:selected:active', 'item:selected:!active']: - css[f'QListView::{state}'].setValues( - borderRadius=QStylePalette.SIZE_BORDER_RADIUS, - backgroundColor=QStylePalette.COLOR_BACKGROUND_4 - ) - - return css - - @property - def _contents_scrollbar_stylesheet(self): - css = qstylizer.style.StyleSheet() - - # Give border a darker color to stand out over the background - css.setValues( - border=f"1px solid {QStylePalette.COLOR_BACKGROUND_5}" - ) - - return css.toString() - - @property - def _separators_stylesheet(self): - css = qstylizer.style.StyleSheet() - - # This makes separators stand out better over the background - css.setValues( - backgroundColor=QStylePalette.COLOR_BACKGROUND_5 - ) - - return css.toString() - - @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/widgets/sidebardialog.py b/spyder/widgets/sidebardialog.py new file mode 100644 index 00000000000..5996ef64c60 --- /dev/null +++ b/spyder/widgets/sidebardialog.py @@ -0,0 +1,510 @@ +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# (see spyder/__init__.py for details) + +# Standard library imports +from typing import List, Optional, Type + +# Third party imports +import qstylizer.style +from qtpy.QtCore import QSize, Qt, Signal +from qtpy.QtGui import QFontMetricsF, QIcon +from qtpy.QtWidgets import ( + QDialog, + QDialogButtonBox, + QFrame, + QGridLayout, + QHBoxLayout, + QListView, + QListWidget, + QListWidgetItem, + QScrollArea, + QStackedWidget, + QVBoxLayout, + QWidget +) +from superqt.utils import qdebounced, signals_blocked + +# Local imports +from spyder.api.config.fonts import SpyderFontType, SpyderFontsMixin +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 PageScrollArea(QScrollArea): + """Scroll area for preference pages.""" + + def widget(self): + """Return the page widget inside the scroll area.""" + return super().widget().page + + +class SidebarPage(QWidget): + """Base class for pages used in SidebarDialog's""" + + # Signals + show_this_page = Signal() + + # Constants + MAX_WIDTH = 620 + MIN_HEIGHT = 550 + + def __init__(self, parent): + QWidget.__init__(self, parent) + + # Set dimensions + self.setMaximumWidth(self.MAX_WIDTH) + self.setMinimumHeight(self.MIN_HEIGHT) + + def initialize(self): + """Initialize page.""" + self.setup_page() + + def get_name(self): + """Return page name.""" + raise NotImplementedError + + def get_icon(self): + """Return page icon.""" + raise NotImplementedError + + def setup_page(self): + """Setup widget to be shown in the page.""" + raise NotImplementedError + + @staticmethod + def create_icon(name): + """Create an icon by name using Spyder's icon manager.""" + return ima.icon(name) + + def sizeHint(self): + """Default page size.""" + return QSize(self.MAX_WIDTH, self.MIN_HEIGHT) + + +class SidebarDialog(QDialog, SpyderFontsMixin): + """Sidebar dialog.""" + + # 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 + PAGES_MINIMUM_WIDTH = 600 + + # To be set by childs + TITLE = "" + ICON = QIcon() + MIN_WIDTH = 800 + MIN_HEIGHT = 600 + PAGE_CLASSES: List[Type[SidebarPage]] = [] + + def __init__(self, parent=None): + QDialog.__init__(self, parent) + + # ---- Attributes + 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) + self.contents_widget = QListWidget(self) + buttons_box, buttons_layout = self.create_buttons() + + # 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 + # a segmentation fault on UNIX or an application crash on Windows + self.setAttribute(Qt.WA_DeleteOnClose) + self.setWindowTitle(self.TITLE) + self.setWindowIcon(self.ICON) + + # ---- Widgets setup + self.pages_widget.setMinimumWidth(self.PAGES_MINIMUM_WIDTH) + + self.contents_widget.setMovement(QListView.Static) + self.contents_widget.setSpacing(3) + self.contents_widget.setCurrentRow(0) + 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 + 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) + + layout = QVBoxLayout() + layout.addLayout(contents_and_pages_layout) + layout.addSpacing(3) + layout.addLayout(buttons_layout) + + self.setLayout(layout) + + # ---- Stylesheet + self.setStyleSheet(self._main_stylesheet) + + self._contents_css = self._generate_contents_stylesheet() + self.contents_widget.setStyleSheet(self._contents_css.toString()) + + self.contents_widget.verticalScrollBar().setStyleSheet( + self._contents_scrollbar_stylesheet + ) + + # ---- Signals and slots + self.pages_widget.currentChanged.connect(self.current_page_changed) + self.contents_widget.currentRowChanged.connect( + self.pages_widget.setCurrentIndex) + buttons_box.accepted.connect(self.accept) + buttons_box.rejected.connect(self.reject) + buttons_box.clicked.connect(self.button_clicked) + + # Add pages to the dialog + self._add_pages() + + # Set index to the initial page + if self.PAGE_CLASSES: + self.set_current_index(0) + + # ---- Public API to be overridden by children + # ------------------------------------------------------------------------- + def button_clicked(self, button): + """Actions to perform after one of the dialog's buttons is clicked.""" + pass + + def current_page_changed(self, index): + """Actions to perform after the current page in the dialog changes.""" + pass + + def create_buttons(self): + """ + Create the buttons that will be displayed in the dialog. + + Override this method if you want different buttons in it. + """ + bbox = QDialogButtonBox(QDialogButtonBox.Ok) + + layout = QHBoxLayout() + layout.addWidget(bbox) + + return bbox, layout + + # ---- Public API + # ------------------------------------------------------------------------- + def get_current_index(self): + """Return current page index""" + return self.contents_widget.currentRow() + + def set_current_index(self, index): + """Set current page index""" + self.contents_widget.setCurrentRow(index) + + def get_page(self, index=None) -> Optional[SidebarPage]: + """Return page widget""" + if index is None: + page = self.pages_widget.currentWidget() + else: + page = self.pages_widget.widget(index) + + # 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 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) + hline.setStyleSheet(self._separators_stylesheet) + 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: SidebarPage): + page.show_this_page.connect(lambda row=self.contents_widget.count(): + self.contents_widget.setCurrentRow(row)) + + # 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('sidebardialog-scrollarea') + scrollarea.setWidgetResizable(True) + 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(page.get_icon()) + except TypeError: + pass + + # Set font for items + item.setFont(self.items_font) + + # ---- Qt methods + # ------------------------------------------------------------------------- + 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 perform several operations when resizing. + """ + QDialog.resizeEvent(self, event) + self._on_resize_event() + + # ---- 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._contents_css['QListView::item'].setValues( + margin=item_margin + ) + + self.contents_widget.setStyleSheet(self._contents_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) + + @property + def _main_stylesheet(self): + """Main style for this widget.""" + # Use the preferences tabbar stylesheet as the base one and extend it. + tabs_stylesheet = PREFERENCES_TABBAR_STYLESHEET.get_copy() + css = tabs_stylesheet.get_stylesheet() + + # Remove border of all scroll areas for pages + css['QScrollArea#sidebardialog-scrollarea'].setValues( + border='0px', + ) + + return css.toString() + + def _generate_contents_stylesheet(self): + """Generate stylesheet for the contents widget""" + css = qstylizer.style.StyleSheet() + + # This also sets the background color of the vertical scrollbar + # associated to this widget + css.setValues( + backgroundColor=QStylePalette.COLOR_BACKGROUND_2 + ) + + # Main style + css.QListView.setValues( + padding=f'{self.ITEMS_MARGIN}px 0px', + border=f'1px solid {QStylePalette.COLOR_BACKGROUND_2}', + ) + + # Remove border color on focus + css['QListView:focus'].setValues( + border=f'1px solid {QStylePalette.COLOR_BACKGROUND_2}', + ) + + # Add margin and padding for items + css['QListView::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::item:hover'].setValues( + borderRadius=QStylePalette.SIZE_BORDER_RADIUS, + ) + + for state in ['item:selected:active', 'item:selected:!active']: + css[f'QListView::{state}'].setValues( + borderRadius=QStylePalette.SIZE_BORDER_RADIUS, + backgroundColor=QStylePalette.COLOR_BACKGROUND_4 + ) + + return css + + @property + def _contents_scrollbar_stylesheet(self): + css = qstylizer.style.StyleSheet() + + # Give border a darker color to stand out over the background + css.setValues( + border=f"1px solid {QStylePalette.COLOR_BACKGROUND_5}" + ) + + return css.toString() + + @property + def _separators_stylesheet(self): + css = qstylizer.style.StyleSheet() + + # This makes separators stand out better over the background + css.setValues( + backgroundColor=QStylePalette.COLOR_BACKGROUND_5 + ) + + return css.toString() + + @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() + + def _add_pages(self): + """Add pages to the dialog.""" + for PageClass in self.PAGE_CLASSES: + page = PageClass(self) + page.initialize() + self.add_page(page) diff --git a/spyder/widgets/tests/test_sidebardialog.py b/spyder/widgets/tests/test_sidebardialog.py new file mode 100644 index 00000000000..a842145aaa1 --- /dev/null +++ b/spyder/widgets/tests/test_sidebardialog.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +# +# Copyright © Spyder Project Contributors +# Licensed under the terms of the MIT License +# + +"""Tests for SidebarDialog.""" + +# Third party imports +from qtpy.QtWidgets import QLabel, QVBoxLayout +import pytest + +# Local imports +from spyder.utils.stylesheet import APP_STYLESHEET +from spyder.widgets.sidebardialog import SidebarDialog, SidebarPage + + +# --- Fixtures +# ----------------------------------------------------------------------------- +@pytest.fixture +def sidebar_dialog(qapp, qtbot): + + # Pages + class Page1(SidebarPage): + + def get_name(self): + return "Page 1" + + def get_icon(self): + return self.create_icon("variable_explorer") + + def setup_page(self): + self.label = QLabel("This is page one!") + layout = QVBoxLayout() + layout.addWidget(self.label) + layout.addStretch(1) + self.setLayout(layout) + + class Page2(SidebarPage): + + def get_name(self): + return "Page 2" + + def get_icon(self): + return self.create_icon("files") + + def setup_page(self): + self.label = QLabel("This is page two!") + layout = QVBoxLayout() + layout.addWidget(self.label) + layout.addStretch(1) + self.setLayout(layout) + + # Dialog + class TestDialog(SidebarDialog): + PAGE_CLASSES = [Page1, Page2] + + qapp.setStyleSheet(str(APP_STYLESHEET)) + dialog = TestDialog() + qtbot.addWidget(dialog) + dialog.show() + return dialog + + +# --- Tests +# ----------------------------------------------------------------------------- +def test_sidebardialog(sidebar_dialog, qtbot): + dialog = sidebar_dialog + assert dialog is not None + + # To check the dialog visually + qtbot.wait(1000) + + # Check label displayed in the initial page + assert "one" in dialog.get_page().label.text() + + # Check label in the second page + dialog.set_current_index(1) + assert "two" in dialog.get_page().label.text()