diff --git a/spyder/api/shellconnect/main_widget.py b/spyder/api/shellconnect/main_widget.py index d69778a1c8a..dffa2727fe3 100644 --- a/spyder/api/shellconnect/main_widget.py +++ b/spyder/api/shellconnect/main_widget.py @@ -15,6 +15,7 @@ # Local imports from spyder.api.translations import _ from spyder.api.widgets.main_widget import PluginMainWidget +from spyder.widgets.helperwidgets import PaneEmptyWidget class ShellConnectMainWidget(PluginMainWidget): @@ -40,6 +41,24 @@ def __init__(self, *args, **kwargs): layout.addWidget(self._stack) self.setLayout(layout) + # ---- PluginMainWidget API + # ------------------------------------------------------------------------ + def current_widget(self): + """ + Return the current widget in the stack. + + Returns + ------- + QWidget + The current widget. + """ + return self._stack.currentWidget() + + def get_focus_widget(self): + return self.current_widget() + + # ---- SpyderWidgetMixin API + # ------------------------------------------------------------------------ def update_style(self): self._stack.setStyleSheet("QStackedWidget {padding: 0px; border: 0px}") @@ -56,20 +75,6 @@ def count(self): """ return self._stack.count() - def current_widget(self): - """ - Return the current figure browser widget in the stack. - - Returns - ------- - QWidget - The current widget. - """ - return self._stack.currentWidget() - - def get_focus_widget(self): - return self.current_widget() - def get_widget_for_shellwidget(self, shellwidget): """return widget corresponding to shellwidget.""" shellwidget_id = id(shellwidget) @@ -80,10 +85,7 @@ def get_widget_for_shellwidget(self, shellwidget): # ---- Public API # ------------------------------------------------------------------------ def add_shellwidget(self, shellwidget): - """ - Create a new widget in the stack and associate it to - shellwidget. - """ + """Create a new widget in the stack and associate it to shellwidget.""" shellwidget_id = id(shellwidget) if shellwidget_id not in self._shellwidgets: widget = self.create_new_widget(shellwidget) @@ -109,17 +111,38 @@ def remove_shellwidget(self, shellwidget): self.update_actions() def set_shellwidget(self, shellwidget): - """ - Set widget associated with shellwidget as the current widget. - """ + """Set widget associated with shellwidget as the current widget.""" old_widget = self.current_widget() widget = self.get_widget_for_shellwidget(shellwidget) if widget is None: return + self._stack.setCurrentWidget(widget) self.switch_widget(widget, old_widget) self.update_actions() + def add_errored_shellwidget(self, shellwidget): + """ + Create a new PaneEmptyWidget in the stack and associate it to + shellwidget. + + This is necessary to show a meaningful message when switching to + consoles with dead kernels. + """ + shellwidget_id = id(shellwidget) + if shellwidget_id not in self._shellwidgets: + widget = PaneEmptyWidget( + self, + "variable-explorer", # TODO: Use custom icon here + _("No connected console"), + _("The current console failed to start, so there is no " + "content to show here.") + ) + + self._stack.addWidget(widget) + self._shellwidgets[shellwidget_id] = widget + self.set_shellwidget(shellwidget) + def create_new_widget(self, shellwidget): """Create a widget to communicate with shellwidget.""" raise NotImplementedError @@ -137,3 +160,7 @@ def refresh(self): if self.count(): widget = self.current_widget() widget.refresh() + + def is_current_widget_empty(self): + """Check if the current widget is a PaneEmptyWidget.""" + return isinstance(self.current_widget(), PaneEmptyWidget) diff --git a/spyder/api/shellconnect/mixins.py b/spyder/api/shellconnect/mixins.py index 87e0e7c9867..6513e0961bd 100644 --- a/spyder/api/shellconnect/mixins.py +++ b/spyder/api/shellconnect/mixins.py @@ -15,11 +15,54 @@ class ShellConnectMixin: """ - Mixin to connect a plugin composed of stacked widgets to the shell - widgets in the IPython console. + Mixin to connect any widget or object to the shell widgets in the IPython + console. + """ + + # ---- Connection to the IPython console + # ------------------------------------------------------------------------- + def register_ipythonconsole(self, ipyconsole): + """Register signals from the console.""" + ipyconsole.sig_shellwidget_changed.connect(self.set_shellwidget) + ipyconsole.sig_shellwidget_created.connect(self.add_shellwidget) + ipyconsole.sig_shellwidget_deleted.connect(self.remove_shellwidget) + ipyconsole.sig_shellwidget_errored.connect( + self.add_errored_shellwidget) + + def unregister_ipythonconsole(self, ipyconsole): + """Unregister signals from the console.""" + ipyconsole.sig_shellwidget_changed.disconnect(self.set_shellwidget) + ipyconsole.sig_shellwidget_created.disconnect(self.add_shellwidget) + ipyconsole.sig_shellwidget_deleted.disconnect(self.remove_shellwidget) + ipyconsole.sig_shellwidget_errored.disconnect( + self.add_errored_shellwidget) + + # ---- Public API + # ------------------------------------------------------------------------- + def set_shellwidget(self, shellwidget): + """Update the current shellwidget.""" + raise NotImplementedError + + def add_shellwidget(self, shellwidget): + """Add a new shellwidget to be registered.""" + raise NotImplementedError + + def remove_shellwidget(self, shellwidget): + """Remove a registered shellwidget.""" + raise NotImplementedError + + def add_errored_shellwidget(self, shellwidget): + """Register a new shellwidget whose kernel failed to start.""" + raise NotImplementedError + + +class ShellConnectPluginMixin(ShellConnectMixin): + """ + Mixin to connect a plugin composed of stacked widgets to the shell widgets + in the IPython console. It is assumed that self.get_widget() returns an instance of - ShellConnectMainWidget + ShellConnectMainWidget. """ # ---- Connection to the IPython console @@ -30,25 +73,12 @@ def on_ipython_console_available(self): ipyconsole = self.get_plugin(Plugins.IPythonConsole) self.register_ipythonconsole(ipyconsole) - def register_ipythonconsole(self, ipyconsole): - """Register the console.""" - ipyconsole.sig_shellwidget_changed.connect(self.set_shellwidget) - ipyconsole.sig_shellwidget_created.connect(self.add_shellwidget) - ipyconsole.sig_shellwidget_deleted.connect(self.remove_shellwidget) - @on_plugin_teardown(plugin=Plugins.IPythonConsole) def on_ipython_console_teardown(self): """Disconnect from the IPython console.""" ipyconsole = self.get_plugin(Plugins.IPythonConsole) self.unregister_ipythonconsole(ipyconsole) - def unregister_ipythonconsole(self, ipyconsole): - """Unregister the console.""" - - ipyconsole.sig_shellwidget_changed.disconnect(self.set_shellwidget) - ipyconsole.sig_shellwidget_created.disconnect(self.add_shellwidget) - ipyconsole.sig_shellwidget_deleted.disconnect(self.remove_shellwidget) - # ---- Public API # ------------------------------------------------------------------------- def set_shellwidget(self, shellwidget): @@ -78,7 +108,7 @@ def add_shellwidget(self, shellwidget): def remove_shellwidget(self, shellwidget): """ - Remove the registered shellwidget. + Remove a registered shellwidget. Parameters ---------- @@ -87,6 +117,17 @@ def remove_shellwidget(self, shellwidget): """ self.get_widget().remove_shellwidget(shellwidget) + def add_errored_shellwidget(self, shellwidget): + """ + Add a new shellwidget whose kernel failed to start. + + Parameters + ---------- + shellwidget: spyder.plugins.ipyconsole.widgets.shell.ShellWidget + The shell widget. + """ + self.get_widget().add_errored_shellwidget(shellwidget) + def current_widget(self): """ Return the current widget displayed at the moment. diff --git a/spyder/api/utils.py b/spyder/api/utils.py index f051a46edd5..c62c562802b 100644 --- a/spyder/api/utils.py +++ b/spyder/api/utils.py @@ -12,11 +12,9 @@ def get_class_values(cls): """ - Get the attribute values for the class enumerations used in our - API. + Get the attribute values for the class enumerations used in our API. - Idea from: - https://stackoverflow.com/a/17249228/438386 + Idea from: https://stackoverflow.com/a/17249228/438386 """ return [v for (k, v) in cls.__dict__.items() if k[:1] != '_'] @@ -54,3 +52,15 @@ def __iter__(self): child = self.children[key] for prefix in child: yield prefix + + +class classproperty(property): + """ + Decorator to declare class constants as properties that require additional + computation. + + Taken from: https://stackoverflow.com/a/7864317/438386 + """ + + def __get__(self, cls, owner): + return classmethod(self.fget).__get__(None, owner)() diff --git a/spyder/app/mainwindow.py b/spyder/app/mainwindow.py index 7c11ff8a2ac..57e0e65a821 100644 --- a/spyder/app/mainwindow.py +++ b/spyder/app/mainwindow.py @@ -666,11 +666,11 @@ def setup(self): logger.info("Applying theme configuration...") ui_theme = self.get_conf('ui_theme', section='appearance') color_scheme = self.get_conf('selected', section='appearance') + qapp = QApplication.instance() if ui_theme == 'dark': if not running_under_pytest(): # Set style proxy to fix combobox popup on mac and qdark - qapp = QApplication.instance() qapp.setStyle(self._proxy_style) dark_qss = str(APP_STYLESHEET) self.setStyleSheet(dark_qss) @@ -680,7 +680,6 @@ def setup(self): elif ui_theme == 'light': if not running_under_pytest(): # Set style proxy to fix combobox popup on mac and qdark - qapp = QApplication.instance() qapp.setStyle(self._proxy_style) light_qss = str(APP_STYLESHEET) self.setStyleSheet(light_qss) @@ -691,7 +690,6 @@ def setup(self): if not is_dark_font_color(color_scheme): if not running_under_pytest(): # Set style proxy to fix combobox popup on mac and qdark - qapp = QApplication.instance() qapp.setStyle(self._proxy_style) dark_qss = str(APP_STYLESHEET) self.setStyleSheet(dark_qss) @@ -703,6 +701,10 @@ def setup(self): self.statusBar().setStyleSheet(light_qss) css_path = CSS_PATH + # This needs to done after applying the stylesheet to the window + logger.info("Set color for links in Qt widgets") + set_links_color(qapp) + # Set css_path as a configuration to be used by the plugins self.set_conf('css_path', css_path, section='appearance') @@ -1439,9 +1441,6 @@ def main(options, args): pass CONF.set('main', 'previous_crash', previous_crash) - # **** Set color for links **** - set_links_color(app) - # **** Create main window **** mainwindow = None try: diff --git a/spyder/plugins/debugger/plugin.py b/spyder/plugins/debugger/plugin.py index fe469cc6693..675192358f2 100644 --- a/spyder/plugins/debugger/plugin.py +++ b/spyder/plugins/debugger/plugin.py @@ -17,7 +17,7 @@ from spyder.api.plugins import Plugins, SpyderDockablePlugin from spyder.api.plugin_registration.decorators import ( on_plugin_available, on_plugin_teardown) -from spyder.api.shellconnect.mixins import ShellConnectMixin +from spyder.api.shellconnect.mixins import ShellConnectPluginMixin from spyder.api.translations import _ from spyder.config.manager import CONF from spyder.plugins.debugger.confpage import DebuggerConfigPage @@ -37,7 +37,7 @@ from spyder.plugins.editor.api.run import CellRun, SelectionRun -class Debugger(SpyderDockablePlugin, ShellConnectMixin, RunExecutor): +class Debugger(SpyderDockablePlugin, ShellConnectPluginMixin, RunExecutor): """Debugger plugin.""" NAME = 'debugger' diff --git a/spyder/plugins/debugger/widgets/main_widget.py b/spyder/plugins/debugger/widgets/main_widget.py index c966658a97a..900a88d6d0f 100644 --- a/spyder/plugins/debugger/widgets/main_widget.py +++ b/spyder/plugins/debugger/widgets/main_widget.py @@ -340,28 +340,29 @@ def setup(self): def update_actions(self): """Update actions.""" - widget = self.current_widget() search_action = self.get_action(DebuggerWidgetActions.Search) enter_debug_action = self.get_action( DebuggerWidgetActions.EnterDebug) inspect_action = self.get_action( DebuggerWidgetActions.Inspect) - if widget is None: - search = False + widget = self.current_widget() + if self.is_current_widget_empty() or widget is None: + search_action.setEnabled(False) show_enter_debugger = False executing = False is_inspecting = False pdb_prompt = False else: - search = widget.finder_is_visible() + search_action.setEnabled(True) + search_action.setChecked(widget.finder_is_visible()) post_mortem = widget.state == FramesBrowserState.Error sw = widget.shellwidget executing = sw._executing show_enter_debugger = post_mortem or executing is_inspecting = widget.state == FramesBrowserState.Inspect pdb_prompt = sw.is_waiting_pdb_input() - search_action.setChecked(search) + enter_debug_action.setEnabled(show_enter_debugger) inspect_action.setEnabled(executing) self.context_menu.setEnabled(is_inspecting) @@ -372,8 +373,7 @@ def update_actions(self): DebuggerWidgetActions.Step, DebuggerWidgetActions.Return, DebuggerWidgetActions.Stop, - DebuggerWidgetActions.GotoCursor, - ]: + DebuggerWidgetActions.GotoCursor]: action = self.get_action(action_name) action.setEnabled(pdb_prompt) @@ -425,9 +425,10 @@ def create_new_widget(self, shellwidget): def switch_widget(self, widget, old_widget): """Set the current FramesBrowser.""" - sw = widget.shellwidget - state = sw.is_waiting_pdb_input() - self.sig_pdb_state_changed.emit(state) + if not self.is_current_widget_empty(): + sw = widget.shellwidget + state = sw.is_waiting_pdb_input() + self.sig_pdb_state_changed.emit(state) def close_widget(self, widget): """Close widget.""" @@ -490,7 +491,7 @@ def set_pdb_take_focus(self, take_focus): next call. """ widget = self.current_widget() - if widget is None: + if widget is None or self.is_current_widget_empty(): return False widget.shellwidget._pdb_take_focus = take_focus @@ -498,14 +499,14 @@ def set_pdb_take_focus(self, take_focus): def toggle_finder(self, checked): """Show or hide finder.""" widget = self.current_widget() - if widget is None: + if widget is None or self.is_current_widget_empty(): return widget.toggle_finder(checked) def get_pdb_state(self): """Get debugging state of the current console.""" widget = self.current_widget() - if widget is None: + if widget is None or self.is_current_widget_empty(): return False sw = widget.shellwidget if sw is not None: @@ -515,7 +516,7 @@ def get_pdb_state(self): def get_pdb_last_step(self): """Get last pdb step of the current console.""" widget = self.current_widget() - if widget is None: + if widget is None or self.is_current_widget_empty(): return None, None sw = widget.shellwidget if sw is not None: diff --git a/spyder/plugins/ipythonconsole/plugin.py b/spyder/plugins/ipythonconsole/plugin.py index d8b5c9f1ac7..eaa6cee2657 100644 --- a/spyder/plugins/ipythonconsole/plugin.py +++ b/spyder/plugins/ipythonconsole/plugin.py @@ -141,6 +141,16 @@ class IPythonConsole(SpyderDockablePlugin, RunExecutor): The shellwigdet. """ + sig_shellwidget_errored = Signal(object) + """ + This signal is emitted when the current shellwidget failed to start. + + Parameters + ---------- + shellwidget: spyder.plugins.ipyconsole.widgets.shell.ShellWidget + The shellwigdet. + """ + sig_render_plain_text_requested = Signal(str) """ This signal is emitted to request a plain text help render. @@ -208,6 +218,7 @@ def on_initialize(self): widget.sig_shellwidget_created.connect(self.sig_shellwidget_created) widget.sig_shellwidget_deleted.connect(self.sig_shellwidget_deleted) widget.sig_shellwidget_changed.connect(self.sig_shellwidget_changed) + widget.sig_shellwidget_errored.connect(self.sig_shellwidget_errored) widget.sig_render_plain_text_requested.connect( self.sig_render_plain_text_requested) widget.sig_render_rich_text_requested.connect( diff --git a/spyder/plugins/ipythonconsole/widgets/client.py b/spyder/plugins/ipythonconsole/widgets/client.py index 034ef277a2f..dd34f1da501 100644 --- a/spyder/plugins/ipythonconsole/widgets/client.py +++ b/spyder/plugins/ipythonconsole/widgets/client.py @@ -22,7 +22,7 @@ import traceback # Third party imports (qtpy) -from qtpy.QtCore import QUrl, QTimer, Signal, Slot, QThread +from qtpy.QtCore import QUrl, QTimer, Signal, Slot from qtpy.QtWidgets import QVBoxLayout, QWidget # Local imports @@ -479,6 +479,9 @@ def show_kernel_error(self, error): self.shellwidget.hide() self.infowidget.show() + # Inform other plugins that the shell failed to start + self.shellwidget.sig_shellwidget_errored.emit(self.shellwidget) + # Stop shellwidget self.shellwidget.shutdown() diff --git a/spyder/plugins/ipythonconsole/widgets/main_widget.py b/spyder/plugins/ipythonconsole/widgets/main_widget.py index a24a9cb5afb..c14758b4e91 100644 --- a/spyder/plugins/ipythonconsole/widgets/main_widget.py +++ b/spyder/plugins/ipythonconsole/widgets/main_widget.py @@ -215,6 +215,16 @@ class IPythonConsoleWidget(PluginMainWidget, CachedKernelMixin): The shellwigdet. """ + sig_shellwidget_errored = Signal(object) + """ + This signal is emitted when the current shellwidget failed to start. + + Parameters + ---------- + shellwidget: spyder.plugins.ipyconsole.widgets.shell.ShellWidget + The shellwigdet. + """ + sig_render_plain_text_requested = Signal(str) """ This signal is emitted to request a plain text help render. @@ -1668,11 +1678,13 @@ def register_client(self, client): shellwidget.sig_exception_occurred.connect( self.sig_exception_occurred) - # Closing Shellwidget + # Signals shellwidget.sig_shellwidget_deleted.connect( self.sig_shellwidget_deleted) shellwidget.sig_shellwidget_created.connect( self.sig_shellwidget_created) + shellwidget.sig_shellwidget_errored.connect( + self.sig_shellwidget_errored) shellwidget.sig_restart_kernel.connect(self.restart_kernel) def close_client(self, index=None, client=None, ask_recursive=True): diff --git a/spyder/plugins/ipythonconsole/widgets/shell.py b/spyder/plugins/ipythonconsole/widgets/shell.py index 81fc3eef359..4edf78638ef 100644 --- a/spyder/plugins/ipythonconsole/widgets/shell.py +++ b/spyder/plugins/ipythonconsole/widgets/shell.py @@ -9,7 +9,6 @@ """ # Standard library imports -import ast import os import os.path as osp import time @@ -118,9 +117,10 @@ class ShellWidget(NamepaceBrowserWidget, HelpWidget, DebuggingWidget, # Request plugins to send additional configuration to the Spyder kernel sig_config_spyder_kernel = Signal() - # To notify of kernel connection / disconnection + # To notify of kernel connection, disconnection and kernel errors sig_shellwidget_created = Signal(object) sig_shellwidget_deleted = Signal(object) + sig_shellwidget_errored = Signal(object) # To request restart sig_restart_kernel = Signal() diff --git a/spyder/plugins/ipythonconsole/widgets/status.py b/spyder/plugins/ipythonconsole/widgets/status.py index 12eef244385..41af9038db6 100644 --- a/spyder/plugins/ipythonconsole/widgets/status.py +++ b/spyder/plugins/ipythonconsole/widgets/status.py @@ -19,8 +19,7 @@ class MatplotlibStatus(StatusBarWidget, ShellConnectMixin): CONF_SECTION = 'ipython_console' def __init__(self, parent): - super(MatplotlibStatus, self).__init__( - parent) + super().__init__(parent) self._gui = None self._shellwidget_dict = {} self._current_id = None @@ -34,7 +33,7 @@ def get_tooltip(self): def toggle_matplotlib(self): """Toggle matplotlib interactive backend.""" - if self._current_id is None: + if self._current_id is None or self._gui == 'failed': return backend = "inline" if self._gui != "inline" else "auto" sw = self._shellwidget_dict[self._current_id]["widget"] @@ -61,6 +60,8 @@ def update(self, gui): self._gui = gui if gui == "inline": text = _("Inline") + elif gui == 'failed': + text = _('No backend') else: text = _("Interactive") self.set_value(text) @@ -103,6 +104,15 @@ def remove_shellwidget(self, shellwidget): shellwidget_id = id(shellwidget) if shellwidget_id in self._shellwidget_dict: del self._shellwidget_dict[shellwidget_id] - + + def add_errored_shellwidget(self, shellwidget): + """Add errored shellwidget.""" + swid = id(shellwidget) + self._shellwidget_dict[swid] = { + "gui": 'failed', + "widget": shellwidget, + } + self.set_shellwidget(shellwidget) + def get_icon(self): return self.create_icon('plot') diff --git a/spyder/plugins/plots/plugin.py b/spyder/plugins/plots/plugin.py index 845cf2975a9..e7c06b8e531 100644 --- a/spyder/plugins/plots/plugin.py +++ b/spyder/plugins/plots/plugin.py @@ -10,12 +10,12 @@ # Local imports from spyder.api.plugins import Plugins, SpyderDockablePlugin -from spyder.api.shellconnect.mixins import ShellConnectMixin +from spyder.api.shellconnect.mixins import ShellConnectPluginMixin from spyder.api.translations import _ from spyder.plugins.plots.widgets.main_widget import PlotsWidget -class Plots(SpyderDockablePlugin, ShellConnectMixin): +class Plots(SpyderDockablePlugin, ShellConnectPluginMixin): """ Plots plugin. """ diff --git a/spyder/plugins/plots/widgets/main_widget.py b/spyder/plugins/plots/widgets/main_widget.py index 175a6114c28..9caf486b02c 100644 --- a/spyder/plugins/plots/widgets/main_widget.py +++ b/spyder/plugins/plots/widgets/main_widget.py @@ -216,7 +216,7 @@ def update_actions(self): value = False widget = self.current_widget() figviewer = None - if widget: + if widget and not self.is_current_widget_empty(): figviewer = widget.figviewer thumbnails_sb = widget.thumbnails_sb value = figviewer.figcanvas.fig is not None diff --git a/spyder/plugins/variableexplorer/plugin.py b/spyder/plugins/variableexplorer/plugin.py index ea6e4ed87ac..70b5d815d13 100644 --- a/spyder/plugins/variableexplorer/plugin.py +++ b/spyder/plugins/variableexplorer/plugin.py @@ -12,7 +12,7 @@ from spyder.api.plugins import Plugins, SpyderDockablePlugin from spyder.api.plugin_registration.decorators import ( on_plugin_available, on_plugin_teardown) -from spyder.api.shellconnect.mixins import ShellConnectMixin +from spyder.api.shellconnect.mixins import ShellConnectPluginMixin from spyder.api.translations import _ from spyder.plugins.variableexplorer.confpage import ( VariableExplorerConfigPage) @@ -20,7 +20,7 @@ VariableExplorerWidget) -class VariableExplorer(SpyderDockablePlugin, ShellConnectMixin): +class VariableExplorer(SpyderDockablePlugin, ShellConnectPluginMixin): """ Variable explorer plugin. """ diff --git a/spyder/plugins/variableexplorer/widgets/main_widget.py b/spyder/plugins/variableexplorer/widgets/main_widget.py index 15728cfca76..47a89bb1ef9 100644 --- a/spyder/plugins/variableexplorer/widgets/main_widget.py +++ b/spyder/plugins/variableexplorer/widgets/main_widget.py @@ -108,13 +108,14 @@ class VariableExplorerWidget(ShellConnectMainWidget): def __init__(self, name=None, plugin=None, parent=None): super().__init__(name, plugin, parent) - # Widgets self.context_menu = None self.empty_context_menu = None - self.filter_button = None + # Attributes + self._is_filter_button_checked = True + # ---- PluginMainWidget API # ------------------------------------------------------------------------ def get_title(self): @@ -227,6 +228,7 @@ def setup(self): tip=_("Filter variables") ) self.filter_button.setCheckable(True) + self.filter_button.toggled.connect(self._set_filter_button_state) # ---- Context menu actions resize_rows_action = self.create_action( @@ -420,13 +422,21 @@ def setup(self): def update_actions(self): """Update the actions.""" + if self.is_current_widget_empty(): + self._set_main_toolbar_state(False) + return + else: + self._set_main_toolbar_state(True) + action = self.get_action(VariableExplorerWidgetActions.ToggleMinMax) action.setEnabled(is_module_installed('numpy')) + nsb = self.current_widget() if nsb: save_data_action = self.get_action( VariableExplorerWidgetActions.SaveData) save_data_action.setEnabled(nsb.filename is not None) + search_action = self.get_action(VariableExplorerWidgetActions.Search) if nsb is None: checked = False @@ -449,7 +459,6 @@ def switch_widget(self, nsb, old_nsb): # ---- Public API # ------------------------------------------------------------------------ - def create_new_widget(self, shellwidget): """Create new NamespaceBrowser.""" nsb = NamespaceBrowser(self) @@ -483,19 +492,19 @@ def import_data(self, filenames=None): """ Import data in current namespace. """ - if self.count(): + if not self.is_current_widget_empty(): nsb = self.current_widget() nsb.refresh_table() nsb.import_data(filenames=filenames) def save_data(self): - if self.count(): + if not self.is_current_widget_empty(): nsb = self.current_widget() nsb.save_data() self.update_actions() def reset_namespace(self): - if self.count(): + if not self.is_current_widget_empty(): nsb = self.current_widget() nsb.reset_namespace() @@ -503,7 +512,7 @@ def reset_namespace(self): def toggle_finder(self, checked): """Hide or show the finder.""" widget = self.current_widget() - if widget is None: + if widget is None or self.is_current_widget_empty(): return widget.toggle_finder(checked) @@ -514,7 +523,7 @@ def hide_finder(self): action.setChecked(False) def refresh_table(self): - if self.count(): + if not self.is_current_widget_empty(): nsb = self.current_widget() nsb.refresh_table() @@ -530,10 +539,12 @@ def free_memory(self): self.sig_free_memory_requested) def resize_rows(self): - self._current_editor.resizeRowsToContents() + if self._current_editor is not None: + self._current_editor.resizeRowsToContents() def resize_columns(self): - self._current_editor.resize_column_contents() + if self._current_editor is not None: + self._current_editor.resize_column_contents() def paste(self): self._current_editor.paste() @@ -576,7 +587,7 @@ def view_item(self): @property def _current_editor(self): editor = None - if self.count(): + if not self.is_current_widget_empty(): nsb = self.current_widget() editor = nsb.editor return editor @@ -622,3 +633,24 @@ def _enable_filter_actions(self, value): self.exclude_capitalized_action.setEnabled(value) self.exclude_unsupported_action.setEnabled(value) self.exclude_callables_and_modules_action.setEnabled(value) + + def _set_main_toolbar_state(self, enabled): + """Set main toolbar enabled state.""" + main_toolbar = self.get_main_toolbar() + for action in main_toolbar.actions(): + action.setEnabled(enabled) + + # Adjustments for the filter button + if enabled: + # Restore state for active consoles + self.filter_button.setChecked(self._is_filter_button_checked) + else: + # Uncheck button for dead consoles if it's checked so that the + # toolbar looks good + if self.filter_button.isChecked(): + self.filter_button.setChecked(False) + self._is_filter_button_checked = True + + def _set_filter_button_state(self, checked): + """Keep track of the filter button checked state.""" + self._is_filter_button_checked = checked diff --git a/spyder/utils/stylesheet.py b/spyder/utils/stylesheet.py index e598f0d12d4..b698f415823 100644 --- a/spyder/utils/stylesheet.py +++ b/spyder/utils/stylesheet.py @@ -19,6 +19,8 @@ # Local imports from spyder.api.config.mixins import SpyderConfigurationAccessor +from spyder.api.config.fonts import SpyderFontType, SpyderFontsMixin +from spyder.api.utils import classproperty from spyder.config.gui import is_dark_interface, OLD_PYQT from spyder.utils.palette import QStylePalette @@ -649,11 +651,39 @@ def set_stylesheet(self): # ============================================================================= # ---- Style for special dialogs # ============================================================================= -class DialogStyle: - """Style constants for tour, about and kite dialogs.""" +class DialogStyle(SpyderFontsMixin): + """Style constants for tour and about dialogs.""" IconScaleFactor = 0.5 - TitleFontSize = '19pt' if MAC else '14pt' - ContentFontSize = '15pt' if MAC else '12pt' - ButtonsFontSize = '15pt' if MAC else '13pt' ButtonsPadding = '6px' if MAC else '4px 10px' + + @classproperty + def _fs(cls): + return cls.get_font(SpyderFontType.Interface).pointSize() + + @classproperty + def TitleFontSize(cls): + if WIN: + return f"{cls._fs + 6}pt" + elif MAC: + return f"{cls._fs + 6}pt" + else: + return f"{cls._fs + 4}pt" + + @classproperty + def ContentFontSize(cls): + if WIN: + return f"{cls._fs + 4}pt" + elif MAC: + return f"{cls._fs + 2}pt" + else: + return f"{cls._fs + 2}pt" + + @classproperty + def ButtonsFontSize(cls): + if WIN: + return f"{cls._fs + 5}pt" + elif MAC: + return f"{cls._fs + 2}pt" + else: + return f"{cls._fs + 3}pt" diff --git a/spyder/widgets/about.py b/spyder/widgets/about.py index 0396602da8d..cc194a23894 100644 --- a/spyder/widgets/about.py +++ b/spyder/widgets/about.py @@ -10,6 +10,7 @@ import sys # Third party imports +import qstylizer.style from qtpy.QtCore import Qt from qtpy.QtGui import QPixmap from qtpy.QtWidgets import (QApplication, QDialog, QDialogButtonBox, @@ -28,7 +29,7 @@ from spyder.utils.icon_manager import ima from spyder.utils.image_path_manager import get_image_path from spyder.utils.palette import QStylePalette -from spyder.utils.stylesheet import APP_STYLESHEET, DialogStyle +from spyder.utils.stylesheet import DialogStyle class AboutDialog(QDialog): @@ -39,6 +40,7 @@ def __init__(self, parent): self.setWindowFlags( self.windowFlags() & ~Qt.WindowContextHelpButtonHint) versions = get_versions() + # Show Git revision for development version revlink = '' if versions['revision']: @@ -60,31 +62,42 @@ def __init__(self, parent): instagram_url = "https://www.instagram.com/spyderide/", self.label_overview = QLabel( f""" + +

+

Spyder IDE

+

- Spyder IDE -

- The Scientific Python Development Environment | - Spyder-IDE.org + The Scientific Python Development Environment
+ Spyder-IDE.org +

+

Python {versions['python']} {versions['bitness']}-bit | Qt {versions['qt']} | - {versions['qt_api']} {versions['qt_api_ver']} | + {versions['qt_api']} {versions['qt_api_ver']} +
{versions['system']} {versions['release']} ({versions['machine']})

-

+ +

GitHub | Twitter | Facebook | YouTube | Instagram +

-
""") + """ + ) self.label_community = QLabel( f""" @@ -176,7 +189,6 @@ def __init__(self, parent): font-weight: normal; '>

- Spyder IDE
{spyder_ver}
{revision}
({installer}) @@ -229,9 +241,10 @@ def __init__(self, parent): tabslayout.setContentsMargins(0, 15, 15, 0) btmhlayout = QHBoxLayout() + btmhlayout.addStretch(1) btmhlayout.addWidget(btn) btmhlayout.addWidget(bbox) - btmhlayout.setContentsMargins(100, 20, 0, 20) + btmhlayout.setContentsMargins(0, 20, 15, 20) btmhlayout.addStretch() vlayout = QVBoxLayout() @@ -248,14 +261,16 @@ def __init__(self, parent): bbox.accepted.connect(self.accept) # Size - self.resize(550, 430) + self.resize(720, 480) # Style - css = APP_STYLESHEET.get_copy() - css = css.get_stylesheet() + css = qstylizer.style.StyleSheet() css.QDialog.setValues(backgroundColor=dialog_background_color) css.QLabel.setValues(backgroundColor=dialog_background_color) - self.setStyleSheet(str(css)) + css.QTabBar.setValues(fontSize=font_size) + css['QTabBar::tab!selected'].setValues( + borderBottomColor=dialog_background_color) + self.setStyleSheet(css.toString()) def copy_to_clipboard(self): QApplication.clipboard().setText(get_versions_text()) diff --git a/spyder/widgets/helperwidgets.py b/spyder/widgets/helperwidgets.py index 91e6fbd3731..fa346333dbc 100644 --- a/spyder/widgets/helperwidgets.py +++ b/spyder/widgets/helperwidgets.py @@ -17,7 +17,9 @@ from qtpy.QtCore import ( QPoint, QRegExp, QSize, QSortFilterProxyModel, Qt, Signal) from qtpy.QtGui import (QAbstractTextDocumentLayout, QColor, QFontMetrics, - QPainter, QRegExpValidator, QTextDocument, QPixmap) + QImage, QPainter, QRegExpValidator, QTextDocument, + QPixmap) +from qtpy.QtSvg import QSvgRenderer from qtpy.QtWidgets import (QApplication, QCheckBox, QLineEdit, QMessageBox, QSpacerItem, QStyle, QStyledItemDelegate, QStyleOptionFrame, QStyleOptionViewItem, @@ -25,12 +27,13 @@ QWidget, QHBoxLayout, QLabel, QFrame) # Local imports +from spyder.api.config.fonts import SpyderFontType, SpyderFontsMixin +from spyder.api.config.mixins import SpyderConfigurationAccessor from spyder.config.base import _ from spyder.utils.icon_manager import ima from spyder.utils.stringmatching import get_search_regex from spyder.utils.palette import QStylePalette, SpyderPalette from spyder.utils.image_path_manager import get_image_path -from spyder.utils.stylesheet import DialogStyle # Valid finder chars. To be improved VALID_ACCENT_CHARS = "ÁÉÍOÚáéíúóàèìòùÀÈÌÒÙâêîôûÂÊÎÔÛäëïöüÄËÏÖÜñÑ" @@ -445,22 +448,18 @@ def filterAcceptsRow(self, row_num, parent): return True -class PaneEmptyWidget(QFrame): +class PaneEmptyWidget(QFrame, SpyderConfigurationAccessor, SpyderFontsMixin): """Widget to show a pane/plugin functionality description.""" def __init__(self, parent, icon_filename, text, description): super().__init__(parent) + + interface_font_size = self.get_font( + SpyderFontType.Interface).pointSize() + # Image - image_path = get_image_path(icon_filename) - image = QPixmap(image_path) - image_height = int(image.height() * 0.8) - image_width = int(image.width() * 0.8) - image = image.scaled( - image_width, image_height, - Qt.KeepAspectRatio, Qt.SmoothTransformation - ) image_label = QLabel(self) - image_label.setPixmap(image) + image_label.setPixmap(self.get_icon(icon_filename)) image_label.setAlignment(Qt.AlignCenter) image_label_qss = qstylizer.style.StyleSheet() image_label_qss.QLabel.setValues(border="0px") @@ -472,7 +471,7 @@ def __init__(self, parent, icon_filename, text, description): text_label.setWordWrap(True) text_label_qss = qstylizer.style.StyleSheet() text_label_qss.QLabel.setValues( - fontSize=DialogStyle.ContentFontSize, + fontSize=f'{interface_font_size + 5}pt', border="0px" ) text_label.setStyleSheet(text_label_qss.toString()) @@ -483,7 +482,7 @@ def __init__(self, parent, icon_filename, text, description): description_label.setWordWrap(True) description_label_qss = qstylizer.style.StyleSheet() description_label_qss.QLabel.setValues( - fontSize="10pt", + fontSize=f"{interface_font_size}pt", backgroundColor=SpyderPalette.COLOR_OCCURRENCE_3, border="0px", padding="20px" @@ -495,15 +494,70 @@ def __init__(self, parent, icon_filename, text, description): pane_empty_layout.addStretch(1) pane_empty_layout.addWidget(image_label) pane_empty_layout.addWidget(text_label) - pane_empty_layout.addWidget(description_label) pane_empty_layout.addStretch(2) - pane_empty_layout.setContentsMargins(10, 0, 10, 8) + pane_empty_layout.addWidget(description_label) + pane_empty_layout.setContentsMargins(10, 0, 10, 10) self.setLayout(pane_empty_layout) # Setup border style self.setFocusPolicy(Qt.StrongFocus) self._apply_stylesheet(False) + def setup(self, *args, **kwargs): + """ + This method is needed when using this widget to show a "no connected + console" message in plugins that inherit from ShellConnectMainWidget. + """ + pass + + def get_icon(self, icon_filename): + """ + Get pane's icon as a QPixmap that it's scaled according to the factor + set by users in Preferences. + """ + image_path = get_image_path(icon_filename) + + if self.get_conf('high_dpi_custom_scale_factor', section='main'): + scale_factor = float( + self.get_conf('high_dpi_custom_scale_factors', section='main') + ) + else: + scale_factor = 1 + + # Get width and height + pm = QPixmap(image_path) + width = pm.width() + height = pm.height() + + # Rescale by 80% but preserving aspect ratio + aspect_ratio = width / height + width = int(width * 0.8) + height = int(width / aspect_ratio) + + # Paint image using svg renderer + image = QImage( + int(width * scale_factor), int(height * scale_factor), + QImage.Format_ARGB32_Premultiplied + ) + image.fill(0) + painter = QPainter(image) + renderer = QSvgRenderer(image_path) + renderer.render(painter) + painter.end() + + # This is also necessary to make the image look good for different + # scale factors + if scale_factor > 1.0: + image.setDevicePixelRatio(scale_factor) + + # Create pixmap out of image + final_pm = QPixmap.fromImage(image) + final_pm = final_pm.copy( + 0, 0, int(width * scale_factor), int(height * scale_factor) + ) + + return final_pm + def focusInEvent(self, event): self._apply_stylesheet(True) super().focusOutEvent(event)